diff --git a/client/project.godot b/client/project.godot index 19f828a..054fc3c 100644 --- a/client/project.godot +++ b/client/project.godot @@ -44,6 +44,20 @@ interact={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) ] } +camera_zoom_in={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"button_index":4,"factor":1.0,"pressed":false,"canceled":false,"double_click":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":61,"physical_keycode":61,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194437,"physical_keycode":4194437,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +camera_zoom_out={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"button_index":5,"factor":1.0,"pressed":false,"canceled":false,"double_click":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":45,"physical_keycode":45,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194435,"physical_keycode":4194435,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/client/resources/isometric_zoom_bands.tres b/client/resources/isometric_zoom_bands.tres new file mode 100644 index 0000000..187e913 --- /dev/null +++ b/client/resources/isometric_zoom_bands.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/zoom_band_config.gd" id="1_zoom_cfg"] + +[resource] +script = ExtResource("1_zoom_cfg") +band_distances = PackedFloat32Array(18, 25.709, 32) +default_band_index = 1 diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 17e10ee..1e98450 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -7,6 +7,7 @@ [ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"] [ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"] [ext_resource type="Script" uid="uid://b8x7c4m3r5t8t2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"] +[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] vertices = PackedVector3Array(-0.6999998, 0.25, -0.6999998, -0.10000038, 0.25, -0.6999998, -0.10000038, 0.25, -9.55, -9.55, 0.25, 0.05000019, -1, 0.25, 0.05000019, -9.55, 0.25, -9.55, 8.75, 0.25, -7.4500003, 8.75, 0.25, -6.4000006, 9.65, 0.25, -6.4000006, 9.65, 0.25, -9.55, 0.5, 0.25, -0.6999998, 6.6499996, 0.25, -5.2000003, 6.200001, 0.25, -5.5, 8.450001, 0.25, -7.75, 6.5, 0.25, -7.75, 6.200001, 0.25, -7.4500003, 7.1000004, 2.8000002, -6.8500004, 7.1000004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.8500004, 7.25, 0.25, -6.7000003, 7.25, 0.25, -6.25, 7.700001, 0.25, -6.25, 7.700001, 0.25, -6.7000003, 8.75, 0.25, -5.5, 7.550001, 0.25, 3.8000002, 7.550001, 0.25, 5, 9.65, 0.25, 5, 7.25, 0.25, 3.5, 0.9499998, 0.25, 0.5, 0.5, 0.25, 0.8000002, 4.55, 0.25, 3.9499998, 4.8500004, 0.25, 3.5, 8.450001, 0.25, -5.2000003, 0.9499998, 0.25, -0.39999962, -0.6999998, 0.25, 0.8000002, 4.55, 0.25, 6.200001, 4.55, 0.25, 9.65, -9.55, 0.25, 9.65, 5.45, 2.2, 4.4000006, 5.45, 2.2, 5.6000004, 6.6499996, 2.2, 5.6000004, 6.6499996, 2.2, 4.4000006, 5.6000004, 0.25, 4.55, 5.6000004, 0.25, 5.45, 6.5, 0.25, 5.45, 6.5, 0.25, 4.55, 7.550001, 0.25, 6.200001, 7.25, 0.25, 6.5, 9.800001, 0.40000004, 9.800001, 4.8500004, 0.25, 6.5) @@ -89,6 +90,7 @@ shadow_enabled = true [node name="IsometricFollowCamera" type="Node3D" parent="World" unique_id=1124088856] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 10, 12) script = ExtResource("7_iso_cam") +zoom_band_config = ExtResource("8_zoom_bands") [node name="Camera3D" type="Camera3D" parent="World/IsometricFollowCamera" unique_id=1124088857] current = true diff --git a/client/scripts/camera_state.gd b/client/scripts/camera_state.gd index 709a772..b71f310 100644 --- a/client/scripts/camera_state.gd +++ b/client/scripts/camera_state.gd @@ -12,10 +12,10 @@ var yaw: float = 0.0 ## Scene path to the follow anchor (typically `CharacterBody3D` / `Node3D`). var follow_target_path: NodePath = NodePath() -## Single-band follow distance until NEON-26 zoom bands (meters). +## Effective follow distance used for framing this tick (meters) — active zoom band or fallback. var distance: float = 0.0 -## Placeholder for discrete zoom steps (NEON-26). +## Active discrete zoom band index when the rig uses a zoom-band resource; otherwise **0**. var zoom_band_index: int = 0 ## World-space point the rig used as look-at focus last tick. diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index b7cb94a..fbef68b 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -1,19 +1,28 @@ extends Node3D ## NEON-25: client-local isometric follow; updates [CameraState] each `_process`. +## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions. ## Pitch / roll are fixed by presentation exports; **orbit yaw** in state stays **0** while ## [member allow_yaw] is false (no rotate input bound). Future orbit: read relative input here, ## add to `_orbit_yaw_rad`, clamp with [member max_yaw_deg]. const CameraStateScript := preload("res://scripts/camera_state.gd") +const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd") + +## TODO(E9.M1): throttled `camera_zoom_changed` telemetry when schema exists. +signal zoom_band_changed(new_index: int, distance: float) ## Tuned to match pre-NEON-25 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5). +## When [member zoom_band_config] is null or has no bands, this is the sole follow distance. @export var follow_target_path: NodePath = NodePath("../Player") @export var follow_distance: float = 25.709 @export var pitch_elevation_deg: float = 20.693 @export var presentation_yaw_deg: float = 45.0 @export var focus_vertical_offset: float = 0.0 +## Designer-tunable discrete bands (`res://resources/isometric_zoom_bands.tres` on main rig). +@export var zoom_band_config: Resource + @export var allow_yaw: bool = false @export var max_yaw_deg: float = 45.0 @@ -30,6 +39,7 @@ var camera_state: var _state = null var _smoothed_eye: Vector3 = Vector3.ZERO var _orbit_yaw_rad: float = 0.0 +var _zoom_band_index: int = 0 var _warned_missing_follow_target: bool = false @onready var camera: Camera3D = $Camera3D @@ -37,6 +47,7 @@ var _warned_missing_follow_target: bool = false func _ready() -> void: _state = CameraStateScript.new() + _init_zoom_band_index() if camera == null: push_error("IsometricFollowCamera: expected child Camera3D") return @@ -50,8 +61,9 @@ func _ready() -> void: ) var focus0: Vector3 = t.global_position + Vector3(0.0, focus_vertical_offset, 0.0) var yaw0: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad + var dist0: float = _current_follow_distance() _smoothed_eye = desired_eye_world( - focus0, follow_distance, deg_to_rad(pitch_elevation_deg), yaw0 + focus0, dist0, deg_to_rad(pitch_elevation_deg), yaw0 ) global_position = _smoothed_eye camera.look_at(focus0, Vector3.UP) @@ -78,8 +90,9 @@ func _process(delta: float) -> void: var focus: Vector3 = target.global_position + Vector3(0.0, focus_vertical_offset, 0.0) var yaw_total: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad + var dist: float = _current_follow_distance() var desired: Vector3 = desired_eye_world( - focus, follow_distance, deg_to_rad(pitch_elevation_deg), yaw_total + focus, dist, deg_to_rad(pitch_elevation_deg), yaw_total ) if _smoothed_eye.distance_to(desired) >= snap_distance: @@ -93,12 +106,63 @@ func _process(delta: float) -> void: _sync_camera_state(focus) +func _unhandled_input(event: InputEvent) -> void: + if event.is_echo(): + return + if event.is_action_pressed("camera_zoom_in"): + _apply_zoom_step(-1) + elif event.is_action_pressed("camera_zoom_out"): + _apply_zoom_step(1) + + +## **Near** = lower band index. Zoom in → closer → decrease index (clamped). +func _apply_zoom_step(delta_bands: int) -> void: + if not _zoom_config_valid(): + return + var cfg: Resource = zoom_band_config + var new_idx: int = cfg.clamp_index(_zoom_band_index + delta_bands) + if new_idx == _zoom_band_index: + return + _zoom_band_index = new_idx + zoom_band_changed.emit(_zoom_band_index, _current_follow_distance()) + + +func _init_zoom_band_index() -> void: + _zoom_band_index = 0 + if not _zoom_config_valid(): + return + var cfg: Resource = zoom_band_config + _zoom_band_index = cfg.clamp_index(cfg.default_band_index) + + +func _zoom_config_valid() -> bool: + return ( + zoom_band_config != null + and zoom_band_config.get_script() == ZoomBandConfigScript + and zoom_band_config.band_count() > 0 + ) + + +func _current_follow_distance() -> float: + return effective_follow_distance(zoom_band_config, _zoom_band_index, follow_distance) + + +static func effective_follow_distance( + zoom_cfg: Resource, band_index: int, fallback_distance: float +) -> float: + if zoom_cfg == null or zoom_cfg.get_script() != ZoomBandConfigScript: + return fallback_distance + if zoom_cfg.band_count() == 0: + return fallback_distance + return zoom_cfg.distance_at(band_index) + + func _sync_camera_state(focus: Vector3) -> void: # `CameraState.yaw` = orbit delta only; world-fixed diagonal framing = # `presentation_yaw_deg`. _state.follow_target_path = follow_target_path - _state.distance = follow_distance - _state.zoom_band_index = 0 + _state.distance = _current_follow_distance() + _state.zoom_band_index = _zoom_band_index if _zoom_config_valid() else 0 _state.focus_world = focus _state.yaw = _orbit_yaw_rad diff --git a/client/scripts/zoom_band_config.gd b/client/scripts/zoom_band_config.gd new file mode 100644 index 0000000..a83dbe7 --- /dev/null +++ b/client/scripts/zoom_band_config.gd @@ -0,0 +1,25 @@ +extends Resource + +## Discrete follow distances (meters), **near → far** (ascending). Index **0** = closest. +## No `class_name` — see repo Godot headless / CI notes. +@export var band_distances: PackedFloat32Array = PackedFloat32Array([25.709]) +@export var default_band_index: int = 0 + + +func band_count() -> int: + return band_distances.size() + + +func clamp_index(i: int) -> int: + var n := band_count() + if n == 0: + return 0 + return clampi(i, 0, n - 1) + + +func distance_at(index: int) -> float: + var n := band_count() + if n == 0: + return 0.0 + var ci := clamp_index(index) + return band_distances[ci] diff --git a/client/scripts/zoom_band_config.gd.uid b/client/scripts/zoom_band_config.gd.uid new file mode 100644 index 0000000..79ebab0 --- /dev/null +++ b/client/scripts/zoom_band_config.gd.uid @@ -0,0 +1 @@ +uid://b6lpmuv4rj0vp diff --git a/client/test/isometric_follow_camera_test.gd b/client/test/isometric_follow_camera_test.gd index da43497..fbe0666 100644 --- a/client/test/isometric_follow_camera_test.gd +++ b/client/test/isometric_follow_camera_test.gd @@ -2,6 +2,7 @@ extends GdUnitTestSuite const IsoScript := preload("res://scripts/isometric_follow_camera.gd") +const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd") func test_desired_eye_matches_previous_static_camera_frame() -> void: @@ -11,3 +12,26 @@ func test_desired_eye_matches_previous_static_camera_frame() -> void: ) var want := Vector3(12.0, 10.0, 12.0) assert_that(eye.distance_to(want)).is_less(0.06) + + +func test_effective_follow_distance_fallback_when_config_null() -> void: + assert_that(IsoScript.effective_follow_distance(null, 3, 12.5)).is_equal(12.5) + + +func test_effective_follow_distance_uses_config_when_valid() -> void: + var cfg = ZoomBandConfigScript.new() + cfg.band_distances = PackedFloat32Array([10.0, 20.0, 30.0]) + assert_that(IsoScript.effective_follow_distance(cfg, 1, 99.0)).is_equal(20.0) + + +func test_effective_follow_distance_clamps_band_index() -> void: + var cfg = ZoomBandConfigScript.new() + cfg.band_distances = PackedFloat32Array([10.0, 20.0]) + assert_that(IsoScript.effective_follow_distance(cfg, 99, 1.0)).is_equal(20.0) + assert_that(IsoScript.effective_follow_distance(cfg, -3, 1.0)).is_equal(10.0) + + +func test_effective_follow_distance_empty_config_bands_falls_back() -> void: + var cfg = ZoomBandConfigScript.new() + cfg.band_distances = PackedFloat32Array() + assert_that(IsoScript.effective_follow_distance(cfg, 0, 7.5)).is_equal(7.5) diff --git a/client/test/zoom_band_config_test.gd b/client/test/zoom_band_config_test.gd new file mode 100644 index 0000000..901b72b --- /dev/null +++ b/client/test/zoom_band_config_test.gd @@ -0,0 +1,48 @@ +# Tests res://scripts/zoom_band_config.gd (NEON-26 discrete bands). +extends GdUnitTestSuite + +const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd") + + +func test_empty_bands_band_count_zero() -> void: + var c = ZoomBandConfigScript.new() + c.band_distances = PackedFloat32Array() + assert_that(c.band_count()).is_equal(0) + + +func test_empty_bands_clamp_index_zero() -> void: + var c = ZoomBandConfigScript.new() + c.band_distances = PackedFloat32Array() + assert_that(c.clamp_index(0)).is_equal(0) + assert_that(c.clamp_index(99)).is_equal(0) + + +func test_empty_bands_distance_at_zero() -> void: + var c = ZoomBandConfigScript.new() + c.band_distances = PackedFloat32Array() + assert_that(c.distance_at(0)).is_equal(0.0) + + +func test_single_band_clamp_always_zero() -> void: + var c = ZoomBandConfigScript.new() + c.band_distances = PackedFloat32Array([42.0]) + assert_that(c.clamp_index(-1)).is_equal(0) + assert_that(c.clamp_index(0)).is_equal(0) + assert_that(c.clamp_index(9)).is_equal(0) + + +func test_multi_band_clamp_and_distance_at() -> void: + var c = ZoomBandConfigScript.new() + c.band_distances = PackedFloat32Array([10.0, 20.0, 30.0]) + assert_that(c.clamp_index(-5)).is_equal(0) + assert_that(c.clamp_index(1)).is_equal(1) + assert_that(c.clamp_index(99)).is_equal(2) + assert_that(c.distance_at(1)).is_equal(20.0) + assert_that(c.distance_at(100)).is_equal(30.0) + + +func test_default_index_clamped_via_clamp_index() -> void: + var c = ZoomBandConfigScript.new() + c.band_distances = PackedFloat32Array([1.0, 2.0, 3.0]) + c.default_band_index = 99 + assert_that(c.clamp_index(c.default_band_index)).is_equal(2) diff --git a/client/test/zoom_band_config_test.gd.uid b/client/test/zoom_band_config_test.gd.uid new file mode 100644 index 0000000..b8b383b --- /dev/null +++ b/client/test/zoom_band_config_test.gd.uid @@ -0,0 +1 @@ +uid://cr1ot54crlhwn diff --git a/docs/decomposition/modules/E1_M2_IsometricCameraController.md b/docs/decomposition/modules/E1_M2_IsometricCameraController.md index 718f4bf..e466ee4 100644 --- a/docs/decomposition/modules/E1_M2_IsometricCameraController.md +++ b/docs/decomposition/modules/E1_M2_IsometricCameraController.md @@ -7,7 +7,7 @@ | **Module ID** | E1.M2 | | **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) | | **Stage target** | Prototype | -| **Status** | In progress — follow + `CameraState` baseline shipped ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)); zoom, occlusion, hardening open ([NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)–[NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)); see [dependency register](module_dependency_register.md) | +| **Status** | In progress — follow + `CameraState` + **discrete zoom bands** shipped ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25), [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)); occlusion + hardening open ([NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)–[NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)); see [dependency register](module_dependency_register.md) | | **Jira** | Feature [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10); prototype backlog stories under parent [NEON-1](https://neon-sprawl.atlassian.net/browse/NEON-1) — [Jira backlog](#jira-backlog) | ## Purpose @@ -55,11 +55,11 @@ Delivers an isometric follow camera that keeps the player readable during motion See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, occlusion policy, **yaw fixed for players in prototype** (implementation still models yaw per [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)); optional telemetry such as throttled `camera_zoom_changed` and perf stress markers for occluders. -## Implementation snapshot (NEON-25, 2026-04-07) +## Implementation snapshot (NEON-25 + NEON-26, 2026-04-08) -- **Godot:** `client/scripts/isometric_follow_camera.gd` on **`World/IsometricFollowCamera`**; child **`Camera3D`** (current). **`scripts/camera_state.gd`** — `RefCounted` tick snapshot (`yaw` = orbit component, **0** while **`allow_yaw`** is false; **`distance`**, **`zoom_band_index`**, **`focus_world`**, **`follow_target_path`**). +- **Godot:** `client/scripts/isometric_follow_camera.gd` on **`World/IsometricFollowCamera`**; child **`Camera3D`** (current). **`scripts/camera_state.gd`** — `RefCounted` tick snapshot (`yaw` = orbit component, **0** while **`allow_yaw`** is false; **`distance`** = effective follow distance; **`zoom_band_index`**; **`focus_world`**, **`follow_target_path`**). - **Seam:** **`allow_yaw`** / **`max_yaw_deg`** on the rig; orbit would adjust **`_orbit_yaw_rad`** (no input bound yet). **Presentation** compass uses **`presentation_yaw_deg`** separately from orbit **`yaw`** in state. -- **Zoom:** single scalar **`follow_distance`** until [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26); **`zoom_band_index`** reserved on **`CameraState`**. +- **Zoom:** **`ZoomBandConfig`** resource (`client/scripts/zoom_band_config.gd`); default **`client/resources/isometric_zoom_bands.tres`** on main rig. Bands are **distance-only** (ascending near→far); **pitch / FOV** unchanged vs NEON-25. Input: **`camera_zoom_in`** / **`camera_zoom_out`** in `client/project.godot` (mouse wheel + `=` / `-` + keypad **+** / **−**). **`follow_distance`** remains **fallback** when config is null or has no bands; state **`zoom_band_index`** is **0** in that case. **`zoom_band_changed`** signal on rig; **TODO(E9.M1)** for throttled `camera_zoom_changed` telemetry. ## Jira backlog diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index a4335b3..1c2b070 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -17,7 +17,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | Planned | | E1.M4 | AbilityInputScaffold | E1.M3, E5.M1 | AbilityCastRequest, HotbarLoadout, CooldownSnapshot | Prototype | Planned | -**E1.M2 note:** Client follow rig + per-tick `CameraState` baseline is in repo ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)). Discrete zoom bands, occlusion policy, and integration hardening remain ([NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)–[NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)). +**E1.M2 note:** Client follow rig + per-tick `CameraState` + **discrete zoom bands** (`ZoomBandConfig`, [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26)) are in repo ([NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25)). Occlusion policy and integration hardening remain ([NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27)–[NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28)). ### Epic 2 — Skills and Progression Framework diff --git a/docs/plans/NEON-26-implementation-plan.md b/docs/plans/NEON-26-implementation-plan.md index 3bf479b..6c55701 100644 --- a/docs/plans/NEON-26-implementation-plan.md +++ b/docs/plans/NEON-26-implementation-plan.md @@ -31,10 +31,10 @@ ## 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). +- [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 NEON-25 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