Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s
ci / verify (push) Successful in 45s
The real cause of the ship/bullet separation, which the previous commit only half-addressed. The server dropped inputs past a lead of 12 while the client only re-synced past 16, so a client whose lead drifted into 13-16 had every input silently rejected while believing its timing was fine. The server coasted on held_input and then stopped; the client kept predicting. The two separated permanently and the reconciler fought it every snapshot -- "shoved around". It needed two independent clocks to drift, hence "only after some time", and nothing in the loop could notice, hence "then persists". The listen-server diagnostic could never reproduce it: one process, one physics tick, lead constant by construction. Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a test; and an ack-stall detector re-syncs when last_input_tick stops advancing, which catches the whole class regardless of cause -- lead alone cannot, because a wrong lead looks normal from the client. diag_prediction.gd now injects a +14 tick drift and exits non-zero unless the gap recovers. Also: - No invulnerability frames. Every bullet that touches a player lands; i-frames made dense patterns safer than sparse ones, which inverts the genre. Measured: a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s drifting. spawn_grace remains the only invulnerable state. - Death is exited with a HUD button, disabled for the first 3s. The lockout is enforced in SimWorld, not just by graying the button -- a client that ignores its own UI still waits. The interact key no longer respawns. - Joining a server that is not there no longer drops the player into an empty lobby they cannot act in. Net.join() only creates an ENet object; the game scene now waits for the server to actually place us in an instance, with an 8s timeout, and headless runs exit non-zero instead of idling. Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass.
This commit is contained in:
+33
-6
@@ -42,19 +42,46 @@ const PLAYER_BULLET_SPEED := 620.0
|
||||
const PLAYER_BULLET_RADIUS := 4.0
|
||||
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
||||
const PLAYER_BULLET_DAMAGE := 6
|
||||
const PLAYER_IFRAMES := 36 # ticks of invulnerability after a hit
|
||||
## There are deliberately NO invulnerability frames after a hit. In a bullet
|
||||
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
|
||||
## into a single cheap hit -- you get punished for the first bullet and gifted
|
||||
## the next thirty. Every bullet that touches you lands. Arrival protection
|
||||
## below is the one exception, and it is a transition, not a combat mechanic.
|
||||
|
||||
## Invulnerable *and* unable to shoot on entering a dungeon, so arriving into a
|
||||
## live bullet field is survivable. Both halves matter: invulnerability alone
|
||||
## would make the spawn point a free firing position.
|
||||
const SPAWN_GRACE_TICKS := 120 # 2 seconds
|
||||
## A downed player cannot leave for the hub until this has elapsed. Enforced
|
||||
## here rather than only by grey-ing out the button, because a disabled button
|
||||
## is a suggestion -- the server is the only thing a modified client cannot
|
||||
## argue with.
|
||||
const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds
|
||||
|
||||
# --- Anti-cheat guards ------------------------------------------------------
|
||||
# --- Input timing -----------------------------------------------------------
|
||||
# The client numbers its input frames INPUT_TARGET_LEAD ahead of the server's
|
||||
# tick and re-syncs whenever its lead leaves [INPUT_LEAD_MIN, INPUT_LEAD_MAX].
|
||||
# The server accepts anything within INPUT_MAX_LEAD.
|
||||
#
|
||||
# INPUT_MAX_LEAD *must* stay comfortably above INPUT_LEAD_MAX. When it did not
|
||||
# (12 vs 16), a client whose lead drifted into the gap had every input silently
|
||||
# rejected while believing its timing was fine -- so the server coasted on a
|
||||
# stale input and the player's ship and the authoritative position separated
|
||||
# permanently. Two independent clocks have to drift for it to happen, which is
|
||||
# why it only showed up minutes in, and never recovered.
|
||||
## Inputs older than this (relative to the newest accepted) are discarded.
|
||||
const INPUT_MAX_AGE := 30
|
||||
## Inputs claiming to be further ahead than this of the server tick are clamped.
|
||||
const INPUT_MAX_LEAD := 12
|
||||
## Hard ceiling on inputs consumed from one peer in a single tick.
|
||||
const INPUT_MAX_PER_TICK := 4
|
||||
## Where the client aims to sit.
|
||||
const INPUT_TARGET_LEAD := 8
|
||||
## The client re-syncs its numbering outside this band.
|
||||
const INPUT_LEAD_MIN := 3
|
||||
const INPUT_LEAD_MAX := 20
|
||||
## Inputs claiming to be further ahead than this of the server tick are dropped.
|
||||
const INPUT_MAX_LEAD := 40
|
||||
## Snapshots with no forward progress on last_input_tick before the client
|
||||
## assumes its numbering is out of the server's window and hard re-syncs. The
|
||||
## backstop that makes the failure above self-healing whatever its cause.
|
||||
const INPUT_ACK_STALL_LIMIT := 8
|
||||
|
||||
# --- Emergency escape -------------------------------------------------------
|
||||
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
|
||||
|
||||
+50
@@ -5,9 +5,15 @@ extends Node
|
||||
|
||||
const GAME_SCENE := preload("res://src/game.tscn")
|
||||
|
||||
## How long to wait for a server to answer before giving up. ENet's own
|
||||
## connection_failed can take considerably longer than a player's patience.
|
||||
const CONNECT_TIMEOUT_SEC := 8.0
|
||||
|
||||
var _menu: Control = null
|
||||
var _game: Node = null
|
||||
var _ticks: int = 0
|
||||
var _connecting: bool = false
|
||||
var _connect_deadline: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -30,6 +36,8 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if _connecting and _now() > _connect_deadline:
|
||||
_abort_connect("no answer from %s:%d" % [GameOpts.host_address, GameOpts.port])
|
||||
_ticks += 1
|
||||
if GameOpts.autoquit_ticks > 0 and _ticks >= GameOpts.autoquit_ticks:
|
||||
GameLog.info("main", "autoquit after %d ticks" % _ticks)
|
||||
@@ -59,15 +67,49 @@ func _show_menu() -> void:
|
||||
add_child(_menu)
|
||||
|
||||
|
||||
## Note what this deliberately does NOT do: enter the game. Net.join() only
|
||||
## means "an ENet client object was created" -- it says nothing about anything
|
||||
## being at the other end. Showing the world at that point dropped the player
|
||||
## into an empty lobby they could not act in, because no server had ever
|
||||
## acknowledged them. The game scene now waits for the server to actually place
|
||||
## us in an instance.
|
||||
func _join(address: String, port: int) -> void:
|
||||
GameOpts.host_address = address
|
||||
GameOpts.port = port
|
||||
if Net.join(address, port) != OK:
|
||||
_menu_status(Net.last_error)
|
||||
return
|
||||
_connecting = true
|
||||
_connect_deadline = _now() + CONNECT_TIMEOUT_SEC
|
||||
_menu_busy(true)
|
||||
_menu_status("connecting to %s:%d ..." % [address, port])
|
||||
Net.client.instance_changed.connect(_on_session_ready, CONNECT_ONE_SHOT)
|
||||
|
||||
|
||||
func _now() -> float:
|
||||
return float(Time.get_ticks_msec()) / 1000.0
|
||||
|
||||
|
||||
## The server has accepted the handshake and placed us somewhere. Only now is
|
||||
## there a world worth drawing.
|
||||
func _on_session_ready() -> void:
|
||||
_connecting = false
|
||||
_enter_game()
|
||||
|
||||
|
||||
func _abort_connect(reason: String) -> void:
|
||||
_connecting = false
|
||||
GameLog.error("main", "connection failed: %s" % reason)
|
||||
if GameOpts.autojoin:
|
||||
# Headless/scripted run: there is no menu to fall back to, and silently
|
||||
# idling would make a broken smoke test look like a passing one.
|
||||
get_tree().quit(1)
|
||||
return
|
||||
Net.shutdown()
|
||||
_show_menu()
|
||||
_menu_status(reason)
|
||||
|
||||
|
||||
## Listen server: one process runs the authoritative world and a local client
|
||||
## connected over loopback. The client takes no shortcuts because of it -- it
|
||||
## still only sends input and still only learns outcomes from snapshots.
|
||||
@@ -100,9 +142,17 @@ func _menu_status(text: String) -> void:
|
||||
_menu.set_status(text)
|
||||
|
||||
|
||||
func _menu_busy(busy: bool) -> void:
|
||||
if _menu != null:
|
||||
_menu.set_busy(busy)
|
||||
|
||||
|
||||
func _on_net_state(state: Net.State) -> void:
|
||||
match state:
|
||||
Net.State.FAILED:
|
||||
if _connecting:
|
||||
_abort_connect(Net.last_error)
|
||||
return
|
||||
_show_menu()
|
||||
_menu_status(Net.last_error)
|
||||
Net.State.OFFLINE:
|
||||
|
||||
@@ -35,6 +35,13 @@ 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.
|
||||
@@ -48,6 +55,12 @@ var roster: Array[Dictionary] = []
|
||||
## Protocol.COUNTDOWN_NONE outside that state.
|
||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||
|
||||
## 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
|
||||
@@ -98,6 +111,11 @@ func _physics_process(delta: float) -> void:
|
||||
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
|
||||
@@ -142,6 +160,17 @@ func on_welcome(peer_id: int) -> void:
|
||||
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_roster(data: PackedByteArray) -> void:
|
||||
roster = NetCodec.decode_roster(data)
|
||||
hud_dirty.emit()
|
||||
@@ -158,7 +187,9 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
predicted_pos = spawn
|
||||
pending.clear()
|
||||
server_tick_est = server_tick
|
||||
input_tick = server_tick + 8
|
||||
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
|
||||
@@ -168,6 +199,8 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
# 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()
|
||||
@@ -187,10 +220,12 @@ func on_snapshot(data: PackedByteArray) -> void:
|
||||
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.
|
||||
# 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 < 2 or lead > 16:
|
||||
input_tick = tick + 8
|
||||
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:
|
||||
@@ -206,9 +241,26 @@ func _reconcile(rec: Dictionary) -> void:
|
||||
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()
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ static func encode_snapshot(world: SimWorld,
|
||||
var flags := 0
|
||||
if p.alive:
|
||||
flags |= Protocol.F_ALIVE
|
||||
if p.iframes > 0:
|
||||
flags |= Protocol.F_INVULN
|
||||
if p.escape_ticks > 0:
|
||||
flags |= Protocol.F_ESCAPING
|
||||
if p.spawn_grace > 0:
|
||||
@@ -37,6 +35,9 @@ static func encode_snapshot(world: SimWorld,
|
||||
flags |= Protocol.F_LINKDEAD
|
||||
b.put_u8(flags)
|
||||
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
|
||||
# Whole ticks are more than the HUD needs; a byte of tenths of a second
|
||||
# covers the lockout with room to spare.
|
||||
b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255))
|
||||
# Echoed so the owning client knows how far to rewind when reconciling.
|
||||
b.put_u32(p.last_input_tick)
|
||||
|
||||
@@ -85,6 +86,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"hp": b.get_u16(),
|
||||
"flags": b.get_u8(),
|
||||
"escape": float(b.get_u8()) / 255.0,
|
||||
"respawn_wait": float(b.get_u8()) / 10.0,
|
||||
"last_input_tick": b.get_u32(),
|
||||
})
|
||||
|
||||
|
||||
+6
-5
@@ -4,8 +4,10 @@ extends RefCounted
|
||||
## server refuses mismatched clients at handshake rather than desyncing later.
|
||||
|
||||
## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
|
||||
## roster message.
|
||||
const VERSION := 2
|
||||
## roster message.
|
||||
## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte
|
||||
## to each player record.
|
||||
const VERSION := 3
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
@@ -22,10 +24,9 @@ enum InstanceKind { LOBBY, DUNGEON }
|
||||
|
||||
## Player flags packed into the snapshot's per-player byte.
|
||||
const F_ALIVE := 1
|
||||
const F_INVULN := 2
|
||||
const F_ESCAPING := 4
|
||||
## Arrival protection: invulnerable and unable to shoot. Distinct from F_INVULN
|
||||
## so the client can label the status rather than just tint the ship.
|
||||
## Arrival protection: invulnerable and unable to shoot. The only invulnerable
|
||||
## state there is -- see the i-frames note in SimConfig.
|
||||
const F_SPAWN_GRACE := 8
|
||||
## The peer behind this player has dropped and is being channelled out.
|
||||
const F_LINKDEAD := 16
|
||||
|
||||
+11
-4
@@ -9,10 +9,11 @@ var pos := Vector2.ZERO
|
||||
var aim: float = 0.0
|
||||
var hp: int = SimConfig.PLAYER_MAX_HP
|
||||
var alive: bool = true
|
||||
var iframes: int = 0
|
||||
var fire_cooldown: int = 0
|
||||
## Ticks of arrival protection left: invulnerable, and unable to shoot.
|
||||
var spawn_grace: int = 0
|
||||
## Ticks before a downed player may ask to return to the hub.
|
||||
var respawn_lockout: int = 0
|
||||
|
||||
## The peer's connection dropped, but the player is deliberately still in the
|
||||
## world. Held here rather than deleted so a disconnect cannot be used to dodge
|
||||
@@ -37,9 +38,10 @@ func escape_progress() -> float:
|
||||
return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0)
|
||||
|
||||
|
||||
## Invulnerable while arriving, or in the usual post-hit window.
|
||||
## Arrival protection only -- there is no post-hit invulnerability. See the
|
||||
## note on SimConfig.SPAWN_GRACE_TICKS.
|
||||
func invulnerable() -> bool:
|
||||
return spawn_grace > 0 or iframes > 0
|
||||
return spawn_grace > 0
|
||||
|
||||
|
||||
## Arrival protection also locks the gun, so the spawn point is not a free
|
||||
@@ -54,8 +56,13 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
|
||||
pos = spawn
|
||||
hp = SimConfig.PLAYER_MAX_HP
|
||||
alive = true
|
||||
iframes = 0
|
||||
spawn_grace = grace
|
||||
respawn_lockout = 0
|
||||
fire_cooldown = 0
|
||||
escape_ticks = 0
|
||||
input_queue.clear()
|
||||
|
||||
|
||||
## True once the lockout has run out and the hub is available again.
|
||||
func can_request_respawn() -> bool:
|
||||
return not alive and respawn_lockout <= 0
|
||||
|
||||
@@ -149,8 +149,6 @@ func step() -> void:
|
||||
|
||||
func _step_players() -> void:
|
||||
for p in players.values():
|
||||
if p.iframes > 0:
|
||||
p.iframes -= 1
|
||||
if p.spawn_grace > 0:
|
||||
p.spawn_grace -= 1
|
||||
if p.fire_cooldown > 0:
|
||||
@@ -159,10 +157,13 @@ func _step_players() -> void:
|
||||
var frame := _take_input(p)
|
||||
|
||||
if not p.alive:
|
||||
if p.respawn_lockout > 0:
|
||||
p.respawn_lockout -= 1
|
||||
# No timed respawn: a downed player waits for the hub. Asking to go
|
||||
# is an input like any other, so a dead client cannot be revived by
|
||||
# anything except its own request reaching the server.
|
||||
if frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead:
|
||||
# anything except its own request reaching the server -- and not
|
||||
# before the lockout expires, however early its UI lets it ask.
|
||||
if p.can_request_respawn() and (frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead):
|
||||
events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
|
||||
continue
|
||||
|
||||
@@ -372,11 +373,11 @@ func _kill_bullet(slot: int) -> void:
|
||||
## SimConfig.ESCAPE_CHANNEL_TICKS for why that would reward pulling the plug.
|
||||
func _damage_player(p: SimPlayer, amount: int) -> void:
|
||||
p.hp = maxi(p.hp - amount, 0)
|
||||
p.iframes = SimConfig.PLAYER_IFRAMES
|
||||
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
|
||||
if p.hp <= 0:
|
||||
p.alive = false
|
||||
p.escape_ticks = 0
|
||||
p.respawn_lockout = SimConfig.RESPAWN_LOCKOUT_TICKS
|
||||
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
|
||||
|
||||
|
||||
|
||||
+33
-4
@@ -6,9 +6,12 @@ const MARGIN := 24.0
|
||||
const BAR_W := 260.0
|
||||
const BAR_H := 16.0
|
||||
|
||||
signal respawn_pressed
|
||||
|
||||
var _canvas: Control
|
||||
var _status: Label
|
||||
var _hint: Label
|
||||
var _respawn_button: Button
|
||||
var _hit_flash: float = 0.0
|
||||
var client: ClientRuntime = null
|
||||
|
||||
@@ -38,6 +41,20 @@ func _ready() -> void:
|
||||
_hint.offset_left = MARGIN
|
||||
_hint.offset_top = -56.0
|
||||
|
||||
# A real Button rather than something drawn in _draw_hud: this is the one
|
||||
# HUD element the player has to actually click, and it starts disabled so
|
||||
# death is not exited by whatever key happened to be under a finger.
|
||||
_respawn_button = Button.new()
|
||||
_respawn_button.custom_minimum_size = Vector2(240.0, 40.0)
|
||||
_respawn_button.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||
_respawn_button.offset_left = -120.0
|
||||
_respawn_button.offset_right = 120.0
|
||||
_respawn_button.offset_top = 40.0
|
||||
_respawn_button.offset_bottom = 80.0
|
||||
_respawn_button.pressed.connect(func() -> void: respawn_pressed.emit())
|
||||
_respawn_button.visible = false
|
||||
_canvas.add_child(_respawn_button)
|
||||
|
||||
|
||||
func _make_label(pos: Vector2) -> Label:
|
||||
var l := Label.new()
|
||||
@@ -53,9 +70,23 @@ func _process(delta: float) -> void:
|
||||
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
|
||||
_status.text = _status_text()
|
||||
_hint.text = _hint_text()
|
||||
_update_respawn_button()
|
||||
_canvas.queue_redraw()
|
||||
|
||||
|
||||
func _update_respawn_button() -> void:
|
||||
var downed := client != null and not client.my_alive
|
||||
_respawn_button.visible = downed
|
||||
if not downed:
|
||||
return
|
||||
# Mirrors SimConfig.RESPAWN_LOCKOUT_TICKS, which the server enforces
|
||||
# independently -- this is presentation, not the rule.
|
||||
var ready := client.my_respawn_wait <= 0.0
|
||||
_respawn_button.disabled = not ready
|
||||
_respawn_button.text = "Return to hub" if ready \
|
||||
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
|
||||
|
||||
|
||||
func _status_text() -> String:
|
||||
if client == null:
|
||||
return "connecting..."
|
||||
@@ -69,7 +100,7 @@ func _hint_text() -> String:
|
||||
if client == null:
|
||||
return ""
|
||||
if not client.my_alive:
|
||||
return "DOWN -- press E to return to the hub"
|
||||
return "DOWN"
|
||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
||||
return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu"
|
||||
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu"
|
||||
@@ -106,9 +137,7 @@ func _draw_hud() -> void:
|
||||
var centre := _canvas.size * 0.5
|
||||
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0),
|
||||
"DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4))
|
||||
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(118.0, -18.0),
|
||||
"press E to return to the hub", HORIZONTAL_ALIGNMENT_LEFT, -1, 16,
|
||||
Color(1.0, 0.75, 0.75))
|
||||
|
||||
|
||||
if _hit_flash > 0.0:
|
||||
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
|
||||
|
||||
+17
-8
@@ -9,6 +9,8 @@ var _address: LineEdit
|
||||
var _port: LineEdit
|
||||
var _name: LineEdit
|
||||
var _status: Label
|
||||
var _join_button: Button
|
||||
var _host_button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -43,15 +45,15 @@ func _ready() -> void:
|
||||
_address = _field(panel, "server address", GameOpts.host_address)
|
||||
_port = _field(panel, "port", str(GameOpts.port))
|
||||
|
||||
var join := Button.new()
|
||||
join.text = "Join"
|
||||
join.pressed.connect(_on_join)
|
||||
panel.add_child(join)
|
||||
_join_button = Button.new()
|
||||
_join_button.text = "Join"
|
||||
_join_button.pressed.connect(_on_join)
|
||||
panel.add_child(_join_button)
|
||||
|
||||
var host := Button.new()
|
||||
host.text = "Host and play (listen server)"
|
||||
host.pressed.connect(_on_host)
|
||||
panel.add_child(host)
|
||||
_host_button = Button.new()
|
||||
_host_button.text = "Host and play (listen server)"
|
||||
_host_button.pressed.connect(_on_host)
|
||||
panel.add_child(_host_button)
|
||||
|
||||
var quit := Button.new()
|
||||
quit.text = "Quit"
|
||||
@@ -78,6 +80,13 @@ func set_status(text: String) -> void:
|
||||
_status.text = text
|
||||
|
||||
|
||||
## Locks the connect buttons while an attempt is in flight, so a second click
|
||||
## cannot start a competing connection over the top of the first.
|
||||
func set_busy(busy: bool) -> void:
|
||||
_join_button.disabled = busy
|
||||
_host_button.disabled = busy
|
||||
|
||||
|
||||
func _commit_name() -> void:
|
||||
var clean := _name.text.strip_edges()
|
||||
if not clean.is_empty():
|
||||
|
||||
@@ -15,6 +15,7 @@ func _ready() -> void:
|
||||
get_viewport().size_changed.connect(_recentre)
|
||||
menu.return_to_hub_requested.connect(_on_return_to_hub)
|
||||
menu.disconnect_requested.connect(_on_disconnect)
|
||||
hud.respawn_pressed.connect(_on_respawn_pressed)
|
||||
|
||||
|
||||
func _recentre() -> void:
|
||||
@@ -42,5 +43,11 @@ func _on_disconnect() -> void:
|
||||
Net.shutdown()
|
||||
|
||||
|
||||
## Sets the intent bit; the server decides whether the lockout has expired.
|
||||
func _on_respawn_pressed() -> void:
|
||||
if _bound != null:
|
||||
_bound.request_respawn = true
|
||||
|
||||
|
||||
func _on_local_hit(_damage: int) -> void:
|
||||
hud.flash_hit()
|
||||
|
||||
Reference in New Issue
Block a user