1dc1952a3c
ci / verify (push) Successful in 45s
(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.
155 lines
5.4 KiB
GDScript
155 lines
5.4 KiB
GDScript
extends Node2D
|
|
## Draws everything that is not a bullet. There are only a handful of actors, so
|
|
## immediate-mode [method CanvasItem.draw_circle] is cheaper than keeping nodes
|
|
## in sync with a replicated world.
|
|
##
|
|
## The view is strictly read-only: it never advances the simulation and never
|
|
## decides anything. Every number it draws came from the server.
|
|
|
|
const COL_ARENA := Color(0.22, 0.24, 0.34)
|
|
const COL_LOCAL := Color(0.5, 1.0, 0.8)
|
|
const COL_REMOTE := Color(0.55, 0.75, 1.0)
|
|
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
|
|
const COL_PORTAL := Color(0.5, 0.9, 1.0)
|
|
## EnemyDef.visual index for the hub's practice target.
|
|
const VISUAL_DUMMY := 3
|
|
const ENEMY_COLORS := [
|
|
Color(0.95, 0.55, 0.55), # drifter
|
|
Color(0.85, 0.7, 0.35), # turret
|
|
Color(0.9, 0.4, 0.9), # stalker
|
|
Color(0.5, 0.55, 0.6), # dummy
|
|
]
|
|
|
|
@onready var bullets: MultiMeshInstance2D = $Bullets
|
|
|
|
var client: ClientRuntime = null
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
client = Net.client
|
|
if client != null:
|
|
bullets.render_pool(client.world.pool)
|
|
queue_redraw()
|
|
|
|
|
|
func _draw() -> void:
|
|
_draw_arena()
|
|
if client == null:
|
|
return
|
|
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
|
_draw_portal()
|
|
for e in client.enemies():
|
|
_draw_enemy(e)
|
|
_draw_boss()
|
|
for p in client.remote_players():
|
|
_draw_remote_player(p)
|
|
_draw_local_player()
|
|
|
|
|
|
func _draw_arena() -> void:
|
|
var h := SimConfig.ARENA_HALF
|
|
draw_rect(Rect2(-h, h * 2.0), COL_ARENA, false, 2.0)
|
|
# Faint grid, purely so movement reads against a background.
|
|
var step := 80.0
|
|
var faint := Color(COL_ARENA, 0.25)
|
|
var x := -h.x + step
|
|
while x < h.x:
|
|
draw_line(Vector2(x, -h.y), Vector2(x, h.y), faint, 1.0)
|
|
x += step
|
|
var y := -h.y + step
|
|
while y < h.y:
|
|
draw_line(Vector2(-h.x, y), Vector2(h.x, y), faint, 1.0)
|
|
y += step
|
|
|
|
|
|
func _draw_portal() -> void:
|
|
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
|
|
draw_arc(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
|
|
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
|
|
draw_circle(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS * 0.25,
|
|
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
|
|
|
|
|
|
func _draw_enemy(e: Dictionary) -> void:
|
|
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
|
|
var r: float = e["radius"]
|
|
if int(e["visual"]) == VISUAL_DUMMY:
|
|
_draw_target_dummy(e["pos"], r, col)
|
|
return
|
|
draw_circle(e["pos"], r, Color(col, 0.35))
|
|
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
|
|
|
|
|
|
## The hub dummy is a shooting-range target, not an enemy. It used to be drawn
|
|
## as a plain grey disc with 100k hit points, which read as scenery -- nothing
|
|
## about it said "shoot me" and nothing visibly happened when you did. Concentric
|
|
## rings make the intent obvious at a glance.
|
|
func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void:
|
|
draw_circle(pos, r, Color(col, 0.18))
|
|
for i in 3:
|
|
var ring := r * (1.0 - 0.3 * float(i))
|
|
draw_arc(pos, ring, 0.0, TAU, 24, Color(col, 0.5 + 0.15 * float(i)), 1.5)
|
|
draw_circle(pos, r * 0.12, Color(1.0, 0.5, 0.4, 0.9))
|
|
|
|
|
|
func _draw_boss() -> void:
|
|
var b := client.boss_state()
|
|
if b.is_empty():
|
|
return
|
|
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
|
var pos: Vector2 = b["pos"]
|
|
draw_circle(pos, r, Color(0.9, 0.3, 0.4, 0.25))
|
|
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5), 3.0)
|
|
draw_arc(pos, r + 10.0, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.25), 1.0)
|
|
|
|
|
|
func _draw_remote_player(p: Dictionary) -> void:
|
|
var flags := int(p["flags"])
|
|
var alive := (flags & Protocol.F_ALIVE) != 0
|
|
var col := COL_REMOTE if alive else COL_DEAD
|
|
_draw_ship(p["pos"], p["aim"], col, alive)
|
|
if (flags & Protocol.F_SPAWN_GRACE) != 0:
|
|
_draw_grace_ring(p["pos"])
|
|
if (flags & Protocol.F_ESCAPING) != 0:
|
|
_draw_escape_ring(p["pos"], float(p["escape"]), col)
|
|
|
|
|
|
func _draw_local_player() -> void:
|
|
if not client.my_alive:
|
|
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
|
|
return
|
|
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
|
|
if client.my_spawn_grace:
|
|
_draw_grace_ring(client.predicted_pos)
|
|
if client.my_escaping:
|
|
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
|
|
|
|
|
|
## Arrival protection. Drawn on every protected ship, not just your own, so it
|
|
## is clear who can currently be shot and who cannot.
|
|
func _draw_grace_ring(pos: Vector2) -> void:
|
|
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.012)
|
|
draw_arc(pos, SimConfig.PLAYER_VISUAL_RADIUS + 5.0, 0.0, TAU, 28,
|
|
Color(0.55, 0.85, 1.0, 0.35 + 0.45 * pulse), 2.0)
|
|
|
|
|
|
## Drawn at PLAYER_VISUAL_RADIUS, larger than the PLAYER_RADIUS hitbox actually
|
|
## used for hits -- see the comment on those constants in sim_config.gd. The
|
|
## mismatch is deliberate, not a placeholder: a bullet can visibly clip the
|
|
## sprite without registering a hit, which reads as more forgiving of latency
|
|
## than the reverse.
|
|
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void:
|
|
var r := SimConfig.PLAYER_VISUAL_RADIUS
|
|
draw_circle(pos, r, Color(col, 0.4 if alive else 0.2))
|
|
draw_arc(pos, r, 0.0, TAU, 20, col, 2.0)
|
|
if alive:
|
|
var dir := Vector2.RIGHT.rotated(aim)
|
|
draw_line(pos + dir * r, pos + dir * (r + 12.0), col, 2.0)
|
|
|
|
|
|
## The escape channel is drawn on the player, not just in the HUD, so other
|
|
## players can see someone is about to leave and react.
|
|
func _draw_escape_ring(pos: Vector2, progress: float, col: Color) -> void:
|
|
draw_arc(pos, SimConfig.PLAYER_VISUAL_RADIUS + 8.0, -PI * 0.5,
|
|
-PI * 0.5 + TAU * progress, 32, Color(col, 0.9), 3.0)
|