Live-session findings, three in one: (1) Gauntlet walls are 2 tiles thick — the outer row's camera-far neighbor is the inner WALL, so its flag stayed 0 and it stood full-height behind the collapsed inner stub; the flag now scans up to 3 tiles through consecutive KNOWN walls (unknown stops the scan — no assumptions past the info boundary), and any newly-learned tile refreshes the wall chain behind it. (2) Corner pillars at the screen-bottom junction never collapsed — their interior floor sits DIAGONALLY behind, which no straight scan line reaches; the combined diagonal joins the scan set. (3) Per-fragment smoothstep falloff carved organic notches (the 'sphere' read) — the cut now computes per-instance in the vertex shader from the box's tile center and quantizes at 0.5: every wall box is either full or stub, X2-crisp. Verified via locomotion_cutaway autopilot capture: south runs read as clean stub rows, corners collapse, camera-far walls stand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
421 lines
17 KiB
GDScript
421 lines
17 KiB
GDScript
class_name GreyboxWorld
|
|
extends Node3D
|
|
## T-1088 greybox world — accumulating tile store + MultiMesh painter (design §3).
|
|
##
|
|
## Two halves in one file:
|
|
## - Store (inner RefCounted, headless-testable — test_greybox_store.gd):
|
|
## accumulates tile knowledge from successive GameState.visible_tiles frames.
|
|
## Never evicts; last observation wins — the embryo of the Phase-5
|
|
## "destroyed-while-unobserved renders remembered" fog-memory record. Survives
|
|
## the Home-key teleport (a feature). Gauntlet worst case 117x125 = 14,625
|
|
## tiles — trivial against the 16,384 preallocation.
|
|
## - Painter (this Node3D): builds the FloorMM/WallMM MultiMeshInstance3D
|
|
## children in _ready() and applies Store diffs ONLY — new tiles get one
|
|
## transform + one color write, visibility flips get one set_instance_color()
|
|
## each. No full-buffer rebuilds (contrast the 2D TileRenderer
|
|
## clear-and-reset, deliberately not copied).
|
|
##
|
|
## Mesh strategy (§3.3, Q-079 engaged): two MultiMeshes because the greybox
|
|
## uniquely needs per-instance state (four-state tint) and per-material shader
|
|
## uniforms (cutaway) — GridMap has neither. All real 3D geometry per D-244.
|
|
##
|
|
## Perception stays server-enforced upstream (D-010): the store only ever sees
|
|
## tiles the server disclosed; never-seen tiles render as nothing — the void.
|
|
##
|
|
## Promotion status (design §1.1): store contract proto-production; meshes
|
|
## disposable.
|
|
|
|
## Preallocated instances per MultiMesh (design §3.1). Resizing a MultiMesh
|
|
## resets its buffers, so allocate once; unused instances stay zero-scaled.
|
|
const INSTANCE_CAPACITY := 16384
|
|
|
|
## Zero-scaled transform for unused/freed instances — renders nothing.
|
|
const ZERO_XFORM := Transform3D(Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO)
|
|
|
|
const FLOOR_SHADER := preload("res://shaders/sandbox/greybox_tile.gdshader")
|
|
const WALL_SHADER := preload("res://shaders/sandbox/greybox_wall_cutaway.gdshader")
|
|
|
|
|
|
## Accumulating last-observation-wins tile knowledge (design §3.1). Pure data —
|
|
## no scene or autoload access — so gdUnit covers it headless.
|
|
class Store:
|
|
extends RefCounted
|
|
|
|
## Wire tile_kind is walkability-derived (server query.rs:78-82); doors are
|
|
## entities on the wire — Door/Object collapse to FLOOR.
|
|
enum Kind { FLOOR, WALL }
|
|
## Render states (§3.2). REMEMBERED = in store but not in the current LOS
|
|
## set. Never-seen tiles have no state — nothing rendered, the void.
|
|
enum Vis { FORWARD, PERIPHERAL, BOUNDARY_WALL, REMEMBERED }
|
|
|
|
## Vector3i(x, y, z) sim tile coords -> Kind. Accumulates forever.
|
|
var _store: Dictionary = {}
|
|
## Vector3i -> Vis: current render state of every stored tile.
|
|
var _state: Dictionary = {}
|
|
## Vector3i -> Vis: the current LOS set, rebuilt each consumed snapshot.
|
|
var _visible_now: Dictionary = {}
|
|
|
|
## Consume one snapshot's GameState.visible_tiles (already merged from both
|
|
## wire spellings by snapshot_handler.gd:52-64 — live dicts carry
|
|
## {x, y, z, visibility, type}; TestHarness dicts omit "visibility").
|
|
## Returns the paint diff — three Array[Vector3i] under fixed keys:
|
|
## "added": first-ever observations (slot + transform + color)
|
|
## "rekinded": kind changed under last-observation-wins (slot moves MM)
|
|
## "recolored": render state flipped only (one set_instance_color)
|
|
func ingest(tiles: Array) -> Dictionary:
|
|
var added: Array[Vector3i] = []
|
|
var rekinded: Array[Vector3i] = []
|
|
var recolored: Array[Vector3i] = []
|
|
var new_visible: Dictionary = {}
|
|
for tile: Variant in tiles:
|
|
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
|
continue
|
|
var key := Vector3i(int(tile["x"]), int(tile["y"]), int(tile.get("z", 0)))
|
|
var kind := _parse_kind(tile)
|
|
var vis := _parse_vis(tile)
|
|
new_visible[key] = vis
|
|
if not _store.has(key):
|
|
_store[key] = kind
|
|
_state[key] = vis
|
|
added.append(key)
|
|
elif _store[key] != kind:
|
|
# Last observation wins — e.g. a wall destroyed while observed.
|
|
_store[key] = kind
|
|
_state[key] = vis
|
|
rekinded.append(key)
|
|
elif _state[key] != vis:
|
|
_state[key] = vis
|
|
recolored.append(key)
|
|
# Tiles that just left LOS dim to REMEMBERED. Tiles remembered on an
|
|
# earlier snapshot already carry the state — the flip emits exactly once.
|
|
for key: Vector3i in _visible_now:
|
|
if new_visible.has(key):
|
|
continue
|
|
if _state[key] != Vis.REMEMBERED:
|
|
_state[key] = Vis.REMEMBERED
|
|
recolored.append(key)
|
|
_visible_now = new_visible
|
|
return {"added": added, "rekinded": rekinded, "recolored": recolored}
|
|
|
|
## Total accumulated tiles (never shrinks — eviction: never, §3.1).
|
|
func size() -> int:
|
|
return _store.size()
|
|
|
|
## Tiles in the current LOS set (DebugHud readout).
|
|
func visible_count() -> int:
|
|
return _visible_now.size()
|
|
|
|
func has_tile(key: Vector3i) -> bool:
|
|
return _store.has(key)
|
|
|
|
## Kind for a stored tile; -1 for never-seen.
|
|
func kind_of(key: Vector3i) -> int:
|
|
return _store.get(key, -1)
|
|
|
|
## Current render state (Vis) for a stored tile; -1 for never-seen — the
|
|
## void (perception is upstream, server-enforced).
|
|
func state_of(key: Vector3i) -> int:
|
|
return _state.get(key, -1)
|
|
|
|
## Persistence seam (design §0 scope fence — in-memory today): a copy of the
|
|
## accumulated knowledge, Vector3i -> Kind. Transient visibility state is
|
|
## not knowledge and is not exported.
|
|
func to_dict() -> Dictionary:
|
|
return _store.duplicate()
|
|
|
|
static func _parse_kind(tile: Dictionary) -> Kind:
|
|
return Kind.WALL if str(tile.get("type", "floor")) == "wall" else Kind.FLOOR
|
|
|
|
static func _parse_vis(tile: Dictionary) -> Vis:
|
|
match str(tile.get("visibility", "Forward")):
|
|
"Peripheral":
|
|
return Vis.PERIPHERAL
|
|
"BoundaryWall":
|
|
return Vis.BOUNDARY_WALL
|
|
_:
|
|
# Forward, plus TestHarness "tiles" dicts (no visibility key —
|
|
# design-input §1.5) and unknown future variants.
|
|
return Vis.FORWARD
|
|
|
|
|
|
## The accumulating knowledge store — read by the DebugHud (store.size(),
|
|
## store.visible_count()) and driven by on_snapshot().
|
|
var store := Store.new()
|
|
|
|
var _floor_mm: MultiMeshInstance3D = null
|
|
var _wall_mm: MultiMeshInstance3D = null
|
|
var _wall_material: ShaderMaterial = null
|
|
## Vector3i -> Vector2i(kind, slot index in that kind's MultiMesh).
|
|
var _slots: Dictionary = {}
|
|
## Next never-used slot per Kind (high watermark).
|
|
var _next_slot: Array[int] = [0, 0]
|
|
## Freed slots per Kind (holes left by rekind moves), reused first.
|
|
var _free_slots: Array = [[], []]
|
|
## Per-Vis instance colors, resolved from SandboxConstants.TINTS in _ready().
|
|
var _state_colors: Array[Color] = []
|
|
var _last_tick: int = -1
|
|
var _capacity_warned := false
|
|
## Bottom-wall scan depth (tiles) along each camera-far direction — covers the
|
|
## Gauntlet's 2-thick walls plus door trim.
|
|
const BOTTOM_SCAN_DEPTH := 3
|
|
|
|
## Cutaway mode: 0 = sightline corridor, 1 = bottom walls low, 2 = both.
|
|
var cutaway_mode: int = 0
|
|
## Camera-far sim-neighbor deltas for the bottom-wall rule (see
|
|
## _compute_behind_dirs — derived from the live WorldRoot basis).
|
|
var _behind_dirs: Array[Vector3i] = []
|
|
|
|
|
|
func _ready() -> void:
|
|
_state_colors = [
|
|
_tint_to_color(SandboxConstants.TINTS["forward"]),
|
|
_tint_to_color(SandboxConstants.TINTS["peripheral"]),
|
|
_tint_to_color(SandboxConstants.TINTS["boundary"]),
|
|
_tint_to_color(SandboxConstants.TINTS["remembered"]),
|
|
]
|
|
|
|
# FloorMM: PlaneMesh faces +Y natively — no orientation fix needed (§3.2).
|
|
var floor_mesh := PlaneMesh.new()
|
|
floor_mesh.size = Vector2(SandboxConstants.SUBTILE_M, SandboxConstants.SUBTILE_M)
|
|
var floor_material := ShaderMaterial.new()
|
|
floor_material.shader = FLOOR_SHADER
|
|
# Grid lines are sampled in sim space: undo this node's global transform
|
|
# (i.e. the WorldRoot 45 deg) so lines land on tile edges, not world axes.
|
|
# Deferred: global_transform is not final until the tree is ready.
|
|
_set_sim_from_world.call_deferred(floor_material)
|
|
_floor_mm = _make_layer("FloorMM", floor_mesh, floor_material)
|
|
|
|
# WallMM: BoxMesh is centered on its origin — the per-instance transform
|
|
# lifts it +WALL_H/2 so the base sits at y=0 (§3.2, explicit).
|
|
var wall_mesh := BoxMesh.new()
|
|
wall_mesh.size = Vector3(
|
|
SandboxConstants.SUBTILE_M, SandboxConstants.WALL_H, SandboxConstants.SUBTILE_M
|
|
)
|
|
_wall_material = ShaderMaterial.new()
|
|
_wall_material.shader = WALL_SHADER
|
|
# Cutaway geometry knobs come from SandboxConstants (single source, §12);
|
|
# the shader defaults mirror them only for standalone/editor use.
|
|
_wall_material.set_shader_parameter("u_wall_h", SandboxConstants.WALL_H)
|
|
_wall_material.set_shader_parameter("u_cut_height", SandboxConstants.CUT_HEIGHT)
|
|
_wall_material.set_shader_parameter("u_corridor_half_w", SandboxConstants.CUT_CORRIDOR_HALF_W)
|
|
_wall_material.set_shader_parameter("u_cut_band", SandboxConstants.CUT_BAND)
|
|
_wall_mm = _make_layer("WallMM", wall_mesh, _wall_material, true)
|
|
set_cutaway_mode(SandboxConstants.CUTAWAY_MODE)
|
|
# Camera-far neighbor directions for the bottom-wall rule — derived from the
|
|
# actual WorldRoot basis at runtime (never hand-derived; the E/W-mirror
|
|
# lesson). A wall is a "bottom wall" when a KNOWN non-wall tile sits on its
|
|
# camera-far side. Deferred: global_transform is not final until tree-ready.
|
|
_compute_behind_dirs.call_deferred()
|
|
|
|
|
|
## Push the world->sim transform into the floor grid shader (deferred from
|
|
## _ready so global_transform reflects the WorldRoot rotation).
|
|
func _set_sim_from_world(mat: ShaderMaterial) -> void:
|
|
mat.set_shader_parameter("u_sim_from_world", global_transform.affine_inverse())
|
|
|
|
|
|
## Snapshot consumer — called directly by the sandbox root (no
|
|
## SnapshotEventRouter, design §13.7). Tick-gated on GameState.current_tick
|
|
## change — pattern: world_renderer.gd:33-36. Paused ticks repeat the tick and
|
|
## are skipped here for free.
|
|
func on_snapshot() -> void:
|
|
if GameState.current_tick == _last_tick:
|
|
return
|
|
_last_tick = GameState.current_tick
|
|
_apply_diff(store.ingest(GameState.visible_tiles))
|
|
|
|
|
|
## DebugHud duck contract (sandbox_debug_hud.gd): total accumulated tiles.
|
|
func get_store_size() -> int:
|
|
return store.size()
|
|
|
|
|
|
## Per-frame cutaway anchor (design §8): pass the rig's INTERPOLATED
|
|
## world-space position (PlayerRig.global_position — it already carries the
|
|
## 45 deg WorldRoot rotation, matching the shader's world-space test) plus the
|
|
## pitch-derived corridor reach (WALL_H / tan(camera pitch) — how far south a
|
|
## WALL_H wall can still occlude the character in ortho). The cut corridor
|
|
## glides with the character and breathes with the tilt; two uniform writes
|
|
## per frame — the entire CPU cost.
|
|
func set_cutaway_char_pos(world_pos: Vector3, reach_m: float) -> void:
|
|
_wall_material.set_shader_parameter("u_char_pos_xz", Vector2(world_pos.x, world_pos.z))
|
|
_wall_material.set_shader_parameter("u_cut_reach", reach_m)
|
|
|
|
|
|
func _make_layer(
|
|
layer_name: String, mesh: Mesh, material: ShaderMaterial, with_custom := false
|
|
) -> MultiMeshInstance3D:
|
|
var mm := MultiMesh.new()
|
|
# Format flags must be set before instance_count (buffer layout is fixed at
|
|
# allocation). use_colors routes set_instance_color() to the shaders' COLOR
|
|
# built-in, which multiplies albedo (§3.2). use_custom_data (walls only)
|
|
# carries the bottom-wall flag to INSTANCE_CUSTOM.r for the cutaway modes.
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.use_colors = true
|
|
mm.use_custom_data = with_custom
|
|
mm.mesh = mesh
|
|
mm.instance_count = INSTANCE_CAPACITY
|
|
for i in INSTANCE_CAPACITY:
|
|
mm.set_instance_transform(i, ZERO_XFORM)
|
|
var instance := MultiMeshInstance3D.new()
|
|
instance.name = layer_name
|
|
instance.multimesh = mm
|
|
instance.material_override = material
|
|
add_child(instance)
|
|
return instance
|
|
|
|
|
|
func _apply_diff(diff: Dictionary) -> void:
|
|
for key: Vector3i in diff["added"]:
|
|
_paint_new(key)
|
|
for key: Vector3i in diff["rekinded"]:
|
|
# Kind flipped — the instance moves between MultiMeshes: zero-scale the
|
|
# old slot, allocate in the other mesh.
|
|
_release_slot(key)
|
|
_paint_new(key)
|
|
for key: Vector3i in diff["recolored"]:
|
|
_repaint_color(key)
|
|
|
|
|
|
func _paint_new(key: Vector3i) -> void:
|
|
var kind := store.kind_of(key)
|
|
var slot := _alloc_slot(kind)
|
|
if slot < 0:
|
|
return # capacity exhausted — the store still knows the tile (warned once)
|
|
var mm := _mm_for(kind).multimesh
|
|
mm.set_instance_transform(slot, _tile_xform(key, kind))
|
|
mm.set_instance_color(slot, _state_colors[store.state_of(key)])
|
|
_slots[key] = Vector2i(kind, slot)
|
|
if kind == Store.Kind.WALL:
|
|
_refresh_bottom_flag(key)
|
|
# Any newly-learned tile can promote camera-ward walls up the scan chain:
|
|
# a floor is the promotion itself; a wall can EXTEND a chain that previously
|
|
# stopped at unknown (Gauntlet walls are 2 tiles thick — the outer row sees
|
|
# floor only THROUGH the inner row).
|
|
for d in _behind_dirs:
|
|
for step in range(1, BOTTOM_SCAN_DEPTH + 1):
|
|
_refresh_bottom_flag(key - d * step)
|
|
|
|
|
|
## Cutaway mode (design amendment, live session 2026-07-06): 0 = sightline
|
|
## corridor, 1 = bottom walls low, 2 = both. Pushed to the wall shader.
|
|
func set_cutaway_mode(mode: int) -> void:
|
|
cutaway_mode = mode % 3
|
|
_wall_material.set_shader_parameter("u_cutaway_mode", cutaway_mode)
|
|
print("GreyboxWorld: cutaway mode %d (%s)" % [
|
|
cutaway_mode, ["sightline corridor", "bottom walls low", "both"][cutaway_mode]
|
|
])
|
|
|
|
|
|
# Dev affordance (same pattern as the camera's T tilt cycle): C cycles the
|
|
# cutaway mode live so the corridor-vs-bottom-walls call is made by feel.
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if event is InputEventKey:
|
|
var key := event as InputEventKey
|
|
if key.pressed and not key.echo and key.physical_keycode == KEY_C:
|
|
set_cutaway_mode(cutaway_mode + 1)
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
## World-space camera-far sim directions (as tile-key deltas): the wall face
|
|
## pointing toward the camera hides the tile at key + behind_dir. Computed from
|
|
## this node's global basis so the WorldRoot rotation can never desync it.
|
|
func _compute_behind_dirs() -> void:
|
|
_behind_dirs.clear()
|
|
for d: Vector3i in [Vector3i(1, 0, 0), Vector3i(-1, 0, 0), Vector3i(0, 1, 0), Vector3i(0, -1, 0)]:
|
|
# Sim x -> local X, sim y -> local Z (SandboxSpace convention).
|
|
var world_dir := global_transform.basis * Vector3(float(d.x), 0.0, float(d.y))
|
|
# Camera looks world -Z, so a camera-ward face points +Z; the tile it
|
|
# hides lies OPPOSITE that face -> behind_dir = -d for camera-ward d.
|
|
if world_dir.z > 0.01:
|
|
_behind_dirs.append(-d)
|
|
# Corner pillars: at a wall-run junction the interior floor sits DIAGONALLY
|
|
# behind (neither straight scan line reaches it) — include the combined
|
|
# diagonal in the scan set.
|
|
if _behind_dirs.size() == 2:
|
|
_behind_dirs.append(_behind_dirs[0] + _behind_dirs[1])
|
|
# Repaint flags for walls painted before the deferred computation ran.
|
|
for key: Vector3i in _slots:
|
|
if (_slots[key] as Vector2i).x == Store.Kind.WALL:
|
|
_refresh_bottom_flag(key)
|
|
|
|
|
|
func _refresh_bottom_flag(key: Vector3i) -> void:
|
|
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
|
|
if slot.y < 0 or slot.x != Store.Kind.WALL:
|
|
return
|
|
# Scan up to BOTTOM_SCAN_DEPTH tiles along each camera-far direction,
|
|
# walking THROUGH consecutive known walls (Gauntlet walls are 2 thick):
|
|
# a known non-wall tile behind the wall run marks the whole run "bottom".
|
|
# Unknown tiles stop the scan — no assumptions past the info boundary.
|
|
var flag := 0.0
|
|
for d in _behind_dirs:
|
|
for step in range(1, BOTTOM_SCAN_DEPTH + 1):
|
|
var n: Vector3i = key + d * step
|
|
if not store.has_tile(n):
|
|
break
|
|
if store.kind_of(n) != Store.Kind.WALL:
|
|
flag = 1.0
|
|
break
|
|
if flag > 0.0:
|
|
break
|
|
_wall_mm.multimesh.set_instance_custom_data(slot.y, Color(flag, 0.0, 0.0, 0.0))
|
|
|
|
|
|
func _repaint_color(key: Vector3i) -> void:
|
|
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
|
|
if slot.y < 0:
|
|
return # never painted (capacity overflow)
|
|
_mm_for(slot.x).multimesh.set_instance_color(slot.y, _state_colors[store.state_of(key)])
|
|
|
|
|
|
func _release_slot(key: Vector3i) -> void:
|
|
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
|
|
if slot.y < 0:
|
|
return
|
|
_mm_for(slot.x).multimesh.set_instance_transform(slot.y, ZERO_XFORM)
|
|
(_free_slots[slot.x] as Array).append(slot.y)
|
|
_slots.erase(key)
|
|
|
|
|
|
func _alloc_slot(kind: int) -> int:
|
|
var free: Array = _free_slots[kind]
|
|
if not free.is_empty():
|
|
return free.pop_back()
|
|
if _next_slot[kind] >= INSTANCE_CAPACITY:
|
|
if not _capacity_warned:
|
|
_capacity_warned = true
|
|
push_error(
|
|
(
|
|
"GreyboxWorld: MultiMesh capacity %d exhausted — further tiles are "
|
|
% INSTANCE_CAPACITY
|
|
)
|
|
+ "stored but not rendered (Gauntlet worst case is 14,625; investigate)"
|
|
)
|
|
return -1
|
|
var slot := _next_slot[kind]
|
|
_next_slot[kind] += 1
|
|
return slot
|
|
|
|
|
|
func _mm_for(kind: int) -> MultiMeshInstance3D:
|
|
return _wall_mm if kind == Store.Kind.WALL else _floor_mm
|
|
|
|
|
|
## Floors sit on the ground plane at the subtile center (SandboxSpace, §2);
|
|
## walls lift +WALL_H/2 so the centered BoxMesh base sits at y=0.
|
|
func _tile_xform(key: Vector3i, kind: int) -> Transform3D:
|
|
var origin := SandboxSpace.tile_to_world(key)
|
|
if kind == Store.Kind.WALL:
|
|
origin.y += SandboxConstants.WALL_H * 0.5
|
|
return Transform3D(Basis.IDENTITY, origin)
|
|
|
|
|
|
## SandboxConstants.TINTS values are Color (used directly) or float (a greyscale
|
|
## albedo value — 85% / 70%), per §3.2.
|
|
func _tint_to_color(tint: Variant) -> Color:
|
|
if tint is Color:
|
|
return tint
|
|
var value := float(tint)
|
|
return Color(value, value, value)
|