NEO-22: fix stream rubber-banding (coalesce, idle gate, soft snap)
Queue only latest move-stream target per POST; clear pending while busy. Skip stream submits when idle; skip tiny authoritative snaps after boot.pull/42/head
parent
a2c6c9f93e
commit
878b1f4aee
|
|
@ -53,7 +53,7 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen
|
|||
|
||||
## Authoritative movement (NEO-7, NEO-11, NEO-22)
|
||||
|
||||
With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with a batch of **absolute** world positions (NEO-22 **option C**). The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**; the client **snaps** to that position. **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player.
|
||||
With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with the **latest** body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). **`PositionAuthorityClient`** coalesces to **one target per request** so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**; the client **snaps** when the correction exceeds a small threshold (boot snap always applies once). **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player.
|
||||
|
||||
**Boot** `sync_from_server()` **snaps** once so spawn matches the server.
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,18 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0
|
|||
## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec).
|
||||
const STREAM_PHYSICS_INTERVAL: int = 6
|
||||
|
||||
## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps.
|
||||
const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004
|
||||
|
||||
## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise.
|
||||
const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08
|
||||
const AUTH_SNAP_MIN_VERTICAL: float = 0.04
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
var _stream_physics_counter: int = 0
|
||||
## After the first `apply_as_snap` authority update, micro-snaps may be skipped (boot always applies).
|
||||
var _allow_authority_soft_snap_skip: bool = false
|
||||
|
||||
## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**):
|
||||
## Restored when the obstacle is shown again.
|
||||
|
|
@ -75,11 +84,16 @@ func _physics_process(_delta: float) -> void:
|
|||
return
|
||||
var wish: Vector3 = _locomotion_wish_world_xz_from_camera()
|
||||
_player.call("set_locomotion_wish_world_xz", wish)
|
||||
_stream_physics_counter += 1
|
||||
if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL:
|
||||
var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
|
||||
var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ
|
||||
if moving:
|
||||
_stream_physics_counter += 1
|
||||
if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL:
|
||||
_stream_physics_counter = 0
|
||||
var batch: Array = [_player.global_position]
|
||||
_authority.call("submit_stream_targets", batch)
|
||||
else:
|
||||
_stream_physics_counter = 0
|
||||
var batch: Array = [_player.global_position]
|
||||
_authority.call("submit_stream_targets", batch)
|
||||
var p: Vector3 = _player.global_position
|
||||
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z]
|
||||
|
||||
|
|
@ -111,6 +125,13 @@ func _on_target_chosen(world: Vector3) -> void:
|
|||
|
||||
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
|
||||
if apply_as_snap:
|
||||
if _allow_authority_soft_snap_skip:
|
||||
var p: Vector3 = _player.global_position
|
||||
var dh: float = Vector2(world.x - p.x, world.z - p.z).length()
|
||||
var dy: float = absf(world.y - p.y)
|
||||
if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL:
|
||||
return
|
||||
_allow_authority_soft_snap_skip = true
|
||||
_player.snap_to_server(world)
|
||||
else:
|
||||
_player.set_authoritative_nav_goal(world)
|
||||
|
|
|
|||
|
|
@ -58,8 +58,13 @@ func submit_move_target(world: Vector3) -> void:
|
|||
_start_move_post(world)
|
||||
|
||||
|
||||
## NEO-22: append samples; sends up to [constant MOVE_STREAM_MAX_PER_REQUEST] per POST when idle.
|
||||
## NEO-22: append samples; each POST sends **only the latest** queued position so slow HTTP cannot
|
||||
## apply a stale multi-step chain after the capsule has already moved (rubber-band / bounce).
|
||||
func submit_stream_targets(targets: Array) -> void:
|
||||
if _busy:
|
||||
# In-flight POST already encodes an older chain; drop queued crumbs so we do not replay
|
||||
# past intent after this response lands.
|
||||
_stream_queue.clear()
|
||||
for t: Variant in targets:
|
||||
if t is Vector3:
|
||||
_stream_queue.append(t as Vector3)
|
||||
|
|
@ -72,11 +77,9 @@ func _try_flush_pending() -> void:
|
|||
if _busy:
|
||||
return
|
||||
if not _stream_queue.is_empty():
|
||||
var n: int = mini(_stream_queue.size(), MOVE_STREAM_MAX_PER_REQUEST)
|
||||
var batch: Array = []
|
||||
for i in range(n):
|
||||
batch.append(_stream_queue[i])
|
||||
_start_stream_post(batch)
|
||||
var latest: Vector3 = _stream_queue[_stream_queue.size() - 1]
|
||||
_stream_queue.clear()
|
||||
_start_stream_post([latest])
|
||||
return
|
||||
if _pending_move_target is Vector3:
|
||||
var w: Vector3 = _pending_move_target as Vector3
|
||||
|
|
@ -200,10 +203,7 @@ func _on_request_completed(
|
|||
_busy = false
|
||||
_try_flush_pending()
|
||||
return
|
||||
# Drop the batch we sent (validated + applied as a unit on the server).
|
||||
var drop: int = mini(_stream_in_flight_count, _stream_queue.size())
|
||||
if drop > 0:
|
||||
_stream_queue = _stream_queue.slice(drop)
|
||||
# Queue was cleared when the POST started; nothing to drop.
|
||||
_stream_in_flight_count = 0
|
||||
_busy = false
|
||||
if response_code == 200:
|
||||
|
|
|
|||
Loading…
Reference in New Issue