# 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.
