feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)

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>
This commit is contained in:
2026-07-06 13:23:16 +02:00
co-authored by Claude Fable 5
parent ce1811b5f9
commit a7801a942a
21 changed files with 3555 additions and 8 deletions
@@ -0,0 +1,55 @@
shader_type spatial;
render_mode diffuse_lambert, specular_disabled;
// T-1088 greybox floor (design §3.3) — sandbox-only.
//
// Per-instance visibility tint x world-space grid lines. The MultiMesh instance
// color (use_colors, GreyboxWorld) arrives as the COLOR built-in and multiplies
// albedo: white = Forward, 85% = Peripheral, 70% = BoundaryWall, dim cool grey =
// Remembered (SandboxConstants.TINTS — §3.2).
//
// Grid: data is per-0.5 m subtile (D-066/D-222) but the floor must READ as 1 m
// visual tiles (2x2 subtiles) — minor lines every 0.5 m (alpha 0.15), major
// lines every 1.0 m (alpha 0.45). Lines are sampled in SIM space (u_sim_from_world
// undoes the 45 deg WorldRoot rotation) so they land on tile edges; sampling raw
// world space would draw the grid rotated 45 deg against the tiles. Continuous
// across instances because sim space is shared. D-243's "voxel = 1 m" is
// generation-cascade vocabulary — the 1 m visual tile exists ONLY as the major
// lines here (§2).
uniform vec3 u_base_albedo : source_color = vec3(0.52, 0.54, 0.58);
uniform vec3 u_line_albedo : source_color = vec3(0.10, 0.11, 0.14);
uniform float u_minor_spacing_m = 0.5;
uniform float u_major_spacing_m = 1.0;
uniform float u_minor_alpha = 0.15;
uniform float u_major_alpha = 0.45;
uniform float u_line_half_width_m = 0.02;
// World -> sim-space transform; set by GreyboxWorld from its own inverse global
// transform so the grid rotates WITH the tiles under the WorldRoot 45 deg.
uniform mat4 u_sim_from_world = mat4(1.0);
varying vec2 v_world_xz;
void vertex() {
// MODEL_MATRIX folds in the per-instance MultiMesh transform (incl. WorldRoot
// rotation); u_sim_from_world undoes the scene rotation back to sim axes.
v_world_xz = (u_sim_from_world * MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
// 1.0 on a grid line of the given spacing, 0.0 off it, one-fwidth AA edge.
float line_mask(vec2 p, float spacing, float half_width) {
vec2 dxy = abs(fract(p / spacing + 0.5) - 0.5) * spacing; // metres to nearest line
float d = min(dxy.x, dxy.y);
float aa = fwidth(d);
return 1.0 - smoothstep(half_width - aa, half_width + aa, d);
}
void fragment() {
float minor = line_mask(v_world_xz, u_minor_spacing_m, u_line_half_width_m) * u_minor_alpha;
float major = line_mask(v_world_xz, u_major_spacing_m, u_line_half_width_m) * u_major_alpha;
float line_a = max(minor, major); // major wins where the grids coincide
vec3 tinted = u_base_albedo * COLOR.rgb;
// Line color is tinted too, so remembered/peripheral tiles keep dim lines.
ALBEDO = mix(tinted, u_line_albedo * COLOR.rgb, line_a);
ROUGHNESS = 1.0;
}
@@ -0,0 +1,57 @@
shader_type spatial;
render_mode diffuse_lambert, specular_disabled;
// T-1088 greybox wall + camera-side cutaway (design §8) — technique
// proto-production.
//
// COLOR-multiplied albedo (same four-state tint contract as the floor shader)
// plus a smoothstep height cut: a half-disc of camera-side wall around the
// character drops to a CUT_HEIGHT stub with smooth radial falloff — the
// Xenonauts read, minus per-building logic the greybox doesn't have. The
// cutaway cuts RENDER HEIGHT; the tint gates KNOWLEDGE — independent factors,
// so remembered walls can be cut too (the player's camera doesn't care what the
// character currently sees). Open box tops are acceptable greybox fidelity;
// ghost band / dither / capping are named polish on this same shader.
//
// u_char_pos_xz is the rig's INTERPOLATED world XZ, pushed once per frame
// (GreyboxWorld.set_cutaway_char_pos) — the cut zone glides with the character,
// so spatial smoothstep becomes temporal smoothness. Parked far away by default:
// no cut until the first push.
//
// Camera-side predicate: the camera has yaw 0 and looks world -Z (the diamond
// view is WorldRoot's 45 deg rotation, never the camera's — D-148), so camera
// ground-forward is the constant vec2(0, -1) in world space. A wall fragment
// sits between camera and character when to_wall.y > 0; the test runs in world
// space, so it stays correct under the WorldRoot rotation.
//
// Geometry uniforms default to the SandboxConstants values; GreyboxWorld pushes
// them at material build so SandboxConstants stays the single source (§12).
uniform vec2 u_char_pos_xz = vec2(1000000.0, 1000000.0);
uniform float u_wall_h = 2.5; // SandboxConstants.WALL_H
uniform float u_cut_height = 0.75; // SandboxConstants.CUT_HEIGHT — the stub
uniform float u_cut_radius = 5.0; // SandboxConstants.CUT_RADIUS — half-disc radius
uniform float u_cut_band = 1.5; // SandboxConstants.CUT_BAND — radial falloff band
uniform vec3 u_base_albedo : source_color = vec3(0.66, 0.64, 0.60);
varying vec3 v_world_pos;
void vertex() {
// MODEL_MATRIX folds in the per-instance MultiMesh transform.
v_world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
vec2 to_wall = v_world_pos.xz - u_char_pos_xz;
// Soft camera-side gate: fully cut from 0.75 m on the camera side of the
// character, fully solid from 0.25 m on the far side — no hard seam.
float side_f = smoothstep(-0.25, 0.75, to_wall.y);
// Radial falloff: full cut inside (radius - band), fading to none at radius.
float rad_f = 1.0 - smoothstep(u_cut_radius - u_cut_band, u_cut_radius, length(to_wall));
float allowed = mix(u_wall_h, u_cut_height, side_f * rad_f);
if (v_world_pos.y > allowed) {
discard;
}
ALBEDO = u_base_albedo * COLOR.rgb;
ROUGHNESS = 1.0;
}