Compare commits
14 Commits
2a5d9fa900
...
d77462012c
| Author | SHA1 | Date |
|---|---|---|
|
|
d77462012c | |
|
|
9f6297c46d | |
|
|
387b61b577 | |
|
|
0a86215bc0 | |
|
|
e96f70d889 | |
|
|
f4b34316a6 | |
|
|
4930fd89a6 | |
|
|
262abb5f2e | |
|
|
644bfc8c3d | |
|
|
00c2c4520e | |
|
|
b07f6980c6 | |
|
|
09257b4cc3 | |
|
|
d06e34b452 | |
|
|
2b00de309b |
|
|
@ -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`**.
|
||||
|
||||
## 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 pre–NEON-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)
|
||||
|
||||
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**.
|
||||
|
||||
- **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.
|
||||
- **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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
; Engine configuration file.
|
||||
; It's best edited using the editor UI, and not directly,
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
|
|
@ -25,14 +25,6 @@ config/icon="res://icon.svg"
|
|||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
|
||||
[input]
|
||||
|
||||
interact={
|
||||
"deadzone": 0.5,
|
||||
"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)
|
||||
]
|
||||
}
|
||||
|
||||
[dotnet]
|
||||
|
||||
project/assembly_name="Neon Sprawl"
|
||||
|
|
@ -41,6 +33,18 @@ project/assembly_name="Neon Sprawl"
|
|||
|
||||
enabled=PackedStringArray("res://addons/gdUnit4/plugin.cfg")
|
||||
|
||||
[gdunit4]
|
||||
|
||||
ui/inspector/tree_view_mode=1
|
||||
|
||||
[input]
|
||||
|
||||
interact={
|
||||
"deadzone": 0.5,
|
||||
"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)
|
||||
]
|
||||
}
|
||||
|
||||
[physics]
|
||||
|
||||
3d/physics_engine="Jolt Physics"
|
||||
|
|
@ -49,5 +53,5 @@ common/physics_interpolation=false
|
|||
|
||||
[rendering]
|
||||
|
||||
anti_aliasing/quality/msaa_3d=2
|
||||
textures/canvas_textures/default_texture_filter=0
|
||||
anti_aliasing/quality/msaa_3d=2
|
||||
|
|
|
|||
|
|
@ -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://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"]
|
||||
|
||||
[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)
|
||||
|
|
@ -85,8 +86,11 @@ transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0
|
|||
light_specular = 0.0
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="Camera3D" type="Camera3D" 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)
|
||||
[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")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="World/IsometricFollowCamera" unique_id=1124088857]
|
||||
current = true
|
||||
fov = 50.0
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
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** the rig’s fixed
|
||||
## presentation compass (`presentation_yaw_deg` on `isometric_follow_camera.gd`).
|
||||
## This is **not** total camera compass—only the optional orbit delta.
|
||||
## Prototype UX keeps this at **0** (`allow_yaw` false).
|
||||
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
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b8x7c4m3r5t8t1
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
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
|
||||
|
||||
## 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
|
||||
var _warned_missing_follow_target: bool = false
|
||||
|
||||
@onready var camera: Camera3D = $Camera3D
|
||||
|
||||
|
||||
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
|
||||
_warn_missing_follow_target_once()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if camera == null or _state == null:
|
||||
return
|
||||
var target: Node3D = _resolve_target()
|
||||
if target == null:
|
||||
# No eye/state update until the path resolves — avoids chasing a freed or miswired node.
|
||||
_warn_missing_follow_target_once()
|
||||
return
|
||||
_warned_missing_follow_target = false
|
||||
|
||||
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:
|
||||
# `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.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
|
||||
|
||||
|
||||
func _warn_missing_follow_target_once() -> void:
|
||||
if _warned_missing_follow_target:
|
||||
return
|
||||
_warned_missing_follow_target = true
|
||||
push_warning(
|
||||
(
|
||||
"IsometricFollowCamera: follow_target_path is empty or does not resolve to a Node3D; "
|
||||
+ "camera not updating."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b8x7c4m3r5t8t2
|
||||
|
|
@ -3,6 +3,8 @@ extends Node3D
|
|||
## 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).
|
||||
## 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
|
||||
## `random_floor_bumps.gd`) before nav bake.
|
||||
|
||||
|
|
@ -11,7 +13,7 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0
|
|||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
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 _player: CharacterBody3D = $World/Player
|
||||
@onready var _ground_pick: Node3D = $GroundPick
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b8x7c4m3r5t8t3
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b8x7c4m3r5t8t4
|
||||
|
|
@ -7,26 +7,39 @@
|
|||
| **Module ID** | E1.M2 |
|
||||
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **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) |
|
||||
| **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
|
||||
|
||||
**Authority:** [Client vs server](client_server_authority.md#e1m2-isometriccameracontroller) — camera is client-local for prototype; server does not use camera pose for gameplay checks.
|
||||
|
||||
Delivers a locked isometric camera that keeps the player readable during motion: follow behavior, discrete zoom bands, occlusion handling, and explicit **no rotation** so combat telegraphs and UI remain consistent with the vision for fixed isometric presentation.
|
||||
Delivers an isometric follow camera that keeps the player readable during motion: follow behavior, discrete zoom bands, occlusion handling, and **yaw held at a fixed default for players in early prototypes** so combat telegraphs and UI stay easy to reason about. **After release, the shipped camera model is settled** (fixed presentation vs product setting that allows yaw)—we do **not** plan to revisit that post-launch. **Before release**, we may still decide to expose **yaw orbit** for comfort; the implementation follows the [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise) so that decision stays mid-project scope, not a rewrite.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Locked isometric follow (camera does not freely orbit).
|
||||
- Locked isometric follow for **player-facing controls in prototype**: **yaw = 0** (no orbit bound in UI) while the rig and **`CameraState`** still model **yaw explicitly** so optional orbit can be enabled later via config and input binding.
|
||||
- Zoom bands and configuration-driven limits.
|
||||
- Occlusion policy so critical gameplay elements remain visible where possible.
|
||||
- Enforcement of no-rotation rules for the prototype camera model.
|
||||
- Pitch / roll remain fixed for the isometric presentation; only **yaw** is the optional future degree of freedom.
|
||||
|
||||
## Mid-project rotation policy (practical compromise)
|
||||
|
||||
**Intent:** Ship prototypes and early milestones **as if** the camera were fully fixed from the player’s perspective, without closing the door to **yaw orbit** before release.
|
||||
|
||||
| Topic | Direction |
|
||||
|--------|-----------|
|
||||
| **When this applies** | **Mid-project only.** We may enable or keep disabled **yaw orbit** while still in development. **Post-release**, treat the chosen shipped behavior as **final**—no planned pivot between “fixed” and “orbit” for live players. |
|
||||
| **Implementation** | Single seam: e.g. **`allow_yaw`**, **`max_yaw_deg`**, or equivalent; default keeps **yaw at 0** and **no rotate input** until product + combat readability agree. `CameraState` carries **yaw** (default `0`) so consumers do not assume “camera yaw does not exist.” |
|
||||
| **Gameplay semantics** | Prefer **world-anchored** movement, targeting, and server checks—not **camera-relative** aim—so turning yaw on later is mostly **presentation + UI**, not a combat authority redesign. |
|
||||
| **Telegraphs & VFX** | Design **now** for a world where yaw might exist: ground shapes remain **world-accurate**; plan **screen-space or height** readability affordances where foreshortening under yaw would otherwise lie (outlines, rings, bars—exact art TBD). |
|
||||
| **Migration risk** | If we add yaw mid-project, expect a **telegraph + minimap / compass** pass. **Fixed → yaw** is usually lower risk than **yaw → fixed** (content and players may already assume spin for sightlines). |
|
||||
|
||||
## Key contracts
|
||||
|
||||
| Contract | Role |
|
||||
|----------|------|
|
||||
| `CameraState` | Current follow target, zoom level, and policy flags for consumers (e.g. risk UX). |
|
||||
| `CameraState` | Current follow target, zoom level, **yaw** (default `0`; optional future orbit), and policy flags for consumers (e.g. risk UX). |
|
||||
| `ZoomBandConfig` | Data-driven min/max or discrete zoom steps. |
|
||||
| `OcclusionPolicy` | Rules for fading, dithering, or offset when geometry blocks the view. |
|
||||
|
||||
|
|
@ -40,11 +53,33 @@ Delivers a locked isometric camera that keeps the player readable during motion:
|
|||
|
||||
## Related implementation slices
|
||||
|
||||
See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, occlusion policy, no rotation; 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
|
||||
|
||||
Parent epic: [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1).
|
||||
|
||||
| Key | Type | Summary |
|
||||
|-----|------|---------|
|
||||
| [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10) | Feature | E1.M2 — IsometricCameraController (module umbrella) |
|
||||
| [NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25) | Story | Isometric follow camera (fixed yaw prototype; CameraState seam) |
|
||||
| [NEON-29](https://neon-sprawl.atlassian.net/browse/NEON-29) | Story | Expand prototype client district for camera/nav stress QA |
|
||||
| [NEON-26](https://neon-sprawl.atlassian.net/browse/NEON-26) | Story | ZoomBandConfig + discrete zoom input (clamped) |
|
||||
| [NEON-27](https://neon-sprawl.atlassian.net/browse/NEON-27) | Story | OcclusionPolicy — keep player readable through geometry |
|
||||
| [NEON-28](https://neon-sprawl.atlassian.net/browse/NEON-28) | Story | Camera integration hardening + dependent contract notes |
|
||||
|
||||
Suggested order: NEON-25 → NEON-26 → NEON-27 → NEON-28. [NEON-29](https://neon-sprawl.atlassian.net/browse/NEON-29) may run in parallel with or after NEON-25 (scene/nav geography; does not require zoom/occlusion).
|
||||
|
||||
## Risks and telemetry
|
||||
|
||||
- Occlusion hiding telegraphs: tune policy early and include readability checklist in prototype gates.
|
||||
- Enabling **yaw orbit** mid-project without a telegraph/UI/minimap pass can undermine readability; design ground effects for **world accuracy** plus **screen-space or height** cues early (see [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)).
|
||||
|
||||
## Source anchors
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E1.M1 | InputAndMovementRuntime | None | MoveCommand, PositionState, InteractionRequest | Prototype | Ready |
|
||||
| E1.M2 | IsometricCameraController | E1.M1 | CameraState, ZoomBandConfig, OcclusionPolicy | Prototype | Planned |
|
||||
| E1.M2 | IsometricCameraController | E1.M1 | CameraState, ZoomBandConfig, OcclusionPolicy | Prototype | In progress |
|
||||
| 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)).
|
||||
|
||||
### Epic 2 — Skills and Progression Framework
|
||||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
# NEON-25 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEON-25 |
|
||||
| **Title** | E1.M2: Isometric follow camera (fixed yaw prototype; CameraState seam) |
|
||||
| **Jira** | [NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25) |
|
||||
| **Parent** | [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1) |
|
||||
| **Module** | [E1.M2 — IsometricCameraController](../decomposition/modules/E1_M2_IsometricCameraController.md); umbrella [NEON-10](https://neon-sprawl.atlassian.net/browse/NEON-10) |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Client-local isometric follow camera that tracks the E1.M1 player anchor with fixed framing for prototype play, while **modeling yaw in state** for future consumers and optional orbit.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Godot: `Camera3D` (on a small rig) for isometric presentation; follow the player / follow target each frame per **locked defaults** below (smooth follow + teleport snap).
|
||||
- **Player-facing controls:** no camera rotate / orbit; **yaw stays at default** (`0` rad) for normal play.
|
||||
- **`CameraState`** (dedicated script type): follow target reference, **yaw** (always `0` in prototype UX), default distance or zoom index (**single band** / scalar distance until NEON-26).
|
||||
- **Seam:** `@export` or config fields (e.g. `allow_yaw`, `max_yaw_deg`) default off; short comment where orbit input would apply.
|
||||
- **Semantics:** movement and picking remain **world-anchored** (existing `ground_pick` + nav); camera does not redefine aim axes.
|
||||
- Authority: camera pose stays **client-only** (see `docs/decomposition/modules/client_server_authority.md`).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Binding rotate input or shipping orbit UX.
|
||||
- Multiple zoom bands (NEON-26).
|
||||
- Occlusion policy (NEON-27).
|
||||
- Server use of camera pose.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] While the player moves via E1.M1, the avatar stays **readably framed** without manual camera control.
|
||||
- [x] **Pitch/roll** do not change from user input; **yaw** remains at configured default (`0`) for prototype UX — document how enforced.
|
||||
- [x] `CameraState` exposes yaw (even when always zero) so dependents do not assume yaw is impossible.
|
||||
- [x] **Seam** for optional yaw (config/flags) documented in code or module implementation snapshot.
|
||||
- [x] Short note in client or `E1_M2_IsometricCameraController.md` implementation snapshot when merged.
|
||||
|
||||
## Locked defaults (agreed before implementation)
|
||||
|
||||
| Topic | Choice |
|
||||
|--------|--------|
|
||||
| **Follow motion** | **Damped / lerped** toward the desired rig position; **teleport snap** when error exceeds a threshold (e.g. after `snap_to_server` or huge separation) so the camera never eases across the map. |
|
||||
| **Framing** | **Match** the current static `World/Camera3D` framing in `main.tscn` as the starting **distance / pitch / FOV**; fine-tune in the inspector after follow is wired. |
|
||||
| **`CameraState` refresh** | Rig **owns one instance** and **updates it every frame** after computing pose (simplest contract for future readers). |
|
||||
| **Follow target wiring** | **`@export NodePath`** (resolve to `Node3D` each tick); tests may assign path or set target via code as needed. |
|
||||
| **Update phase** | **`_process`** (not `_physics_process`) for the rig. |
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Introduce `CameraState`** as a small **RefCounted** (or equivalent) script under `res://scripts/` with explicit fields: follow target path (or resolved node identity in state for debug), **yaw** (`float`, default `0`), **distance** or zoom index placeholder for NEON-26, and room for future flags. Avoid `class_name` so headless CI matches [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md) guidance; tests can `preload` the script.
|
||||
2. **Isometric rig** — add a **`Node3D`** (e.g. `IsometricFollowCamera` or `CameraRig`) with script that:
|
||||
- Owns or references the active **`Camera3D`** (`current = true`).
|
||||
- Each **`_process`**: resolve **`NodePath`** follow target, read **`global_position`**, compute desired eye position from **fixed pitch** + **effective yaw** (forced to `0` when `allow_yaw` is false), **lerp** rig toward target+offset; **snap** rig/camera when separation exceeds a configured threshold.
|
||||
- Updates the owned **`CameraState`** instance **every frame** after pose is finalized.
|
||||
3. **Scene wiring** — replace the static **`World/Camera3D`** transform in `main.tscn` with a rig under `World` (camera as child), **`@export`** follow **`NodePath`** → `Player`, initial exports chosen to **match** the previous static camera readability.
|
||||
4. **`main.gd`** — keep thin: obtain rig/camera reference for **`ground_pick.fallback_camera`** (unchanged contract); no camera math in `main.gd`.
|
||||
5. **Yaw seam** — when `allow_yaw` is false: ignore any future input hook and set state yaw to `0`; when true (dev-only): clamp by `max_yaw_deg` and apply to basis (implementation stub can leave input unbound).
|
||||
6. **Docs** — add a short **Implementation snapshot** subsection to `E1_M2_IsometricCameraController.md` after merge (or README pointer + module snapshot — satisfy AC).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `client/scripts/camera_state.gd` | RefCounted (or similar) holding follow target ref/path, **yaw**, distance/zoom index placeholder, future flags; no `class_name`. |
|
||||
| `client/scripts/isometric_follow_camera.gd` | `Node3D` rig: positions/orients `Camera3D`, updates `CameraState`, exports distance/pitch/yaw seam. |
|
||||
|
||||
Godot may add companion `.uid` files when scripts are first touched in the editor; commit them if the toolchain creates them.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scenes/main.tscn` | Replace fixed `Camera3D` transform with rig + follow target wiring; keep `current` camera and layers consistent with picking. |
|
||||
| `client/scripts/main.gd` | Point `_camera` (and `ground_pick.fallback_camera`) at the rig’s **`Camera3D`**; minimal diff. |
|
||||
| `client/README.md` | One short paragraph: follow camera behavior, fixed yaw prototype, where to tune distance/pitch. |
|
||||
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | **Implementation snapshot** (date + NEON-25): rig script names, yaw seam, single-band distance until NEON-26. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | What to cover |
|
||||
|------|----------------|
|
||||
| `client/test/camera_state_test.gd` | **New** — default **yaw** is `0`; fields survive assign/read; optional: follow target path assignment (if exposed). |
|
||||
| `client/test/isometric_follow_camera_test.gd` | **New** — if pure helpers are extracted (e.g. static offset from pitch/distance/yaw), unit-test them; otherwise minimal suite: instantiate rig + dummy `Node3D` target, advance one frame, assert camera global position moves toward expected quadrant (tolerance-based). If full follow is too scene-heavy, document **manual** verification as primary and keep **camera_state** tests as the automated baseline. |
|
||||
|
||||
**Manual (required):** Run main scene with server per README: click-move across floor and **NEON-7** bumps; confirm avatar stays framed, no user-driven rotation, ground pick still hits walkable surfaces from the moving camera.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Smoothing tuning:** If follow feels floaty or laggy after the locked defaults, adjust lerp speed or snap threshold; document chosen constants in script or README.
|
||||
- **`player.gd` `_snap_capsule_upright`:** Identity basis today; camera yaw `0` matches world-aligned capsule. Future facing yaw must stay consistent with E1.M2 policy (not this story’s implementation unless trivial).
|
||||
- **gdUnit rig tests:** May require small refactor (static math helpers) for stable headless asserts; acceptable follow-up within the same story if the first PR ships manual AC + `camera_state` tests.
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Code review: NEON-25 (isometric follow camera)
|
||||
|
||||
**Date:** 2026-04-07
|
||||
**Scope:** Branch `NEON-25-isometric-follow-camera` vs `main`; issue [NEON-25](https://neon-sprawl.atlassian.net/browse/NEON-25). Commits on branch include implementation plan, locked defaults, **NEON-29** backlog row in `E1_M2_IsometricCameraController.md`, and camera implementation + tests + docs. Manual testing reported OK by author.
|
||||
**Base:** `main` (compare: `main...NEON-25-isometric-follow-camera`).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
The change replaces a static `Camera3D` with an `IsometricFollowCamera` rig that follows the player using a documented spherical offset (`desired_eye_world`), exponential smoothing, teleport snap beyond `snap_distance`, and per-frame `look_at` toward a configurable focus. A `RefCounted` `CameraState` is owned by the rig and refreshed each `_process`, with orbit yaw held at zero while `allow_yaw` is false—matching prototype policy. `main.gd` stays a thin composer; `ground_pick` still receives the active `Camera3D`. Automated tests cover `CameraState` fields and the static eye-position helper; manual verification (movement, bumps, picking) is appropriate per plan. No server or authority surface is added. Risk is low: presentation-only, well-scoped, aligns with E1.M2 and the implementation plan.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| `docs/plans/NEON-25-implementation-plan.md` | **Matches** — locked defaults (`_process`, damped follow + snap, `NodePath` follow, per-frame `CameraState`), file list, tests, and README/module snapshot are reflected in the diff. |
|
||||
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | **Matches** — implementation snapshot documents rig scripts, yaw seam, single-band distance; Jira table includes NEON-29 (separate ticket; see nits). |
|
||||
| `docs/decomposition/modules/client_server_authority.md` (E1.M2) | **Matches** — camera and `CameraState` remain client-local; no server use of camera pose. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Partially matches** — E1.M2 row still **Planned** with full contract set; NEON-25 delivers a slice (follow + `CameraState` seam), not zoom/occlusion. Updating register or module **Status** after merge is optional hygiene, not required for this PR’s correctness. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **N/A** — no E1.M2-specific row found; no conflict. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. **Null follow target:** If `follow_target_path` breaks or resolves empty, `_process` returns without updating `_smoothed_eye` or syncing state—acceptable for the current scene, but a one-line comment or dev warning could save a future scene refactor.
|
||||
2. **Register / status (optional):** After merge, consider whether `module_dependency_register.md` or the E1.M2 summary **Status** should reflect partial delivery (e.g. follow baseline landed; zoom/occlusion still open)—only if the team tracks that table at story granularity.
|
||||
|
||||
## Nits
|
||||
|
||||
- **Nit:** `camera_state.gd` header references `[member presentation_yaw_deg]`; that property lives on `isometric_follow_camera.gd`, not on `CameraState`—minor doc confusion for Godot help tooltips.
|
||||
- **Nit:** Same branch carries **NEON-29** (prototype district) backlog registration; harmless, but reviewers merging a “NEON-25 only” PR should expect that small doc delta.
|
||||
- **Nit:** Plan wording “set state yaw to `0` when `allow_yaw` is false” is satisfied via `_orbit_yaw_rad = 0` and `_state.yaw = _orbit_yaw_rad`; presentation yaw remains in exports—consistent with module intent but worth remembering for readers who expect a single “total yaw” on state.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Automated:** From `client/`, run GdUnit headless per `client/README.md` (Godot 4.6 import + `GdUnitCmdTool` on `res://test`).
|
||||
- **Manual:** (Author confirmed OK.) Re-run: server + F5, click-move floor and NEON-7 bumps, ground pick from moving camera, idle observation; optional inspector tweak of `follow_smoothness` / `snap_distance` if feel regresses on different hardware.
|
||||
Loading…
Reference in New Issue