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:
@@ -110,6 +110,15 @@ ticks in milliseconds with no SceneTree.
|
||||
- **`PLAYER_RADIUS` (hitbox) < `PLAYER_VISUAL_RADIUS` (sprite), and
|
||||
`PLAYER_MUZZLE_OFFSET` derives from the visual one.** Prefer a visible
|
||||
near-miss over an invisible hit; keep the muzzle clear of the sprite.
|
||||
- **`INPUT_MAX_LEAD` must stay well above `INPUT_LEAD_MAX`.** The server's
|
||||
input acceptance window has to be wider than the band in which the client
|
||||
re-syncs its own numbering. Violate it and drifting clocks land in a silent
|
||||
dead zone where the server rejects everything and the client never notices —
|
||||
the ship and the authoritative position separate permanently. Pinned by
|
||||
`tests/unit/test_input_lead.gd`.
|
||||
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
|
||||
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
|
||||
dense patterns safer than sparse ones.
|
||||
- **A disconnect is not an exit.** Dropping in a dungeon keeps the player in the
|
||||
world as `linkdead`, channelling out over the same second the escape costs.
|
||||
Damage must never cancel the escape channel, or quitting beats the button.
|
||||
|
||||
@@ -71,6 +71,50 @@ slow relative to bullet speed, so this reads as latency rather than unfairness.
|
||||
If it becomes a complaint, the fix is to lag-compensate **player bullets against
|
||||
enemies only**, never enemy bullets against players.
|
||||
|
||||
## Input timing, and a bug worth remembering
|
||||
|
||||
The client numbers its input frames `INPUT_TARGET_LEAD` ticks ahead of the
|
||||
server's tick; the server accepts anything within `INPUT_MAX_LEAD` and re-syncs
|
||||
nothing itself. **These two numbers must be chosen together.**
|
||||
|
||||
They originally were not — the server dropped inputs past a lead of 12, while
|
||||
the client only corrected itself past 16. A client whose lead drifted into 13–16
|
||||
therefore had *every* input silently rejected, while believing its own timing
|
||||
was fine. The server, receiving nothing, coasted on the last input it had
|
||||
(`held_input`) and then stopped; the client kept predicting forward. The ship
|
||||
and the authoritative position separated permanently, the reconciler fought it
|
||||
every snapshot, and the player got shoved around. It needed two independent
|
||||
clocks to drift apart, so it appeared only after minutes of play — and never
|
||||
recovered, because nothing in the loop could notice.
|
||||
|
||||
Two defences now:
|
||||
|
||||
1. `INPUT_MAX_LEAD` (40) is far wider than the client's correction band
|
||||
(`INPUT_LEAD_MIN` 3 … `INPUT_LEAD_MAX` 20), so the client always re-syncs
|
||||
long before the server starts refusing anything. `test_input_lead.gd`
|
||||
asserts that ordering directly, so narrowing the window fails a test rather
|
||||
than shipping.
|
||||
2. An **ack-stall detector**: if `last_input_tick` does not advance across
|
||||
`INPUT_ACK_STALL_LIMIT` snapshots, the client concludes its numbering is
|
||||
outside the window and hard re-syncs. Lead alone cannot detect this, because
|
||||
a wrong lead looks perfectly normal from the client's side. This is the
|
||||
backstop that makes the whole class of failure self-healing regardless of
|
||||
cause.
|
||||
|
||||
`tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap
|
||||
returns to normal; it exits non-zero if it does not.
|
||||
|
||||
## No invulnerability frames
|
||||
|
||||
A hit grants no immunity — every bullet that touches you deals its damage. In a
|
||||
bullet hell the wall *is* the threat, and i-frames invert that: you are punished
|
||||
for the first bullet of a pattern and gifted the next thirty, which makes dense
|
||||
patterns *safer* than sparse ones. Measured cost: a stationary player survives
|
||||
~13.6s of the Warden's opening phase, ~17.5s while drifting aimlessly.
|
||||
|
||||
Arrival protection (`SPAWN_GRACE_TICKS`) is the sole exception, and it is a
|
||||
transition, not a combat mechanic.
|
||||
|
||||
## Input validation
|
||||
|
||||
`SimWorld.queue_input()` is the single audit point. It drops:
|
||||
@@ -81,6 +125,11 @@ enemies only**, never enemy bullets against players.
|
||||
| Stale input | `f.tick < tick - INPUT_MAX_AGE` |
|
||||
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
|
||||
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
|
||||
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side |
|
||||
|
||||
The respawn lockout is worth calling out: the HUD disables its button for the
|
||||
same three seconds, but that is presentation. A client that ignores its own UI
|
||||
and holds the bit down still waits, because the check lives in `SimWorld`.
|
||||
|
||||
Beyond that, the wire format itself constrains the cheat surface: the move
|
||||
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
|
||||
|
||||
+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()
|
||||
|
||||
@@ -81,7 +81,6 @@ func test_bullets_killed_by_a_hit_are_removed_from_the_replica() -> void:
|
||||
server.add_player(1, "tester")
|
||||
# Just below the boss, inside the ring pattern rather than at its centre.
|
||||
server.players[1].pos = Vector2(0.0, 20.0)
|
||||
server.players[1].iframes = 0
|
||||
server.spawn_boss(Content.warden())
|
||||
var hits := 0
|
||||
for _i in 900:
|
||||
|
||||
@@ -9,7 +9,6 @@ const PEER := 3
|
||||
func before_each() -> void:
|
||||
world = SimWorld.new(1)
|
||||
world.add_player(PEER, "tester")
|
||||
world.players[PEER].iframes = 0
|
||||
|
||||
|
||||
func _hold_escape(ticks: int) -> void:
|
||||
@@ -65,7 +64,6 @@ func test_dying_ends_the_channel() -> void:
|
||||
_hold_escape(20)
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.hp = 1
|
||||
p.iframes = 0
|
||||
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
@@ -91,7 +89,6 @@ func test_a_linkdead_player_is_still_killable_while_channelling() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.linkdead = true
|
||||
p.hp = 1
|
||||
p.iframes = 0
|
||||
world.step()
|
||||
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
extends GutTest
|
||||
## The client numbers its input frames a fixed "lead" ahead of the server's
|
||||
## tick, and the server accepts a window around its own tick. These two numbers
|
||||
## have to be chosen together.
|
||||
##
|
||||
## They were not, and the result was the worst kind of bug: the server silently
|
||||
## dropped every input from a client whose lead had drifted into the gap between
|
||||
## "server stops accepting" and "client notices", the client never corrected
|
||||
## because by its own reckoning nothing was wrong, and the server coasted on a
|
||||
## stale input forever. It needed two independent clocks to drift apart, so it
|
||||
## only appeared after a few minutes -- and then never went away.
|
||||
|
||||
var world: SimWorld
|
||||
const PEER := 5
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
world = SimWorld.new(1)
|
||||
world.add_player(PEER, "tester")
|
||||
world.tick = 1000
|
||||
|
||||
|
||||
func _queue_at_lead(lead: int) -> void:
|
||||
var frames: Array[InputFrame] = [
|
||||
InputFrame.make(world.tick + lead, Vector2.RIGHT, 0.0, 0)]
|
||||
world.queue_input(PEER, frames)
|
||||
|
||||
|
||||
## The invariant that was violated. The client corrects itself at
|
||||
## INPUT_LEAD_MAX; the server must keep accepting well past that, or there is a
|
||||
## band where the server refuses input the client still thinks is fine.
|
||||
func test_the_server_accepts_every_lead_the_client_tolerates() -> void:
|
||||
assert_gt(SimConfig.INPUT_MAX_LEAD, SimConfig.INPUT_LEAD_MAX,
|
||||
"the server's acceptance window must be wider than the client's " +
|
||||
"correction band, or drift lands in a silent dead zone")
|
||||
|
||||
|
||||
func test_inputs_across_the_whole_client_band_are_accepted() -> void:
|
||||
for lead in range(SimConfig.INPUT_LEAD_MIN, SimConfig.INPUT_LEAD_MAX + 1):
|
||||
world.players[PEER].input_queue.clear()
|
||||
world.players[PEER].last_input_tick = 0
|
||||
_queue_at_lead(lead)
|
||||
assert_eq(world.players[PEER].input_queue.size(), 1,
|
||||
"lead %d is inside the band the client will not correct, so the " % lead +
|
||||
"server has to accept it")
|
||||
|
||||
|
||||
func test_absurd_lead_is_still_rejected() -> void:
|
||||
_queue_at_lead(SimConfig.INPUT_MAX_LEAD + 1)
|
||||
assert_eq(world.players[PEER].input_queue.size(), 0,
|
||||
"the window is wider, not gone -- a client claiming a far-future tick " +
|
||||
"still buys nothing")
|
||||
|
||||
|
||||
## What the drift actually looked like from the server's side: nothing arrives,
|
||||
## so it coasts on the last input it saw. That is correct behaviour for a brief
|
||||
## hiccup and catastrophic as a permanent state, which is why the client now
|
||||
## has a stall detector rather than relying on its lead estimate alone.
|
||||
func test_a_starved_server_coasts_then_stops() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
_queue_at_lead(SimConfig.INPUT_TARGET_LEAD)
|
||||
world.step()
|
||||
var moved_once: Vector2 = p.pos
|
||||
assert_ne(moved_once, Vector2.ZERO, "setup: the input should have moved us")
|
||||
for _i in SimConfig.INPUT_MAX_AGE + 5:
|
||||
world.step()
|
||||
var coasted: Vector2 = p.pos
|
||||
for _i in 60:
|
||||
world.step()
|
||||
assert_eq(p.pos, coasted, "coasting has to end, or a silent client drifts forever")
|
||||
@@ -0,0 +1 @@
|
||||
uid://xemxybh1gvvo
|
||||
@@ -91,7 +91,6 @@ func test_a_starved_player_eventually_stops_moving() -> void:
|
||||
func test_enemy_bullets_damage_the_player_and_are_consumed() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.iframes = 0
|
||||
world.pool.spawn(Vector2(-1.0, 0.0), Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
@@ -99,22 +98,37 @@ func test_enemy_bullets_damage_the_player_and_are_consumed() -> void:
|
||||
assert_eq(world.pool.live_count, 0, "a bullet that hits must be consumed")
|
||||
|
||||
|
||||
func test_invulnerability_frames_stop_a_second_hit() -> void:
|
||||
## The inverse of what this asserted before. There are no i-frames: in a bullet
|
||||
## hell the wall is the threat, and letting one hit grant immunity to the rest
|
||||
## of the wall inverts the whole risk model.
|
||||
func test_every_bullet_in_a_wall_lands() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.iframes = 0
|
||||
for _i in 2:
|
||||
for _i in 3:
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 25, "two bullets in one tick is still one hit")
|
||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 75,
|
||||
"three bullets is three hits, not one")
|
||||
|
||||
|
||||
func test_consecutive_ticks_both_deal_damage() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 20,
|
||||
"there is no window of immunity after a hit")
|
||||
|
||||
|
||||
func test_a_replica_world_never_resolves_a_hit() -> void:
|
||||
world.authoritative = false
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.iframes = 0
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
world.step()
|
||||
@@ -126,7 +140,6 @@ func _kill_player() -> SimPlayer:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.hp = 5
|
||||
p.iframes = 0
|
||||
p.spawn_grace = 0
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
@@ -150,6 +163,7 @@ func test_a_downed_player_stays_down_without_input() -> void:
|
||||
|
||||
func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void:
|
||||
_kill_player()
|
||||
_drive(SimConfig.RESPAWN_LOCKOUT_TICKS)
|
||||
world.drain_events()
|
||||
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
||||
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 1,
|
||||
@@ -159,6 +173,28 @@ func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void:
|
||||
assert_false(world.players[PEER].alive)
|
||||
|
||||
|
||||
## The HUD disables its button for the same duration, but that is presentation.
|
||||
## A client that ignores its own UI and spams the bit still has to wait.
|
||||
func test_the_respawn_lockout_is_enforced_by_the_server() -> void:
|
||||
_kill_player()
|
||||
world.drain_events()
|
||||
_drive(SimConfig.RESPAWN_LOCKOUT_TICKS - 2, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
||||
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0,
|
||||
"holding the request down must not shorten the lockout")
|
||||
_drive(4, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
||||
assert_gt(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0,
|
||||
"and it must actually open once the lockout expires")
|
||||
|
||||
|
||||
func test_the_lockout_is_reported_to_the_client() -> void:
|
||||
_kill_player()
|
||||
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
||||
var wait: float = snap["players"][0]["respawn_wait"]
|
||||
assert_almost_eq(wait,
|
||||
float(SimConfig.RESPAWN_LOCKOUT_TICKS) / float(SimConfig.TICK_RATE), 0.3,
|
||||
"the HUD needs the countdown to render its disabled button")
|
||||
|
||||
|
||||
func test_respawn_request_never_reaches_the_client() -> void:
|
||||
_kill_player()
|
||||
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
||||
@@ -171,7 +207,6 @@ func test_respawn_request_never_reaches_the_client() -> void:
|
||||
func test_spawn_grace_blocks_damage_and_firing() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.iframes = 0
|
||||
p.spawn_grace = 60
|
||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
|
||||
@@ -8,10 +8,19 @@ extends Node
|
||||
## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual
|
||||
## Net loopback), not a mock, so node process order is exactly production's.
|
||||
|
||||
## Tick at which to shove the client's input numbering out of sync, simulating
|
||||
## the clock drift between two independent processes that produced the original
|
||||
## "ship and bullets separate after a few minutes, permanently" bug. 0 disables.
|
||||
const DRIFT_AT := 120
|
||||
const DRIFT_BY := 14
|
||||
|
||||
var _ticks: int = 0
|
||||
var _worst: float = 0.0
|
||||
var _sum: float = 0.0
|
||||
var _samples: int = 0
|
||||
## Measured only after the injected drift, which is the interesting window.
|
||||
var _worst_after_drift: float = 0.0
|
||||
var _drifted: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -42,6 +51,17 @@ func _physics_process(_delta: float) -> void:
|
||||
_sum += delta_px
|
||||
_samples += 1
|
||||
|
||||
if DRIFT_AT > 0 and _ticks == DRIFT_AT:
|
||||
# Exactly what two drifting clocks do, only instantly. With the old
|
||||
# constants this landed in the gap between "server stops accepting" and
|
||||
# "client notices", and the two never re-converged.
|
||||
cli.input_tick += DRIFT_BY
|
||||
_drifted = true
|
||||
print("--- injected +%d tick input drift ---" % DRIFT_BY)
|
||||
# Give the client a moment to notice before judging it.
|
||||
if _drifted and _ticks > DRIFT_AT + 90:
|
||||
_worst_after_drift = maxf(_worst_after_drift, delta_px)
|
||||
|
||||
if _ticks % 20 == 0:
|
||||
print("tick=%4d srv_tick=%4d srv_pos=%-22s pred=%-22s gap=%6.2fpx acked=%4d cli_tick=%4d queue=%d" % [
|
||||
_ticks, inst.world.tick, str(sp.pos.round()), str(cli.predicted_pos.round()),
|
||||
@@ -55,5 +75,12 @@ func _physics_process(_delta: float) -> void:
|
||||
print("muzzle offset = %.1fpx, drawn ship radius = %.1fpx, hitbox = %.1fpx" % [
|
||||
SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS,
|
||||
SimConfig.PLAYER_RADIUS])
|
||||
var ok := true
|
||||
if _drifted:
|
||||
print("RECOVERED gap after drift: worst=%.2fpx" % _worst_after_drift)
|
||||
# One tick of movement is 4px; anything near that means the client
|
||||
# re-synced. Tens of px means it is stuck, which is the bug.
|
||||
ok = _worst_after_drift < 12.0
|
||||
print("DRIFT_RECOVERY_" + ("PASS" if ok else "FAIL"))
|
||||
Net.shutdown()
|
||||
get_tree().quit(0)
|
||||
get_tree().quit(0 if ok else 1)
|
||||
|
||||
Reference in New Issue
Block a user