1dc1952a3c
ci / verify (push) Successful in 45s
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
- ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
not consumed until frame N+1, leaving the drawn ship a constant one tick
(4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
ClientRuntime now sets process_physics_priority = -10. Gap on a listen
server: 4.00px -> 0.10px mean, 0.30px worst.
- PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
ship, so bullets were born inside the sprite. Regression from the previous
commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.
(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.
(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.
(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.
(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.
(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.
(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.
Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
292 lines
9.9 KiB
GDScript
292 lines
9.9 KiB
GDScript
class_name ClientRuntime
|
|
extends Node
|
|
## The client half: sample input, predict the local player, interpolate everyone
|
|
## else, and replay the bullets the server told us about.
|
|
##
|
|
## The client owns exactly one thing -- where it *thinks* the local player is,
|
|
## so the stick feels instant. Every consequence (damage, death, escape, loot)
|
|
## comes back from the server and overwrites whatever the client believed.
|
|
|
|
signal instance_changed
|
|
signal hud_dirty
|
|
signal local_hit(damage: int)
|
|
|
|
var my_peer: int = 0
|
|
var instance_id: int = 0
|
|
var instance_kind: Protocol.InstanceKind = Protocol.InstanceKind.LOBBY
|
|
var boss_def: BossDef = null
|
|
|
|
## Replica world. [member SimWorld.authoritative] is false, so it integrates
|
|
## bullets and nothing else.
|
|
var world := SimWorld.new()
|
|
|
|
## Local estimate of the server's tick, used to age incoming bullets.
|
|
var server_tick_est: int = 0
|
|
var input_tick: int = 0
|
|
|
|
var predicted_pos := Vector2.ZERO
|
|
var aim: float = 0.0
|
|
var pending: Array[InputFrame] = []
|
|
|
|
# Authoritative mirror of the local player.
|
|
var my_hp: int = SimConfig.PLAYER_MAX_HP
|
|
var my_alive: bool = true
|
|
var my_escape: float = 0.0
|
|
var my_escaping: bool = false
|
|
## Arrival protection: invulnerable and unable to shoot.
|
|
var my_spawn_grace: bool = false
|
|
|
|
## Set by the in-game menu's "return to hub" -- synthesises a held escape
|
|
## button, so the menu route runs the identical server-side channel as the key.
|
|
## Cleared when the channel resolves or the player cancels.
|
|
var request_escape: bool = false
|
|
|
|
## Who is online and where, for the hub's player list. Server-pushed.
|
|
var roster: Array[Dictionary] = []
|
|
|
|
## Whole seconds until a cleared dungeon returns the party, or
|
|
## Protocol.COUNTDOWN_NONE outside that state.
|
|
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
|
|
|
var snap_prev: Dictionary = {}
|
|
var snap_curr: Dictionary = {}
|
|
var _interp: float = 0.0
|
|
var _bot_tick: int = 0
|
|
|
|
|
|
func _ready() -> void:
|
|
world.authoritative = false
|
|
# Sample and send input BEFORE ServerRuntime ticks. On a listen server both
|
|
# live in one process, and default tree order put the server first -- so the
|
|
# input sampled on frame N was not consumed until the server's frame N+1,
|
|
# leaving the drawn ship a permanent one-tick (4px at 240 u/s) ahead of the
|
|
# authoritative one. Bullets, spawned at the authoritative position, visibly
|
|
# trailed the ship. Going first closes the gap to zero on a listen server
|
|
# and costs a remote client nothing.
|
|
process_physics_priority = -10
|
|
set_physics_process(true)
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
server_tick_est += 1
|
|
input_tick += 1
|
|
|
|
var frame := _sample_input()
|
|
pending.append(frame)
|
|
# Only enough history to cover the worst reconciliation window.
|
|
while pending.size() > SimConfig.INPUT_MAX_AGE:
|
|
pending.pop_front()
|
|
|
|
if my_alive:
|
|
predicted_pos = Movement.step_player(predicted_pos, frame.move,
|
|
SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
|
|
|
# Send the last few frames every tick. Inputs are unreliable-ordered, so the
|
|
# redundancy is what covers a dropped packet without a retransmit stall.
|
|
var redundant: Array[InputFrame] = []
|
|
var from := maxi(pending.size() - 3, 0)
|
|
for i in range(from, pending.size()):
|
|
redundant.append(pending[i])
|
|
Net.send_input(NetCodec.encode_inputs(redundant))
|
|
|
|
world.step()
|
|
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
|
|
|
|
|
|
# --- Input ------------------------------------------------------------------
|
|
|
|
func _sample_input() -> InputFrame:
|
|
if GameOpts.bot_client:
|
|
return _bot_input()
|
|
var move := Input.get_vector("move_left", "move_right", "move_up", "move_down")
|
|
var mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5
|
|
var to_mouse := mouse - predicted_pos
|
|
if to_mouse.length_squared() > 1.0:
|
|
aim = to_mouse.angle()
|
|
var buttons := 0
|
|
if Input.is_action_pressed("fire"):
|
|
buttons |= InputFrame.BTN_FIRE
|
|
if Input.is_action_pressed("emergency_escape") or request_escape:
|
|
buttons |= InputFrame.BTN_ESCAPE
|
|
if Input.is_action_pressed("interact"):
|
|
buttons |= InputFrame.BTN_INTERACT
|
|
return InputFrame.make(input_tick, move, aim, buttons)
|
|
|
|
|
|
## Scripted input so `tools/smoke.sh` can play the game with no display: orbit
|
|
## the arena, fire constantly, take the portal, then punch out with the escape.
|
|
func _bot_input() -> InputFrame:
|
|
_bot_tick += 1
|
|
var t := float(_bot_tick) * SimConfig.TICK_DELTA
|
|
var move := Vector2(cos(t * 0.9), sin(t * 1.3))
|
|
aim = t * 2.1
|
|
var buttons := InputFrame.BTN_FIRE
|
|
if not my_alive:
|
|
# Downed bots ask for the hub, same as a player would -- otherwise a bot
|
|
# that dies mid-run just lies there and the smoke test stalls.
|
|
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
|
|
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
|
|
buttons |= InputFrame.BTN_INTERACT
|
|
# Walk onto the portal instead of orbiting, or interact never lands.
|
|
move = (SimConfig.PORTAL_POS - predicted_pos).normalized()
|
|
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
|
|
buttons |= InputFrame.BTN_ESCAPE
|
|
return InputFrame.make(input_tick, move, aim, buttons)
|
|
|
|
|
|
# --- Server messages --------------------------------------------------------
|
|
|
|
func on_welcome(peer_id: int) -> void:
|
|
my_peer = peer_id
|
|
set_physics_process(true)
|
|
GameLog.info("client", "welcome, peer id %d" % peer_id)
|
|
|
|
|
|
func on_roster(data: PackedByteArray) -> void:
|
|
roster = NetCodec.decode_roster(data)
|
|
hud_dirty.emit()
|
|
|
|
|
|
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
|
spawn: Vector2) -> void:
|
|
instance_id = id
|
|
instance_kind = kind as Protocol.InstanceKind
|
|
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
|
|
world.pool.clear()
|
|
snap_prev = {}
|
|
snap_curr = {}
|
|
predicted_pos = spawn
|
|
pending.clear()
|
|
server_tick_est = server_tick
|
|
input_tick = server_tick + 8
|
|
my_alive = true
|
|
my_hp = SimConfig.PLAYER_MAX_HP
|
|
my_escape = 0.0
|
|
my_escaping = false
|
|
my_spawn_grace = false
|
|
# Arriving somewhere is the natural end of an escape request, however it
|
|
# was triggered -- otherwise the synthesised button would keep firing and
|
|
# bounce the player straight back out of the hub.
|
|
request_escape = false
|
|
cleared_countdown = Protocol.COUNTDOWN_NONE
|
|
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
|
instance_changed.emit()
|
|
hud_dirty.emit()
|
|
|
|
|
|
func on_snapshot(data: PackedByteArray) -> void:
|
|
var snap := NetCodec.decode_snapshot(data)
|
|
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
|
|
return # stale or duplicate; unreliable channel, newest wins
|
|
cleared_countdown = int(snap["cleared_countdown"])
|
|
snap_prev = snap_curr
|
|
snap_curr = snap
|
|
_interp = 0.0
|
|
|
|
var tick := int(snap["tick"])
|
|
if server_tick_est < tick or server_tick_est > tick + 12:
|
|
server_tick_est = tick
|
|
# Keep the client roughly one buffer ahead of the server so inputs arrive
|
|
# just before they are needed rather than late.
|
|
var lead := input_tick - tick
|
|
if lead < 2 or lead > 16:
|
|
input_tick = tick + 8
|
|
|
|
for rec: Dictionary in snap["players"]:
|
|
if int(rec["peer"]) == my_peer:
|
|
_reconcile(rec)
|
|
break
|
|
|
|
|
|
## Rewind to the server's position, replay every input it has not seen yet, and
|
|
## land where the client should actually be right now.
|
|
func _reconcile(rec: Dictionary) -> void:
|
|
my_hp = int(rec["hp"])
|
|
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
|
|
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
|
|
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
|
|
my_escape = float(rec["escape"])
|
|
hud_dirty.emit()
|
|
|
|
var acked := int(rec["last_input_tick"])
|
|
while not pending.is_empty() and pending[0].tick <= acked:
|
|
pending.pop_front()
|
|
|
|
var p: Vector2 = rec["pos"]
|
|
if my_alive:
|
|
for f in pending:
|
|
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
|
var error := predicted_pos.distance_to(p)
|
|
if error > 24.0:
|
|
predicted_pos = p # real divergence: take the server's word
|
|
elif error > 0.5:
|
|
predicted_pos = predicted_pos.lerp(p, 0.3) # smooth out jitter
|
|
|
|
|
|
func on_events(data: PackedByteArray) -> void:
|
|
var packet := NetCodec.decode_events(data)
|
|
var catchup := clampi(server_tick_est - int(packet["tick"]), 0, 30)
|
|
for ev: Dictionary in packet["events"]:
|
|
match int(ev["t"]):
|
|
SimEvent.Type.BULLET_SPAWN:
|
|
var slot := world.pool.spawn(ev["pos"], ev["vel"], ev["r"], ev["life"], 0,
|
|
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
|
|
if slot >= 0 and catchup > 0:
|
|
world.pool.advance_slot(slot, catchup)
|
|
SimEvent.Type.BULLET_DESPAWN:
|
|
world.apply_event(ev)
|
|
SimEvent.Type.PLAYER_HIT:
|
|
if int(ev["peer"]) == my_peer:
|
|
my_hp = int(ev["hp"])
|
|
local_hit.emit(int(ev["dmg"]))
|
|
hud_dirty.emit()
|
|
SimEvent.Type.PLAYER_DIED:
|
|
if int(ev["peer"]) == my_peer:
|
|
my_alive = false
|
|
hud_dirty.emit()
|
|
SimEvent.Type.PLAYER_RESPAWNED:
|
|
if int(ev["peer"]) == my_peer:
|
|
my_alive = true
|
|
predicted_pos = ev["pos"]
|
|
pending.clear()
|
|
hud_dirty.emit()
|
|
_:
|
|
pass
|
|
|
|
|
|
# --- View queries -----------------------------------------------------------
|
|
|
|
## Remote players, interpolated between the last two snapshots. The local player
|
|
## is excluded -- the view draws [member predicted_pos] for that one.
|
|
func remote_players() -> Array[Dictionary]:
|
|
return _interpolated("players", "peer", [my_peer])
|
|
|
|
|
|
func enemies() -> Array[Dictionary]:
|
|
return _interpolated("enemies", "id", [])
|
|
|
|
|
|
func boss_state() -> Dictionary:
|
|
if snap_curr.is_empty() or snap_curr.get("boss") == null:
|
|
return {}
|
|
return snap_curr["boss"]
|
|
|
|
|
|
func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]:
|
|
var out: Array[Dictionary] = []
|
|
if snap_curr.is_empty():
|
|
return out
|
|
var prev_by_id := {}
|
|
if not snap_prev.is_empty():
|
|
for r: Dictionary in snap_prev[list_key]:
|
|
prev_by_id[r[id_key]] = r
|
|
for r: Dictionary in snap_curr[list_key]:
|
|
if exclude.has(r[id_key]):
|
|
continue
|
|
var rec: Dictionary = r.duplicate()
|
|
var old: Variant = prev_by_id.get(r[id_key])
|
|
if old != null:
|
|
rec["pos"] = (old["pos"] as Vector2).lerp(r["pos"], _interp)
|
|
out.append(rec)
|
|
return out
|