Files
settled-reach/client/scripts/sandbox/greybox_world.gd
T
jpmschweitzerandClaude Fable 5 6372537f2f fix(client): bottom-wall scan pierces 2-thick walls + corner diagonal; whole-box quantized cuts (T-1088)
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>
2026-07-06 20:10:28 +02:00

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)