neon-sprawl/docs/plans/NEO-15-implementation-plan.md

95 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# NEO-15 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-15 |
| **Title** | E1.M2: Isometric follow camera (fixed yaw prototype; CameraState seam) |
| **Linear** | [NEO-15](https://linear.app/neon-sprawl/issue/NEO-15) |
| **Parent** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) |
| **Module** | [E1.M2 — IsometricCameraController](../decomposition/modules/E1_M2_IsometricCameraController.md); umbrella [E1.M2](https://linear.app/neon-sprawl/project/e1m2-isometriccameracontroller-deac8ef10395) |
## Goal, scope, and out-of-scope
**Goal:** Client-local isometric follow camera that tracks the E1.M1 player anchor with fixed framing for prototype play, while **modeling yaw in state** for future consumers and optional orbit.
**In scope**
- Godot: `Camera3D` (on a small rig) for isometric presentation; follow the player / follow target each frame per **locked defaults** below (smooth follow + teleport snap).
- **Player-facing controls:** no camera rotate / orbit; **yaw stays at default** (`0` rad) for normal play.
- **`CameraState`** (dedicated script type): follow target reference, **yaw** (always `0` in prototype UX), default distance or zoom index (**single band** / scalar distance until NEO-16).
- **Seam:** `@export` or config fields (e.g. `allow_yaw`, `max_yaw_deg`) default off; short comment where orbit input would apply.
- **Semantics:** movement and picking remain **world-anchored** (existing `ground_pick` + nav); camera does not redefine aim axes.
- Authority: camera pose stays **client-only** (see `docs/decomposition/modules/client_server_authority.md`).
**Out of scope**
- Binding rotate input or shipping orbit UX.
- Multiple zoom bands (NEO-16).
- Occlusion policy (NEO-17).
- Server use of camera pose.
## Acceptance criteria checklist
- [x] While the player moves via E1.M1, the avatar stays **readably framed** without manual camera control.
- [x] **Pitch/roll** do not change from user input; **yaw** remains at configured default (`0`) for prototype UX — document how enforced.
- [x] `CameraState` exposes yaw (even when always zero) so dependents do not assume yaw is impossible.
- [x] **Seam** for optional yaw (config/flags) documented in code or module implementation snapshot.
- [x] Short note in client or `E1_M2_IsometricCameraController.md` implementation snapshot when merged.
## Locked defaults (agreed before implementation)
| Topic | Choice |
|--------|--------|
| **Follow motion** | **Damped / lerped** toward the desired rig position; **teleport snap** when error exceeds a threshold (e.g. after `snap_to_server` or huge separation) so the camera never eases across the map. |
| **Framing** | **Match** the current static `World/Camera3D` framing in `main.tscn` as the starting **distance / pitch / FOV**; fine-tune in the inspector after follow is wired. |
| **`CameraState` refresh** | Rig **owns one instance** and **updates it every frame** after computing pose (simplest contract for future readers). |
| **Follow target wiring** | **`@export NodePath`** (resolve to `Node3D` each tick); tests may assign path or set target via code as needed. |
| **Update phase** | **`_process`** (not `_physics_process`) for the rig. |
## Technical approach
1. **Introduce `CameraState`** as a small **RefCounted** (or equivalent) script under `res://scripts/` with explicit fields: follow target path (or resolved node identity in state for debug), **yaw** (`float`, default `0`), **distance** or zoom index placeholder for NEO-16, and room for future flags. Avoid `class_name` so headless CI matches [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md) guidance; tests can `preload` the script.
2. **Isometric rig** — add a **`Node3D`** (e.g. `IsometricFollowCamera` or `CameraRig`) with script that:
- Owns or references the active **`Camera3D`** (`current = true`).
- Each **`_process`**: resolve **`NodePath`** follow target, read **`global_position`**, compute desired eye position from **fixed pitch** + **effective yaw** (forced to `0` when `allow_yaw` is false), **lerp** rig toward target+offset; **snap** rig/camera when separation exceeds a configured threshold.
- Updates the owned **`CameraState`** instance **every frame** after pose is finalized.
3. **Scene wiring** — replace the static **`World/Camera3D`** transform in `main.tscn` with a rig under `World` (camera as child), **`@export`** follow **`NodePath`** → `Player`, initial exports chosen to **match** the previous static camera readability.
4. **`main.gd`** — keep thin: obtain rig/camera reference for **`ground_pick.fallback_camera`** (unchanged contract); no camera math in `main.gd`.
5. **Yaw seam** — when `allow_yaw` is false: ignore any future input hook and set state yaw to `0`; when true (dev-only): clamp by `max_yaw_deg` and apply to basis (implementation stub can leave input unbound).
6. **Docs** — add a short **Implementation snapshot** subsection to `E1_M2_IsometricCameraController.md` after merge (or README pointer + module snapshot — satisfy AC).
## Files to add
| Path | Purpose |
|------|---------|
| `client/scripts/camera_state.gd` | RefCounted (or similar) holding follow target ref/path, **yaw**, distance/zoom index placeholder, future flags; no `class_name`. |
| `client/scripts/isometric_follow_camera.gd` | `Node3D` rig: positions/orients `Camera3D`, updates `CameraState`, exports distance/pitch/yaw seam. |
Godot may add companion `.uid` files when scripts are first touched in the editor; commit them if the toolchain creates them.
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | Replace fixed `Camera3D` transform with rig + follow target wiring; keep `current` camera and layers consistent with picking. |
| `client/scripts/main.gd` | Point `_camera` (and `ground_pick.fallback_camera`) at the rigs **`Camera3D`**; minimal diff. |
| `client/README.md` | One short paragraph: follow camera behavior, fixed yaw prototype, where to tune distance/pitch. |
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | **Implementation snapshot** (date + NEO-15): rig script names, yaw seam, single-band distance until NEO-16. |
## Tests
| File | What to cover |
|------|----------------|
| `client/test/camera_state_test.gd` | **New** — default **yaw** is `0`; fields survive assign/read; optional: follow target path assignment (if exposed). |
| `client/test/isometric_follow_camera_test.gd` | **New** — if pure helpers are extracted (e.g. static offset from pitch/distance/yaw), unit-test them; otherwise minimal suite: instantiate rig + dummy `Node3D` target, advance one frame, assert camera global position moves toward expected quadrant (tolerance-based). If full follow is too scene-heavy, document **manual** verification as primary and keep **camera_state** tests as the automated baseline. |
**Manual (required):** Run main scene with server per README: click-move across floor and **NEO-10** bumps; confirm avatar stays framed, no user-driven rotation, ground pick still hits walkable surfaces from the moving camera.
## Open questions / risks
- **Smoothing tuning:** If follow feels floaty or laggy after the locked defaults, adjust lerp speed or snap threshold; document chosen constants in script or README.
- **`player.gd` `_snap_capsule_upright`:** Identity basis today; camera yaw `0` matches world-aligned capsule. Future facing yaw must stay consistent with E1.M2 policy (not this storys implementation unless trivial).
- **gdUnit rig tests:** May require small refactor (static math helpers) for stable headless asserts; acceptable follow-up within the same story if the first PR ships manual AC + `camera_state` tests.