neon-sprawl/client/README.md

13 KiB
Raw Blame History

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 frame on the rig. ground_pick uses the viewport camera; main.gd sets fallback_camera to World/IsometricFollowCamera/Camera3D.

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): player.gd often steers straight in xz toward the goal when the picks Y is below the capsule origin (smooth stepped bumps). Automatic routing around tall obstacles on one click is 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). Player: interp Off, avoidance_enabled false, floor_block_on_wall true — do not rewrite global_transform in _process (can ghost CharacterBody3D). _snap_capsule_upright() after motion. 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), 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 toward the authoritative target (may follow nav waypoints or bee-line in xz per the tradeoff above). NEON-7 reject clicks still show the reject label and do not start a path.
  5. Click the far pad or pedestal top (from spawn) to confirm horizontal_step_exceeded / vertical_step_exceeded behavior is unchanged.
  6. After a move (or at spawn), stand idle with no click goal for 10+ seconds and confirm the capsule does not visibly vibrate on the floor or 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.

Movement prototype (NEON-2 → NEON-8)

player.gd uses NavigationAgent3D.get_next_path_position() + move_and_slide() for horizontal motion when following the mesh; it may steer directly at the goal in xz when the descend bypass applies (NEON-8). snap_to_server() remains for boot (and would apply for any future hard reconcile).

  • The avatar is on physics layer 2 with mask 1 (floor + obstacle on layer 1); the pick ray uses mask 1 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, and ground_pick.gd (walkable collider check). main.gd, full _input / ray pick flows, and scene wiring are not automated here—use manual checks above.

Reports: GdUnit writes under reports/ (gitignored); ignore locally generated HTML/XML when committing.