NEON-26: ZoomBandConfig, discrete zoom input, CameraState sync
parent
f4a30b1b9d
commit
753ce84861
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b6lpmuv4rj0vp
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cr1ot54crlhwn
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue