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:
+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