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)