New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world derived from server snapshots. Per-leg constant-velocity interpolation keyed to the stance throttle, 'server feet / client eyes' facing (wire octant while moving, client aim octant idle), cadence-synced gait state machine on AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg default, T-cycle presets), sim-space grid shader, camera-side wall cutaway, accumulating never-evict tile store with four-state visibility tint. Additive seams only: InputMapper.facing_angle_provider (2D path unchanged), CharacterVisual.play_animation blend_time param + get_animation_player(). Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting. 210 new gdUnit assertions across five suites; verified live (230/230 total, clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
325 lines
13 KiB
GDScript
325 lines
13 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
|
|
|
|
|
|
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_cut_radius", SandboxConstants.CUT_RADIUS)
|
|
_wall_material.set_shader_parameter("u_cut_band", SandboxConstants.CUT_BAND)
|
|
_wall_mm = _make_layer("WallMM", wall_mesh, _wall_material)
|
|
|
|
|
|
## 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) so the cut
|
|
## zone glides with the character and spatial smoothstep becomes temporal
|
|
## smoothness. One uniform write per frame — the entire CPU cost.
|
|
func set_cutaway_char_pos(world_pos: Vector3) -> void:
|
|
_wall_material.set_shader_parameter("u_char_pos_xz", Vector2(world_pos.x, world_pos.z))
|
|
|
|
|
|
func _make_layer(layer_name: String, mesh: Mesh, material: ShaderMaterial) -> 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).
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.use_colors = true
|
|
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)
|
|
|
|
|
|
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)
|