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 ## Seconds left before the HUD's return-to-hub button becomes available. The ## server enforces the same lockout; this only drives the button's look. var my_respawn_wait: float = 0.0 ## Set by the HUD button, not by a key. Death is deliberately exited through a ## deliberate click rather than whatever the player happened to be holding. var request_respawn: 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 ## Where this world's dungeon portal is. Per-map now, so it has to be told. var portal_pos := Vector2.ZERO ## Backstop for input-numbering drift: if the server stops acknowledging new ## inputs, our tick numbering has fallen outside its acceptance window and no ## amount of waiting fixes it. Counted in snapshots, not ticks. var _last_acked: int = -1 var _ack_stall: int = 0 var snap_prev: Dictionary = {} var snap_curr: Dictionary = {} var _interp: float = 0.0 var _bot_tick: int = 0 var _dungeon_ticks: 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, world.map) # 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) _maybe_bot_leave() ## Bot harness: quit cleanly mid-run so the smoke test proves a polite ## disconnect is caught by the same channel a SIGKILL is. Deferred because ## Net.shutdown() frees this node. func _maybe_bot_leave() -> void: if GameOpts.bot_leave_after <= 0 or instance_kind != Protocol.InstanceKind.DUNGEON: return _dungeon_ticks += 1 if _dungeon_ticks == GameOpts.bot_leave_after: GameLog.info("client", "BOT_GRACEFUL_LEAVE") Net.shutdown.call_deferred() # --- Input ------------------------------------------------------------------ func _sample_input() -> InputFrame: if GameOpts.bot_client: return _bot_input() if not my_alive: # Downed: no movement, no fire, and the interact key does nothing. The # only way out is the HUD button, which sets request_respawn. var dead_buttons := InputFrame.BTN_INTERACT if request_respawn else 0 return InputFrame.make(input_tick, Vector2.ZERO, aim, dead_buttons) 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 = (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) ## Re-number our input stream relative to the server and drop the history that ## was numbered under the old scheme -- replaying it would apply inputs the ## server never accepted. func _resync_input_tick(server_tick: int, why: String) -> void: input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD pending.clear() _ack_stall = 0 _last_acked = -1 GameLog.warn("client", "input re-sync: %s" % why) func on_map_chunks(from_instance: int, data: PackedByteArray) -> void: # A chunk still in flight when we changed instances describes the wrong map. if from_instance != instance_id or world.map == null: return NetCodec.decode_map_chunks_into(world.map, data) 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, map_w: int, map_h: int, portal: Vector2) -> void: instance_id = id instance_kind = kind as Protocol.InstanceKind portal_pos = portal boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null # We are told how big the map is and nothing else. Every tile starts UNKNOWN # and is filled in by streaming as the player moves, so the client never # holds terrain it has not been near -- there is no seed here to regenerate # from. Origin matches the server's centre_on_origin(). var blank := MapGrid.new(maxi(map_w, 1), maxi(map_h, 1), MapGrid.Kind.UNKNOWN) blank.centre_on_origin() world.set_map(blank) world.pool.clear() snap_prev = {} snap_curr = {} predicted_pos = spawn pending.clear() server_tick_est = server_tick input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD _last_acked = -1 _ack_stall = 0 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 request_respawn = false my_respawn_wait = 0.0 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. The band is deliberately # narrower than the server's acceptance window (SimConfig.INPUT_MAX_LEAD), # so we always correct before the server starts dropping anything. var lead := input_tick - tick if lead < SimConfig.INPUT_LEAD_MIN or lead > SimConfig.INPUT_LEAD_MAX: _resync_input_tick(tick, "lead %d out of band" % lead) 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"]) my_respawn_wait = float(rec["respawn_wait"]) if my_alive: request_respawn = false hud_dirty.emit() var acked := int(rec["last_input_tick"]) # If the server is not consuming anything we send, our numbering is outside # its window; lead alone cannot detect that, because a wrong lead looks # perfectly normal from here. This is what makes the failure self-healing. if acked == _last_acked: _ack_stall += 1 if _ack_stall >= SimConfig.INPUT_ACK_STALL_LIMIT: _resync_input_tick(int(snap_curr["tick"]), "server stopped acknowledging input") return else: _last_acked = acked _ack_stall = 0 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, world.map) 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