neon-sprawl/docs/plans/NEON-25-implementation-plan.md

85 lines
6.9 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.

# NEON-25 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEON-25 |
| **Title** | E1.M2: Isometric follow camera (fixed yaw prototype; CameraState seam) |
| **Jira** | [NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25) |
| **Parent** | [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1) |
| **Module** | [E1.M2 — IsometricCameraController](../decomposition/modules/E1_M2_IsometricCameraController.md); umbrella [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10) |
## 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 (**smooth follow** via lerp/damped tracking unless profiling suggests snap — document the chosen behavior in code/README).
- **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 NEON-26).
- **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 (NEON-26).
- Occlusion policy (NEON-27).
- Server use of camera pose.
## Acceptance criteria checklist
- [ ] While the player moves via E1.M1, the avatar stays **readably framed** without manual camera control.
- [ ] **Pitch/roll** do not change from user input; **yaw** remains at configured default (`0`) for prototype UX — document how enforced.
- [ ] `CameraState` exposes yaw (even when always zero) so dependents do not assume yaw is impossible.
- [ ] **Seam** for optional yaw (config/flags) documented in code or module implementation snapshot.
- [ ] Short note in client or `E1_M2_IsometricCameraController.md` implementation snapshot when merged.
## Technical approach
1. **Introduce `CameraState`** as a small **RefCounted** (or equivalent) script under `res://scripts/` with explicit fields: follow target (`Node3D` reference or `NodePath` resolved each frame), **yaw** (`float`, default `0`), **distance** or zoom index placeholder for NEON-26, 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` (or `_physics_process` if we want lockstep with player; default `_process` for smooth view): read player **`global_position`**, compute desired eye position from **fixed pitch** + **effective yaw** (forced to `0` when `allow_yaw` is false), optionally **lerp** rig origin toward target+offset for stability.
- Updates a **`CameraState`** instance each frame (or on change) for future consumers.
3. **Scene wiring** — replace the static **`World/Camera3D`** transform in `main.tscn` with a rig under `World` (camera as child), **`@export`** follow target = `Player`, distances matching current prototype readability (~existing diagonal framing).
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 + NEON-25): rig script names, yaw seam, single-band distance until NEON-26. |
## 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 **NEON-7** bumps; confirm avatar stays framed, no user-driven rotation, ground pick still hits walkable surfaces from the moving camera.
## Open questions / risks
- **Smooth vs snap:** Default to **damped follow**; if lag is noticeable at 120 Hz physics, reduce smoothing or align update phase — document final choice.
- **`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.