From f4a30b1b9dbd4c5f4ab4ebfd9f4e7507e577ce4f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 8 Apr 2026 22:17:36 -0400 Subject: [PATCH] NEON-26: add implementation plan for zoom bands --- docs/plans/NEON-26-implementation-plan.md | 84 +++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/plans/NEON-26-implementation-plan.md diff --git a/docs/plans/NEON-26-implementation-plan.md b/docs/plans/NEON-26-implementation-plan.md new file mode 100644 index 0000000..3bf479b --- /dev/null +++ b/docs/plans/NEON-26-implementation-plan.md @@ -0,0 +1,84 @@ +# NEON-26 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEON-26 | +| **Title** | E1.M2: ZoomBandConfig + discrete zoom input (clamped) | +| **Jira** | [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26) | +| **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:** 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 NEON-25 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 Jira) + +- Smooth analog zoom between bands (unless a one-line lerp is explicitly chosen later — default **no**). +- Occlusion policy (**NEON-27**). +- Yaw orbit input (**NEON-25** seam stays; do not bind rotate). + +## Acceptance criteria checklist + +- [ ] Zoom input only moves between **defined bands** (clamped; no out-of-range index). +- [ ] At each band, player remains **readably framed** during motion (manual playtest + distances chosen around current NEON-25 baseline). +- [ ] `CameraState` reflects **active band** and **effective distance**, and still exposes **yaw** unchanged in meaning. +- [ ] 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: ZoomBandConfig`. Keep **`follow_distance`** as **fallback** when config is null or has no bands (preserves NEON-25 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 `var d := effective_follow_distance()` from config + index or fallback export. Replace uses of raw `follow_distance` in `desired_eye_world` with `d`. **`_sync_camera_state`:** assign `_state.zoom_band_index` and `_state.distance = d` (and 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 Jira examples. +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 NEON-25); 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`. | + +## 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 NEON-26: `ZoomBandConfig` path, input actions, discrete bands replace “single scalar until NEON-26”. | + +## 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.