Fix bullet/ship desync; rework death, escape, arrival and hub awareness
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
- ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
not consumed until frame N+1, leaving the drawn ship a constant one tick
(4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
ClientRuntime now sets process_physics_priority = -10. Gap on a listen
server: 4.00px -> 0.10px mean, 0.30px worst.
- PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
ship, so bullets were born inside the sprite. Regression from the previous
commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.
(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.
(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.
(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.
(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.
(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.
(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.
Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
This commit is contained in:
@@ -11,6 +11,9 @@ enum Type {
|
||||
PLAYER_HIT, ## peer, damage, hp
|
||||
PLAYER_DIED, ## peer
|
||||
PLAYER_RESPAWNED, ## peer, pos
|
||||
## A downed player asked to be returned to the hub. Server-only: the
|
||||
## instance layer performs the transfer, the client just sees it arrive.
|
||||
RESPAWN_REQUESTED, ## peer
|
||||
ENEMY_HIT, ## id, damage, hp
|
||||
ENEMY_DIED, ## id
|
||||
BOSS_PHASE, ## phase index
|
||||
|
||||
+24
-4
@@ -11,7 +11,14 @@ var hp: int = SimConfig.PLAYER_MAX_HP
|
||||
var alive: bool = true
|
||||
var iframes: int = 0
|
||||
var fire_cooldown: int = 0
|
||||
var respawn_timer: int = 0
|
||||
## Ticks of arrival protection left: invulnerable, and unable to shoot.
|
||||
var spawn_grace: 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
|
||||
## a dangerous moment: a linkdead player keeps channelling the escape (and stays
|
||||
## killable) for the same second everyone else would. See ServerRuntime.
|
||||
var linkdead: bool = false
|
||||
|
||||
## Ticks the emergency escape has been held. 0 means not channelling.
|
||||
var escape_ticks: int = 0
|
||||
@@ -30,12 +37,25 @@ func escape_progress() -> float:
|
||||
return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0)
|
||||
|
||||
|
||||
func reset_for_instance(spawn: Vector2) -> void:
|
||||
## Invulnerable while arriving, or in the usual post-hit window.
|
||||
func invulnerable() -> bool:
|
||||
return spawn_grace > 0 or iframes > 0
|
||||
|
||||
|
||||
## Arrival protection also locks the gun, so the spawn point is not a free
|
||||
## firing position.
|
||||
func can_fire() -> bool:
|
||||
return alive and spawn_grace <= 0 and fire_cooldown == 0
|
||||
|
||||
|
||||
## [param grace] is 0 for the hub (nothing there can shoot) and
|
||||
## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
|
||||
func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
|
||||
pos = spawn
|
||||
hp = SimConfig.PLAYER_MAX_HP
|
||||
alive = true
|
||||
iframes = SimConfig.PLAYER_IFRAMES
|
||||
iframes = 0
|
||||
spawn_grace = grace
|
||||
fire_cooldown = 0
|
||||
respawn_timer = 0
|
||||
escape_ticks = 0
|
||||
input_queue.clear()
|
||||
|
||||
+27
-18
@@ -28,6 +28,9 @@ var events: Array[Dictionary] = []
|
||||
## Set on a lobby world so the interact button can open a dungeon.
|
||||
var portal_enabled: bool = false
|
||||
var spawn_point := Vector2(0.0, 240.0)
|
||||
## Arrival protection granted to players entering this world. 0 in the hub,
|
||||
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
||||
var spawn_grace_ticks: int = 0
|
||||
|
||||
var _next_actor_id: int = 1
|
||||
var _ctx := EmitContext.new()
|
||||
@@ -50,7 +53,7 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer:
|
||||
var p := SimPlayer.new()
|
||||
p.peer_id = peer_id
|
||||
p.display_name = display_name
|
||||
p.reset_for_instance(spawn_point)
|
||||
p.reset_for_instance(spawn_point, spawn_grace_ticks)
|
||||
players[peer_id] = p
|
||||
return p
|
||||
|
||||
@@ -148,24 +151,25 @@ 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:
|
||||
p.fire_cooldown -= 1
|
||||
|
||||
var frame := _take_input(p)
|
||||
|
||||
if not p.alive:
|
||||
p.respawn_timer -= 1
|
||||
if p.respawn_timer <= 0:
|
||||
p.alive = true
|
||||
p.hp = SimConfig.PLAYER_MAX_HP
|
||||
p.pos = spawn_point
|
||||
p.iframes = SimConfig.PLAYER_IFRAMES
|
||||
events.append({"t": SimEvent.Type.PLAYER_RESPAWNED, "peer": p.peer_id, "pos": p.pos})
|
||||
# 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:
|
||||
events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
|
||||
continue
|
||||
|
||||
var frame := _take_input(p)
|
||||
p.aim = frame.aim
|
||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
|
||||
if frame.pressed(InputFrame.BTN_FIRE) and p.fire_cooldown == 0:
|
||||
if frame.pressed(InputFrame.BTN_FIRE) and p.can_fire():
|
||||
_fire_player_shot(p)
|
||||
|
||||
_step_escape(p, frame)
|
||||
@@ -196,7 +200,7 @@ func _fire_player_shot(p: SimPlayer) -> void:
|
||||
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
|
||||
var dir := Vector2.RIGHT.rotated(p.aim)
|
||||
pool.spawn(
|
||||
p.pos + dir * (SimConfig.PLAYER_RADIUS + 6.0),
|
||||
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
|
||||
dir * SimConfig.PLAYER_BULLET_SPEED,
|
||||
SimConfig.PLAYER_BULLET_RADIUS,
|
||||
SimConfig.PLAYER_BULLET_LIFETIME,
|
||||
@@ -206,7 +210,10 @@ func _fire_player_shot(p: SimPlayer) -> void:
|
||||
|
||||
|
||||
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
|
||||
if frame.pressed(InputFrame.BTN_ESCAPE):
|
||||
# A dropped connection is treated as holding the button down. Pulling the
|
||||
# plug then costs exactly what pressing escape costs -- one second of
|
||||
# standing there, still killable -- instead of an instant, safe exit.
|
||||
if p.linkdead or frame.pressed(InputFrame.BTN_ESCAPE):
|
||||
if p.escape_ticks == 0:
|
||||
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
|
||||
p.escape_ticks += 1
|
||||
@@ -317,7 +324,10 @@ func _resolve_bullet_hits() -> void:
|
||||
var br: float = pool.radius[i]
|
||||
if pool.team[i] == SimConfig.TEAM_ENEMY:
|
||||
for p in players.values():
|
||||
if not p.alive or p.iframes > 0:
|
||||
# invulnerable() covers arrival grace as well as post-hit
|
||||
# i-frames -- a player who just walked into a live bullet field
|
||||
# must not be hit by what was already in the air.
|
||||
if not p.alive or p.invulnerable():
|
||||
continue
|
||||
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
|
||||
_damage_player(p, pool.damage[i])
|
||||
@@ -346,7 +356,7 @@ func _resolve_contact_damage() -> void:
|
||||
if not e.alive or e.def.contact_damage <= 0:
|
||||
continue
|
||||
for p in players.values():
|
||||
if not p.alive or p.iframes > 0:
|
||||
if not p.alive or p.invulnerable():
|
||||
continue
|
||||
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
|
||||
_damage_player(p, e.def.contact_damage)
|
||||
@@ -358,16 +368,15 @@ func _kill_bullet(slot: int) -> void:
|
||||
pool.despawn(slot)
|
||||
|
||||
|
||||
## Damage deliberately does NOT interrupt an escape channel -- see the note on
|
||||
## 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
|
||||
if SimConfig.ESCAPE_BREAK_ON_DAMAGE and p.escape_ticks > 0:
|
||||
p.escape_ticks = 0
|
||||
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
|
||||
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
|
||||
if p.hp <= 0:
|
||||
p.alive = false
|
||||
p.respawn_timer = SimConfig.PLAYER_RESPAWN_DELAY
|
||||
p.escape_ticks = 0
|
||||
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user