neon-sprawl/client
VinPropane db1f4da068 NEON-29: Aim walk ledge probes at click when nav column descends
Column steering follows path tangents on the violet deck while the
authoritative goal sits on the lower floor; velocity-aligned support rays
kept hitting deck in the tangent direction and suppressed ledge gravity.
Use want_goal_h for probe offsets when vertical routing + column steer
+ on floor + goal surface below feet.
2026-04-12 18:18:43 -04:00
..
addons/gdUnit4 NS-21: track GdUnit4 bin/ scripts (unignore vs root bin/ rule) 2026-04-06 22:29:08 -04:00
resources NEON-27: implement OcclusionPolicy — RayCast-based material fade 2026-04-08 23:34:00 -04:00
scenes NEON-29: Show player world x,y,z in prototype HUD 2026-04-12 16:52:19 -04:00
scripts NEON-29: Aim walk ledge probes at click when nav column descends 2026-04-12 18:18:43 -04:00
test NEON-29: Ease flat-floor idle latch (HUD XZ drift) 2026-04-12 17:33:49 -04:00
README.md NEON-29: Fix nav column steer ping-pong — path lookahead + air bee-line 2026-04-12 18:03:47 -04:00
icon.svg Update README and documentation to reflect project rebranding to "Neon Sprawl". Revise links to vision plan and adjust references in tech stack and decomposition documents. Enhance README with repository layout and instructions for running the server and client. 2026-03-29 17:23:00 -04:00
icon.svg.import fix(client): reliable NS-14 click-to-move without NavigationMesh 2026-03-29 22:08:25 -04:00
project.godot NEON-28: Dev obstacle smoke, nav reparent on hide, and docs 2026-04-10 23:55:50 -04:00

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 (bootstrap scripts/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 (NEON-25)

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 preNEON-25 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.35 applies 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 violets north / east / west (TPB_*, six risers for 0.6 m) match the teal convention.
  • QA / thin props: PrototypeTerminal, MoveRejectPedestal, MoveRejectFarPad are unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment.
  • Occluders are plain StaticBody3D with no walkable tag; 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 NavigationRegion3D boundary (beyond the floor edge) will be rejected by NavigationAgent3D path queries.
  • The MoveRejectPedestal (orange box) top is ~2.5 m above the floor and will be rejected by the server's MaxVerticalStep = 1.25 m check — the only remaining move-rejection criterion. Horizontal distance is never a rejection reason.
  • New obstacles are tagged "occluder" so the NEON-27 occlusion policy and NEON-30 click-through both apply automatically.

Authoritative movement (NEON-4, NEON-8)

With the game server running (server/README.md), each valid floor click sends a MoveCommand (POST) and a follow-up GET for PositionState. The server still snaps authority to the target (NEON-4/19); the client moves toward that verified position using NavigationAgent3D + a baked mesh instead of teleporting on the GET (NEON-8). Boot sync_from_server() snaps once so spawn matches the server.

Tradeoff (prototype): Horizontal motion is a direct XZ bee-line toward the servers 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 Jolts 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.

NEON-7 / 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 (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. Goal arrival uses 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 — NEON-30), 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 (NEON-25 follow), thin scripts/main.gd (nav bake on first frame, then wiring).
  • Scene: scenes/main.tscn — walkable StaticBody3D geometry lives under World/NavigationRegion3D. main.gd waits one process_frame, spawns random test bumps on Floor, then bake_navigation_mesh(false) (main-thread bake), then waits two **physics_frame**s so NavigationServer3D has a map before agents query paths. The baked NavigationMesh asset in the scene file is stale until you run or re-bake in the editor.
  • Inspector: select PositionAuthorityClient on the main scene to set base_url (default http://127.0.0.1:5253) and dev_player_id (default dev-local-1, must match server Game:DevPlayerId).

Manual check (NEON-4 + NEON-8)

  1. From repo root: cd server/NeonSprawl.Server && dotnet run (note the URL/port, usually http://localhost:5253).
  2. If the port differs, set base_url on PositionAuthorityClient accordingly (e.g. http://127.0.0.1:5253).
  3. Open the client in Godot and run the main scene (F5). The player should snap to the servers default position (e.g. (-5, 0.9, -5) per Game:DefaultPosition / NEON-6 walk demo).
  4. Left-click the floor: the client POSTs the target, then GETs position; the capsule walks in XZ straight toward that target (nav path only for vertical routing when you are already near the goal column). Server-rejected clicks show the reject label and do not start a path.
  5. Click the pedestal top (orange box at ~(7.5, 0, 6.5)) to confirm vertical_step_exceeded rejection — the top is ~2.5 m above floor, which exceeds MaxVerticalStep. The MoveRejectFarPad (blue pad at (9, 9)) is now a normal walkable surface; clicking it from any distance should succeed.
  6. After a move (or at spawn), stand idle with no click goal 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 (NEON-6)

The main scene includes a prototype terminal at the map center (same world X/Z as the servers 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.cspreview 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 prints allowed / reasonCode (or HTTP errors). While a request is in flight, further E presses are ignored (same pattern as PositionAuthorityClient).
  • Inspector: match base_url / dev_player_id to PositionAuthorityClient and the server Game:DevPlayerId.

Manual check (NEON-6)

  1. Run the game server and client as in NEON-4.
  2. 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).
  3. Press E (input action interact in project.godot): Output should show allowed=true when markers glow, allowed=false with reasonCode=out_of_range when 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 (NEON-30 — occluder click-through)

  1. Run the main scene (F5) with the server running.
  2. While Obstacle is opaque (player not behind it): left-click the ground directly behind World/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.
  3. While Obstacle is 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.
  4. Regression — normal floor: click a clear area of the floor; confirm the player walks there as before.
  5. Regression — far pad / pedestal: confirm click-rejection behavior on MoveRejectPedestal and MoveRejectFarPad is unchanged.

Movement prototype (NEON-2 → NEON-8)

player.gd steers XZ toward the authoritative goal and uses NavigationAgent3Ds current path only for the vertical-routing case above; move_and_slide() does the motion (NEON-8). 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)

  1. Run the main scene (F5) with server running for movement behavior above.
  2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repos 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 viewports current camera and mouse position (the script does this so the embedded Game view matches the ray).

First run

  1. Install Godot 4.x.
  2. In the project manager, Import and select client/project.godot.
  3. 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):

  1. Download Godot_v4.6-stable_linux.x86_64.zip from the 4.6-stable release.
  2. Unzip and put the binary on your PATH, e.g. ~/.local/opt/godot-4.6-stable/ and ln -s …/Godot_v4.6-stable_linux.x86_64 ~/.local/bin/godot.
  3. Ensure ~/.local/bin is on PATH, then from client/:
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, NEON-14)

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 (NEON-28): 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 repos 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.