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

86 lines
7.5 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-16 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-16 |
| **Title** | E1.M2: ZoomBandConfig + discrete zoom input (clamped) |
| **Linear** | [NEO-16](https://linear.app/neon-sprawl/issue/NEO-16) |
| **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:** Add **data-driven discrete zoom bands** so the player can adjust framing within limits; **`CameraState`** reports the active band and **effective distance** while **preserving yaw** and the NEO-15 orbit seam.
**In scope**
- **`ZoomBandConfig`** — `Resource` (no `class_name`; match [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md)) holding an ordered list of **follow distances** (meters per band), plus a **default band index** (clamped to valid range when applied).
- **`isometric_follow_camera.gd`** — `@export` reference to `ZoomBandConfig`; **internal active band index** clamped to `[0, band_count - 1]`; each frame use **distance for the active band** in `desired_eye_world` (same pitch / presentation yaw as today).
- **Input:** cycle bands **in** / **out** (e.g. mouse wheel up/down and/or `+`/`-` keys) via **`InputMap` actions** in `project.godot`; handle in **`_unhandled_input`** (or equivalent) on the rig so UI can steal focus later if needed.
- **`CameraState`:** set **`zoom_band_index`** to the active index and **`distance`** to the **effective** distance used for framing (not the legacy single export alone when bands are active).
- **Designer tuning:** one **Resource** asset (e.g. `res://resources/...tres` or inline default) editable in the inspector; rig points at it.
- **Optional:** stub signal / throttled `print` / `TODO(E9.M1)` for `camera_zoom_changed` telemetry when a schema exists.
**Out of scope** (per Linear)
- Smooth analog zoom between bands (unless a one-line lerp is explicitly chosen later — default **no**).
- Occlusion policy (**NEO-17**).
- Yaw orbit input (**NEO-15** seam stays; do not bind rotate).
## Acceptance criteria checklist
- [x] Zoom input only moves between **defined bands** (clamped; no out-of-range index).
- [x] At each band, player remains **readably framed** during motion (manual playtest + distances chosen around current NEO-15 baseline).
- [x] `CameraState` reflects **active band** and **effective distance**, and still exposes **yaw** unchanged in meaning.
- [x] Config lives in **one tunable place** for designers (`ZoomBandConfig` resource referenced by the rig).
## Technical approach
1. **`zoom_band_config.gd`** — `extends Resource` with e.g. `@export var band_distances: PackedFloat32Array` (length ≥ 1) and `@export var default_band_index: int`. Provide **`band_count`**, **`clamp_index(i: int) -> int`**, **`distance_at(index: int) -> float`** (uses clamped index). Validate in `_validate_property` or getter: if empty at runtime, treat as single band using rig fallback (see below).
2. **`isometric_follow_camera.gd`** — Add **`@export var zoom_band_config: Resource`** (no `class_name` on the config script); at runtime require **`get_script() == preload("…/zoom_band_config.gd")`** so headless CI and typed exports stay aligned. Conceptually this is **`ZoomBandConfig`**. Keep **`follow_distance`** as **fallback** when config is null, empty, or has nonpositive band distances (preserves NEO-15 scenes/tests). Track **`_zoom_band_index`** initialized from config default (clamped). On zoom-in/out actions: adjust index with `clamp_index`, no op at ends (or wrap — **clamp** per story). **`_process`:** compute effective distance from config + index or fallback export. Replace uses of raw `follow_distance` in `desired_eye_world` with that value. **`_sync_camera_state`:** pass effective distance into state (`zoom_band_index` + `distance`, plus existing yaw / path / focus).
3. **`project.godot`** — Add **`camera_zoom_in`** and **`camera_zoom_out`** (or one action with axis — prefer two actions for wheel + keys) with mouse wheel and key events as in the Linear issue / product notes.
4. **Scene / resource** — Add a default **`ZoomBandConfig`** `.tres` under `res://resources/` (or co-located under `client/resources/`) with **≥ 3** bands bracketing **~25.709** m so readability can be checked; assign on **`IsometricFollowCamera`** in `main.tscn`.
5. **Telemetry stub** — Optional `signal zoom_band_changed(new_index: int, distance: float)` or `TODO` comment referencing E9.M1; no external schema required for this story.
**Decisions (pre-implementation)**
| Topic | Choice |
|--------|--------|
| **Band parameter** | **Distance-only** per step (same pitch / FOV as NEO-15); document in module snapshot. FOV-per-band deferred unless trivial. |
| **Bounds at ends** | **Clamp** (no wrap): input at min/max does nothing extra. |
| **Config null / empty** | Use **`follow_distance`** export only; **`zoom_band_index`** in state = `0`. |
| **Invalid band values** | Any **≤ 0** distance in **`band_distances`** treats the config as unusable (same fallback as null/empty). |
## Files to add
| Path | Purpose |
|------|---------|
| `client/scripts/zoom_band_config.gd` | `Resource` type for ordered band distances + default index; clamp helpers (no `class_name`). |
| `client/resources/isometric_zoom_bands.tres` | Default `ZoomBandConfig` instance for designers (≥ 3 bands around current baseline distance). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/isometric_follow_camera.gd` | Wire config, band index, input handling, effective distance for eye position, correct `CameraState` sync (replace hard-coded `zoom_band_index = 0`). |
| `client/scripts/camera_state.gd` | Tighten comments only if needed so **`distance`** = effective follow distance and **`zoom_band_index`** = active band are unambiguous (fields already exist). |
| `client/project.godot` | Register **`camera_zoom_in`** / **`camera_zoom_out`** input actions. |
| `client/scenes/main.tscn` | Assign **`zoom_band_config`** on the follow rig to the new `.tres` (or leave null to exercise fallback — prefer assigned for acceptance “designer tunable”). |
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Update **implementation snapshot** for NEO-16: `ZoomBandConfig` path, input actions, discrete bands replace “single scalar until NEO-16”. |
## Tests
| File | Coverage |
|------|----------|
| `client/test/zoom_band_config_test.gd` | **New:** `clamp_index` for empty / single / multi band; `distance_at` matches configured values; default index clamped when out of range. |
| `client/test/isometric_follow_camera_test.gd` | **Change:** add a test for a **small static helper** on the rig script (e.g. resolve effective follow distance from optional config + band index + fallback export) so headless CI covers band selection **without** simulating `InputEvent`. |
**Manual verification:** in running client, wheel and keys cycle bands; confirm framing stays readable at each step and `CameraState` matches (debugger or brief log).
## Open questions / risks
- **Band count = 1:** Still valid; zoom input should no-op (clamped range size 1).
- **Readability AC** is partly subjective; pick band spread conservatively and adjust `.tres` after first playtest.