NEON-25: isometric follow camera, CameraState, tests, and docs

pull/31/head
VinPropane 2026-04-07 23:46:04 -04:00
parent 262abb5f2e
commit 4930fd89a6
13 changed files with 200 additions and 9 deletions

View File

@ -11,6 +11,10 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose**
**Tests with script changes:** Any PR that adds or changes GDScript under **`scripts/`** should **add or update** tests under **`test/`** in the same change set (see [`.cursor/rules/testing-expectations.md`](../.cursor/rules/testing-expectations.md)). CI runs them via **`.github/workflows/gdscript.yml`**. **Tests with script changes:** Any PR that adds or changes GDScript under **`scripts/`** should **add or update** tests under **`test/`** in the same change set (see [`.cursor/rules/testing-expectations.md`](../.cursor/rules/testing-expectations.md)). CI runs them via **`.github/workflows/gdscript.yml`**.
## Follow camera (NEON-25)
The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the preNEON-25 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every frame on the rig. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**.
## Authoritative movement (NEON-4, NEON-8) ## Authoritative movement (NEON-4, NEON-8)
With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NEON-4/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEON-8). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NEON-4/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEON-8). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
@ -21,7 +25,7 @@ With the game server running ([`server/README.md`](../server/README.md)), each v
**Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** after motion. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**. **Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** after motion. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). - **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEON-25 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring).
- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor. - **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor.
- **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`). - **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`).

View File

@ -6,6 +6,7 @@
[ext_resource type="Script" uid="uid://ds5fkbscljkxi" path="res://scripts/position_authority_client.gd" id="4_auth"] [ext_resource type="Script" uid="uid://ds5fkbscljkxi" path="res://scripts/position_authority_client.gd" id="4_auth"]
[ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"] [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://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"]
[sub_resource type="NavigationMesh" id="NavigationMesh_district"] [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) 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)
@ -85,8 +86,11 @@ transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0
light_specular = 0.0 light_specular = 0.0
shadow_enabled = true shadow_enabled = true
[node name="Camera3D" type="Camera3D" parent="World" unique_id=1124088856] [node name="IsometricFollowCamera" type="Node3D" parent="World" unique_id=1124088856]
transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.573576, -0.579228, 0.579228, 12, 10, 12) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 10, 12)
script = ExtResource("7_iso_cam")
[node name="Camera3D" type="Camera3D" parent="World/IsometricFollowCamera" unique_id=1124088857]
current = true current = true
fov = 50.0 fov = 50.0

View File

@ -0,0 +1,20 @@
extends RefCounted
## Client-local camera snapshot for consumers (risk UX, UI, debug). Refreshed each frame by
## `isometric_follow_camera.gd`. No `class_name` — see repo Godot headless / CI notes.
## Orbit yaw (rad) around the follow target **vertical axis**, relative to [member presentation_yaw_deg]
## on the rig. Prototype UX keeps this at **0** (`allow_yaw` false). Mid-project orbit would drive this.
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).
var distance: float = 0.0
## Placeholder for discrete zoom steps (NEON-26).
var zoom_band_index: int = 0
## World-space point the rig used as look-at focus last tick.
var focus_world: Vector3 = Vector3.ZERO

View File

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

View File

@ -0,0 +1,116 @@
extends Node3D
## NEON-25: client-local isometric follow; updates [CameraState] each `_process`.
## 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")
## Tuned to match pre-NEON-25 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5).
@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
@export var allow_yaw: bool = false
@export var max_yaw_deg: float = 45.0
## Higher = snappier follow (`1 - exp(-smoothness * delta)`).
@export var follow_smoothness: float = 12.0
## When eye-to-desired distance exceeds this, snap (server snap / huge teleports).
@export var snap_distance: float = 24.0
@onready var camera: Camera3D = $Camera3D
## Latest [CameraState] tick; same object each frame (see NEON-25 plan).
var camera_state:
get:
return _state
var _state = null
var _smoothed_eye: Vector3 = Vector3.ZERO
var _orbit_yaw_rad: float = 0.0
func _ready() -> void:
_state = CameraStateScript.new()
if camera == null:
push_error("IsometricFollowCamera: expected child Camera3D")
return
var t: Node3D = _resolve_target()
if t != null:
if not allow_yaw:
_orbit_yaw_rad = 0.0
else:
_orbit_yaw_rad = clampf(
_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg)
)
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
_smoothed_eye = desired_eye_world(
focus0, follow_distance, deg_to_rad(pitch_elevation_deg), yaw0
)
global_position = _smoothed_eye
camera.look_at(focus0, Vector3.UP)
_sync_camera_state(focus0)
else:
_smoothed_eye = global_position
func _process(delta: float) -> void:
if camera == null or _state == null:
return
var target: Node3D = _resolve_target()
if target == null:
return
if not allow_yaw:
_orbit_yaw_rad = 0.0
else:
_orbit_yaw_rad = clampf(
_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg)
)
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 desired: Vector3 = desired_eye_world(
focus, follow_distance, deg_to_rad(pitch_elevation_deg), yaw_total
)
if _smoothed_eye.distance_to(desired) >= snap_distance:
_smoothed_eye = desired
else:
var k: float = 1.0 - exp(-follow_smoothness * delta)
_smoothed_eye = _smoothed_eye.lerp(desired, k)
global_position = _smoothed_eye
camera.look_at(focus, Vector3.UP)
_sync_camera_state(focus)
func _sync_camera_state(focus: Vector3) -> void:
_state.follow_target_path = follow_target_path
_state.distance = follow_distance
_state.zoom_band_index = 0
_state.focus_world = focus
_state.yaw = _orbit_yaw_rad
func _resolve_target() -> Node3D:
if follow_target_path.is_empty():
return null
var n: Node = get_node_or_null(follow_target_path)
if n is Node3D:
return n as Node3D
return null
static func desired_eye_world(
focus: Vector3, distance: float, pitch_elevation_rad: float, yaw_rad: float
) -> Vector3:
var h: float = distance * cos(pitch_elevation_rad)
return focus + Vector3(
h * sin(yaw_rad), distance * sin(pitch_elevation_rad), h * cos(yaw_rad)
)

