Merge pull request #41 from ViPro-Technologies/NEON-29-expand-prototype-district
Neon 29 expand prototype districtpull/43/head
commit
aee3ca80d8
|
|
@ -2,6 +2,9 @@
|
|||
"plugins": {
|
||||
"atlassian": {
|
||||
"enabled": true
|
||||
},
|
||||
"linear": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,15 +15,51 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose**
|
|||
|
||||
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–NEO-15 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 **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**.
|
||||
|
||||
## Prototype district (NEON-29)
|
||||
|
||||
The default play space is a **45 × 45** unit flat floor (~2 000 sq units, ~5× the pre-NEON-29 footprint). All geometry lives under `World/NavigationRegion3D` in `scenes/main.tscn`; `main.gd` rebakes the nav mesh at startup so the scene-file vertices are intentionally stale.
|
||||
|
||||
### Props at a glance
|
||||
|
||||
Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screenshots and discussion can name the right prop (`Mat_terrace_platform_a`, `Mat_terrace_b_step`, etc.). The main **floor** slab uses neutral gray (`Mat_floor_slab`). **Occluders** keep the default mesh gray.
|
||||
|
||||
| Node | Group | Tint | Size (m) | World XZ | Purpose |
|
||||
|------|-------|------|----------|----------|---------|
|
||||
| `Floor` | `walkable` | Neutral gray | 45 × 45 | (0, 0) | Main play surface |
|
||||
| `PrototypeTerminal` | `walkable` | Slate blue | 0.9 × 0.4 | (0, 0) | Interaction target |
|
||||
| `MoveRejectPedestal` | `walkable` | Orange | 1.5 × 1.5 | (7.5, −6.5) | NEON-7 vertical reject |
|
||||
| `MoveRejectFarPad` | `walkable` | Blue-gray | 2.5 × 2.5 | (9, 9) | Near-floor walkable pad; no longer a reject target (horizontal limit removed NEON-29) |
|
||||
| `Obstacle` | `occluder` | Default gray | 2 × 2 | (6, 5) | Original occlusion / click-through target |
|
||||
| `ObstacleB` | `occluder` | Default gray | 3 × 3 | (−10, 5) | Second occluder (wider angle from spawn) |
|
||||
| `ObstacleC` | `occluder` | Default gray | 2 × 4 | (5, −12) | Tall occluder, south quadrant |
|
||||
| `ObstacleD` | `occluder` | Default gray | 3 × 2 | (−8, −16) | Far-south occluder |
|
||||
| `TerracePlatformA` | `walkable` | **Teal** | 10 × 10, h=0.3 m | (13, −14) | Large SE pad; **three darker-teal approach treads per cardinal** (`TerracePlatformA_Approach` / `PAS_*`) — **1.0 m run** along the climb axis (~0.104 m rise each), wider than the **0.8 m** capsule diameter so the body is not wider than each tread |
|
||||
| `TerraceStepB` | `walkable` | **Gold** | 6 × 3, h=0.3 m | (−15, 8.5) | NW approach step; **`TerraceStepB_Approach`** / `TSB_*` — three treads per **S/E/W** (darker gold), **1.0 m** run on NS arms |
|
||||
| `TerracePlatformB` | `walkable` | **Violet** | 6 × 6, h=0.6 m | (−15, 13) | Upper NW terrace; **`TerracePlatformB_Approach`** / `TPB_*` — **six** treads per **N/E/W** (~0.104 m rise) for **0.6 m** to floor (**south** uses gold step, not violet treads) |
|
||||
| `TerracePlatformC` | `walkable` | **Green** | 8 × 8, h=0.3 m | (14, 14) | NE pad; **`TerracePlatformC_Approach`** / `PCS_*` — same tread pattern as teal (`PAS_*`), darker green albedo |
|
||||
|
||||
### Height variation notes
|
||||
|
||||
- **Single-step terraces (A, C):** 0.3 m rise — within `agent_max_climb = 0.35`, so the nav mesh routes directly from floor onto the pad; one click suffices. **`TerracePlatformA`** (teal) sits **12 mm** above the main floor slab and has **cardinal walkable treads** (`TerracePlatformA_Approach`) so movement does not rely on a bare vertical box lip; nav rebakes at startup.
|
||||
- **Two-level terrace B:** floor → `TerraceStepB` (**gold**, 0.3 m) → `TerracePlatformB` (**violet**, +0.3 m). **`agent_max_climb = 0.35`** applies to the nav mesh **and** to **walk step assist**: you cannot cheese the violet deck from the floor in one move — click the **gold step** (or the platform after you are already on the step), not the violet top from below. **Approach treads** on gold (**`TSB_*`**) and on violet’s **north / east / west** (**`TPB_*`**, six risers for 0.6 m) match the teal convention.
|
||||
- **QA / thin props:** **`PrototypeTerminal`**, **`MoveRejectPedestal`**, **`MoveRejectFarPad`** are unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment.
|
||||
- **Occluders** are plain `StaticBody3D` with no `walkable` tag; the nav bake excludes their top surfaces, so they remain true obstacles for routing.
|
||||
|
||||
### Designer / QA limits
|
||||
|
||||
- Floor bounds: **±22.5 m** in X and Z from world origin. Clicks outside the `NavigationRegion3D` boundary (beyond the floor edge) will be rejected by `NavigationAgent3D` path queries.
|
||||
- The `MoveRejectPedestal` (orange box) top is ~2.5 m above the floor and will be rejected by the server's `MaxVerticalStep` (**2.2 m** by default) — the only remaining move-rejection criterion with default server config. Horizontal distance is never a rejection reason when `HorizontalStepEnabled` is false.
|
||||
- New obstacles are tagged `"occluder"` so the NEON-27 occlusion policy and NEON-30 click-through both apply automatically.
|
||||
|
||||
## Authoritative movement (NEO-7, NEO-11)
|
||||
|
||||
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 (NEO-7); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEO-11). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
|
||||
|
||||
**Tradeoff (prototype):** `player.gd` only steers **straight in xz** toward the goal for real **descend** cases, where the pick’s **Y** is below the capsule **feet** by a small margin (smooth **stepped bumps**). Same-height floor clicks now stay on the baked **`NavigationAgent3D`** path instead of looking like a descend just because the capsule origin sits above the floor. **Automatic routing around tall obstacles on one click is still not guaranteed** for those true descend-bypass cases — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
|
||||
**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the server’s verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolt’s per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
|
||||
|
||||
**NEO-10 / movement QA bumps:** On **run**, **`spawn_short_random_bumps`** adds **two** green cylinders, each on its own **`StaticBody3D`** **sibling** of **`Floor`** under **`NavigationRegion3D`** (**`walkable`** on bump roots — avoids compound **internal-edge** jitter vs floor+cylinder on one body). Bump meshes use Godot group **`random_floor_bump_mesh`**. **Collision radius** = mesh **+ `COLLISION_RADIUS_EXTRA`** (see **`scripts/random_floor_bump_collision_constants.gd`**, capped by **`COLLISION_RADIUS_MAX`**). **`bake_navigation_mesh(false)`** after spawn.
|
||||
|
||||
**Idle stability (NEO-14):** **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`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. 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 (NEO-14 / 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`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. **Uniform shallow ramps** can latch stable idle; **nav-column path steering** is gated to flatter floors so ramp ribbons do not flip velocity each tick. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. 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`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEO-20), `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` (NEO-15 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.
|
||||
|
|
@ -34,8 +70,8 @@ With the game server running ([`server/README.md`](../server/README.md)), each v
|
|||
1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`).
|
||||
2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`).
|
||||
3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEO-9 walk demo).
|
||||
4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** toward the authoritative target (may follow nav waypoints or bee-line in xz per the tradeoff above). NEO-10 reject clicks still show the reject label and do **not** start a path.
|
||||
5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged.
|
||||
4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** in **XZ** straight toward that target (nav path only for **vertical** routing when you are already near the goal column on **flatter** floor). Server-rejected clicks show the reject label and do **not** start a path.
|
||||
5. Click the **pedestal top** (orange box at ~(7.5, 0, −6.5)) to confirm **`vertical_step_exceeded`** rejection — the top is ~2.5 m above floor, which exceeds default **`MaxVerticalStep` (2.2 m)**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) is a normal walkable surface; clicking it from any distance should succeed.
|
||||
6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run).
|
||||
|
||||
If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes.
|
||||
|
|
@ -63,9 +99,9 @@ The main scene includes a **prototype terminal** at the map center (same world *
|
|||
|
||||
## Movement prototype (NEO-5 → NEO-11)
|
||||
|
||||
**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion when following the mesh; it may steer **directly at the goal** in xz when the descend bypass applies (NEO-11). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
|
||||
**`player.gd`** steers **XZ** toward the authoritative goal and uses **`NavigationAgent3D`**’s **current path** only for the **vertical-routing** case above (on sufficiently flat floor); **`move_and_slide()`** does the motion. **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
|
||||
|
||||
- The avatar is on **physics layer 2** with **mask 1** (floor + obstacle on layer 1); the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor.
|
||||
- The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well. The pick ray uses **mask 1** only, so clicks pass through the avatar and hit the floor.
|
||||
|
||||
### Manual check (remnants)
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ dev_toggle_occluder_obstacle={
|
|||
|
||||
common/physics_ticks_per_second=120
|
||||
3d/physics_engine="Jolt Physics"
|
||||
3d/default_gravity=9.81
|
||||
|
||||
[rendering]
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,8 @@
|
|||
extends Node3D
|
||||
|
||||
## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main.
|
||||
## Flat tops on `occluder` bodies (horizontal normal) are valid destinations; vertical occluder
|
||||
## faces still punch through so ground behind can be picked.
|
||||
## NS-19: reject vertical faces (pedestal). Stepped ray: steep walkable hits (bump slopes) advance
|
||||
## along the ray so the next hit can be flat floor beyond — fixes “stuck” when leaving plateau.
|
||||
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
|
||||
|
|
@ -64,6 +66,17 @@ func _try_pick(screen_pos: Vector2) -> void:
|
|||
break
|
||||
var hit_collider: Variant = hit.get("collider")
|
||||
if _collider_is_occluder(hit_collider):
|
||||
var hit_normal_o: Variant = hit.get("normal", Vector3.ZERO)
|
||||
if hit_normal_o is not Vector3:
|
||||
break
|
||||
var nrm_o: Vector3 = (hit_normal_o as Vector3).normalized()
|
||||
var ndot_o: float = nrm_o.dot(Vector3.UP)
|
||||
if ndot_o >= MIN_WALKABLE_NORMAL_DOT_UP:
|
||||
var top_pos: Variant = hit.get("position")
|
||||
if top_pos is Vector3:
|
||||
chosen = top_pos as Vector3
|
||||
have_chosen = true
|
||||
break
|
||||
var occluder_pos: Variant = hit.get("position")
|
||||
if occluder_pos is not Vector3:
|
||||
break
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ extends Node3D
|
|||
## (see `isometric_follow_camera.gd`).
|
||||
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
|
||||
## `random_floor_bumps.gd`) before nav bake.
|
||||
## Prototype: `PhysicsRampTest*` (+X extension: west slab ends before the dip;
|
||||
## `PhysicsRampTestFloorExtensionNorthFill` / `SouthFill` restore walkable floor past the dip in ±Z;
|
||||
## block; gentle ramp on **+Z**; steep on **+X**), plus `PhysicsSmoothHillTest` /
|
||||
## `PhysicsSmoothDipTest` (HeightMap collision).
|
||||
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
|
||||
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
|
||||
## under `World` only (not nav source geometry).
|
||||
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel`
|
||||
## (updated in `_physics_process` so it matches physics ticks).
|
||||
|
||||
const MOVE_REJECT_MSG_SECONDS: float = 4.0
|
||||
|
||||
|
|
@ -29,10 +38,12 @@ var _dev_obstacle_smoke: Node3D
|
|||
@onready var _authority: Node = $PositionAuthorityClient
|
||||
@onready var _radius_preview: Node3D = $World/InteractionMarkers
|
||||
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
|
||||
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_physics_process(true)
|
||||
set_process_unhandled_key_input(true)
|
||||
await get_tree().process_frame
|
||||
_dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D
|
||||
|
|
@ -54,6 +65,13 @@ func _ready() -> void:
|
|||
_radius_preview.call("setup_player", _player)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label):
|
||||
return
|
||||
var p: Vector3 = _player.global_position
|
||||
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z]
|
||||
|
||||
|
||||
func _on_target_chosen(world: Vector3) -> void:
|
||||
_authority.call("submit_move_target", world)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,124 @@
|
|||
extends StaticBody3D
|
||||
## Procedural smooth grid for the physics ramp strip: raised hill or sunken dip.
|
||||
## Uses HeightMapShape3D for collision (CharacterBody3D tunnels through ConcavePolygonShape3D
|
||||
## trimesh).
|
||||
## Keep the extension slab (`PhysicsRampTestFloorExtension`) short of this body's xz footprint so a
|
||||
## dip is not covered by a flat floor collider/render.
|
||||
|
||||
enum PieceKind { HILL, DIP }
|
||||
|
||||
@export var kind: PieceKind = PieceKind.HILL
|
||||
@export var footprint_x: float = 7.0
|
||||
@export var footprint_z: float = 4.5
|
||||
@export var amplitude: float = 0.42
|
||||
@export var subdiv_x: int = 36
|
||||
@export var subdiv_z: int = 22
|
||||
## Slight Y offset so the rim is not coplanar with the slab (reduces z-fighting with the floor).
|
||||
@export var base_lift: float = 0.022
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = 1
|
||||
_build_mesh_and_visual()
|
||||
_build_heightmap_collision()
|
||||
|
||||
|
||||
func _build_mesh_and_visual() -> void:
|
||||
var mi: MeshInstance3D = get_node_or_null("MeshInstance3D") as MeshInstance3D
|
||||
if mi == null:
|
||||
push_error("prototype_smooth_hill_piece: expected MeshInstance3D child.")
|
||||
return
|
||||
|
||||
var sx: int = maxi(3, subdiv_x)
|
||||
var sz: int = maxi(3, subdiv_z)
|
||||
var half_x: float = footprint_x * 0.5
|
||||
var half_z: float = footprint_z * 0.5
|
||||
var height_sign := 1.0 if kind == PieceKind.HILL else -1.0
|
||||
|
||||
var verts := PackedVector3Array()
|
||||
verts.resize((sx + 1) * (sz + 1))
|
||||
var uvs := PackedVector2Array()
|
||||
uvs.resize(verts.size())
|
||||
|
||||
var idx := 0
|
||||
for j in sz + 1:
|
||||
var v: float = float(j) / float(sz)
|
||||
var z: float = lerpf(-half_z, half_z, v)
|
||||
for i in sx + 1:
|
||||
var u: float = float(i) / float(sx)
|
||||
var x: float = lerpf(-half_x, half_x, u)
|
||||
var envelope: float = sin(PI * u) * sin(PI * v)
|
||||
var y: float = base_lift + height_sign * amplitude * envelope
|
||||
verts[idx] = Vector3(x, y, z)
|
||||
uvs[idx] = Vector2(u, v)
|
||||
idx += 1
|
||||
|
||||
var st := SurfaceTool.new()
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
for j in sz:
|
||||
for i in sx:
|
||||
var a: int = j * (sx + 1) + i
|
||||
var b: int = a + 1
|
||||
var c: int = a + (sx + 1)
|
||||
var d: int = c + 1
|
||||
for tri in [[a, c, b], [b, c, d]]:
|
||||
st.set_uv(uvs[tri[0]])
|
||||
st.add_vertex(verts[tri[0]])
|
||||
st.set_uv(uvs[tri[1]])
|
||||
st.add_vertex(verts[tri[1]])
|
||||
st.set_uv(uvs[tri[2]])
|
||||
st.add_vertex(verts[tri[2]])
|
||||
st.generate_normals()
|
||||
var mesh: ArrayMesh = st.commit()
|
||||
mi.mesh = mesh
|
||||
mi.material_override = _qa_material_for_kind()
|
||||
|
||||
|
||||
func _build_heightmap_collision() -> void:
|
||||
var cs: CollisionShape3D = get_node_or_null("CollisionShape3D") as CollisionShape3D
|
||||
if cs == null:
|
||||
push_error("prototype_smooth_hill_piece: expected CollisionShape3D child.")
|
||||
return
|
||||
|
||||
var sx: int = maxi(3, subdiv_x)
|
||||
var height_sign := 1.0 if kind == PieceKind.HILL else -1.0
|
||||
|
||||
var mw: int = maxi(3, sx + 1)
|
||||
var md: int = maxi(3, subdiv_z + 1)
|
||||
var s: float = footprint_x / float(mw - 1)
|
||||
while (md - 1) * s < footprint_z - 0.0001 and md < 512:
|
||||
md += 1
|
||||
|
||||
var data := PackedFloat32Array()
|
||||
data.resize(mw * md)
|
||||
for j in md:
|
||||
var v: float = float(j) / float(md - 1) if md > 1 else 0.0
|
||||
for i in mw:
|
||||
var u: float = float(i) / float(mw - 1) if mw > 1 else 0.0
|
||||
var envelope: float = sin(PI * u) * sin(PI * v)
|
||||
var y_world: float = base_lift + height_sign * amplitude * envelope
|
||||
data[j * mw + i] = y_world / s
|
||||
|
||||
var hm := HeightMapShape3D.new()
|
||||
hm.map_width = mw
|
||||
hm.map_depth = md
|
||||
hm.map_data = data
|
||||
cs.shape = hm
|
||||
cs.scale = Vector3(s, s, s)
|
||||
cs.position = Vector3.ZERO
|
||||
|
||||
|
||||
func _qa_material_for_kind() -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
m.roughness = 0.45
|
||||
m.metallic = 0.0
|
||||
m.emission_enabled = true
|
||||
m.emission_energy_multiplier = 0.85
|
||||
if kind == PieceKind.HILL:
|
||||
m.albedo_color = Color(0.98, 0.12, 0.42)
|
||||
m.emission = Color(0.45, 0.02, 0.15)
|
||||
else:
|
||||
m.albedo_color = Color(0.08, 0.35, 0.98)
|
||||
m.emission = Color(0.02, 0.12, 0.45)
|
||||
return m
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dqm8s4k2h1xwy
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Idle-support stability tests for res://scripts/player.gd (static helpers).
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const PLAYER_SCRIPT: Script = preload("res://scripts/player.gd")
|
||||
|
||||
|
||||
func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
# Must be steeper than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] (0.998) *and* below
|
||||
# [member IDLE_SLOPE_STABLE_MIN_UP_DOT] (0.88), or the ramp short-circuit treats support as stable.
|
||||
var tilted_floor: Vector3 = Vector3(0.5, 0.82, 0.0).normalized()
|
||||
assert_that(tilted_floor.dot(Vector3.UP)).is_less(
|
||||
PLAYER_SCRIPT.get("IDLE_SLOPE_STABLE_MIN_UP_DOT")
|
||||
)
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, tilted_floor, slide_normals, 0)
|
||||
assert_that(stable).is_false()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_min_flat_up_dot_parameter() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var floor_n: Vector3 = Vector3(0.05, 0.99875, 0.0).normalized()
|
||||
var strict: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 0, 0.999)
|
||||
var holdish: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 0, 0.992)
|
||||
assert_that(strict).is_false()
|
||||
assert_that(holdish).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_true_when_flat_floor_has_extra_contacts() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(0.0, 0.6, 0.8).normalized()]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_true_when_loose_ticks_and_ridged_but_floor_still_level() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(1.0, 0.0, 0.0)]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_loose_ticks_and_ridged_and_shallow_floor() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(1.0, 0.0, 0.0)]
|
||||
# Normal must be outside the ramp-stable band [IDLE_SLOPE_STABLE_MIN_UP_DOT, IDLE_RIM) or
|
||||
# `idle_ridged_stair_lip_only` is false and the ramp short-circuit returns stable. Use a tread
|
||||
# tilt above the rim dot but still below the ridged hold threshold (0.992).
|
||||
var floor_n: Vector3 = Vector3(0.14, 0.98, 0.0).normalized()
|
||||
var up_dot: float = floor_n.dot(Vector3.UP)
|
||||
assert_that(up_dot).is_greater_equal(PLAYER_SCRIPT.get("IDLE_RIM_MIN_FLOOR_UP_DOT") as float)
|
||||
assert_that(up_dot).is_less(PLAYER_SCRIPT.get("STABLE_IDLE_FLOOR_MIN_UP_DOT") as float)
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 12)
|
||||
assert_that(stable).is_false()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_true_when_loose_ticks_but_flat_open_support() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_true_when_loose_ticks_and_shallow_seam_not_ridged() -> void:
|
||||
var n2: Vector3 = Vector3(0.0, 0.42, sqrt(1.0 - 0.42 * 0.42)).normalized()
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP, n2]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_not_on_floor() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(false, Vector3.UP, slide_normals, 0)
|
||||
assert_that(stable).is_false()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://b7k2idle8support1test
|
||||
|
|
@ -48,36 +48,18 @@ func test_clear_nav_goal_clears_velocity_and_nav_target() -> void:
|
|||
assert_that(nav.target_position).is_equal(p.global_position)
|
||||
|
||||
|
||||
func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(
|
||||
true, Vector3(0.02, 0.995, 0.0).normalized(), slide_normals, 0
|
||||
)
|
||||
assert_that(stable).is_false()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_true_when_flat_floor_has_extra_contacts() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(0.0, 0.6, 0.8).normalized()]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_loose_ticks_active() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12)
|
||||
assert_that(stable).is_false()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_not_on_floor() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(false, Vector3.UP, slide_normals, 0)
|
||||
assert_that(stable).is_false()
|
||||
func test_nav_goal_lifecycle_resets_vert_route_latch() -> void:
|
||||
var p := _make_player()
|
||||
p.set_authoritative_nav_goal(Vector3(1.0, 0.0, 2.0))
|
||||
p.set("_walk_vert_route_latched", true)
|
||||
p.clear_nav_goal()
|
||||
assert_that(p.get("_walk_vert_route_latched")).is_false()
|
||||
p.set("_walk_vert_route_latched", true)
|
||||
p.set_authoritative_nav_goal(Vector3(3.0, 0.0, 4.0))
|
||||
assert_that(p.get("_walk_vert_route_latched")).is_false()
|
||||
p.set("_walk_vert_route_latched", true)
|
||||
p.snap_to_server(Vector3(0.0, 0.9, 0.0))
|
||||
assert_that(p.get("_walk_vert_route_latched")).is_false()
|
||||
|
||||
|
||||
func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void:
|
||||
|
|
@ -89,7 +71,7 @@ func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void:
|
|||
|
||||
p.global_position = Vector3(1.2, 0.7, 2.3)
|
||||
p.call("_hold_idle_anchor")
|
||||
assert_that(p.global_position).is_equal(Vector3(1.0, 0.7, 2.0))
|
||||
assert_that(p.global_position).is_equal(Vector3(1.0, 0.5, 2.0))
|
||||
|
||||
|
||||
func test_snap_to_server_clears_idle_anchor() -> void:
|
||||
|
|
@ -119,6 +101,39 @@ func test_vertical_arrival_error_stays_large_for_origin_height_only_match() -> v
|
|||
assert_that(absf(err - 0.5)).is_less(0.000001)
|
||||
|
||||
|
||||
func test_arrival_vert_err_large_for_step_goal_from_floor() -> void:
|
||||
# Player at floor height (body Y=0.9). Goal at step surface (Y=0.3).
|
||||
# Using actual capsule bottom (CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS) the error
|
||||
# must exceed VERT_ARRIVE_EPS so the arrival check does NOT fire prematurely.
|
||||
var goal_y := 0.3
|
||||
var body_y := 0.9
|
||||
var full_half: float = (
|
||||
(
|
||||
PLAYER_SCRIPT.get("CAPSULE_HALF_HEIGHT") as float
|
||||
+ PLAYER_SCRIPT.get("PLAYER_CAPSULE_RADIUS")
|
||||
)
|
||||
as float
|
||||
)
|
||||
var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_y, full_half)
|
||||
assert_that(err).is_greater(PLAYER_SCRIPT.get("VERT_ARRIVE_EPS") as float)
|
||||
|
||||
|
||||
func test_arrival_vert_err_zero_when_standing_on_step() -> void:
|
||||
# Player body centred at goal_y + total_half_height (i.e. actually standing on the step).
|
||||
# Error must be within VERT_ARRIVE_EPS so arrival fires correctly once the player is up.
|
||||
var goal_y := 0.3
|
||||
var full_half: float = (
|
||||
(
|
||||
PLAYER_SCRIPT.get("CAPSULE_HALF_HEIGHT") as float
|
||||
+ PLAYER_SCRIPT.get("PLAYER_CAPSULE_RADIUS")
|
||||
)
|
||||
as float
|
||||
)
|
||||
var body_y: float = goal_y + full_half
|
||||
var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_y, full_half)
|
||||
assert_that(err).is_less_equal(PLAYER_SCRIPT.get("VERT_ARRIVE_EPS") as float)
|
||||
|
||||
|
||||
func test_flat_floor_goal_is_not_below_feet_height_margin() -> void:
|
||||
var goal_y := 0.0
|
||||
var body_origin_y := 0.4
|
||||
|
|
@ -127,3 +142,8 @@ func test_flat_floor_goal_is_not_below_feet_height_margin() -> void:
|
|||
goal_y < PLAYER_SCRIPT.capsule_feet_y(body_origin_y, 0.5) - descend_margin
|
||||
)
|
||||
assert_that(below_feet_margin).is_false()
|
||||
|
||||
|
||||
func test_step_assist_max_surface_delta_matches_navigation_mesh_agent_max_climb() -> void:
|
||||
# Keep in sync with NavigationMesh `agent_max_climb` in `scenes/main.tscn`.
|
||||
assert_that(PLAYER_SCRIPT.get("WALK_STEP_ASSIST_MAX_SURFACE_DELTA") as float).is_equal(0.35)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# NEO-22 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-22 |
|
||||
| **Title** | Retire prototype click-to-move; ship WASD locomotion on the client |
|
||||
| **Linear** | [NEO-22](https://linear.app/neon-sprawl/issue/NEO-22) |
|
||||
| **Parent** | [E1.M1 InputAndMovementRuntime](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4) (or current movement project) |
|
||||
| **Supersedes / deprecates (prototype)** | [NEO-5](NEO-5-implementation-plan.md), [NEO-11](NEO-11-implementation-plan.md) click-to-move surface; [NEO-14](NEO-14-implementation-plan.md) seam/jitter mitigations tied to nav + goal steering |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Replace **mouse click → ground pick → `MoveCommand` → nav path → `player.gd` goal steering** with **keyboard WASD** (and optional gamepad later) as the **primary** locomotion input in the Godot prototype. Movement should feel predictable on stepped geometry without the column / nav-ribbon / slide-normal fight that click-to-move accumulated.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **Input:** `project.godot` actions (or code) for **W/A/S/D**; read them in a thin place (`main.gd` or a small `locomotion_input.gd`) and drive the player’s **horizontal** intent each physics tick.
|
||||
- **Locomotion model:** `CharacterBody3D` + `move_and_slide()` with velocity derived from **wish direction × speed**, camera-relative or world-relative (decide in [Decisions](#decisions)); **no `NavigationAgent3D` path goal** for routine walking unless a later slice reintroduces hybrid “click to mark + auto-run” explicitly.
|
||||
- **Remove / disable** left-click ground pick as the default move trigger (`ground_pick.gd` → `main.gd` → `PositionAuthorityClient.submit_move_target`). Either delete the flow for this slice or gate it behind a dev flag documented as off by default.
|
||||
- **Server / authority:** Resolve how WASD maps to **NEO-7 `MoveCommand`** and **authoritative position** (see [Open questions](#open-questions--risks)). Minimum acceptable outcomes are spelled in acceptance criteria — pick one path and document it in `client/README.md` + decomposition.
|
||||
- **Cleanup:** Delete or drastically narrow **NEO-11 / NEO-14 / NEO-16** workaround blocks in `player.gd` (column seam damp, nav column steering, idle trace scaffolding tied to click goals) **only** after WASD path is stable; prefer one commit that switches input then a follow-up that deletes dead code.
|
||||
- **Docs:** Update `client/README.md`, `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md`, and this plan’s checklist when shipped.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Full **client prediction + server reconciliation** for FPS-grade netcode (unless explicitly pulled into this story by product).
|
||||
- **Click-to-interact** changes (NEO-12-style interaction) except where click conflicts with removed move binding (document if unchanged).
|
||||
- **Occlusion / camera** feature work (NEO-17/20) beyond what’s needed so WASD direction feels correct with the follow camera.
|
||||
- **Production** navmesh tooling for NPCs — only player move input changes here.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] **WASD** moves the avatar on the default **`main.tscn`** floor with **no** left-click requirement; speed and direction are stable on flat ground and on **stepped** QA props without the prior ±`MOVE_SPEED` seam oscillation at column/stair corners (subjective + optional `debug_idle_trace` spot-check).
|
||||
- [ ] **Left-click** no longer starts a walk by default (or is clearly dev-only and off in normal play, documented).
|
||||
- [ ] **Authoritative position** still converges with the server: document whether **continuous** sync, **throttled** `MoveCommand`, or **temporary client-only** prototype applies — and add the minimal client/server change so CI + manual steps do not lie.
|
||||
- [ ] **Cold boot `snap_to_server`** still works; no spawn underground / stale goal regressions.
|
||||
- [ ] **NEO-10** rejection UX: if clicks are removed, another path still exercises reject payloads in dev (automated test or documented console-only command) **or** clicks remain only for “set goal” debug, not default locomotion.
|
||||
- [ ] **GdUnit** (or existing harness) covers at least one pure helper for wish-dir / speed clamp if logic is non-trivial.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Linear + branch:** Issue **NEO-22** exists in Backlog; on kickoff set **In Progress** and branch `neo-22-retire-click-to-move-implement-wasd-locomotion-godot-client` (or team naming).
|
||||
2. **Authority model (blocking):** Decide with product/server owner:
|
||||
- **A)** Client integrates WASD locally only for prototype; server receives **rate-limited** position or move deltas (may need new endpoint — likely **out of this slice** unless already planned), or
|
||||
- **B)** Keep **discrete `MoveCommand`** but drive target from **keyboard-sampled “stick to last intent”** (awkward), or
|
||||
- **C)** Server accepts a **stream** of small moves (largest change — schedule separately).
|
||||
Document chosen option in README before merging locomotion feel work.
|
||||
3. **Input layer:** Map WASD to `Vector2` in screen/camera space → world XZ tangent to `Camera3D` basis (same pattern as many third-person controllers); normalize; zero when no keys.
|
||||
4. **`player.gd` refactor:** Replace `_has_walk_goal` / `_auth_walk_goal` steering path with **wish velocity** from parent or injected each `_physics_process`; keep gravity, floor snap policy, and `snap_to_server` entry points. Strip `NavigationAgent3D` dependency for default walk if decision says so (agent node can stay in scene unused until a later hybrid story).
|
||||
5. **Remove click wiring:** Disconnect `target_chosen` → `submit_move_target` in `main.gd` for default build; optionally keep `ground_pick.gd` for debug “teleport goal” behind `OS.is_debug_build()` flag.
|
||||
6. **Tests + manual:** Run README manual path; add unit test for direction math if extracted.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Choice | Rationale |
|
||||
|------|--------|-----------|
|
||||
| **Issue id** | **NEO-22** | Created in Linear 2026-04-16; plan filename matches this key. |
|
||||
| **Camera vs world steering** | **TBD in kickoff** — default recommendation: **camera-relative XZ** | Matches isometric follow rig; document if world-axis is chosen instead. |
|
||||
| **NavAgent** | Default **off** for routine WASD in this slice | Removes nav-ribbon + column latch class of bugs; re-add only with explicit hybrid design. |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| None required. | Prefer extending existing scripts unless `locomotion_input.gd` (or similar) is needed to keep `main.gd` thin per `godot-client-script-organization`. If extracted, add here and list in “Files to modify” as N/A for that line. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/project.godot` | Declare **`move_forward` / `move_back` / `move_left` / `move_right`** (or reuse Godot defaults) bound to WASD. |
|
||||
| `client/scripts/main.gd` | Stop wiring click pick to `submit_move_target` by default; optionally forward WASD intent to player/authority per chosen model. |
|
||||
| `client/scripts/ground_pick.gd` | Disable default `_input` pick **or** gate behind export/debug; update header comments (NS-16 lineage). |
|
||||
| `client/scripts/player.gd` | Replace goal/nav horizontal steering with WASD-driven velocity; delete obsolete seam/nav state once stable. |
|
||||
| `client/scripts/position_authority_client.gd` | Align with authority model (throttle, stream, or document client-only prototype). |
|
||||
| `client/scenes/main.tscn` | Only if `NavigationAgent3D` or nodes are removed/changed or input singleton paths change. |
|
||||
| `client/README.md` | Replace “click-move” manual checks with WASD + server steps; note deprecation of click-to-move prototype. |
|
||||
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Snapshot: click-to-move retired in favor of WASD for prototype; link this plan. |
|
||||
| `docs/plans/NEO-11-implementation-plan.md` | Add one-line “superseded for locomotion input by NEO-22” in header or Resolution if policy is to annotate predecessors. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `client/test/player_test.gd` | **Change:** remove or replace tests that assumed `_has_walk_goal` / nav steering; add tests for any extracted **wish-direction** or speed clamp helper. |
|
||||
| `client/test/position_authority_client_test.gd` | **Change:** align with new submit/sync cadence if `PositionAuthorityClient` changes. |
|
||||
| **Manual (required)** | README path: boot with server, WASD on flat + stepped geometry, `snap_to_server`, and whichever NEO-10 rejection path remains after click removal. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Server contract:** Today’s **`MoveCommand`** is **discrete target**-shaped ([NEO-7](NEO-7-implementation-plan.md)). WASD needs **continuous** intent — either extend the protocol, rate-limit many small commands, or temporarily diverge prototype authority. **This is the main schedule risk**; do not merge feel-only client changes without an explicit written decision in README + this plan.
|
||||
- **Interaction vs pick:** Confirm **E** / interaction does not rely on the same mouse button in a way that breaks when pick is disabled.
|
||||
- **Gamepad:** Out of scope unless trivial (`Input.get_vector`); note for follow-up story.
|
||||
- **Jira naming:** If the team uses **NEON-*** keys elsewhere, align Linear/Git subject lines per [linear-git-naming](../../.cursor/rules/jira-git-naming.md).
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
# NEON-29 Implementation Plan — Expand prototype client district for camera/nav stress QA
|
||||
|
||||
## Story reference
|
||||
|
||||
- **Key:** [NEON-29](https://neon-sprawl.atlassian.net/browse/NEON-29)
|
||||
- **Title:** E1.M2: Expand prototype client district for camera/nav stress QA
|
||||
- **Parent epic:** [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1)
|
||||
- **Labels:** E1, E1.M2, client-qa-district, decomposition
|
||||
|
||||
---
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
### Goal
|
||||
|
||||
Provide a **~5× footprint** (order-of-magnitude) prototype play space versus the current district, with **more obstacles** and **height-changing regions** (steps, terraces, bumps) so day-to-day client work can stress **motion, framing, and readability** for the camera, navigation, ground pick, and interaction systems.
|
||||
|
||||
### Scope
|
||||
|
||||
- `client/scenes/main.tscn`: expand walkable floor / nav source geometry; add props that participate in `NavigationRegion3D` bake and `walkable` picking as today; include elevated terrace/step platforms for vertical variation.
|
||||
- `client/README.md`: short runbook note covering district scale, intent, and any new designer-facing limits.
|
||||
- Optional: pointer in `docs/decomposition/modules/E1_M2_IsometricCameraController.md` that this geography is the default stress playground for client stories.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- New camera features (zoom bands NEON-26, occlusion NEON-27) — geography may help test them, but this story does not implement them.
|
||||
- Server schema or MoveCommand contract changes unless existing NEON-7 step/bounds defaults visibly break on the larger map (in that case: document + minimal config tune only).
|
||||
- Production art pass or zone graph / Epic 4 content.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Playable area is **materially larger** (~5× order of magnitude vs pre-change footprint) with **additional obstacles** and **vertical variation** usable with click-to-move.
|
||||
- [ ] **NavigationRegion3D** bakes successfully at startup; long clicks behave per existing NEON-8 tradeoffs (document if new geometry exposes new edge cases).
|
||||
- [ ] **Ground pick** and `walkable` grouping remain correct on new surfaces.
|
||||
- [ ] README updated for anyone running F5 + server.
|
||||
|
||||
---
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Current baseline
|
||||
|
||||
The current `main.tscn` district is a single **20 × 20** unit flat floor (`BoxMesh_floor`, placed at Y = -0.1), giving an approximate playable area of ~400 sq units. Static props:
|
||||
|
||||
- `PrototypeTerminal` (0.9 × 1 × 0.4) at origin
|
||||
- `MoveRejectPedestal` (1.5 × 2.5 × 1.5) at (7.5, 0, -6.5)
|
||||
- `MoveRejectFarPad` (2.5 × 0.12 × 2.5) at (9, 0.06, 9)
|
||||
- `Obstacle` (2 × 2 × 2, `occluder` group) at (6, 0, 5)
|
||||
- Player spawn at (-5, 0.9, -5)
|
||||
|
||||
The `NavigationMesh` stored in the scene file is intentionally stale; `main.gd` calls `bake_navigation_mesh(false)` at startup so the live mesh always reflects actual collision geometry.
|
||||
|
||||
### Expansion plan
|
||||
|
||||
**Floor size:** Replace the 20 × 20 floor with a **45 × 45** unit floor, giving ~2025 sq units (~5× of 400). Keep floor mesh center at origin (X=0, Z=0) so spawn position and existing props stay roughly central.
|
||||
|
||||
**Additional obstacles (nav stress + camera occlusion):** Add 3–4 extra `StaticBody3D` nodes in the `occluder` group, distributed across the expanded area — positioned so the follow camera must work around varying angles.
|
||||
|
||||
**Vertical variation (terraces / steps):** Add 2–3 raised terrace platforms as `StaticBody3D` nodes in the `walkable` group, elevated ~0.5–2.5 m above the main floor, with ramp/step approach geometry. These are `StaticBody3D` siblings of `Floor` under `NavigationRegion3D` so the runtime bake includes them as nav sources. Each terrace:
|
||||
- Has its own `CollisionShape3D` (BoxShape3D).
|
||||
- Is in the `walkable` group so ground-pick raycasts hit it correctly.
|
||||
- Has a small `StaticBody3D` ramp or step approach (also `walkable`) so `NavigationAgent3D` can route onto it within the NEON-8 step/climb parameters (`agent_max_climb = 0.35` in existing NavMesh settings — ramp grade must respect this, or use multi-step approach).
|
||||
|
||||
**MoveReject props:** Keep `MoveRejectPedestal` and `MoveRejectFarPad` in place — they exercise NEON-7 reject behavior and must remain reachable. Shift `MoveRejectFarPad` outward if needed to stay within the expanded floor bounds and remain "far."
|
||||
|
||||
**NavigationMesh settings:** No changes to agent parameters (height=1.0, radius=0.4, max_climb=0.35, cell_size=0.15) — they remain aligned with the player capsule per NEON-8. The stale baked mesh in the scene will update at runtime. The `geometry_collision_mask = 1` stays, so all collision-layer-1 bodies under the region contribute.
|
||||
|
||||
**NEON-7 edge case check:** With a larger floor the server's `step_size` / `MaxDistance` defaults may now allow clicks that would previously have been out-of-range. Document in the README if this changes observed reject behavior; no server code change unless a new reject case is broken (not just shifted in range).
|
||||
|
||||
---
|
||||
|
||||
## Files to add
|
||||
|
||||
None. All geometry lives in the existing scene file; no new GDScript logic is required.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scenes/main.tscn` | Replace 20×20 floor with 45×45; add terrace/step platforms and extra obstacle props; update pre-baked `NavigationMesh` vertices (will be overwritten at runtime, but keeping a reasonable approximation avoids editor warnings). |
|
||||
| `client/README.md` | Add district runbook note: new footprint, vertical variation, intent as stress playground, updated manual-check geometry references. |
|
||||
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Optional lightweight pointer that this expanded geography is the default QA map for E1.M2 camera stories. |
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
No new automated GDScript tests are added for this story — there is no new GDScript logic. This story is pure scene geometry.
|
||||
|
||||
**Manual verification replaces automated tests:**
|
||||
|
||||
1. **F5** with server running: confirm player spawns and nav bake completes without errors in Output.
|
||||
2. Click-to-move across the expanded floor, up/down each terrace, and through obstacles — confirm `NavigationAgent3D` routes and `move_and_slide` behaves per NEON-8 tradeoffs.
|
||||
3. Ground-pick regression: click behind each new `occluder`-tagged obstacle with player in and out of occlusion fade range (per NEON-30 manual check in README).
|
||||
4. Confirm `MoveRejectPedestal` and `MoveRejectFarPad` still reject correctly.
|
||||
5. Follow camera: walk to far corners of the expanded floor — confirm `IsometricFollowCamera` tracks smoothly without snapping or losing the player (snap_distance not tripped by longer walks).
|
||||
6. Interaction radius: walk near `PrototypeTerminal` to confirm range indicators still work correctly.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
- **No horizontal step limit (resolved):** The server's `MaxHorizontalStep = 18 m` was too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). Added `HorizontalStepEnabled` bool to `MovementValidationOptions` (defaults `false`), matching the `DistrictBoundsEnabled` pattern. The horizontal check is now skipped by default; the property remains available to opt back in via config. `MoveRejectFarPad` no longer exercises a horizontal range reject; it retains its walkable surface for vertical-step regression only.
|
||||
- **Step assist guard/lift bug fixed (resolved):** `_try_walk_step_assist` in `player.gd` compared the goal's surface Y against the **capsule centre** (`global_position.y ≈ 0.9`) instead of the actual capsule bottom (`global_position.y − CAPSULE_HALF_HEIGHT − PLAYER_CAPSULE_RADIUS = 0.0`). This disabled the assist for any surface below ≈ 0.925 m — including all three terrace platforms. The lift formula used offsets of 0.45/0.55 instead of the full total half-height (0.9), giving zero or negative lift. Fixed: guard now uses `actual_feet_y`; lift targets `goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` via `clampf`.
|
||||
- **Arrival check using wrong capsule reference (resolved):** `vertical_arrival_error` was called with `CAPSULE_HALF_HEIGHT = 0.5`, giving code-feet at `body_y − 0.5 = 0.4` at floor level. Step surfaces (Y = 0.3) are only 0.1 m from that reference. When the Jolt physics engine nudges the capsule body down to ~0.8 m (bottom hemisphere contacts the step edge), code-feet becomes 0.3 — matching the step surface Y and making `vert_err = 0 ≤ VERT_ARRIVE_EPS`. If the player is also within `ARRIVE_EPS = 0.35 m` horizontally, the arrival check fires immediately, clearing the nav goal before the capsule has moved. Fixed: pass `CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9) to `vertical_arrival_error` so actual feet = `body_y − 0.9`. Step goal vert_err is now 0.3 >> 0.055 from floor height; arrival fires correctly only once the step assist has lifted the capsule onto the surface (body_y = 1.2, vert_err ≈ 0). Two regression tests added.
|
||||
- **snap_to_server places capsule underground (resolved):** The server stores the raw surface Y from the client's click (e.g. 0.6 for a terrace platform), not the capsule body-centre Y (which should be 0.6 + 0.9 = 1.5). `snap_to_server` was setting `global_position.y` directly to this surface Y, putting the capsule bottom at −0.3 m. With `_floor_angle_loose_ticks = 0` and a stale `is_on_floor() = true`, the idle path's `_stable_idle_support()` check passed and held the player without calling `move_and_slide()`, preventing Jolt from resolving the penetration. Fixed: clamp `global_position.y ≥ CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9 m) in `snap_to_server`; also set `_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP` (96 ticks) to force corrective idle physics before the next walk goal.
|
||||
- **Step assist undone by floor snap (resolved):** Each `_try_walk_step_assist()` cycle lifts the capsule 0.11 m. The prior `floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m` meant the snap always reached the lower floor (0.11 m below the lifted bottom), pulling the capsule straight back down every tick. The player "nudged and stopped" because each lift was immediately cancelled. Fixed: added `WALK_STEP_ASSIST_SNAP = 0.09 m` constant (below 0.11 m so the snap cannot reach the previous floor, but above 0.03 m so it catches the step surface once cleared) and a `_step_assist_active` boolean flag. During active climbing, `floor_snap_length` is set to `WALK_STEP_ASSIST_SNAP` in both `_after_walk_move_and_slide()` and the main physics loop. The flag is cleared when the player lands cleanly on a floor without wall contact (`is_on_floor() and not is_on_wall()`), or when the nav goal is cleared/snapped.
|
||||
- **Terrace B descend / climb (resolved, iterated):** Scene lifts and **descend-bypass** bee-lines caused regressions (stuck climbs, lip oscillation, wrong-direction slides). **Removed the descend bypass:** vertical moves (e.g. platform ↔ floor) use the **baked nav path + step assist** only. **Walk gravity** is **`!is_on_floor()`** again — never “goal below feet” while still walking on an upper surface (that applied gravity across the whole **TerracePlatformB** deck and destroyed horizontal motion). **`velocity.y`** is cleared by the horizontal steer each tick as before.
|
||||
- **Lip stuck (floor→platform / platform→floor):** QA screenshots for this were on **`TerracePlatformA`** (10 × 10 large SE pad, **0.3 m** single step — not the NW **Terrace B** step/platform pair). Step assist required **`vel_h.dot(want) ≤ 0.52`**, so a **head-on** push into the vertical face (dot ≈ 1) **never** triggered the lift. **Removed** that gate. For **platform→floor**, **`floor_block_on_wall = false`** only when the nav goal is **below** the feet **and** **`is_on_wall()`** or a **wall-ish slide normal** exists, so open deck walking stays stable.
|
||||
- **Teal pad still stuck (follow-up):** Step assist used **slide normals only**; Jolt often exposes the lip via **`is_on_wall()`** / **`get_wall_normal()`** instead — re-added a **directional** wall check (`wnh.dot(want) < -0.02`). Descend relax now also triggers on **shallow floor normal** (`get_floor_normal().dot(UP) < 0.992`) and slightly wider vertical-ish slide band. **`TerracePlatformA`** root **Y = 0.012 m** breaks **coplanarity** with the main floor without the larger gap that hurt **TerraceStepB** approach.
|
||||
- **Nav waypoint steering dropped (movement):** Following **`get_next_path_position()`** by default caused **near-zero** horizontal progress under **Jolt** + **120 Hz** (waypoint advance / finished edge cases), read as universal **stuck** movement even with the server up. **`player.gd`** now **bee-lines in XZ** toward **`_auth_walk_goal`** and uses path-based steer only when **`needs_vertical_routing`** (Schmitt **`WALK_VERT_ROUTE_LATCH_*`**) **and** **`NAV_COLUMN_STEER_*`** hysteresis **and** **`is_on_floor()`** (mid-air column steer caused **180° XZ flips** while falling). **`_set_horizontal_velocity_from_nav_path_or_goal`** scans **`get_current_navigation_path()`** for the first waypoint **≥ `NAV_PATH_STEER_MIN_LOOKAHEAD` (0.22 m)** in XZ, with a **goal-direction dot** guard — raw **`get_next_path_position()`** and a **5 cm** scan both sat inside Jolt’s per-tick slide and could flip velocity **180°** each tick.
|
||||
- **Walk ledge / void gravity (iterated):** **`is_on_floor()`** + **`floor_snap_length`** could keep the capsule **glued** while crossing void to a lower click. **Fix:** **multi-sample** down rays under the **foot disk** (offsets along **move direction**; depth ~**0.32**); apply gravity when **none** hit; **`floor_snap_length = 0`** that tick so snap does not cancel **`velocity.y`**. Dropped a **floor-normal** gate that skipped the probe on **lip** contacts.
|
||||
- **Step assist vs `agent_max_climb`:** Nav **`agent_max_climb = 0.35`** only constrained mesh links; **bee-line + chained step assist** could still climb **TerracePlatformB** (~**0.6 m** from floor) in one click. **`_try_walk_step_assist`** now returns false when **`_auth_walk_goal.y > feet + WALK_STEP_ASSIST_MAX_SURFACE_DELTA`** (**0.35**, same as the mesh).
|
||||
- **Teal cardinal / tread shake (rollback):** Aggressive tweaks (**0.16** lift, **1**-tick cooldown, **`descending_stall`** toggling **`floor_block_on_wall`**, **seam-based** step-assist clear, **walk-stall nav replan**) caused **oscillation** on the small approach treads. **Reverted** to **`WALK_STEP_ASSIST_DELTA = 0.11`**, **`WALK_STEP_ASSIST_COOLDOWN_TICKS = 8`**, **no** stall replan, **no** `descending_stall` branch, **no** seam-based assist clear — only the original clear (clean floor without wall, or airborne with no slides). **Climb** wallish snap cap applies only when **`goal.y > feet_y + 0.12`** so shallow tread-to-tread motion is not capped to assist snap every frame.
|
||||
- **Teal pad — geometry wins:** Script-only mitigations still failed in practice; **`TerracePlatformA_Approach`** adds **twelve** `walkable` **`StaticBody3D`** treads (three per cardinal) with **~0.104 m** rise each, **darker teal** albedo, flush to the platform lip — nav + `CharacterBody3D` use **sloped contact** instead of fighting a **single vertical face**. **Follow-up:** tread **run** was **0.45 m** along the climb axis while the capsule **diameter is 0.8 m** — the body was wider than each tread, causing straddle / stuck motion when **entering** and blocking horizontal progress toward the lip when **exiting**. Treads widened to **1.0 m** run with positions re-centered. **`NavigationAgent3D`** `path_desired_distance` / `target_desired_distance` were briefly lowered to **0.22** but that caused **floor** movement to **stall**; **reverted to 0.35**.
|
||||
|
||||
## Approach treads (district convention)
|
||||
|
||||
- **Shipped in scene:** **`TerracePlatformC_Approach`** (`PCS_*`, teal-style **3×4** cardinals, **7.2 m** NS / **WE** span), **`TerraceStepB_Approach`** (`TSB_*`, **S/E/W** only — north meets violet), **`TerracePlatformB_Approach`** (`TPB_*`, **N/E/W**, **six** ~**0.104 m** risers for **0.6 m** deck height; south remains **gold step**). **Smooth ramps** for other props can follow the same rise/run idea without new `player.gd` logic.
|
||||
- **Idle jitter on thin treads / flat stops:** (1) **`idle_support_is_stable`**: post-stop **`loose_ticks` + ridged** blocks stable idle only when **`floor_normal.dot(UP) < IDLE_RIM_MIN_FLOOR_UP_DOT`** — flat tread + vertical riser (e.g. **TerraceStepB_Approach** corner) no longer spends ~0.8 s in corrective idle. (2) **`use_loose_floor_angle`** not tied to **`loose_ticks` alone**. (3) **`STABLE_IDLE_FLOOR_MIN_UP_DOT` 0.998** (was 0.999 — Jolt flat-floor normals often ~0.9991+ so the anchor rarely latched and **`move_and_slide`** + nudges drifted HUD XZ); **`STABLE_IDLE_ENTER_STREAK_FRAMES` 2**; **`STABLE_IDLE_UNLATCH_TICKS` 10**; ridged **wallish ny < 0.35**; **stable latch + Y anchor**. (4) **Arrival** stable shortcut (immediate latch, no enter streak). (5) **Nav column steer** hysteresis. (6) **`needs_vertical_routing` Schmitt latch**; **`debug_idle_trace`** walking extras.
|
||||
- **Not in this pass:** **`PrototypeTerminal`**, **MoveReject** props, **runtime random bumps** — add geometry when those need climbable access.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **NavMesh bake time:** A 45×45 floor with several terraces will bake more polygons than the 20×20 baseline. Expected to be negligible for `bake_navigation_mesh(false)` on desktop, but monitor Output for a perceptible pause.
|
||||
- **Agent climb on terraces:** `agent_max_climb = 0.35` limits single-step height. Each terrace transition is 0.30 m; verify in Godot editor nav mesh debug overlay before shipping.
|
||||
3
gdlintrc
3
gdlintrc
|
|
@ -32,7 +32,8 @@ function-preload-variable-name: ([A-Z][a-z0-9]*)+
|
|||
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
|
||||
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
|
||||
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
max-file-lines: 1000
|
||||
# `player.gd` is a single CharacterBody3D controller (~1.4k lines); keep headroom until it splits.
|
||||
max-file-lines: 1600
|
||||
max-line-length: 100
|
||||
max-public-methods: 20
|
||||
max-returns: 6
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@ public class MoveCommandApiTests
|
|||
{
|
||||
b.ConfigureTestServices(services =>
|
||||
{
|
||||
services.PostConfigure<GamePositionOptions>(o => o.MovementValidation.MaxHorizontalStep = 1.0);
|
||||
services.PostConfigure<GamePositionOptions>(o =>
|
||||
{
|
||||
o.MovementValidation.HorizontalStepEnabled = true;
|
||||
o.MovementValidation.MaxHorizontalStep = 1.0;
|
||||
});
|
||||
});
|
||||
});
|
||||
var client = factory.CreateClient();
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ namespace NeonSprawl.Server.Tests.Game.PositionState;
|
|||
|
||||
public class MoveCommandValidationTests
|
||||
{
|
||||
private static MovementValidationOptions Rules(double maxH, double maxV, bool bounds = false) =>
|
||||
private static MovementValidationOptions Rules(
|
||||
double maxH, double maxV, bool bounds = false, bool horizontalEnabled = true) =>
|
||||
new()
|
||||
{
|
||||
HorizontalStepEnabled = horizontalEnabled,
|
||||
MaxHorizontalStep = maxH,
|
||||
MaxVerticalStep = maxV,
|
||||
DistrictBoundsEnabled = bounds,
|
||||
|
|
@ -63,6 +65,15 @@ public class MoveCommandValidationTests
|
|||
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldAllow_WhenHorizontalDisabled()
|
||||
{
|
||||
var from = new PositionSnapshot(0, 0, 0, 0);
|
||||
var to = new PositionVector { X = 1000, Y = 0, Z = 1000 };
|
||||
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code));
|
||||
Assert.Equal("", code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,13 +18,16 @@ public static class MoveCommandValidation
|
|||
{
|
||||
reasonCode = "";
|
||||
|
||||
var dx = to.X - from.X;
|
||||
var dz = to.Z - from.Z;
|
||||
var horizontal = Math.Sqrt(dx * dx + dz * dz);
|
||||
if (horizontal > rules.MaxHorizontalStep)
|
||||
if (rules.HorizontalStepEnabled)
|
||||
{
|
||||
reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded;
|
||||
return false;
|
||||
var dx = to.X - from.X;
|
||||
var dz = to.Z - from.Z;
|
||||
var horizontal = Math.Sqrt(dx * dx + dz * dz);
|
||||
if (horizontal > rules.MaxHorizontalStep)
|
||||
{
|
||||
reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var dy = Math.Abs(to.Y - from.Y);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ namespace NeonSprawl.Server.Game.PositionState;
|
|||
/// <summary>Per-command movement sanity limits (NS-19). Bound from <c>Game:MovementValidation</c>.</summary>
|
||||
public sealed class MovementValidationOptions
|
||||
{
|
||||
/// <summary>Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.</summary>
|
||||
/// <summary>When true, enforces <see cref="MaxHorizontalStep"/>. False (default) means no horizontal limit.</summary>
|
||||
public bool HorizontalStepEnabled { get; set; }
|
||||
|
||||
/// <summary>Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.
|
||||
/// Only applied when <see cref="HorizontalStepEnabled"/> is true.</summary>
|
||||
public double MaxHorizontalStep { get; set; } = 18.0;
|
||||
|
||||
/// <summary>Max absolute delta on Y in one move (meters). Inclusive at the limit.</summary>
|
||||
public double MaxVerticalStep { get; set; } = 1.25;
|
||||
public double MaxVerticalStep { get; set; } = 2.2;
|
||||
|
||||
/// <summary>When true, target must lie inside the axis-aligned district box (inclusive min/max).</summary>
|
||||
public bool DistrictBoundsEnabled { get; set; }
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@
|
|||
"Z": -5
|
||||
},
|
||||
"MovementValidation": {
|
||||
"HorizontalStepEnabled": false,
|
||||
"MaxHorizontalStep": 18,
|
||||
"MaxVerticalStep": 1.25,
|
||||
"MaxVerticalStep": 2.2,
|
||||
"DistrictBoundsEnabled": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,11 +70,11 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
|
|||
| Config key | Meaning | Default (see `appsettings.json`) |
|
||||
|------------|---------|-----------------------------------|
|
||||
| `Game:MovementValidation:MaxHorizontalStep` | Max XZ displacement per command (m); inclusive at limit | `18` |
|
||||
| `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `1.25` |
|
||||
| `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `2.2` |
|
||||
| `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` |
|
||||
| `Game:MovementValidation:DistrictMinX` … `DistrictMaxZ` | District extents when enabled | ±12 / Y −2…24 |
|
||||
|
||||
**Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**8–14 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~2.5 m above floor so a floor-level click yields **`vertical_step_exceeded`**; **far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`**. The client’s **`ground_pick.gd`** only accepts ray hits whose surface **normal** is mostly **upward** (`MIN_WALKABLE_NORMAL_DOT_UP`), so **vertical faces** on the pedestal do not emit move targets—this complements server per-step limits and prevents “stair-stepping” up walls.
|
||||
**Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**8–14 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so a floor-level click still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so clicking the floor from it is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled. The client’s **`ground_pick.gd`** only accepts ray hits whose surface **normal** is mostly **upward** (`MIN_WALKABLE_NORMAL_DOT_UP`), so **vertical faces** on the pedestal do not emit move targets—this complements server per-step limits and prevents “stair-stepping” up walls.
|
||||
|
||||
Request body (example):
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue