NEON-26: ZoomBandConfig, discrete zoom input, CameraState sync

pull/33/head
VinPropane 2026-04-08 22:32:45 -04:00
parent f4a30b1b9d
commit 753ce84861
13 changed files with 202 additions and 15 deletions

View File

@ -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]

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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]

View File

@ -0,0 +1 @@
uid://b6lpmuv4rj0vp

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1 @@
uid://cr1ot54crlhwn

View File

@ -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

View File

@ -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

View File

@ -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