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) ## Cosmetic signals for the view. Every one is raised from a server event, not ## from a local guess, so what the player hears matches what actually happened. signal shot_fired signal enemy_died signal boss_died ## Item transactions, straight from server events. Carry the item id so the ## view can name what happened without guessing from the inventory diff. signal item_picked_up(item: StringName) signal item_used(item: StringName) signal item_dropped(item: StringName) ## The account's character roster changed: created, selected, levelled or died. signal characters_changed signal select_failed(reason: String) 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] = [] ## Movement from the most recent sampled input. Survives `pending` being ## drained, which is the whole point -- see is_moving(). var _last_move := Vector2.ZERO # Authoritative mirror of the local player. var my_hp: int = SimConfig.PLAYER_MAX_HP ## Follows the character's level, so the HUD bar cannot be computed from a ## constant. var my_max_hp: int = SimConfig.PLAYER_MAX_HP ## Lifetime experience, straight from the snapshot so the bar moves per kill ## rather than per roster message. var my_total_xp: int = 0 ## Carried items as wire indices (0 = empty slot). Replaced wholesale by every ## snapshot, so it can never drift from what the server thinks you have -- ## there is deliberately no local "I picked that up" optimism here. var my_inventory: Array[int] = [] 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] = [] ## This account's characters, and which one is being played. Server-pushed; ## the client never invents an entry. var characters: Array[Dictionary] = [] var selected_character: String = "" ## True once the server has told us the roster, so the UI can tell "no ## characters yet" from "not asked yet". var characters_known: bool = false ## 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 ## Offset the view applies when drawing the world: screen = world + this. ## Published by the game scene every frame, rather than assumed, so aiming ## stays correct if the camera ever stops being exactly centred on the player ## (clamping at map edges, screen shake, a look-ahead offset). var camera_offset := Vector2.ZERO ## Screen point to world point. The camera scrolls now, so this is no longer ## "relative to the middle of the screen" -- treating it as such made the ship ## aim at a fixed world location instead of at the cursor. static func screen_to_world(screen: Vector2, offset: Vector2) -> Vector2: return screen - offset ## 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() _last_move = frame.move 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 world_mouse := screen_to_world(get_viewport().get_mouse_position(), camera_offset) var to_mouse := world_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 # Number keys use a slot; shift-number drops it. The bit is sent for as long # as the key is held and the server takes the leading edge, so a stuck or # repeated packet cannot spend more than one item. var slot := held_slot() if slot >= 0: buttons |= InputFrame.BTN_DROP if Input.is_key_pressed(KEY_SHIFT) \ else InputFrame.BTN_USE return InputFrame.make(input_tick, move, aim, buttons, maxi(slot, 0)) ## Which inventory slot key is down, or -1. Lowest wins, so pressing 1 while 2 ## is held reads as "now slot 1" rather than as nothing. func held_slot() -> int: for i in SimConfig.INVENTORY_SLOTS: if Input.is_action_pressed("use_slot_%d" % (i + 1)): return i return -1 ## 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() var slot := 0 if instance_kind == Protocol.InstanceKind.DUNGEON: # Grab at whatever is underfoot and occasionally drink, so the item # messages are exercised over a real socket by the smoke test. Bots are # poor shots and rarely produce loot, so this is coverage of the wire # format rather than of the drop rules -- tools/diag_loot.tscn covers # those. if _bot_tick % 90 < 6: buttons |= InputFrame.BTN_INTERACT if _bot_tick % 150 == 0: buttons |= InputFrame.BTN_USE if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900: buttons |= InputFrame.BTN_ESCAPE return InputFrame.make(input_tick, move, aim, buttons, slot) # --- 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) ## Whether the local player is moving, for choosing a run vs idle animation. ## View-only; nothing in the simulation asks. ## ## Reads the last sampled input rather than the tail of `pending`, which is ## drained on every reconcile: derived from `pending` this flickered false ~20 ## times a second, so the ship alternated between its run and idle strips and ## looked like both were playing at once. func is_moving() -> bool: return _last_move.length_squared() > 0.04 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_characters(data: PackedByteArray) -> void: var decoded := NetCodec.decode_characters(data) characters = decoded["characters"] selected_character = String(decoded["selected"]) characters_known = true characters_changed.emit() hud_dirty.emit() _bot_pick_character() ## A bot has no roster screen to click, so it makes the choice the screen would ## offer: resume a living character, or create one. Without this the smoke test ## would authenticate and then stand at a menu forever. func _bot_pick_character() -> void: if not GameOpts.bot_client or not selected_character.is_empty(): return for c in characters: if c["active"]: Net.select_character(String(c["id"])) return Net.create_character(GameOpts.player_name) func on_select_result(result: int, reason: String) -> void: if result != Protocol.SelectResult.OK: GameLog.warn("client", "character selection refused: %s" % reason) select_failed.emit(reason) ## The character currently being played, or an empty dictionary while none is. func current_character() -> Dictionary: for c in characters: if String(c["id"]) == selected_character: return c return {} ## True when the player has no character in the world and must pick one -- at ## first login, or after their last one died. func needs_character() -> bool: return characters_known and selected_character.is_empty() 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 my_inventory = [] 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"]) my_inventory = snap["inventory"] 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_max_hp = int(rec["max_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"]) my_total_xp = int(rec["total_xp"]) 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() SimEvent.Type.PLAYER_FIRED: shot_fired.emit() SimEvent.Type.ENEMY_DIED: enemy_died.emit() SimEvent.Type.BOSS_DIED: boss_died.emit() SimEvent.Type.ITEM_PICKED_UP: if int(ev["peer"]) == my_peer: item_picked_up.emit(ev["item"]) hud_dirty.emit() SimEvent.Type.ITEM_USED: if int(ev["peer"]) == my_peer: item_used.emit(ev["item"]) hud_dirty.emit() SimEvent.Type.ITEM_DROPPED: if int(ev["peer"]) == my_peer: item_dropped.emit(ev["item"]) 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", []) ## Ground loot the server has told us about. Not interpolated -- items do not ## move -- and never filtered here: what arrives is already exactly what this ## player is allowed to see. func ground_loot() -> Array: if snap_curr.is_empty(): return [] return snap_curr["loot"] ## The item that pressing interact would pick up, or an empty dictionary. Purely ## for the prompt: the server does this same search for itself and does not care ## what the client concluded. func loot_in_reach() -> Dictionary: var best := {} var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS for l: Dictionary in ground_loot(): var d: float = predicted_pos.distance_squared_to(l["pos"]) if d <= best_d: best_d = d best = l return best ## True when every slot is taken, so the HUD can explain why a pickup did ## nothing rather than looking broken. func inventory_full() -> bool: if my_inventory.is_empty(): return false for index in my_inventory: if index == 0: return false return true 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