050b8251a7
ci / verify (push) Successful in 48s
Four always-on-screen slots, items as data, and loot tables on enemies and bosses. Health potions drop rarely from trash and always from the Warden; the Warden also drops a Warden's Ration, one per living player, which does nothing at all. The ration is not filler. Player-instanced loot is a separate code path from shared loot -- a distinct entity per owner, filtered per peer in the snapshot encoder -- and the cheapest way to keep that path honest is to have something in the game that exercises it on every boss kill. Item actions ride the input frame rather than becoming new client messages. InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss redundancy, the replay guard on last_input_tick, ordering against movement on the same tick, and a rate limit of one action per tick -- all of which a separate RPC would have needed bolted back on. The cost is that anything in the frame which must not repeat has to be edge-triggered, since frames are resent and a starved server coasts on the last one it holds. Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor interest radius: a peer is never told another player's copy exists. Hiding it client-side would have been the same mistake as relying on fog to hide enemies. Inventories live on the character and are written to the store on every transaction, so a crash between "picked it up" and "wrote it down" cannot lose or duplicate an item. Anything dropped becomes world-shared whatever it was before, and a potion used at full health is refused rather than spent. tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use -> drop plus both visibilities on the wire, for the same reason diag_progression exists: bots are poor shots and almost never produce a drop. It asserts each input frame was actually consumed, after an early version silently dropped its first press and every later check passed for the wrong reason. check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
561 lines
20 KiB
GDScript
561 lines
20 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)
|
|
## 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
|