# 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 non‑positive 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.