Stable idle latches _idle_anchor_active and _hold_idle_anchor clamps XZ each tick. Locomotion bypasses that branch, so the anchor stayed at the pre-walk position while the capsule moved; releasing keys ran idle again and _hold_idle_anchor teleported to the stale anchor. |
||
|---|---|---|
| .. | ||
| addons/gdUnit4 | ||
| resources | ||
| scenes | ||
| scripts | ||
| test | ||
| README.md | ||
| icon.svg | ||
| icon.svg.import | ||
| project.godot | ||
README.md
Neon Sprawl — Godot client
Open this client/ directory as a project in Godot 4.6 (4.x compatible). Use the standard Godot build (not the .NET build)—client code is GDScript (.gd).
- Main scene:
scenes/main.tscn(bootstrapscripts/main.gd). - Networking will use WebSocket or TCP to the C# server; authoritative logic stays on the server per
docs/architecture/tech_stack.md.
Script organization (repo policy)
Do not grow an all-in-one main.gd. The main scene root should compose child nodes and scripts; put picking, server sync, and similar features in dedicated scripts (typically under scripts/) and connect via signals or small APIs. Use Autoloads only when several scenes truly need the same global. Full guidance for contributors and tooling: .cursor/rules/godot-client-script-organization.md.
Tests with script changes: Any PR that adds or changes GDScript under scripts/ should add or update tests under test/ in the same change set (see .cursor/rules/testing-expectations.md). CI runs them via .github/workflows/gdscript.yml.
Follow camera (NEO-15)
The main scene uses World/IsometricFollowCamera (scripts/isometric_follow_camera.gd): client-local isometric follow for Player (NodePath ../Player), with damped eye motion (follow_smoothness, exponential lerp) and teleport snap when the eye is farther than snap_distance from the desired pose (e.g. after snap_to_server). Framing exports follow_distance, pitch_elevation_deg, and presentation_yaw_deg match the pre–NEO-15 static camera at spawn. Orbit is off in the prototype: allow_yaw is false, CameraState.yaw stays 0, and no rotate input is read—enable allow_yaw later and drive _orbit_yaw_rad from input on the rig (see docs/decomposition/modules/E1_M2_IsometricCameraController.md). CameraState (scripts/camera_state.gd) is refreshed every _physics_process tick on the rig (process_physics_priority after Player) so framing tracks move_and_slide without display-vs-physics jitter. ground_pick uses the viewport camera; main.gd sets fallback_camera to World/IsometricFollowCamera/Camera3D.
Prototype district (NEON-29)
The default play space is a 45 × 45 unit flat floor (~2 000 sq units, ~5× the pre-NEON-29 footprint). All geometry lives under World/NavigationRegion3D in scenes/main.tscn; main.gd rebakes the nav mesh at startup so the scene-file vertices are intentionally stale.
Props at a glance
Elevated walkables use distinct albedo tints in scenes/main.tscn so screenshots and discussion can name the right prop (Mat_terrace_platform_a, Mat_terrace_b_step, etc.). The main floor slab uses neutral gray (Mat_floor_slab). Occluders keep the default mesh gray.
| Node | Group | Tint | Size (m) | World XZ | Purpose |
|---|---|---|---|---|---|
Floor |
walkable |
Neutral gray | 45 × 45 | (0, 0) | Main play surface |
PrototypeTerminal |
walkable |
Slate blue | 0.9 × 0.4 | (0, 0) | Interaction target |
MoveRejectPedestal |
walkable |
Orange | 1.5 × 1.5 | (7.5, −6.5) | NEON-7 vertical reject |
MoveRejectFarPad |
walkable |
Blue-gray | 2.5 × 2.5 | (9, 9) | Near-floor walkable pad; no longer a reject target (horizontal limit removed NEON-29) |
Obstacle |
occluder |
Default gray | 2 × 2 | (6, 5) | Original occlusion / click-through target |
ObstacleB |
occluder |
Default gray | 3 × 3 | (−10, 5) | Second occluder (wider angle from spawn) |
ObstacleC |
occluder |
Default gray | 2 × 4 | (5, −12) | Tall occluder, south quadrant |
ObstacleD |
occluder |
Default gray | 3 × 2 | (−8, −16) | Far-south occluder |
TerracePlatformA |
walkable |
Teal | 10 × 10, h=0.3 m | (13, −14) | Large SE pad; three darker-teal approach treads per cardinal (TerracePlatformA_Approach / PAS_*) — 1.0 m run along the climb axis (~0.104 m rise each), wider than the 0.8 m capsule diameter so the body is not wider than each tread |
TerraceStepB |
walkable |
Gold | 6 × 3, h=0.3 m | (−15, 8.5) | NW approach step; TerraceStepB_Approach / TSB_* — three treads per S/E/W (darker gold), 1.0 m run on NS arms |
TerracePlatformB |
walkable |
Violet | 6 × 6, h=0.6 m | (−15, 13) | Upper NW terrace; TerracePlatformB_Approach / TPB_* — six treads per N/E/W (~0.104 m rise) for 0.6 m to floor (south uses gold step, not violet treads) |
TerracePlatformC |
walkable |
Green | 8 × 8, h=0.3 m | (14, 14) | NE pad; TerracePlatformC_Approach / PCS_* — same tread pattern as teal (PAS_*), darker green albedo |
Height variation notes
- Single-step terraces (A, C): 0.3 m rise — within
agent_max_climb = 0.35, so the nav mesh routes directly from floor onto the pad; one click suffices.TerracePlatformA(teal) sits 12 mm above the main floor slab and has cardinal walkable treads (TerracePlatformA_Approach) so movement does not rely on a bare vertical box lip; nav rebakes at startup. - Two-level terrace B: floor →
TerraceStepB(gold, 0.3 m) →TerracePlatformB(violet, +0.3 m).agent_max_climb = 0.35applies to the nav mesh and to walk step assist: you cannot cheese the violet deck from the floor in one move — click the gold step (or the platform after you are already on the step), not the violet top from below. Approach treads on gold (TSB_*) and on violet’s north / east / west (TPB_*, six risers for 0.6 m) match the teal convention. - QA / thin props:
PrototypeTerminal,MoveRejectPedestal,MoveRejectFarPadare unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment. - Occluders are plain
StaticBody3Dwith nowalkabletag; the nav bake excludes their top surfaces, so they remain true obstacles for routing.
Designer / QA limits
- Floor bounds: ±22.5 m in X and Z from world origin. Clicks outside the
NavigationRegion3Dboundary (beyond the floor edge) will be rejected byNavigationAgent3Dpath queries. - The
MoveRejectPedestal(orange box) top is ~2.5 m above the floor and will be rejected by the server'sMaxVerticalStep(2.2 m by default) — the only remaining move-rejection criterion with default server config. Horizontal distance is never a rejection reason whenHorizontalStepEnabledis false. - New obstacles are tagged
"occluder"so the NEON-27 occlusion policy and NEON-30 click-through both apply automatically.
Authoritative movement (NEO-7, NEO-11, NEO-22)
With the game server running (server/README.md), WASD (input actions move_* in project.godot) drives camera-relative XZ wish direction on the Player, and main.gd periodically **POST**s /game/players/{id}/move-stream with the latest body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). PositionAuthorityClient coalesces to one target per request so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single MoveCommand, applies all targets in order (reject = no partial apply), and returns PositionStateResponse. A successful move-stream response is treated as an ack only (no client teleport): the echoed position typically trails move_and_slide by roughly one RTT, so snapping it caused rubber-banding. Boot GET and debug click-move verify GET still emit authoritative_position_received so main.gd can snap_to_server once or set a nav walk goal. POST /game/players/{id}/move remains for debug click-to-move when running a debug Godot build (ground_pick.gd only listens in OS.is_debug_build()): it still does POST + GET verify and sets a nav walk goal on the player.
Boot sync_from_server() snaps once so spawn matches the server.
Tradeoff (prototype): Horizontal motion is a direct XZ bee-line toward the server’s verified target. The baked NavigationMesh is still used when vertical routing is latched and the capsule is inside the nav column hysteresis band (NAV_COLUMN_STEER_ENTER_DIST / EXIT) and is_on_floor() — terraces / steps then steer along get_current_navigation_path() using the first waypoint at least NAV_PATH_STEER_MIN_LOOKAHEAD (~22 cm) away in XZ (unlike a 5 cm scan or raw get_next_path_position(), which both sat inside Jolt’s per-tick slide and could flip velocity 180° every tick). While airborne, column path steering is off so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). No descend bypass for gravity while on an upper deck (same as before). While walking, gravity still runs when is_on_floor() is false; if snap / lip contacts keep is_on_floor() true over open space (e.g. gold deck → gray floor), player.gd casts several short down rays under the foot disk (center + offsets along move direction so the leading edge off a deck is tested) and applies gravity when none hit upward-facing ground within WALK_SUPPORT_PROBE_DEPTH. In that case floor_snap_length is set to 0 for the tick so moving snap does not cancel velocity.y on every physics step. Automatic routing around tall obstacles on one click is still not guaranteed — use several clicks to go around e.g. the gray Obstacle when needed.
NEO-10 / movement QA bumps: On run, spawn_short_random_bumps adds two green cylinders, each on its own StaticBody3D sibling of Floor under NavigationRegion3D (walkable on bump roots — avoids compound internal-edge jitter vs floor+cylinder on one body). Bump meshes use Godot group random_floor_bump_mesh. Collision radius = mesh + COLLISION_RADIUS_EXTRA (see scripts/random_floor_bump_collision_constants.gd, capped by COLLISION_RADIUS_MAX). bake_navigation_mesh(false) after spawn.
Idle stability (NEO-14 / NEON-16): Jolt Physics; TPS 120. physics/common/physics_interpolation is off — with on, small physics position changes on bump edges were blended across render frames and looked like ghosting / extra jitter. snap_to_server() still calls reset_physics_interpolation() for compatibility if you turn interpolation on later. floor_max_angle ~50° walking / ~35° idle; loose angle ~0.8 s after walk stops. Walk step assist. Idle rim / straddle: moving floor_max_angle when floor normal is shallow or slide hits mix floor + wall. One idle move_and_slide(), rim settle, random_floor_bump_mesh lip / rim / vertical-wall escape (IDLE_BUMP_ESCAPE_STEP, PLAYER_CAPSULE_RADIUS, collider fudge from random_floor_bump_collision_constants.gd). Flat idle hardening: when support is already effectively level (floor normal almost Vector3.UP and no post-arrival loose-floor window), player.gd now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an idle x/z anchor, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. Uniform shallow ramps can latch stable idle; nav-column path steering is gated to flatter floors so ramp ribbons do not flip velocity each tick. Goal arrival and descend-bypass checks both use the capsule feet height rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. Player: interp Off, avoidance_enabled false, floor_block_on_wall true — do not rewrite global_transform in _process (can ghost CharacterBody3D). _snap_capsule_upright() now short-circuits when the basis is already identity. Idle FLOOR_SNAP_IDLE ~11 cm; walking FLOOR_SNAP_MOVING ~0.32. Rendering: Mat_player_capsule, cast_shadow = 0, capsule mesh +Y ~3.4 cm (visual-only vs CapsuleShape3D — less z-fight vs floor/bump tops), light_specular = 0, msaa_3d = 2. safe_margin 0.055.
- Scripts:
scripts/ground_pick.gd(walkable pick +target_chosen; occluder bodies tagged"occluder"are passed through unconditionally so clicks reach the ground behind them — NEO-20),scripts/position_authority_client.gd(PositionAuthorityClient: POST move, GET verify; second signal arg = boot snap vs nav goal),scripts/player.gd(path-follow),scripts/isometric_follow_camera.gd+scripts/camera_state.gd(NEO-15 follow), thinscripts/main.gd(nav bake on first frame, then wiring). - Scene:
scenes/main.tscn— walkableStaticBody3Dgeometry lives underWorld/NavigationRegion3D.main.gdwaits oneprocess_frame, spawns random test bumps onFloor, thenbake_navigation_mesh(false)(main-thread bake), then waits two **physics_frame**s soNavigationServer3Dhas a map before agents query paths. The bakedNavigationMeshasset in the scene file is stale until you run or re-bake in the editor. - Inspector: select
PositionAuthorityClienton the main scene to setbase_url(defaulthttp://127.0.0.1:5253) anddev_player_id(defaultdev-local-1, must match serverGame:DevPlayerId).
Manual check (NEO-7 + NEO-11 + NEO-22)
- From repo root:
cd server/NeonSprawl.Server && dotnet run(note the URL/port, usuallyhttp://localhost:5253). - If the port differs, set
base_urlonPositionAuthorityClientaccordingly (e.g.http://127.0.0.1:5253). - Open the client in Godot and run the main scene (F5). The player should snap to the server’s default position (e.g. (-5, 0.9, -5) per
Game:DefaultPosition/ NEO-9 walk demo). - WASD: walk on the floor; the HUD position label should update and the server should accept
move-streamsteps (watch server logs or use a proxy if needed). Release keys and confirm the capsule settles to idle without runaway drift. - Debug build only — click-to-move: Left-click the floor: the client POSTs
/move, then GETs position; the capsule walks toward that verified target with nav assist where the old prototype applies. Server-rejected clicks show the reject label. - Walk to the orange reject pedestal and try to climb it with WASD / jumps as applicable;
move-streamshould returnvertical_step_exceededwhen a requested step exceedsMaxVerticalStep. In a debug build, click the pedestal top to confirm the samevertical_step_exceededpath via/move. From the physics ramp test block top (2 m), clicking the floor should succeed. TheMoveRejectFarPad(blue pad at (9, 9)) remains a normal walkable surface. - After movement, stand idle for 10+ seconds and confirm the capsule does not visibly vibrate or drift in x/z on flat floor, then repeat on the random green bumps (positions change each run).
If the server is down, boot GET fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes.
Interaction + range preview (NEO-9)
The main scene includes a prototype terminal at the map center (same world X/Z as the server’s static registry) and two glowing markers driven by scripts/interaction_radius_indicators.gd. That script compares the CharacterBody3D global_position (after server snap) to the anchor in scripts/prototype_interaction_constants.gd using horizontal distance on X/Z and the same interaction_radius as PrototypeInteractableRegistry.cs — preview only; the server POST /game/players/{id}/interact is authoritative.
InteractionRequestClient(child of the main scene root): press E to POST interact for the prototype id; Output printsallowed/reasonCode(or HTTP errors). While a request is in flight, further E presses are ignored (same pattern asPositionAuthorityClient).- Inspector: match
base_url/dev_player_idtoPositionAuthorityClientand the serverGame:DevPlayerId.
Manual check (NEO-9)
- Run the game server and client as in NEO-7.
- F5 in Godot: default spawn is out of range of the terminal; markers should stay dim. Click-move toward the center until markers brighten (within 3 m on the floor plane).
- Press E (input action
interactinproject.godot): Output should showallowed=truewhen markers glow,allowed=falsewithreasonCode=out_of_rangewhen dim (if you walk back out). Interaction uses_input, not_unhandled_input, so keys register reliably in the embedded Game dock; click the game view if the editor had focus elsewhere.
Manual check (NEO-20 — occluder click-through)
- Run the main scene (F5) with the server running.
- While
Obstacleis opaque (player not behind it): left-click the ground directly behindWorld/NavigationRegion3D/Obstacle(roughly(6, 0, 5)+ a few units further). The player should receive a valid move target and walk toward that position — not stop or ignore the click. - While
Obstacleis faded (player positioned so the occlusion policy is actively fading the obstacle): repeat the same click. Behavior should be identical — the occluder fade state must not affect pick success. - Regression — normal floor: click a clear area of the floor; confirm the player walks there as before.
- Regression — far pad / pedestal: confirm click-rejection behavior on
MoveRejectPedestalandMoveRejectFarPadis unchanged.
Movement prototype (NEO-5 → NEO-11)
player.gd steers XZ toward the authoritative goal and uses NavigationAgent3D’s current path only for the vertical-routing case above (on sufficiently flat floor); move_and_slide() does the motion. snap_to_server() remains for boot (and would apply for any future hard reconcile).
- The avatar is on physics layer 2 with collision_mask 3 (scans layers 1 and 2 so all walkables/occluders pair reliably). Environment StaticBody3D nodes stay on collision_layer 1 with collision_mask 3 as well. The pick ray uses mask 1 only, so clicks pass through the avatar and hit the floor.
Manual check (remnants)
- Run the main scene (F5) with server running for movement behavior above.
- Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—run the server for the demo path.
Clicks still ignored?
In the Game dock, Input must be active (not the 2D / 3D scene-picking tools) so events go to the game. If Input is on and clicks still do nothing, pick rays must use the viewport’s current camera and mouse position (the script does this so the embedded Game view matches the ray).
First run
- Install Godot 4.x.
- In the project manager, Import and select
client/project.godot. - Press F5 to run the main scene.
Godot CLI (Linux smoke test)
For agents and CI, use the official Linux x86_64 editor binary (matches config/features 4.6 in project.godot):
- Download Godot_v4.6-stable_linux.x86_64.zip from the 4.6-stable release.
- Unzip and put the binary on your
PATH, e.g.~/.local/opt/godot-4.6-stable/andln -s …/Godot_v4.6-stable_linux.x86_64 ~/.local/bin/godot. - Ensure
~/.local/binis onPATH, then fromclient/:
godot --version
godot --headless --path . --quit-after 5
A clean checkout has client/.godot/ gitignored, so scripts intentionally avoid class_name global types that require a full editor import before headless can resolve them.
Automated tests (GDScript, NEO-12)
Framework: GdUnit4 v6.1.2 is vendored under addons/gdUnit4/ (plugin enabled in project.godot). Test suites live in test/ (*_test.gd files extending GdUnitTestSuite).
Editor: Open the project in Godot 4.6; use the GdUnit dock to run tests, or run headless (below).
Headless (Linux, from client/): Use the same 4.6-stable binary as in Godot CLI. On a fresh clone you may need a one-time import so .godot exists:
godot --headless --import --path . --quit-after 8
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test
On Linux (including GitHub Actions), the path must be gdUnit4 with a capital U; gdunit4 does not resolve.
CI: The GDScript workflow fails the job if Godot prints SCRIPT ERROR: or ERROR: Failed to load script while running GdUnit. GdUnit alone can still exit 0 when a test file fails to parse (that suite is skipped); the workflow guards against that.
Scope: Unit tests cover player.gd, position_authority_client.gd, ground_pick.gd (walkable collider check + occluder ancestry check), isometric_follow_camera.gd (eye math + effective zoom distance), camera_state.gd, and zoom_band_config.gd. main.gd, full _input / ray pick flows, and scene wiring are not automated here—use manual checks above.
Camera zoom: Input actions camera_zoom_in / camera_zoom_out (mouse wheel, = / -, keypad + / −) are defined in project.godot. On layouts where + requires Shift, remap or add a binding under Project → Project Settings → Input Map if needed.
Dev (NEO-18): dev_toggle_occluder_obstacle (default Ctrl+Shift+K) toggles the prototype Obstacle visibility/collision in main.gd. F9 / F10 are used by the embedded debugger; use this action instead when the game is running in the editor.
Git hook (recommended): install the repo’s local pre-push GDScript lint hook from the repo root (once per clone):
./scripts/install-git-hooks.sh
On Windows (PowerShell), same hook:
pwsh -File scripts/install-git-hooks.ps1
It runs gdlint client/scripts client/test and gdformat --check client/scripts client/test before each push. It prefers .venv-gd/bin/ (Unix) or .venv-gd/Scripts/ (Windows venv) when present; without that venv or a global install, the hook fails and blocks the push — same as CI.
If CI fails gdlint but your push succeeded: the hook was not installed, gdlint/gdformat were missing when the hook ran, or push bypassed hooks (--no-verify).
Reports: GdUnit writes under reports/ (gitignored); ignore locally generated HTML/XML when committing.