View File

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

View File

@ -3,6 +3,7 @@ extends Node3D
## NS-16: composes ground pick + server authority; see `ground_pick.gd` / ## NS-16: composes ground pick + server authority; see `ground_pick.gd` /
## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes). ## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes).
## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal. ## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal.
## NEON-25: follow camera is `World/IsometricFollowCamera/Camera3D` (see `isometric_follow_camera.gd`).
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see ## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
## `random_floor_bumps.gd`) before nav bake. ## `random_floor_bumps.gd`) before nav bake.
@ -11,7 +12,7 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0
## Bump on each rejection so older one-shot timers do not clear a newer message. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
@onready var _camera: Camera3D = $World/Camera3D @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
@onready var _player: CharacterBody3D = $World/Player @onready var _player: CharacterBody3D = $World/Player
@onready var _ground_pick: Node3D = $GroundPick @onready var _ground_pick: Node3D = $GroundPick

View File

@ -0,0 +1,23 @@
# Tests res://scripts/camera_state.gd defaults and field round-trip.
extends GdUnitTestSuite
const CameraStateScript := preload("res://scripts/camera_state.gd")
func test_default_yaw_is_zero() -> void:
var s = CameraStateScript.new()
assert_that(s.yaw).is_equal(0.0)
func test_fields_round_trip() -> void:
var s = CameraStateScript.new()
s.follow_target_path = NodePath("../Player")
s.distance = 18.5
s.zoom_band_index = 2
s.focus_world = Vector3(1.0, 2.0, 3.0)
s.yaw = 0.12
assert_that(s.follow_target_path).is_equal(NodePath("../Player"))
assert_that(s.distance).is_equal(18.5)
assert_that(s.zoom_band_index).is_equal(2)
assert_that(s.focus_world).is_equal(Vector3(1.0, 2.0, 3.0))
assert_that(s.yaw).is_equal(0.12)

View File

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

View File

@ -0,0 +1,13 @@
# Static eye-position helper for res://scripts/isometric_follow_camera.gd (NEON-25 framing).
extends GdUnitTestSuite
const IsoScript := preload("res://scripts/isometric_follow_camera.gd")
func test_desired_eye_matches_previous_static_camera_frame() -> void:
var focus := Vector3(-5.0, 0.9, -5.0)
var eye: Vector3 = IsoScript.desired_eye_world(
focus, 25.709, deg_to_rad(20.693), deg_to_rad(45.0)
)
var want := Vector3(12.0, 10.0, 12.0)
assert_that(eye.distance_to(want)).is_less(0.06)

View File

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

View File

@ -55,6 +55,12 @@ 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. 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)
- **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`**).
- **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`**.
## Jira backlog ## Jira backlog
Parent epic: [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1). Parent epic: [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1).

View File

@ -32,11 +32,11 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] While the player moves via E1.M1, the avatar stays **readably framed** without manual camera control. - [x] While the player moves via E1.M1, the avatar stays **readably framed** without manual camera control.
- [ ] **Pitch/roll** do not change from user input; **yaw** remains at configured default (`0`) for prototype UX — document how enforced. - [x] **Pitch/roll** do not change from user input; **yaw** remains at configured default (`0`) for prototype UX — document how enforced.
- [ ] `CameraState` exposes yaw (even when always zero) so dependents do not assume yaw is impossible. - [x] `CameraState` exposes yaw (even when always zero) so dependents do not assume yaw is impossible.
- [ ] **Seam** for optional yaw (config/flags) documented in code or module implementation snapshot. - [x] **Seam** for optional yaw (config/flags) documented in code or module implementation snapshot.
- [ ] Short note in client or `E1_M2_IsometricCameraController.md` implementation snapshot when merged. - [x] Short note in client or `E1_M2_IsometricCameraController.md` implementation snapshot when merged.
## Locked defaults (agreed before implementation) ## Locked defaults (agreed before implementation)