docs+fix: sidequest close-out — T-1095 head tint, design doc, wardrobe docs, changelog (T-1088/T-1089)
T-1095 fixed: template heads now receive the body's skin tone (same treatment as seg_head) — _load_head passes tone.lit with a full-white fallback mask through _attach_to_bone; without a mask sidecar the tint was silently ignored, so untinted templates read grey against mid/dark tones. Verified by lookbook render (head_001 on child, tone 6 — face matches arms). Docs: the T-1088 locomotion design document committed to docs/design/ (the sandbox code's 'design §' references now resolve in-repo); character-asset- organization gains §11 Wardrobe Pipeline and sheds three stale claims (the never-built indexed-greyscale mask plan, single-mask-per-asset for clothing, the resolved multi-region open question); CHANGELOG carries the sidequest entry; D-250 amended with the shipped cutaway-modes verdict (mode 2 default, user-decided live). Tickets: T-1088, T-1089, T-1095 closed; T-1096 filed (S9 live feel-tuning, needs the user). The Fable-5 cascade exception is closed — the cascade resumes at Phase 4 (T-750). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -444,9 +444,14 @@ Convention: `<asset_name>_mask.png` as a sidecar file next to the GLB.
|
||||
- `hat_hardhat.glb` + `hat_hardhat_mask.png`
|
||||
- For clothing, the mask lives in the item directory: `coveralls_basic/reference_mask.png`
|
||||
|
||||
Masks are per-asset, not per-variant. The same mask applies to all 11 body-type variants of a
|
||||
clothing item because the mask regions (collar is region A, sleeves are region B) correspond
|
||||
to UV regions, and the UV layout is preserved through Surface Deform fitting.
|
||||
Masks are per-asset, not per-variant, for hair/facial hair/eyebrows/accessories/heads -- one
|
||||
mask applies across all 11 body-type variants because the mask is UV-space and the UV layout
|
||||
is preserved through fitting.
|
||||
|
||||
**Superseded for clothing (T-1089, see Section 11):** offset-shell garments now author a mask
|
||||
**per body type** (`<body_type>_mask.png`, that body's own UV0 layout), falling back to
|
||||
`reference_mask.png` only for the Surface-Deform-fit route (suit, uniform, Fantasy-derived
|
||||
cuts), which still shares one mask across variants as described above.
|
||||
|
||||
### Head Templates
|
||||
|
||||
@@ -567,13 +572,17 @@ For every GLB that supports recoloring, a `_mask.png` sidecar sits next to it:
|
||||
|
||||
- **Format:** PNG, same UV layout as the GLB's texture
|
||||
- **Channels:** Greyscale (R channel only). White = tintable region, black = preserve original
|
||||
- **For multi-region recoloring:** Use distinct greyscale values per region (e.g. 0.0, 0.33,
|
||||
0.66, 1.0 for up to 4 independent tint regions). The shader samples the mask and routes
|
||||
each region to a different `tint_color` uniform. This is not yet implemented in the shader
|
||||
but the mask format supports it.
|
||||
- **Default:** `hint_default_black` in the shader means "no mask loaded = no tinting."
|
||||
Assets without masks render with their original Trellis/authored texture.
|
||||
|
||||
**Multi-region resolved differently than proposed here (T-1089, see Section 11).** The
|
||||
distinct-greyscale-band idea above (0.0/0.33/0.66/1.0 in one channel) was never built --
|
||||
indexed greyscale bilinear-filters badly at region boundaries (a filtered sample between two
|
||||
index levels reads as a wrong third region, not a blend). Clothing multi-region instead
|
||||
channel-routes an **RGBA mask** (R/G/B/A -> 4 independent tints, `toon_garment.gdshader`),
|
||||
so bilinear filtering cross-fades neighboring regions correctly. This is clothing-only; the
|
||||
single-channel binary convention above is unchanged for hair/facial hair/eyebrows/accessories.
|
||||
|
||||
### Surface Deform Batch Output
|
||||
|
||||
The Blender batch pipeline takes one reference clothing mesh and produces 11 fitted variants.
|
||||
@@ -639,6 +648,7 @@ CharacterVisualDescriptor:
|
||||
skin_tone: int # index into the 9-tone palette
|
||||
clothing_slots: Dictionary # slot -> item_id (e.g. "torso" -> "coveralls_basic")
|
||||
clothing_tints: Dictionary # item_id -> Array[Color] for multi-region recolor
|
||||
clothing_logos: Dictionary # item_id -> brand id (logo_capable items only; T-1089, Section 11)
|
||||
accessory_slots: Dictionary # slot -> item_id (e.g. "hat" -> "hat_hardhat")
|
||||
accessory_tints: Dictionary # item_id -> Color
|
||||
```
|
||||
@@ -726,11 +736,11 @@ What does NOT carry forward:
|
||||
|
||||
## 10. Open Questions for Review
|
||||
|
||||
**Q: Multi-region recolor masks -- implement now or defer?**
|
||||
The current `toon_masked` shader supports single-region (binary mask). Multi-region requires
|
||||
a shader update to sample distinct greyscale bands and route them to separate tint uniforms.
|
||||
Recommendation: defer to when the first multi-region clothing item is authored. The mask PNG
|
||||
format supports it already (greyscale values), so no re-export is needed later.
|
||||
**Q: Multi-region recolor masks -- implement now or defer?** **RESOLVED (T-1089, Section 11).**
|
||||
Deferred until the first multi-region clothing item (the t-shirt) needed it, then implemented
|
||||
as an RGBA channel-routed mask + `toon_garment.gdshader` -- not the greyscale-band shader
|
||||
update this note anticipated (see the corrections in Sections 3 and 5 above). `toon_masked`
|
||||
is retained for single-region assets (hair, accessories).
|
||||
|
||||
**Q: Teen clothing variants -- separate catalogue or shared with adult?**
|
||||
Teen body types use the same clothing system (Surface Deform fitting) but teen proportions
|
||||
@@ -741,6 +751,94 @@ revisit if visual quality is insufficient.
|
||||
|
||||
---
|
||||
|
||||
## 11. Wardrobe Pipeline (T-1089)
|
||||
|
||||
The clothing engine that shipped the first real catalogue (2026-07-06/07). This section
|
||||
supersedes the single-region greyscale mask model assumed in Sections 3/5/10 above for
|
||||
**clothing specifically** -- hair/facial hair/eyebrows/accessories/heads keep the
|
||||
single-channel convention as originally written.
|
||||
|
||||
### Authoring: offset-shell from our own body segments
|
||||
|
||||
The preferred authoring route is **offset-shell**
|
||||
(`tooling/garment-fit/blender_author_offset_shell.py`, one per-garment companion script per
|
||||
item, e.g. `blender_author_hoodie.py`, `blender_author_boots.py`): extract the body segment(s)
|
||||
a garment covers, cut neckline/hem/sleeve boundaries via bone-plane thresholds, offset the
|
||||
surface outward along vertex normals, solidify for real cloth thickness. Because the shell
|
||||
*is* body topology, bone weights are inherited by construction -- no Surface Deform, no re-rig.
|
||||
|
||||
**PER-BODY mode (`--per-body`) is now the standard for offset-shell garments**, not
|
||||
single-reference-then-fit. Q-060 evidence: single-reference Surface-Deform fitting degraded
|
||||
with girth divergence (muscular_m hit an 859px worst clip at 24mm standoff on the
|
||||
reference-authored t-shirt); authoring per body guarantees clearance + exact weights by
|
||||
construction and lets the offset drop back to the ~12mm ideal. Cut/mask thresholds are
|
||||
derived from each body's own bone landmarks using the proportional ratios that reproduce the
|
||||
hand-calibrated reference constants -- recomputed per body, not copied.
|
||||
|
||||
Shared geometry practices proven across garments: **boundary weld** (`weld_boundaries` --
|
||||
merges coincident segment-boundary verts so the offset can't open cracks) and **open-rim
|
||||
flattening** (`flatten_open_rims` -- pulls the jagged segment-splitter boundary rings onto
|
||||
clean planes before the offset runs; first required by jeans and the track jacket).
|
||||
**`convex_toe_box`** (shared helper, used by boots/sneakers/shoes_formal) reshapes the
|
||||
forefoot into one smooth per-slice ellipse cap so individual-toe geometry doesn't poke the
|
||||
shoe shell -- residual toe hints are accepted for first release (see Catalogue below).
|
||||
|
||||
### Fitting: Surface-Deform batch route
|
||||
|
||||
`tooling/garment-fit/blender_batch_fit_skinned.py` remains the route for garments sharing one
|
||||
reference mesh + mask across all 11 bodies -- Fantasy-pack-derived cuts and hand-authored
|
||||
pieces on a single UV layout (the suit, the uniform). SD-bind to each target body, bake the
|
||||
deformed rest shape, transfer + normalize weights, retarget the armature, export with
|
||||
**`export_skins=True`** (the predecessor script's `export_skins=False` output could not
|
||||
animate on the shared skeleton -- why it was superseded, not merely extended).
|
||||
|
||||
### Runtime contract
|
||||
|
||||
- **`coverage.json`** per clothing item: `hides` (segment names to hide), `torso_variant`
|
||||
(`full`/`upper`), and the new **`multi_region`** flag (routes the item through
|
||||
`toon_garment.gdshader` instead of the single-tint `toon_masked.gdshader`).
|
||||
- **Per-body masks:** `<body_type>_mask.png` sits beside each body's fitted GLB; the
|
||||
compositor prefers it and falls back to `reference_mask.png` for the Surface-Deform-fit
|
||||
route. See the mask-convention correction in Section 3.
|
||||
- **`toon_garment.gdshader`** (`client/assets/characters/shaders/`): channel-routes FOUR tints
|
||||
through an RGBA region mask (R/G/B/A -> `tint_0`..`tint_3`), channel-blended rather than
|
||||
indexed-greyscale (see the correction in Section 5). A logo decal samples a dedicated UV2
|
||||
(TEXCOORD_1) chest channel, composited **after** region tinting (brand colors stay
|
||||
faithful) but **before** the toon shadow (the logo darkens with its fabric).
|
||||
- **`CharacterVisualDescriptor.clothing_logos`:** `item_id -> brand id`, per-character logo
|
||||
choice (Section 6), alongside `clothing_tints` (`item_id -> Array[Color]`, one per region).
|
||||
- **Manifest clothing schema** (`client/assets/characters/manifest.json`, `clothing` key):
|
||||
`slot`, `style` (`modern`/`formal`/`utility`/... -- the D-251 style-tag seam), `logo_capable`,
|
||||
and for multi-region items `regions` (named R/G/B/A) + `default_tints`.
|
||||
- **`outfits.json`:** named complete looks (`presets`) composing manifest items with per-item
|
||||
tints/logos -- 12 presets (casual/business/sport/beach/worker/sleep) for NPC dressing and
|
||||
shop display.
|
||||
|
||||
### QA: chromakey acceptance gate
|
||||
|
||||
`tooling/garment-qa` (`run-garment-qa <config>`) is the acceptance gate every garment in this
|
||||
pipeline runs before it ships: a two-pass depth-proximity chromakey renders the same frozen
|
||||
frame twice -- once keying claimed-covered body segments magenta, once with the garment
|
||||
nudged a few cm toward the camera and re-keyed cyan. A body-key pixel that flips from magenta
|
||||
to cyan is a genuine clip (`clip_through_pixels`, gates on connected-component size); a pixel
|
||||
that never flips despite being exposed is informational (`exposed_skin_pixels` -- collars,
|
||||
sleeveless armholes). Wave 1/2 garments were accepted at <=150px worst clip component.
|
||||
`client/tools/garment_preview/lookbook_scene` renders the full catalogue for visual review --
|
||||
the pass that caught the missing-albedo and manifest-nesting findings fixed in `e562418d0`.
|
||||
|
||||
### Current catalogue
|
||||
|
||||
24 entries: 21 modern (tops, bottoms, feet, full-body, swim -- wave 1 + wave 2 + the
|
||||
fill-in-the-blanks addendum) + 3 legacy peasant pieces (pre-dating this pipeline, still on
|
||||
the Surface-Deform-fit route). All 24 fitted across all 11 body types.
|
||||
|
||||
**Known artifact classes, accepted for first release:** sleeve/opening-hem epsilon blobs
|
||||
(offset-shell boundary artifacts, offset-insensitive); residual toe hints on closed footwear
|
||||
after `convex_toe_box` ("I still see some toes, but this will do" -- user review, 2026-07-07).
|
||||
Further smoothing is polish backlog, not open scope.
|
||||
|
||||
---
|
||||
|
||||
## Decisions Referenced
|
||||
|
||||
- **D-159:** 5 adult body types x 2 genders + 1 child = 11 base meshes (updated: added Teen)
|
||||
@@ -749,3 +847,4 @@ revisit if visual quality is insufficient.
|
||||
- **D-162:** Clothing is pre-baked per body type via Surface Deform
|
||||
- **D-163:** Trellis generates heads via BoneAttachment3D (offline asset production, curated into template library)
|
||||
- **D-164:** Fork Quaternius skeleton, replace all body meshes (Source tier .blends as starting point)
|
||||
- **D-251:** Character asset route reconfirmed 2026-07-06 -- Quaternius rig stays, wardrobe is in-house (offset-shell + Surface-Deform routes, Section 11), UAL1/UAL2 animation tiers purchased
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
# T-1088 — 3D Locomotion Sandbox: Final Design (synthesized)
|
||||
|
||||
> Provenance: synthesized 2026-07-06 from three independent design candidates +
|
||||
> a three-lens judge panel (T-1088 sidequest). This is the document the sandbox
|
||||
> code's "design §" comments reference (client/scripts/sandbox/*). Governance:
|
||||
> D-248 (interpolation), D-249 (facing authority, amended by D-252), D-250
|
||||
> (cutaway decoupling), D-252 (view/movement protocol split). Committed at
|
||||
> sidequest close-out so the section references resolve in-repo.
|
||||
|
||||
**Ticket:** T-1088 (story, under Phase-5 epic T-749; sanctioned Fable-5-window cascade exception)
|
||||
**Base:** Candidate 1 (feel-first) — unanimous #1 across all three judge lenses, zero fatal flaws — with grafts from Candidates 0 and 2 where judges flagged superior ideas, and fixes for every deduction judges recorded against Candidate 1 itself.
|
||||
**Ground truth:** `design-input.md` (code-verified 2026-07-06) + `spike-findings.md`. The two load-bearing repo facts that sank the other candidates were independently re-verified during this synthesis pass: `character_visual.gd:184-193` (mirrored E/W facing table) and `input_mapper.gd:182-195` (2D-canvas-transform mouse anchor).
|
||||
|
||||
## 0. Feel thesis + scope fence
|
||||
|
||||
Walking feels right when three channels each have **exactly one smoothing layer**, ordered crisp→soft:
|
||||
|
||||
1. **Position** — constant-velocity motion between server-confirmed subtile centers, speed derived per leg from `dist/interval`. No easing on the character itself: easing on a repeating 0.4 s step reads as scooting.
|
||||
2. **Yaw** — shortest-arc rotation under a per-stance deg/s budget with ease-out. The body is the only thing that eases.
|
||||
3. **Camera** — exponential low-pass over the character's position. Against a constant-velocity target an exponential follower converges to a *constant* lag offset moving at the *same* velocity — zero jitter while walking, gentle ease on start/stop. This is why the character must NOT also be exponentially eased (the 2D renderer's stacked 12.0-entity + 8.0-camera lerps produce the floaty feel we are replacing).
|
||||
|
||||
Animation is slaved to channel 1: clip playback rate is scaled so foot cadence matches actual ground speed (kills foot-slide), and gait selection keys off **render velocity, never input** (so Q-020 silent rejections and bump-to-turn produce a character who turns and stands, not one who walks into a wall).
|
||||
|
||||
### Scope fence (every deferral has a named seam — grafted from Candidate 0)
|
||||
|
||||
| Deferred | Seam left for it |
|
||||
|---|---|
|
||||
| NPC / other-entity rendering | `LocomotionRig.set_wire_target()` single-input contract (§4.0) + injectable `idle_facing_provider`; attach one rig per NPC later |
|
||||
| Footstep audio (Q-063) | `locomotion_anim.gd` emits `gait_changed(state)`; clip phase exposed via `get_animation_player()` |
|
||||
| Fog-of-perception 3D overlay (Q-081) | the four-state tint (§3.2) *is* the memory layer; a fog shader composes on top later |
|
||||
| Day/night lighting (Q-086) | scene-local lights, no baked assumptions |
|
||||
| `ual_extended.glb` wiring (risk #11) | not needed (no locomotion clip lives there); named-library + `"lib/Clip"` wiring plan recorded in the D-record |
|
||||
| Client movement prediction | none, by decision (D-054/D-010) — this is what makes Q-020 free (§4.3) |
|
||||
| Stepped player camera yaw | **OUT** — not canon (ticket-premise correction, design-input §3); requires a new D/Q record before anyone builds it |
|
||||
| New Gauntlet room | **not needed** — Hub + four 2-wide exit corridors + Sprint Gauntlet 28-tile straight cover idle/walk/sprint/corner/cutaway; constraint 12 untriggered, StableId-77 append held in reserve, server untouched |
|
||||
| Implant/HudGroups occlusion | one-line `HudGroups.gameplay_occluded` connect to a pause flag (D-170 row 13: the mechanism is CanvasItem-only, the signal is not) |
|
||||
| Persisting the greybox store | in-memory; store is `RefCounted` with a `to_dict()` seam |
|
||||
| Foot-lock IK, ghost silhouettes, dithered cut edges | later polish on the same rig/shader |
|
||||
|
||||
## 1. Scene + file layout
|
||||
|
||||
### 1.1 New files (with promotion status — grafted from Candidate 2, kept under `sandbox/` to avoid pre-committing Phase-5 shapes)
|
||||
|
||||
| File | Promotion status |
|
||||
|---|---|
|
||||
| `client/scenes/locomotion_sandbox.tscn` | sandbox-only |
|
||||
| `client/scripts/sandbox/locomotion_sandbox.gd` — boot, connect, poll, pump, descriptor, first-snapshot latch, autopilot | sandbox-only |
|
||||
| `client/scripts/sandbox/sandbox_constants.gd` — every tuning constant (§12) | proto-production (values migrate) |
|
||||
| `client/scripts/sandbox/sandbox_space.gd` — `class_name SandboxSpace` static: THE conversion authority (§2) | **proto-production** |
|
||||
| `client/scripts/sandbox/greybox_world.gd` — accumulating tile store + MultiMesh painter | store contract proto-production; meshes disposable |
|
||||
| `client/scripts/sandbox/locomotion_rig.gd` — pure-core mover + facing (static funcs) + thin Node3D wrapper | **proto-production** |
|
||||
| `client/scripts/sandbox/locomotion_anim.gd` — gait state machine | **proto-production** |
|
||||
| `client/scripts/sandbox/follow_camera_3d.gd` — D-148 ortho rig | **proto-production** |
|
||||
| `client/scripts/sandbox/sandbox_debug_hud.gd` — numeric instrumentation | sandbox-only |
|
||||
| `client/shaders/sandbox/greybox_tile.gdshader` — floor tint + 0.5 m minor / 1.0 m major grid | sandbox-only |
|
||||
| `client/shaders/sandbox/greybox_wall_cutaway.gdshader` — wall tint + camera-facing height cut | technique proto-production |
|
||||
| `client/tests/unit/test_greybox_store.gd`, `test_locomotion_math.gd`, `test_gait_table.gd`, `test_sandbox_space.gd` | keep |
|
||||
|
||||
Directory moves are cheap at the T-962 gate; the promotion column documents the seam without a `rendering3d/` structure the slice doesn't need.
|
||||
|
||||
**Modified files (all additive):** `input_mapper.gd` (§9 provider seam), `character_visual.gd` (§6.3 blend param + player accessor), `client/tests/visual_capture.gd` + `tests/visual.json` (§10.2 per-scenario scene field). **Untouched:** `main.gd`, `main.tscn`, `entity_renderer.gd`, `world_renderer.gd`, `tile_renderer.gd`, fog code, `SnapshotEventRouter`, everything under `server/` — no new Gauntlet room.
|
||||
|
||||
### 1.2 Scene tree
|
||||
|
||||
```
|
||||
LocomotionSandbox (Node3D) — locomotion_sandbox.gd
|
||||
├─ WorldRoot (Node3D) # rotation.y = 45° — THE D-148 map rotation, set once in .tscn
|
||||
│ ├─ Greybox (Node3D) — greybox_world.gd
|
||||
│ │ ├─ FloorMM (MultiMeshInstance3D) # PlaneMesh 0.5×0.5 (faces +Y natively), greybox_tile.gdshader
|
||||
│ │ └─ WallMM (MultiMeshInstance3D) # BoxMesh 0.5×2.5×0.5 offset so base sits at y=0, cutaway shader
|
||||
│ └─ PlayerRig (Node3D) — locomotion_rig.gd # local position = interpolated sim position (metres)
|
||||
│ └─ ModelRoot (Node3D) # local rotation.y = smoothed facing yaw (D-151)
|
||||
│ └─ CharacterVisual # runtime-instantiated; its own rotation.y stays 0
|
||||
├─ CameraRig (Node3D) — follow_camera_3d.gd
|
||||
│ └─ Camera3D # PROJECTION_ORTHOGONAL, yaw 0 always
|
||||
├─ KeyLight (DirectionalLight3D) # energy 1.2, shadows on (character_creation.tscn:65-92 recipe)
|
||||
├─ FillLight (DirectionalLight3D) # energy 0.4, no shadows
|
||||
├─ WorldEnvironment # ambient env, same source
|
||||
└─ DebugHud (CanvasLayer) — sandbox_debug_hud.gd
|
||||
```
|
||||
|
||||
Sim coords stay axis-aligned inside `WorldRoot`; the diamond look is that single static `Transform3D` (D-148 letter: rotation on the tile-grid root, never the camera). Facing yaw is applied on `ModelRoot` in WorldRoot-**local** space, so octant→yaw composes with the map rotation **exactly once** (the compositor-api-spec §2 concern). The camera lives outside WorldRoot at yaw 0 and follows `PlayerRig.global_position` (already rotated).
|
||||
|
||||
### 1.3 Boot sequence
|
||||
|
||||
`_ready()`: (1) `SimBridge.server_port` from `SR_PORT` if set (pattern `visual_capture.gd:79-88`); `connect_to_sim()` guarded on DISCONNECTED. Handshake + StartupMessage are automatic. (2) Build a local `CharacterVisualDescriptor` from the first valid `manifest.json` ids per category (`GameState.character_visual_descriptor` is never populated — §2.1 of design-input); no hardcoded id strings. (3) `CharacterVisual.new()` → `ModelRoot.add_child()` → `load_descriptor()` — **in-tree before load_descriptor** (shader-load-in-`_ready` constraint). Hidden until first snapshot. (4) Install the InputMapper facing provider (§9). (5) One-line `HudGroups.gameplay_occluded` connect to a pause flag. (6) Parse `SR_AUTOPILOT` if set (§10.2).
|
||||
|
||||
`_process(delta)`: (1) `var snap = SimBridge.poll_snapshot(); if snap: GameState.apply_snapshot(snap)` then **direct calls** `greybox.on_snapshot()` and `player_rig.on_snapshot_wire(...)` — no `SnapshotEventRouter` (its registration lives in `main.gd`; two consumers don't justify touching it — grafted from Candidates 0/2, resolving a judge-flagged ambiguity in Candidate 1). (2) Input pump: `for entry in InputMapper.flush_queue(): SimBridge.send_input(entry)` — **the only pump in the codebase lives in main.gd; without this the sandbox connects but never moves**. (3) First-snapshot latch: snap rig + camera + seed greybox + show character (first snapshot arrives in `_process`, never `_ready`). (4) Push the cutaway shader uniform (§8). (5) Autopilot tick if active.
|
||||
|
||||
### 1.4 Copied vs extracted vs reimplemented (D-166)
|
||||
|
||||
- **Reimplemented from pattern (~40 lines):** poll loop, pump, connect guard, SR_PORT read, first-snapshot latch, `TELEPORT_DISTANCE_THRESHOLD = 5`. Extracting a shared session driver means editing `main.gd`, which D-166 freezes until Phase 5 proper. Each copy carries `# Pattern: main.gd:NNN — extract to shared driver in Phase 5 (T-962)`.
|
||||
- **Reused as-is:** `SimBridge`, `GameState`, `SnapshotHandler`, `Protocol` (autoloads load from `project.godot` regardless of scene — precedent: standalone `character_creation.tscn`).
|
||||
- **Reused with one additive seam each:** `InputMapper` (§9), `CharacterVisual` (§6.3).
|
||||
- **Reimplemented greenfield:** everything 3D. Spike camera math is ported, not the file (spikes live outside `client/`; the spike's 45°-iso preset is struck by D-148).
|
||||
- Sandbox scripts are not autoloads → full `class_name` typing is fine; the one InputMapper field added follows the autoload untyped-var rule.
|
||||
|
||||
## 2. Coordinate & scale convention (§5.5 — stated once, converted in one file)
|
||||
|
||||
All conversion lives in **`sandbox_space.gd`** (grafted from Candidate 2 — the strongest structural answer to risk #8's "three cheap ways to be half-a-tile off"):
|
||||
|
||||
> **THE CONVENTION: one sim `TilePosition` step = one 0.5 m subtile (D-066/D-222). All sandbox positions/speeds are metres. Sim +x (East) → local +X; sim +y (South, Y-down) → local +Z; sim z-level → Y (Gauntlet z=0 → Y=0). Tile index → world: `Vector3((t.x + 0.5) * 0.5, 0, (t.y + 0.5) * 0.5)`. Sim facing angle θ (0=East, +π/2=South, Y-down radians, `vision_cone.rs`) → ModelRoot yaw: `yaw = PI/2 − θ`.**
|
||||
|
||||
Verification (re-checked this pass against Godot's actual +yaw semantics, where +90° yaw turns a +Z-facing model to +X): θ=π/2 (South) → yaw 0 → +Z=South ✓; θ=0 (East) → yaw +90° → +X ✓; θ=π (West) → −90° → −X ✓; θ=−π/2 (North) → 180° → −Z ✓. Full octant table (derived, golden-tested): South 0°, Southeast 45°, **East +90°**, Northeast 135°, North 180°, Northwest −135°, **West −90°**, Southwest −45°.
|
||||
|
||||
Three traps explicitly closed:
|
||||
- **`CharacterVisual.set_facing()` is never called.** Its internal table (`west=+90°, east=−90°`, `character_visual.gd:184-193`) assumes a mirrored axis mapping (East→−X) incompatible with ours; using it makes the model face west while walking east — the fatal flaw both other candidates shipped. The rig owns `ModelRoot.rotation.y` exclusively; a comment at the instantiation site names the trap.
|
||||
- **Live wire sends tile-center floats (N+0.5); TestHarness sends integers.** Always `floori()` the wire float to recover the tile index, then recompute the center — byte-identical behavior in both modes. **Never `round()`.**
|
||||
- **D-243 "voxel = 1 m" is generation-cascade vocabulary** and appears nowhere in this code. The 1 m visual tile (2×2 subtiles) exists only as major grid lines in the floor shader.
|
||||
|
||||
Scale sanity check (grafted from Candidate 2): corridor walkable width is 2 subtiles = 1 m — the ~1.7 m-tall character should just fit; if it doesn't, a conversion is wrong.
|
||||
|
||||
## 3. Greybox tile store (risk #1 — the one genuinely new subsystem)
|
||||
|
||||
### 3.1 Data structure (`greybox_world.gd`; store core is a `RefCounted`, headless-testable)
|
||||
|
||||
```gdscript
|
||||
enum Kind { FLOOR, WALL } # wire tile_kind is walkability-derived (query.rs:78-82);
|
||||
# doors are entities on the wire — Door/Object collapse to FLOOR
|
||||
enum Vis { FORWARD, PERIPHERAL, BOUNDARY_WALL, REMEMBERED }
|
||||
var _store: Dictionary = {} # Vector3i(x,y,z) sim coords -> Kind (last observation wins)
|
||||
var _instance_index: Dictionary = {} # Vector3i -> int (slot in its MultiMesh)
|
||||
var _visible_now: Dictionary = {} # Vector3i -> Vis, rebuilt each consumed snapshot
|
||||
```
|
||||
|
||||
Per consumed snapshot (gated on `GameState.current_tick` change, the `world_renderer.gd:33-36` pattern; called directly by the root): rebuild `_visible_now` from `GameState.visible_tiles` (already merged from both wire spellings by `snapshot_handler.gd:52-64`); new tiles append to `_store` and get a MultiMesh slot with its `Transform3D` written once; tiles whose visibility state flipped get one `set_instance_color()` each. **Diff-only — no full-buffer rebuilds** (contrast the 2D `TileRenderer` clear-and-reset we deliberately do not copy).
|
||||
|
||||
**Eviction: never.** Gauntlet worst case 117×125 = 14,625 tiles — trivial. Never-evicting is also the correct semantic: **last observation wins** is the embryo of the Phase-5 "destroyed-while-unobserved renders remembered" fog-memory D-record (grafted framing from Candidate 2). Persists across the Home-key teleport — a feature. MultiMeshes preallocate `instance_count = 16384`, unallocated instances scaled to zero.
|
||||
|
||||
### 3.2 Render states — four-state tint (grafted from Candidate 2; surfaces the wire's visibility enum)
|
||||
|
||||
| State | Instance color |
|
||||
|---|---|
|
||||
| Visible, Forward | white (full albedo) |
|
||||
| Visible, Peripheral | 85% value |
|
||||
| BoundaryWall fringe | 70% value (beyond LOS edge by design, #584) |
|
||||
| Remembered (in store, not visible now) | `Color(0.42, 0.45, 0.52)` — dim, cool, clearly "memory" |
|
||||
| Never seen | nothing rendered — the void; perception is upstream, server-enforced |
|
||||
|
||||
Mechanics specified (fixing a Candidate-1 hand-wave): both MultiMeshes set `use_colors = true`; instance color arrives in the spatial shaders as the `COLOR` built-in and multiplies albedo. Floors use `PlaneMesh` 0.5×0.5 (faces +Y natively — no orientation fix needed); walls use `BoxMesh` 0.5×2.5×0.5 with the mesh center offset so the base sits at y=0 (explicit, fixing Candidate 2's unstated origin detail).
|
||||
|
||||
### 3.3 Mesh strategy (engages Q-079, both sides recorded)
|
||||
|
||||
**Choice: two `MultiMeshInstance3D`s.** The two things this greybox uniquely needs are per-instance state (four-state tint) and per-material shader uniforms (cutaway) — GridMap has neither per-cell color nor per-cell shader state, so it would need duplicate MeshLibrary items per tint state (item churn on every LOS change). Plain per-tile `MeshInstance3D` (≈15k nodes) rejected outright. **Q-079 annotation carries both positions** (grafted from Candidate 0's counter-case per judge instruction): GridMap with a code-built MeshLibrary + diff-only `set_cell_item` is a legitimately simpler greybox *if per-instance color is not required*, and remains the natural Phase-5 candidate for static real-asset geometry; MultiMesh won here because tint + cutaway demand per-instance parameters. Data point, not resolution. D-244 satisfied: everything is real 3D geometry.
|
||||
|
||||
Floor read at 1 m: `greybox_tile.gdshader` draws world-space grid lines — minor every 0.5 m (alpha 0.15), major every 1.0 m (alpha 0.45) — so the floor *reads* as D-066 1 m tiles while data stays per-subtile.
|
||||
|
||||
## 4. Locomotion interpolation (§5.2 — the core feel decision)
|
||||
|
||||
### 4.0 Rig input contract (grafted from Candidate 2, reconciled with the facing split)
|
||||
|
||||
```gdscript
|
||||
# locomotion_rig.gd — thin Node3D over a pure static core
|
||||
func set_wire_target(pos_m: Vector3, facing_octant: String, stance: String, tick: int) -> void
|
||||
var step_window_ms_provider: Callable # player adapter: func(): return InputMapper.MOVE_INTERVAL_MS[stance]
|
||||
var idle_facing_provider: Callable # player adapter: InputMapper octant (§5); NPC adapter: leave invalid → wire facing always
|
||||
```
|
||||
|
||||
The rig never reads `GameState` or `InputMapper` itself — the sandbox root adapts snapshot fields into `set_wire_target`. An NPC adapter later feeds the same call from `VisibleEntity` rows with no rig changes; leaving `idle_facing_provider` unset collapses the facing split (§5) to pure-wire — single code path by construction, answering the NPC-uniformity objection to the split. `MOVE_INTERVAL_MS` is **read from the InputMapper autoload, never copied** (grafted from Candidate 2, fixing Candidate 1's drift risk).
|
||||
|
||||
### 4.1 Model: per-leg constant velocity, `dist / interval`
|
||||
|
||||
**Rejected — exponential lerp** (2D `LERP_SPEED 12.0`): velocity is a per-step sawtooth (~90% of a 0.5 m step in ~190 ms, then near-stop until the next target at 400–800 ms cadence) — rhythmic lurch with the walk clip cycling over near-zero ground speed; foot cadence can never sync to a sawtooth. Correct for 2D sprites, wrong for a walking skeleton (this was Candidate 0's fatal feel flaw).
|
||||
**Rejected — D-054's fixed 100–150 ms tween:** dash-then-stand stutter at Walk/Careful/Crouch (the §5.2 conflict; resolving it is governance delta #1).
|
||||
**Chosen — constant-velocity legs sized to the step:** on a new target, cover the distance in exactly one stance interval, so held-key motion is seamless — the character arrives precisely when the next confirmed step lands.
|
||||
|
||||
```gdscript
|
||||
# On snapshot with changed player tile:
|
||||
# dist = _render_pos.distance_to(new_target)
|
||||
# if dist > SNAP_DIST_M (2.5 m = 5 subtiles, matches 2D TELEPORT_DISTANCE_THRESHOLD): snap all channels
|
||||
# else:
|
||||
# interval = step_window_ms_provider.call() / 1000.0
|
||||
# _leg_speed = clamp(dist / interval, base, CATCHUP_MAX_FACTOR(3.0) * base)
|
||||
# Per frame:
|
||||
# _render_pos = _render_pos.move_toward(target, _leg_speed * delta)
|
||||
# _velocity = dir * _leg_speed (zero on arrival) # consumed by gait selection + cadence sync
|
||||
```
|
||||
|
||||
One rule handles everything: **cardinal** 0.5/0.4 = 1.25 m/s (Walk); **diagonal** 0.707/0.4 = 1.77 m/s — arrives exactly on time (no √2 on the wire, D-053; a fixed speed would accumulate lag on held diagonals); **multi-tile deltas** (latest-wins drops, risk #2) close within ~one interval under the 3× clamp with feet speeding up to match (§6.2); **teleports** (Home key, cross-map) exceed 2.5 m → hard snap of position, yaw, camera, and a 0.0-blend anim reset. First-ever snapshot snaps (2D precedent `entity_renderer.gd:133-140`). Stance-lag on toggle (≤1 RTT) self-corrects because every leg recomputes `dist/interval`.
|
||||
|
||||
### 4.2 Idle hysteresis (walk↔idle flicker killer)
|
||||
|
||||
Snapshot arrival jitters ±1–2 ticks against the client throttle, so the rig frequently arrives a few frames early. `is_moving` stays true until at-target for `IDLE_ENTER_DELAY_S = 0.18` (covers 2–3 ticks of jitter at every stance; a real stop reaches Idle inside the human ~0.3 s settle expectation, overlapped by the 0.20 s gait→idle blend). This names and kills the most common discrete-step artifact.
|
||||
|
||||
### 4.3 Paused ticks, blocked moves
|
||||
|
||||
- **Paused** (`game_time.tick_rate == "Paused"`): identical-position snapshots keep arriving; target-chasing is idempotent; the rig settles to Idle through normal hysteresis; the Idle clip keeps breathing (living pause).
|
||||
- **Blocked move (Q-020):** the rig chases only server-confirmed positions — nothing to roll back, ever. Visible behavior *is* bump-to-turn: facing updates (server sets Facing even on blocked moves), position doesn't, and gait keys off render velocity so the character turns to face the wall and stands. **Annotate Q-020: a strictly interpolate-only client needs zero blocked-move handling by construction** (Candidate 0's crisp framing, grafted).
|
||||
|
||||
## 5. Facing authority (§5.3 — "server feet, client eyes", new governance record)
|
||||
|
||||
| Rig state | Yaw target source | Why |
|
||||
|---|---|---|
|
||||
| Moving (incl. hysteresis window) | snapshot `player_facing` octant | Server sets Facing from the move delta on every accepted move (`movement.rs:479-482`) — it *is* the motion direction, arrives in the same snapshot as the position change. |
|
||||
| Idle | `idle_facing_provider` → `InputMapper.facing_octant` — the **same snapped octant that rides the SetFacing wire** | D-054: only the octant crosses the wire, so any mouse-responsive idle facing is client-local by construction. Using InputMapper's own octant (not the raw angle) guarantees the model never shows an octant the server wasn't told. 2D precedent: `entity_renderer.gd:186-193` already draws the indicator from client-local facing. |
|
||||
| Idle + `dialogue_active`/`free_camera_mode` | frozen (last target held) | InputMapper computes but does not send octants while suppressed (`input_mapper.gd:71`) — freeze on the same condition. |
|
||||
|
||||
**Quantified artifact:** idle, the model turns up to one RTT (~50–100 ms localhost) before the server's vision cone — imperceptible, and disagreement is bounded by the in-flight SetFacing. Moving, mouse aim is visually ignored (feet follow feet). Single-source alternatives are strictly worse: pure-snapshot idle facing = ~250–300 ms laggy 45° pops (the 2D client already cheats past this); pure-client facing breaks server-authoritative display while moving. The NPC-uniformity objection is dissolved by the provider seam (§4.0): NPCs simply don't install a provider. D-151 ("ModelRoot rotates to the true octant angle") satisfied in both states.
|
||||
|
||||
**Turn smoothing:** `yaw = lerp_angle(yaw, target, 1 − exp(−TURN_SHARPNESS·δ))`, `TURN_SHARPNESS = 14.0/s`, per-frame change clamped to per-stance deg/s budgets: Sprint 1080, Walk 720, Careful 540, Crouch 420, Idle 600. A 180° reversal at Walk completes in ~0.25 s ≈ half a step; a 45° corner resolves in ~60 ms.
|
||||
|
||||
**Governance:** new policy → claim a D record (architecture — id assigned at claim time via `pql decisions claim`, never presumed) and annotate Q-084 (stays open; it parks the camera-rotation variant).
|
||||
|
||||
## 6. Animation state machine
|
||||
|
||||
### 6.1 States and clips (verified imported bare names — library `""`, `_Loop` stripped, case-sensitive)
|
||||
|
||||
`gait(stance, moving) -> StringName` is a pure function; the table is the ONLY place clip strings live:
|
||||
|
||||
| stance | idle | moving | NATIVE_MPS (initial guess, HUD-tuned) |
|
||||
|---|---|---|---|
|
||||
| Sprint | `Idle` (2.50 s) | `Sprint` (0.67 s) | 3.2 |
|
||||
| Walk | `Idle` | `Walk` (1.33 s) | 1.4 |
|
||||
| Careful | `Idle` | **`Walk_Formal`** (1.33 s) | 1.2 |
|
||||
| Crouch | `Crouch_Idle` (2.93 s) | `Crouch_Fwd` (2.00 s) | 0.9 |
|
||||
|
||||
**Careful pick (§5.4): `Walk_Formal`** — unanimous across candidates. Stance readability is a D-053 goal; a distinct upright-deliberate silhouette reads at ortho distance where slowed `Walk` reads as lag. Fallback if it reads "parade march": `Walk` at 0.6× — one table cell. `Jog_Fwd` is the reserve if `Sprint` reads too aggressive at 2.5 m/s. A gdUnit test asserts every table cell exists in a real headless CharacterVisual's `get_animation_list()` (grafted from Candidate 2 — guards the case-sensitive silent-miss end-to-end, not against a constant copy).
|
||||
|
||||
### 6.2 Cadence sync (kills foot-slide)
|
||||
|
||||
While moving: `speed_scale = clamp(rig.current_speed / NATIVE_MPS[clip], 0.6, 1.8)`. Because rig speed is **constant per leg**, speed_scale is constant per leg — no within-step wobble (the whole point of rejecting exponential lerp). Catch-up bursts push it up so feet chase instead of skating. Idle states run at 1.0. The DebugHud shows live speed + speed_scale, making tuning `NATIVE_MPS` a five-minute on-screen job.
|
||||
|
||||
### 6.3 Transitions: `AnimationPlayer.play(name, custom_blend)` — no AnimationTree
|
||||
|
||||
Additive `CharacterVisual` API (CharacterVisual is the D-149 3D compositor, explicitly not the D-166-frozen 2D renderer; this ticket is its designated first external driver):
|
||||
|
||||
```gdscript
|
||||
func play_animation(anim_name: String, blend_time: float = -1.0) -> void # custom_blend passthrough; -1 = current hard cut
|
||||
func get_animation_player() -> AnimationPlayer # speed_scale + phase access
|
||||
```
|
||||
|
||||
Blend table: Idle→gait **0.12 s**, gait→Idle **0.20 s**, gait↔gait **0.15 s**, ↔Crouch_* **0.25 s**, teleport **0.0** (hard cut — a cross-map jump must not smear). Transitions fire only on state change (edge-triggered); loops never restart mid-cycle.
|
||||
|
||||
- **Rejected — AnimationTree:** `load_descriptor()` re-runs destroy/recreate `AnimPlayer` (`character_visual.gd:761-767`), invalidating external tree paths; blend spaces buy nothing for discrete tile locomotion; UAL has no start/stop/turn clips for a tree to use anyway. The anim driver's `play(clip, blend, speed)` output surface is where a tree slots in later.
|
||||
- **Rejected — manual two-player crossfade:** reimplements the engine.
|
||||
- **Known caveat (grafted from Candidate 0):** cross-clip blending with `remove_immutable_tracks=true` can pop on bones animated in only one clip; the six clips share the full 65-bone skeleton so exposure is low — if a pop shows, the fallback is a rig-local AnimationTree, finding recorded on the ticket.
|
||||
|
||||
**Phase preservation on gait↔gait:** capture `phase = current_position/length` before switching, `play(new, blend)`, `seek(phase * new_length, false)` so legs keep their beat. Pre-flagged caveat: verify seek-during-blend doesn't cancel the crossfade in Godot 4.6; if it does, drop the seek — the 0.15 s blend masks resync acceptably (tune-by-eye, recorded on the ticket).
|
||||
|
||||
**`ual_extended.glb`: deferred.** No locomotion clip needs it; naive wiring collides on the duplicate `""` library name. Wiring plan (register under an explicit library name, switch addressing to `"lib/Clip"`, per the spike loader) recorded in the D-record. Q-063 seam: `gait_changed(state)` signal + clip-phase access.
|
||||
|
||||
## 7. Camera (D-148 rig, locked follow)
|
||||
|
||||
`follow_camera_3d.gd`, spike pivot-orbit math (`camera.position = pivot + basis * Vector3(0, 0, CAM_DIST=30)`), orthographic `size = 9.0`, near 0.1 / far 100.
|
||||
|
||||
- **Presets (pitch from horizontal — trust D-148's preset list over its contradictory opening prose; editorial note filed, §11):** `[-30 (gameplay default), -5 frontal, -80 overhead]`. **T-key cycles** — sanctioned dev affordance (D-148/D-158), tilt lerp 3.0/s. The spike's −45 iso preset is struck by D-148 and absent.
|
||||
- **Yaw 0, always. Stepped player yaw is OUT** (not canon; a D/Q record comes first). The diamond view is entirely `WorldRoot.rotation.y = 45°`.
|
||||
- **Locked follow (D-015, no panning):** `pivot = pivot.lerp(PlayerRig.global_position, 1 − exp(−6.0·δ))` — deliberately the *only* soft layer (§0). Against constant velocity this converges to a constant trailing offset `v/k`: Walk 0.21 m, Sprint 0.42 m (~4.7% of view height) — reads as speed, zero jitter. `LOOKAHEAD_S = 0.0` knob exposed for the tuning pass.
|
||||
- **Snap:** rig emits `teleported` → hard pivot set; same on first-snapshot latch.
|
||||
- **Zoom:** scroll lerps ortho size 6–14 — sandbox dev affordance, ungoverned and kept that way.
|
||||
|
||||
## 8. Wall cutaway — first pass (ticket scoping call: client-side render mode)
|
||||
|
||||
Entirely in `greybox_wall_cutaway.gdshader`; per-frame CPU cost = one uniform write (`u_char_pos_xz` = the rig's **interpolated** world XZ, so the cut zone glides with the character and spatial smoothstep becomes temporal smoothness). Camera ground-forward is a constant `vec2(0, -1)` world-space (yaw-0 camera looks −Z; correct under the 45° WorldRoot because the test runs in world space).
|
||||
|
||||
```glsl
|
||||
vec2 to_wall = v_world_xz - u_char_pos_xz;
|
||||
float side_f = smoothstep(-0.25, 0.75, to_wall.y); // wall between camera and character
|
||||
float rad_f = 1.0 - smoothstep(CUT_RADIUS - CUT_BAND, CUT_RADIUS, length(to_wall)); // 5.0 m, 1.5 m band
|
||||
float allowed = mix(WALL_H, CUT_HEIGHT, side_f * rad_f); // 2.5 m -> 0.75 m stub
|
||||
if (v_world_y > allowed) discard;
|
||||
```
|
||||
|
||||
A half-disc of camera-side wall drops to a 0.75 m stub with smooth falloff — the Xenonauts read minus per-building logic the greybox doesn't have. Open box tops are acceptable greybox fidelity; ghost band/dither/capping are named polish on the same shader. **Rejected:** per-cell mesh swap / per-instance `scale.y` rewrite (Candidate 2 — pops per tile step, full-buffer churn); hard-edged discard window (Candidate 0 — works, but the smoothstep is strictly smoother at equal cost); transparency ghosting (ortho sorting headaches).
|
||||
|
||||
**Composition with fog (the ticket's scoping call, verified consistent):** cutaway cuts *render height*; the four-state tint (§3.2) gates *knowledge* — independent factors in the same shader. Remembered walls can be cut too (the player's camera doesn't care what the character currently sees). Perception stays server-enforced upstream. If this holds through implementation, mint the perception-domain D record on landing, per the ticket.
|
||||
|
||||
## 9. Input integration — the `facing_angle_provider` seam (mandatory; repo-verified this pass)
|
||||
|
||||
`InputMapper._update_facing_from_mouse()` (`input_mapper.gd:182-195`) anchors on `GameState.player_position * Constants.TILE_SIZE` through the 2D canvas transform — in a 3D scene that anchor is a far-off-screen point, so `facing_angle` is garbage and `_wasd_to_world_dir()` (:202-213) rotates ALL WASD movement by it: the character could be moved but not steered, with wrong SetFacing octants riding the wire. An anchor-only override (Candidate 0) is insufficient — the screen-space mouse angle still isn't a sim-space angle under the 45° WorldRoot rotation + −30° ortho foreshortening (constant ~45° skew plus up to ~19° warp). The only correct fix, one additive seam, 2D path byte-identical when unset:
|
||||
|
||||
```gdscript
|
||||
# input_mapper.gd (autoload — untyped var per parse-order rule)
|
||||
var facing_angle_provider = Callable() # () -> float sim-space radians (0=East, +PI/2=South), or NAN = no update
|
||||
|
||||
func _update_facing_from_mouse() -> void:
|
||||
if facing_angle_provider.is_valid():
|
||||
var a: float = facing_angle_provider.call()
|
||||
if is_finite(a):
|
||||
facing_angle = a
|
||||
facing_octant = _angle_to_octant(a)
|
||||
return
|
||||
# ... existing 2D canvas-transform path, unchanged ...
|
||||
```
|
||||
|
||||
The sandbox provider: unproject the mouse (`project_ray_origin/normal`) onto the y=0 plane, convert through `WorldRoot.to_local()` (undoing the 45°), take the delta from the rig's local position, return `atan2(delta.z, delta.x)` — which *is* the sim convention because local +Z = sim +y (§2). Deadzone 0.1 m (mirrors the 2D jitter guard). Everything downstream — octant snap, SetFacing-on-change, mouse-relative WASD, dialogue suppression — runs unchanged; D-054 semantics preserved exactly. D-166: InputMapper is input infrastructure, not the frozen 2D render path; the change is additive and inert for 2D.
|
||||
|
||||
## 10. Verification
|
||||
|
||||
### 10.1 Primary loop — live eyeball with numeric instrumentation
|
||||
|
||||
All feel judgment happens under `SR_LIVE=1` (CLAUDE.md preference; TestHarness diverges in coords/pacing — risk #9):
|
||||
|
||||
```bash
|
||||
# T1: cd server && cargo run --bin settled-reach-server -- --test-mode
|
||||
# T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
|
||||
```
|
||||
|
||||
Server exits on client disconnect — fresh spawn per run. DebugHud: tick, tick_rate, stance, player tile, render speed (m/s), leg distance, clip + speed_scale, snapshot age, yaw target/actual, store size — cadence sync is verifiable numerically on screen, not just by eye.
|
||||
|
||||
### 10.2 Harness + deterministic input
|
||||
|
||||
`visual_capture.gd` gains an additive per-scenario `"scene"` field (default `res://scenes/main.tscn` — zero change to existing scenarios/goldens); the fog-overlay wait is `get_node_or_null`-guarded when absent. **Deterministic capture input is `SR_AUTOPILOT`** (grafted from Candidate 0 — highest-fidelity of the three): `SR_AUTOPILOT="east:2.0,south:1.5,stance_up,east:1.0"` parsed by the sandbox root into timed `Input.action_press/release` on the **real actions**, so scripted input exercises the genuine InputMapper throttle/octant/facing path — no test-only code in the rig, no SimBridge bypass masking input bugs. ~25 lines.
|
||||
|
||||
`tests/visual.json` additions: `locomotion_idle_live` (`"live": true`, new scene, static golden — character idling at Hub spawn, −30° frame, greybox floor); `locomotion_gaits` (autopilot walk east → turn south → stance ladder → `make visual-movie` contact sheet — the gait/cutaway/memory-dim review artifact; animation frames deliberately **not** golden-compared); `locomotion_cutaway` (autopilot walk south near a wall → golden of the stub). If the harness lacks env passthrough per scenario, that is a second small additive key in the same edit.
|
||||
|
||||
### 10.3 Milestones — how each is seen (grafted spine from Candidate 2)
|
||||
|
||||
| Milestone | Seen via |
|
||||
|---|---|
|
||||
| M1 Boot: character idles at Hub spawn (50,58), lit, outlined, greybox under feet | live recipe + `make screenshot SCENARIO=locomotion_idle_live` |
|
||||
| M2 Walk: continuous cadence, no inter-step stall, correct E/W facing | movie frames — equal displacement per frame; HUD speed constant |
|
||||
| M3 Stance ladder: 4 speeds, distinct silhouettes, foot-slide tuning | `locomotion_gaits` movie + live Sprint Gauntlet 28-tile straight |
|
||||
| M4 Steer/aim: mouse-relative WASD correct in 3D, idle aim turns the model | live manual session (the §9 seam's proof) |
|
||||
| M5 Store: walk a loop — remembered tiles dim behind you, void beyond | live + before/after screenshots |
|
||||
| M6 Camera: −30° default, T-cycle, constant-offset follow, 45° diamond | live; screenshot per preset |
|
||||
| M7 Cutaway: south walls drop to stub, character readable behind | `locomotion_cutaway` screenshot |
|
||||
| M8 Teleport: Home mid-walk → snap, no glide, anim hard-cut | live manual |
|
||||
|
||||
### 10.4 gdUnit4 (headless — logic only, by design of the pure-core split)
|
||||
|
||||
CAN cover: `SandboxSpace` golden conversions (tile↔world, wire-float `floori` recovery, all 8 octant yaws incl. the E/W-critical cells); store accumulation/never-evict/four-state flips (synthetic dicts + a fixture-replay case pushing `tests/fixtures/gauntlet/*.msgpack` through `Protocol.decode_snapshot()` → `GameState` → store); mover math (leg-speed derivation incl. diagonal 1.77 m/s, 3× clamp, 2.5 m snap, idle hysteresis, paused idempotence, blocked-move zero-motion); yaw shortest-arc + budget clamp; `gait()` every cell exists in a real headless CharacterVisual's `get_animation_list()`; provider octant math; `WallCutaway`-equivalent predicate if extracted. CANNOT cover: blend quality, foot slide, cutaway pixels, ortho composition — §10.1/10.2's job. `tests/run-godot --filter locomotion|sandbox`; the pre-push gate runs everything — no pre-emptive local duplication (team rule).
|
||||
|
||||
## 11. Governance deltas (consolidated)
|
||||
|
||||
1. **Claim D (architecture)** — *3D locomotion interpolation*: per-leg constant-velocity windows keyed to the client throttle (`dist/interval`, read from `InputMapper.MOVE_INTERVAL_MS`; 3× catch-up clamp; 2.5 m/5-tile teleport snap; diagonal 1.41× accepted as sim truth), cadence-synced `speed_scale`, Careful=`Walk_Formal`, `ual_extended` named-library wiring plan, stepped camera yaw reaffirmed OUT / T-cycle as the sanctioned dev affordance. Resolves design-input §5.2; **annotate D-054** ("100–150 ms Tween" scoped to the 2D renderer henceforth). Id assigned at claim time via `pql decisions claim D architecture "…"`.
|
||||
2. **Claim D (architecture)** — *3D facing presentation authority*: moving = server snapshot octant; idle = client-local aim octant (the same octant sent as SetFacing), injected via `idle_facing_provider` (NPCs: unset → pure wire); frozen under input suppression; includes the verified octant→yaw table (South 0°, East +90°, West −90°, North 180° — `yaw = π/2 − θ`) as the confirmed compositor-api-spec §2 composition (rotation composes once via WorldRoot), and the explicit "never call `CharacterVisual.set_facing()` from the rig" trap note. Resolves design-input §5.3.
|
||||
3. **Annotate Q-084** — facing-source settled by delta #2 for presentation; walk-vs-aim animation split and camera-rotation option remain parked.
|
||||
4. **Annotate Q-079** — MultiMesh chosen for the sandbox (per-instance four-state tint + cutaway shader state); counter-case recorded: GridMap with code-built MeshLibrary + diff painting is simpler where per-instance color isn't needed and remains the Phase-5 static-geometry candidate. Data point, not resolution.
|
||||
5. **Annotate Q-020** — strict no-prediction rig confirmed: silent move rejection needs zero client handling by construction; visible behavior is bump-to-turn.
|
||||
6. **Annotate Q-063** — footstep seam exists (`gait_changed` signal + clip-phase access); animation-event vs raycast still open.
|
||||
7. **Deferred D (perception), minted at sidequest close-out per the ticket's own instruction** — wall cutaway is a client-side render mode on wall data inside the information boundary, decoupled from and composing with fog-of-perception; fog-memory-applied-to-geometry nuance recorded for Phase 5.
|
||||
8. **Editorial note on D-148** — its opening "30° tilt (60° from horizontal)" contradicts its own preset list; add a clarifying sentence (presets authoritative: −30° from horizontal). Flagged per CLAUDE.md rather than silently trusted.
|
||||
9. **T-1088 close-out notes** — no new Gauntlet room needed (constraint 12 untriggered, server untouched); Phase-5 extraction note for the ~40 lines of duplicated boot boilerplate; seek-during-blend and `remove_immutable_tracks` caveats with outcomes.
|
||||
|
||||
## 12. Constants appendix (`sandbox_constants.gd` — every feel knob in one file)
|
||||
|
||||
```gdscript
|
||||
const SUBTILE_M := 0.5 # D-066/D-222 — THE convention (§2)
|
||||
const WALL_H := 2.5
|
||||
const CUT_HEIGHT := 0.75
|
||||
const CUT_RADIUS := 5.0
|
||||
const CUT_BAND := 1.5
|
||||
# stance intervals: NOT duplicated — read InputMapper.MOVE_INTERVAL_MS at runtime
|
||||
const CATCHUP_MAX_FACTOR := 3.0
|
||||
const SNAP_DIST_M := 2.5 # 5 subtiles — matches 2D TELEPORT_DISTANCE_THRESHOLD
|
||||
const IDLE_ENTER_DELAY_S := 0.18
|
||||
const TURN_SHARPNESS := 14.0
|
||||
const TURN_BUDGET_DEG := {"Sprint": 1080.0, "Walk": 720.0, "Careful": 540.0, "Crouch": 420.0, "Idle": 600.0}
|
||||
const GAIT_CLIP := {...} # §6.1 — the ONLY place clip strings live
|
||||
const NATIVE_MPS := {"Walk": 1.4, "Walk_Formal": 1.2, "Sprint": 3.2, "Crouch_Fwd": 0.9}
|
||||
const SPEED_SCALE_CLAMP := Vector2(0.6, 1.8)
|
||||
const BLEND := {"idle_to_gait": 0.12, "gait_to_idle": 0.20, "gait_to_gait": 0.15, "crouch": 0.25, "teleport": 0.0}
|
||||
const CAM_PITCH_PRESETS := [-30.0, -5.0, -80.0] # D-148; -45 iso struck
|
||||
const CAM_DIST := 30.0
|
||||
const CAM_ORTHO_SIZE := 9.0
|
||||
const CAM_FOLLOW_RATE := 6.0
|
||||
const CAM_TILT_RATE := 3.0
|
||||
const LOOKAHEAD_S := 0.0
|
||||
const MAP_ROTATION_DEG := 45.0 # WorldRoot — the single D-148 Transform3D
|
||||
const TINTS := {"forward": Color.WHITE, "peripheral": 0.85, "boundary": 0.70, "remembered": Color(0.42, 0.45, 0.52)}
|
||||
const MOUSE_AIM_DEADZONE_M := 0.1
|
||||
```
|
||||
|
||||
## 13. Decisions made and why (where candidates disagreed)
|
||||
|
||||
1. **Interpolation model** — exp lerp k=12 (C0) vs per-leg `dist/interval` constant velocity (C1) vs constant-speed seek + clamp (C2): **C1.** Exp lerp produces a per-step velocity sawtooth foot cadence can never sync to (two judges called it fatal for feel). C1 and C2 are near-equivalent; C1's per-leg recompute plus cadence-synced `speed_scale` is the only model under which foot-slide elimination is possible, and C2's elegant single-clamp formula is effectively subsumed.
|
||||
2. **Octant→yaw table** — C0 and C2 copied `character_visual.gd`'s `east=−90°/west=+90°` table under a sim-x→+X mapping: **verified wrong against the repo this pass** (the model would face west walking east, and both would have baked the mirror into governance/goldens). C1's `yaw = π/2 − θ` (East=+90°) is arithmetically verified and adopted; `set_facing()` is never called.
|
||||
3. **Mouse input in 3D** — C2 missed the breakage entirely (fatal: unsteerable character); C0 fixed only the anchor (screen angle ≠ sim angle under 45°+pitch); **C1's `facing_angle_provider`** (ray-to-ground-plane + `WorldRoot.to_local` + `atan2(z,x)`) is the only correct D-054 path and is adopted verbatim.
|
||||
4. **Facing authority** — single wire source (C0/C2) vs "server feet, client eyes" split (C1): **C1's split**, because pure-snapshot idle aim is ~250–300 ms of laggy 45° pops the 2D client already cheats past, and the shown octant is always one the server was told. C2's NPC-uniformity objection is answered structurally: the idle source is an injected provider — NPCs leave it unset and collapse to single-source, one code path.
|
||||
5. **Mesh strategy** — GridMap (C0) vs MultiMesh (C1/C2): **MultiMesh**, because the four-state tint and cutaway need per-instance/per-material state GridMap lacks; C0's GridMap counter-case is preserved inside the Q-079 annotation per judge instruction.
|
||||
6. **Cutaway** — hard discard window (C0) vs smoothstep height cut in shader (C1) vs CPU classifier + instance scale.y (C2): **C1** — fragment-based like C0 but with spatial falloff keyed to the interpolated position (temporal smoothness for free); C2's per-step buffer rewrite pops.
|
||||
7. **Snapshot fan-out** — SnapshotEventRouter (C1, ambiguous) vs direct consumer calls (C0/C2): **direct calls** — the router is wired by main.gd for the 2D stack; two consumers don't justify touching it.
|
||||
8. **Stance intervals** — copied table (C0/C1) vs read from the autoload (C2): **read `InputMapper.MOVE_INTERVAL_MS`** — kills the drift risk.
|
||||
9. **Deterministic capture input** — flows (C1) vs `SR_SCRIPT` SimBridge bypass (C2) vs `SR_AUTOPILOT` real Input actions (C0): **C0's autopilot** — it exercises the genuine InputMapper path, so captures would have caught the very input bug C2 shipped.
|
||||
10. **File organization** — `sandbox/` (C0/C1) vs `rendering3d/` promotion split (C2): **`sandbox/` + a promotion-status column** — documents the seam without pre-committing Phase-5 directory shapes the sandbox exists to discover (judges split; the column captures C2's value at zero structural cost). C2's `SandboxSpace` single-conversion-authority file is adopted regardless.
|
||||
11. **Wall height** — 1.5 m (C0) vs 2.5 m (C1/C2): **2.5 m** — walls shorter than the 1.7 m character read wrong.
|
||||
12. **Peripheral/BoundaryWall tint** — ignored (C0/C1) vs four-state (C2): **four-state** — one instance-color value each, and it surfaces the wire's visibility enum in the greybox for free.
|
||||
13. **New Gauntlet room** — all three: not needed. Hub + corridors + Sprint Gauntlet cover every milestone; constraint 12 untriggered.
|
||||
14. **D-record ids** — C1 presumed "next free is D-248": **never presume**; ids are assigned at `pql decisions claim` time.
|
||||
|
||||
## 14. Implementation plan (each step one-agent-sized; groups run in parallel)
|
||||
|
||||
- **Group A (serial foundation):** S1 scene skeleton + boot + space/constants.
|
||||
- **Group B (parallel on A):** S2 input seam · S3 greybox store/renderer · S4 locomotion rig · S5 camera.
|
||||
- **Group C (parallel on B):** S6 animation machine + CharacterVisual API · S7 cutaway shader.
|
||||
- **Group D (serial close-out):** S8 harness + autopilot + scenarios → S9 live feel-tuning + governance records.
|
||||
|
||||
Per team rules: no pre-emptive fmt/clippy/test runs — the push gate owns verification; no server changes, so full `cargo test` is untouched. Feel tuning (S9) is deliberately last and human-in-the-loop against the live server.
|
||||
Reference in New Issue
Block a user