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:
@@ -100,6 +100,21 @@ ticks in milliseconds with no SceneTree.
|
|||||||
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
|
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
|
||||||
in `src/main.gd` instead of trusted to `project.godot`.
|
in `src/main.gd` instead of trusted to `project.godot`.
|
||||||
|
|
||||||
|
## Non-obvious invariants
|
||||||
|
|
||||||
|
- **`ClientRuntime.process_physics_priority = -10`.** The client must sample and
|
||||||
|
send input before `ServerRuntime` ticks, or a listen server's drawn ship sits
|
||||||
|
a permanent tick ahead of the authoritative one and bullets trail it. Measure
|
||||||
|
with `godot --headless --path . res://tools/diag_prediction.tscn` (~0.1px is
|
||||||
|
healthy, 4px means the ordering broke).
|
||||||
|
- **`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.
|
||||||
|
- **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.
|
||||||
|
See [docs/NETCODE.md](docs/NETCODE.md).
|
||||||
|
|
||||||
## Adding content
|
## Adding content
|
||||||
|
|
||||||
A new enemy or boss is data, never code. Add a builder to
|
A new enemy or boss is data, never code. Add a builder to
|
||||||
|
|||||||
@@ -91,6 +91,43 @@ rate as tapping it correctly.
|
|||||||
A client that stops sending coasts on its last input for `INPUT_MAX_AGE` ticks
|
A client that stops sending coasts on its last input for `INPUT_MAX_AGE` ticks
|
||||||
and then stops, so a dropped connection does not leave a player sliding.
|
and then stops, so a dropped connection does not leave a player sliding.
|
||||||
|
|
||||||
|
## Leaving a run, and why disconnecting is not an escape
|
||||||
|
|
||||||
|
The emergency escape is a **one-second server-owned channel** that damage does
|
||||||
|
*not* interrupt. That combination is deliberate and the two halves depend on
|
||||||
|
each other.
|
||||||
|
|
||||||
|
An interruptible channel sounds like the right kind of risk until you follow it
|
||||||
|
through: a player about to die under fire can never finish the channel, so
|
||||||
|
killing the game process becomes strictly better than using the button. The
|
||||||
|
escape hatch turns into the exploit.
|
||||||
|
|
||||||
|
So a dropped connection runs the same channel. On disconnect the peer is removed
|
||||||
|
from the send list but its player **stays in the world** (`SimPlayer.linkdead`),
|
||||||
|
treated as holding the escape button down, still fully killable, for the same
|
||||||
|
second everyone else pays. Only when that resolves is the peer forgotten. Pulling
|
||||||
|
the plug is therefore never cheaper than pressing the key, and there is no
|
||||||
|
timing window where it is.
|
||||||
|
|
||||||
|
Two details that are easy to get wrong and are covered by tests:
|
||||||
|
|
||||||
|
- An instance must not close while a linkdead body is still resolving
|
||||||
|
(`Instance.has_linkdead()`), or a solo player's drop would delete their body
|
||||||
|
on the next tick and hand back the exact exploit.
|
||||||
|
- Dying still ends the channel. That is the one thing the escape cannot beat,
|
||||||
|
and it is what keeps the second a real risk rather than a formality.
|
||||||
|
|
||||||
|
`tools/smoke.sh` SIGKILLs a bot mid-dungeon and asserts the server channels it
|
||||||
|
out rather than dropping it instantly.
|
||||||
|
|
||||||
|
## Arriving in a run
|
||||||
|
|
||||||
|
Entering a dungeon grants `SimConfig.SPAWN_GRACE_TICKS` (2s) of **arrival
|
||||||
|
protection**: invulnerable *and* unable to shoot. Both halves matter —
|
||||||
|
invulnerability alone would make the spawn point a free firing position. It is
|
||||||
|
flagged in the snapshot (`Protocol.F_SPAWN_GRACE`) so every client can draw it
|
||||||
|
on every protected ship, not just its own.
|
||||||
|
|
||||||
## Prediction and reconciliation
|
## Prediction and reconciliation
|
||||||
|
|
||||||
`ClientRuntime` keeps every unacknowledged `InputFrame`. Each snapshot echoes
|
`ClientRuntime` keeps every unacknowledged `InputFrame`. Each snapshot echoes
|
||||||
@@ -99,6 +136,20 @@ frames, takes the server's authoritative position, and replays the rest through
|
|||||||
the same `Movement.step_player()` the server used. Divergence over 24 px snaps;
|
the same `Movement.step_player()` the server used. Divergence over 24 px snaps;
|
||||||
smaller errors lerp at 0.3 so ordinary jitter does not read as rubber-banding.
|
smaller errors lerp at 0.3 so ordinary jitter does not read as rubber-banding.
|
||||||
|
|
||||||
|
`ClientRuntime` sets `process_physics_priority = -10` so it samples and sends
|
||||||
|
input *before* `ServerRuntime` ticks. This matters only on a listen server,
|
||||||
|
where both live in one process: with the default tree order the server ran
|
||||||
|
first, so input sampled on frame N was not consumed until frame N+1, leaving the
|
||||||
|
drawn ship a permanent one tick (4px at 240 u/s) ahead of the authoritative one
|
||||||
|
— and bullets, which spawn at the authoritative position, visibly trailed behind
|
||||||
|
the ship. `tools/diag_prediction.gd` measures this gap; it is currently ~0.1px
|
||||||
|
mean, down from a constant 4.00px.
|
||||||
|
|
||||||
|
The other half of that fix is `SimConfig.PLAYER_MUZZLE_OFFSET`, derived from the
|
||||||
|
*visual* radius rather than the hitbox. A remote client still draws its ship a
|
||||||
|
tick or two ahead of the server, and that margin is what keeps bullets emerging
|
||||||
|
from the nose rather than the middle of the sprite.
|
||||||
|
|
||||||
Remote players, enemies and the boss are interpolated between the last two
|
Remote players, enemies and the boss are interpolated between the last two
|
||||||
snapshots. Bullets are not interpolated — they are simulated, and a spawn event
|
snapshots. Bullets are not interpolated — they are simulated, and a spawn event
|
||||||
carries the server tick it was generated at so the client fast-forwards the
|
carries the server tick it was generated at so the client fast-forwards the
|
||||||
|
|||||||
+13
-2
@@ -9,9 +9,13 @@
|
|||||||
| Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. |
|
| Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. |
|
||||||
| Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. |
|
| Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. |
|
||||||
| Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. |
|
| Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. |
|
||||||
| Emergency escape | Three-second channel, cancelled by damage or release, server-owned. |
|
| Emergency escape | One-second server-owned channel, cancelled by release but not by damage; a disconnect runs the same channel so quitting is never a cheaper exit. |
|
||||||
|
| Leaving and arriving | Downed players return to the hub on request (no timed respawn); entering a dungeon grants 2s of invulnerable, weapons-cold arrival protection. |
|
||||||
|
| Hub awareness | Server-pushed roster showing who is online and which dungeon they are in. |
|
||||||
|
| In-game menu | Escape opens return-to-hub / disconnect / quit. |
|
||||||
|
|
||||||
78 tests plus an end-to-end smoke test over a real socket.
|
91 tests plus an end-to-end smoke test over a real socket, including a bot that
|
||||||
|
is SIGKILLed mid-dungeon to prove the disconnect path.
|
||||||
|
|
||||||
## Next, in rough order of value
|
## Next, in rough order of value
|
||||||
|
|
||||||
@@ -20,6 +24,13 @@ Rooms, doors, and a `SimWorld` with static geometry — which means adding a
|
|||||||
collision representation for walls (segment-vs-circle for players, segment
|
collision representation for walls (segment-vs-circle for players, segment
|
||||||
crossing for bullets) since there is no physics engine to lean on.
|
crossing for bullets) since there is no physics engine to lean on.
|
||||||
|
|
||||||
|
**1b. Progression, and what it turns on.** Several things are currently sized
|
||||||
|
for "no persistence yet" and should be revisited together with it: death sends
|
||||||
|
you to the hub rather than costing anything, a linkdead body is simply deleted
|
||||||
|
once it channels out (there is no hub state to put it in), and `peer_names` is
|
||||||
|
client-supplied and trusted for display. All three are fine now and none of them
|
||||||
|
are once a character has anything worth losing.
|
||||||
|
|
||||||
**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot
|
**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot
|
||||||
texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and
|
texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and
|
||||||
sound. None of it touches the simulation — this is entirely `src/view/`.
|
sound. None of it touches the simulation — this is entirely `src/view/`.
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ interact={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
system_menu={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ func send_events(peer_id: int, data: PackedByteArray) -> void:
|
|||||||
s_events.rpc_id(peer_id, data)
|
s_events.rpc_id(peer_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
func send_roster(peer_id: int, data: PackedByteArray) -> void:
|
||||||
|
if _is_local(peer_id):
|
||||||
|
client.on_roster(data)
|
||||||
|
else:
|
||||||
|
s_roster.rpc_id(peer_id, data)
|
||||||
|
|
||||||
|
|
||||||
func send_reject(peer_id: int, reason: String) -> void:
|
func send_reject(peer_id: int, reason: String) -> void:
|
||||||
if _is_local(peer_id):
|
if _is_local(peer_id):
|
||||||
GameLog.error("net", "local client rejected: %s" % reason)
|
GameLog.error("net", "local client rejected: %s" % reason)
|
||||||
@@ -237,6 +244,12 @@ func s_reject(reason: String) -> void:
|
|||||||
_set_state(State.FAILED)
|
_set_state(State.FAILED)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable", 1)
|
||||||
|
func s_roster(data: PackedByteArray) -> void:
|
||||||
|
if client != null:
|
||||||
|
client.on_roster(data)
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "unreliable", 2)
|
@rpc("authority", "call_remote", "unreliable", 2)
|
||||||
func s_snapshot(data: PackedByteArray) -> void:
|
func s_snapshot(data: PackedByteArray) -> void:
|
||||||
if client != null:
|
if client != null:
|
||||||
|
|||||||
+22
-4
@@ -29,6 +29,13 @@ const PLAYER_SPEED := 240.0
|
|||||||
const PLAYER_RADIUS := 6.0
|
const PLAYER_RADIUS := 6.0
|
||||||
## Render-only. Used by src/view/, never by anything under src/sim/.
|
## Render-only. Used by src/view/, never by anything under src/sim/.
|
||||||
const PLAYER_VISUAL_RADIUS := 13.0
|
const PLAYER_VISUAL_RADIUS := 13.0
|
||||||
|
## Where a player's bullets are born, measured from the ship's centre. Derived
|
||||||
|
## from the VISUAL radius rather than the hitbox on purpose: this is the one
|
||||||
|
## sim number whose whole job is to line up with what the player sees. Keep it
|
||||||
|
## clear of PLAYER_VISUAL_RADIUS by a few px, or bullets appear to spawn inside
|
||||||
|
## the ship -- and on a remote client, the ship is also drawn a tick or two
|
||||||
|
## ahead of the server, so this margin is what absorbs that too.
|
||||||
|
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
|
||||||
const PLAYER_MAX_HP := 100
|
const PLAYER_MAX_HP := 100
|
||||||
const PLAYER_FIRE_COOLDOWN := 7 # ticks
|
const PLAYER_FIRE_COOLDOWN := 7 # ticks
|
||||||
const PLAYER_BULLET_SPEED := 620.0
|
const PLAYER_BULLET_SPEED := 620.0
|
||||||
@@ -36,7 +43,10 @@ const PLAYER_BULLET_RADIUS := 4.0
|
|||||||
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
||||||
const PLAYER_BULLET_DAMAGE := 6
|
const PLAYER_BULLET_DAMAGE := 6
|
||||||
const PLAYER_IFRAMES := 36 # ticks of invulnerability after a hit
|
const PLAYER_IFRAMES := 36 # ticks of invulnerability after a hit
|
||||||
const PLAYER_RESPAWN_DELAY := 180 # ticks
|
## 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
|
||||||
|
|
||||||
# --- Anti-cheat guards ------------------------------------------------------
|
# --- Anti-cheat guards ------------------------------------------------------
|
||||||
## Inputs older than this (relative to the newest accepted) are discarded.
|
## Inputs older than this (relative to the newest accepted) are discarded.
|
||||||
@@ -47,9 +57,13 @@ const INPUT_MAX_LEAD := 12
|
|||||||
const INPUT_MAX_PER_TICK := 4
|
const INPUT_MAX_PER_TICK := 4
|
||||||
|
|
||||||
# --- Emergency escape -------------------------------------------------------
|
# --- Emergency escape -------------------------------------------------------
|
||||||
const ESCAPE_CHANNEL_TICKS := 180 # 3 seconds
|
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
|
||||||
## Taking damage while channelling cancels the escape.
|
## Taking damage does NOT interrupt the channel. It used to, which sounds like
|
||||||
const ESCAPE_BREAK_ON_DAMAGE := true
|
## the right kind of risk until you notice the interaction with disconnects: if
|
||||||
|
## a player under fire cannot escape, quitting the process is strictly better
|
||||||
|
## than using the button, and the escape hatch becomes the cheese. A dropped
|
||||||
|
## connection now runs the same one-second channel (see SimPlayer.linkdead),
|
||||||
|
## which only works if being shot cannot cancel it.
|
||||||
|
|
||||||
# --- Bullets ----------------------------------------------------------------
|
# --- Bullets ----------------------------------------------------------------
|
||||||
const MAX_BULLETS := 4096
|
const MAX_BULLETS := 4096
|
||||||
@@ -67,6 +81,10 @@ const LOBBY_INSTANCE_ID := 1
|
|||||||
const DUNGEON_PARTY_MAX := 4
|
const DUNGEON_PARTY_MAX := 4
|
||||||
## How long a forming dungeon waits for more players before it locks.
|
## How long a forming dungeon waits for more players before it locks.
|
||||||
const DUNGEON_FORMING_TICKS := 300
|
const DUNGEON_FORMING_TICKS := 300
|
||||||
|
## Victory lap: how long a cleared dungeon holds the party before returning
|
||||||
|
## them to the hub and closing. Once closed (or once it empties), the next
|
||||||
|
## player through the portal opens a fresh instance.
|
||||||
|
const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
|
||||||
|
|
||||||
# --- Portal -----------------------------------------------------------------
|
# --- Portal -----------------------------------------------------------------
|
||||||
const PORTAL_POS := Vector2(0.0, -220.0)
|
const PORTAL_POS := Vector2(0.0, -220.0)
|
||||||
|
|||||||
+5
-1
@@ -1,9 +1,10 @@
|
|||||||
[gd_scene load_steps=5 format=3]
|
[gd_scene load_steps=6 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
|
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
|
||||||
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
|
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
|
||||||
[ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"]
|
[ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"]
|
||||||
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
|
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
|
||||||
|
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"]
|
||||||
|
|
||||||
[node name="Game" type="Node2D"]
|
[node name="Game" type="Node2D"]
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
@@ -17,3 +18,6 @@ script = ExtResource("3")
|
|||||||
|
|
||||||
[node name="HUD" type="CanvasLayer" parent="."]
|
[node name="HUD" type="CanvasLayer" parent="."]
|
||||||
script = ExtResource("4")
|
script = ExtResource("4")
|
||||||
|
|
||||||
|
[node name="GameMenu" type="CanvasLayer" parent="."]
|
||||||
|
script = ExtResource("5")
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
|
|||||||
inst.seed_value = dungeon_seed
|
inst.seed_value = dungeon_seed
|
||||||
inst.world = SimWorld.new(dungeon_seed)
|
inst.world = SimWorld.new(dungeon_seed)
|
||||||
inst.world.spawn_point = Vector2(0.0, 260.0)
|
inst.world.spawn_point = Vector2(0.0, 260.0)
|
||||||
|
# Arriving into a fight already in progress needs a moment of protection;
|
||||||
|
# arriving in the hub does not.
|
||||||
|
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
|
||||||
inst.boss_id = Content.BOSS_WARDEN
|
inst.boss_id = Content.BOSS_WARDEN
|
||||||
inst.state = State.FORMING
|
inst.state = State.FORMING
|
||||||
if GameOpts.boss_rush:
|
if GameOpts.boss_rush:
|
||||||
@@ -68,10 +71,40 @@ func remove_peer(peer_id: int) -> void:
|
|||||||
world.remove_player(peer_id)
|
world.remove_player(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Drop a peer from the send list but leave its player in the world. Used when a
|
||||||
|
## connection dies: the body stays behind, still killable, channelling its way
|
||||||
|
## out (see SimPlayer.linkdead), while we stop trying to send to a dead socket.
|
||||||
|
func detach_peer(peer_id: int) -> void:
|
||||||
|
peers.erase(peer_id)
|
||||||
|
var p: SimPlayer = world.players.get(peer_id)
|
||||||
|
if p != null:
|
||||||
|
p.linkdead = true
|
||||||
|
|
||||||
|
|
||||||
|
## Peers we still talk to. Linkdead bodies are not counted -- an instance whose
|
||||||
|
## only occupant has dropped should wind down, not wait on a ghost.
|
||||||
func is_empty() -> bool:
|
func is_empty() -> bool:
|
||||||
return peers.is_empty()
|
return peers.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
## True while a dropped player is still resolving its escape channel. Keeps the
|
||||||
|
## instance alive just long enough for that to finish.
|
||||||
|
func has_linkdead() -> bool:
|
||||||
|
for p in world.players.values():
|
||||||
|
if p.linkdead:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Whole seconds until a cleared dungeon returns its party, or
|
||||||
|
## Protocol.COUNTDOWN_NONE when that does not apply.
|
||||||
|
func exit_countdown_seconds() -> int:
|
||||||
|
if state != State.CLEARED:
|
||||||
|
return Protocol.COUNTDOWN_NONE
|
||||||
|
return mini(int(ceil(float(stage_delay) / float(SimConfig.TICK_RATE))),
|
||||||
|
Protocol.COUNTDOWN_NONE - 1)
|
||||||
|
|
||||||
|
|
||||||
func accepts_new_party_member() -> bool:
|
func accepts_new_party_member() -> bool:
|
||||||
return kind == Protocol.InstanceKind.DUNGEON \
|
return kind == Protocol.InstanceKind.DUNGEON \
|
||||||
and state == State.FORMING \
|
and state == State.FORMING \
|
||||||
@@ -110,7 +143,9 @@ func _step_dungeon() -> void:
|
|||||||
if world.boss != null and world.boss.alive:
|
if world.boss != null and world.boss.alive:
|
||||||
return
|
return
|
||||||
state = State.CLEARED
|
state = State.CLEARED
|
||||||
stage_delay = 300
|
stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS
|
||||||
|
GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [
|
||||||
|
id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE])
|
||||||
return
|
return
|
||||||
|
|
||||||
stage += 1
|
stage += 1
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ var my_hp: int = SimConfig.PLAYER_MAX_HP
|
|||||||
var my_alive: bool = true
|
var my_alive: bool = true
|
||||||
var my_escape: float = 0.0
|
var my_escape: float = 0.0
|
||||||
var my_escaping: bool = false
|
var my_escaping: bool = false
|
||||||
|
## Arrival protection: invulnerable and unable to shoot.
|
||||||
|
var my_spawn_grace: 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.
|
||||||
|
## Cleared when the channel resolves or the player cancels.
|
||||||
|
var request_escape: bool = false
|
||||||
|
|
||||||
|
## Who is online and where, for the hub's player list. Server-pushed.
|
||||||
|
var roster: Array[Dictionary] = []
|
||||||
|
|
||||||
|
## Whole seconds until a cleared dungeon returns the party, or
|
||||||
|
## Protocol.COUNTDOWN_NONE outside that state.
|
||||||
|
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||||
|
|
||||||
var snap_prev: Dictionary = {}
|
var snap_prev: Dictionary = {}
|
||||||
var snap_curr: Dictionary = {}
|
var snap_curr: Dictionary = {}
|
||||||
@@ -42,6 +56,14 @@ var _bot_tick: int = 0
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
world.authoritative = false
|
world.authoritative = false
|
||||||
|
# Sample and send input BEFORE ServerRuntime ticks. On a listen server both
|
||||||
|
# live in one process, and default tree order put the server first -- so the
|
||||||
|
# input sampled on frame N was not consumed until the server's frame N+1,
|
||||||
|
# leaving the drawn ship a permanent one-tick (4px at 240 u/s) ahead of the
|
||||||
|
# authoritative one. Bullets, spawned at the authoritative position, visibly
|
||||||
|
# trailed the ship. Going first closes the gap to zero on a listen server
|
||||||
|
# and costs a remote client nothing.
|
||||||
|
process_physics_priority = -10
|
||||||
set_physics_process(true)
|
set_physics_process(true)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +106,7 @@ func _sample_input() -> InputFrame:
|
|||||||
var buttons := 0
|
var buttons := 0
|
||||||
if Input.is_action_pressed("fire"):
|
if Input.is_action_pressed("fire"):
|
||||||
buttons |= InputFrame.BTN_FIRE
|
buttons |= InputFrame.BTN_FIRE
|
||||||
if Input.is_action_pressed("emergency_escape"):
|
if Input.is_action_pressed("emergency_escape") or request_escape:
|
||||||
buttons |= InputFrame.BTN_ESCAPE
|
buttons |= InputFrame.BTN_ESCAPE
|
||||||
if Input.is_action_pressed("interact"):
|
if Input.is_action_pressed("interact"):
|
||||||
buttons |= InputFrame.BTN_INTERACT
|
buttons |= InputFrame.BTN_INTERACT
|
||||||
@@ -99,6 +121,10 @@ func _bot_input() -> InputFrame:
|
|||||||
var move := Vector2(cos(t * 0.9), sin(t * 1.3))
|
var move := Vector2(cos(t * 0.9), sin(t * 1.3))
|
||||||
aim = t * 2.1
|
aim = t * 2.1
|
||||||
var buttons := InputFrame.BTN_FIRE
|
var buttons := InputFrame.BTN_FIRE
|
||||||
|
if not my_alive:
|
||||||
|
# Downed bots ask for the hub, same as a player would -- otherwise a bot
|
||||||
|
# that dies mid-run just lies there and the smoke test stalls.
|
||||||
|
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
|
||||||
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
|
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
|
||||||
buttons |= InputFrame.BTN_INTERACT
|
buttons |= InputFrame.BTN_INTERACT
|
||||||
# Walk onto the portal instead of orbiting, or interact never lands.
|
# Walk onto the portal instead of orbiting, or interact never lands.
|
||||||
@@ -116,6 +142,11 @@ func on_welcome(peer_id: int) -> void:
|
|||||||
GameLog.info("client", "welcome, peer id %d" % peer_id)
|
GameLog.info("client", "welcome, peer id %d" % peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func on_roster(data: PackedByteArray) -> void:
|
||||||
|
roster = NetCodec.decode_roster(data)
|
||||||
|
hud_dirty.emit()
|
||||||
|
|
||||||
|
|
||||||
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||||
spawn: Vector2) -> void:
|
spawn: Vector2) -> void:
|
||||||
instance_id = id
|
instance_id = id
|
||||||
@@ -132,6 +163,12 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
|||||||
my_hp = SimConfig.PLAYER_MAX_HP
|
my_hp = SimConfig.PLAYER_MAX_HP
|
||||||
my_escape = 0.0
|
my_escape = 0.0
|
||||||
my_escaping = false
|
my_escaping = false
|
||||||
|
my_spawn_grace = false
|
||||||
|
# Arriving somewhere is the natural end of an escape request, however it
|
||||||
|
# was triggered -- otherwise the synthesised button would keep firing and
|
||||||
|
# bounce the player straight back out of the hub.
|
||||||
|
request_escape = false
|
||||||
|
cleared_countdown = Protocol.COUNTDOWN_NONE
|
||||||
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
||||||
instance_changed.emit()
|
instance_changed.emit()
|
||||||
hud_dirty.emit()
|
hud_dirty.emit()
|
||||||
@@ -141,6 +178,7 @@ func on_snapshot(data: PackedByteArray) -> void:
|
|||||||
var snap := NetCodec.decode_snapshot(data)
|
var snap := NetCodec.decode_snapshot(data)
|
||||||
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
|
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
|
||||||
return # stale or duplicate; unreliable channel, newest wins
|
return # stale or duplicate; unreliable channel, newest wins
|
||||||
|
cleared_countdown = int(snap["cleared_countdown"])
|
||||||
snap_prev = snap_curr
|
snap_prev = snap_curr
|
||||||
snap_curr = snap
|
snap_curr = snap
|
||||||
_interp = 0.0
|
_interp = 0.0
|
||||||
@@ -166,6 +204,7 @@ func _reconcile(rec: Dictionary) -> void:
|
|||||||
my_hp = int(rec["hp"])
|
my_hp = int(rec["hp"])
|
||||||
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
|
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
|
||||||
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
|
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_escape = float(rec["escape"])
|
||||||
hud_dirty.emit()
|
hud_dirty.emit()
|
||||||
|
|
||||||
|
|||||||
+59
-3
@@ -8,10 +8,14 @@ extends RefCounted
|
|||||||
|
|
||||||
# --- Snapshot ---------------------------------------------------------------
|
# --- Snapshot ---------------------------------------------------------------
|
||||||
|
|
||||||
static func encode_snapshot(world: SimWorld) -> PackedByteArray:
|
## [param cleared_countdown] is whole seconds until a cleared dungeon returns
|
||||||
|
## its party, or Protocol.COUNTDOWN_NONE when that does not apply.
|
||||||
|
static func encode_snapshot(world: SimWorld,
|
||||||
|
cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray:
|
||||||
var b := StreamPeerBuffer.new()
|
var b := StreamPeerBuffer.new()
|
||||||
b.big_endian = false
|
b.big_endian = false
|
||||||
b.put_u32(world.tick)
|
b.put_u32(world.tick)
|
||||||
|
b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE))
|
||||||
|
|
||||||
b.put_u8(mini(world.players.size(), 255))
|
b.put_u8(mini(world.players.size(), 255))
|
||||||
for p in world.players.values():
|
for p in world.players.values():
|
||||||
@@ -27,6 +31,10 @@ static func encode_snapshot(world: SimWorld) -> PackedByteArray:
|
|||||||
flags |= Protocol.F_INVULN
|
flags |= Protocol.F_INVULN
|
||||||
if p.escape_ticks > 0:
|
if p.escape_ticks > 0:
|
||||||
flags |= Protocol.F_ESCAPING
|
flags |= Protocol.F_ESCAPING
|
||||||
|
if p.spawn_grace > 0:
|
||||||
|
flags |= Protocol.F_SPAWN_GRACE
|
||||||
|
if p.linkdead:
|
||||||
|
flags |= Protocol.F_LINKDEAD
|
||||||
b.put_u8(flags)
|
b.put_u8(flags)
|
||||||
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
|
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
|
||||||
# Echoed so the owning client knows how far to rewind when reconciling.
|
# Echoed so the owning client knows how far to rewind when reconciling.
|
||||||
@@ -62,7 +70,11 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
|||||||
var b := StreamPeerBuffer.new()
|
var b := StreamPeerBuffer.new()
|
||||||
b.big_endian = false
|
b.big_endian = false
|
||||||
b.data_array = data
|
b.data_array = data
|
||||||
var snap := {"tick": b.get_u32(), "players": [], "enemies": [], "boss": null}
|
var snap := {
|
||||||
|
"tick": b.get_u32(),
|
||||||
|
"cleared_countdown": b.get_u8(),
|
||||||
|
"players": [], "enemies": [], "boss": null,
|
||||||
|
}
|
||||||
|
|
||||||
var pcount := b.get_u8()
|
var pcount := b.get_u8()
|
||||||
for _i in pcount:
|
for _i in pcount:
|
||||||
@@ -99,7 +111,13 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
|||||||
# --- Events -----------------------------------------------------------------
|
# --- Events -----------------------------------------------------------------
|
||||||
|
|
||||||
## Events the client never sees; the instance layer consumes them server-side.
|
## Events the client never sees; the instance layer consumes them server-side.
|
||||||
const SERVER_ONLY := [SimEvent.Type.PORTAL_USED, SimEvent.Type.ESCAPE_COMPLETED]
|
## These describe a decision the server is about to act on, not an outcome --
|
||||||
|
## leaking them would tell a client about a message it might try to forge.
|
||||||
|
const SERVER_ONLY := [
|
||||||
|
SimEvent.Type.PORTAL_USED,
|
||||||
|
SimEvent.Type.ESCAPE_COMPLETED,
|
||||||
|
SimEvent.Type.RESPAWN_REQUESTED,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
## [param server_tick] rides along so the client can fast-forward a bullet by
|
## [param server_tick] rides along so the client can fast-forward a bullet by
|
||||||
@@ -232,3 +250,41 @@ static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]:
|
|||||||
for _i in count:
|
for _i in count:
|
||||||
out.append(InputFrame.read(b))
|
out.append(InputFrame.read(b))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- Roster -----------------------------------------------------------------
|
||||||
|
# Who is online and where. Low frequency (membership changes only), so it is
|
||||||
|
# the one message that carries strings; everything hot stays fixed-width.
|
||||||
|
|
||||||
|
static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray:
|
||||||
|
var b := StreamPeerBuffer.new()
|
||||||
|
b.big_endian = false
|
||||||
|
b.put_u8(mini(entries.size(), 255))
|
||||||
|
for e in entries:
|
||||||
|
b.put_u32(int(e["peer"]))
|
||||||
|
b.put_utf8_string(String(e["name"]))
|
||||||
|
b.put_u8(int(e["kind"]))
|
||||||
|
b.put_u32(int(e["instance"]))
|
||||||
|
b.put_u8(1 if e["alive"] else 0)
|
||||||
|
return b.data_array
|
||||||
|
|
||||||
|
|
||||||
|
static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
|
||||||
|
var out: Array[Dictionary] = []
|
||||||
|
if data.size() < 1:
|
||||||
|
return out
|
||||||
|
var b := StreamPeerBuffer.new()
|
||||||
|
b.big_endian = false
|
||||||
|
b.data_array = data
|
||||||
|
var count := b.get_u8()
|
||||||
|
for _i in count:
|
||||||
|
# get_utf8_string reads its own length prefix, so a truncated packet
|
||||||
|
# yields empty strings rather than reading off the end.
|
||||||
|
out.append({
|
||||||
|
"peer": b.get_u32(),
|
||||||
|
"name": b.get_utf8_string(),
|
||||||
|
"kind": b.get_u8(),
|
||||||
|
"instance": b.get_u32(),
|
||||||
|
"alive": b.get_u8() == 1,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|||||||
+12
-1
@@ -3,7 +3,9 @@ extends RefCounted
|
|||||||
## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the
|
## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the
|
||||||
## server refuses mismatched clients at handshake rather than desyncing later.
|
## server refuses mismatched clients at handshake rather than desyncing later.
|
||||||
|
|
||||||
const VERSION := 1
|
## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
|
||||||
|
## roster message.
|
||||||
|
const VERSION := 2
|
||||||
const DEFAULT_PORT := 27015
|
const DEFAULT_PORT := 27015
|
||||||
const MAX_CLIENTS := 32
|
const MAX_CLIENTS := 32
|
||||||
|
|
||||||
@@ -22,3 +24,12 @@ enum InstanceKind { LOBBY, DUNGEON }
|
|||||||
const F_ALIVE := 1
|
const F_ALIVE := 1
|
||||||
const F_INVULN := 2
|
const F_INVULN := 2
|
||||||
const F_ESCAPING := 4
|
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.
|
||||||
|
const F_SPAWN_GRACE := 8
|
||||||
|
## The peer behind this player has dropped and is being channelled out.
|
||||||
|
const F_LINKDEAD := 16
|
||||||
|
|
||||||
|
## Snapshot's cleared-countdown byte when the instance is not in the cleared
|
||||||
|
## state -- 30s fits in a byte, so 255 is free to mean "not applicable".
|
||||||
|
const COUNTDOWN_NONE := 255
|
||||||
|
|||||||
+97
-18
@@ -38,13 +38,18 @@ func _physics_process(_delta: float) -> void:
|
|||||||
inst.step()
|
inst.step()
|
||||||
_dispatch_events(inst)
|
_dispatch_events(inst)
|
||||||
if send_snapshot and not inst.peers.is_empty():
|
if send_snapshot and not inst.peers.is_empty():
|
||||||
var snap := NetCodec.encode_snapshot(inst.world)
|
var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds())
|
||||||
for peer in inst.peers:
|
for peer in inst.peers:
|
||||||
Net.send_snapshot(peer, snap)
|
Net.send_snapshot(peer, snap)
|
||||||
if inst.kind == Protocol.InstanceKind.DUNGEON \
|
if inst.kind != Protocol.InstanceKind.DUNGEON:
|
||||||
and inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
|
continue
|
||||||
|
if inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
|
||||||
closing.append(inst.id)
|
closing.append(inst.id)
|
||||||
elif inst.kind == Protocol.InstanceKind.DUNGEON and inst.is_empty() and inst.age > 60:
|
elif inst.is_empty() and not inst.has_linkdead() and inst.age > 60:
|
||||||
|
# has_linkdead() matters: without it a solo player could drop, the
|
||||||
|
# instance would close on the next tick, and their body would
|
||||||
|
# vanish before finishing the escape channel -- exactly the
|
||||||
|
# disconnect cheese the channel exists to prevent.
|
||||||
closing.append(inst.id)
|
closing.append(inst.id)
|
||||||
|
|
||||||
for id in closing:
|
for id in closing:
|
||||||
@@ -52,18 +57,25 @@ func _physics_process(_delta: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
## Split the tick's events into the ones clients need and the ones only the
|
## Split the tick's events into the ones clients need and the ones only the
|
||||||
## server acts on (escape completion, portal use).
|
## server acts on (escape completion, portal use, respawn requests).
|
||||||
func _dispatch_events(inst: Instance) -> void:
|
func _dispatch_events(inst: Instance) -> void:
|
||||||
var events := inst.world.drain_events()
|
var events := inst.world.drain_events()
|
||||||
if events.is_empty():
|
if events.is_empty():
|
||||||
return
|
return
|
||||||
var transfers: Array[Dictionary] = []
|
# Collected and applied after the send below, because a transfer mutates
|
||||||
|
# inst.peers and would otherwise change the list mid-broadcast.
|
||||||
|
var to_lobby: Array[int] = []
|
||||||
|
var to_dungeon: Array[int] = []
|
||||||
for ev in events:
|
for ev in events:
|
||||||
match int(ev["t"]):
|
match int(ev["t"]):
|
||||||
SimEvent.Type.ESCAPE_COMPLETED:
|
SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED:
|
||||||
transfers.append({"peer": int(ev["peer"]), "to_lobby": true})
|
var peer := int(ev["peer"])
|
||||||
|
if not to_lobby.has(peer):
|
||||||
|
to_lobby.append(peer)
|
||||||
SimEvent.Type.PORTAL_USED:
|
SimEvent.Type.PORTAL_USED:
|
||||||
transfers.append({"peer": int(ev["peer"]), "to_lobby": false})
|
var peer := int(ev["peer"])
|
||||||
|
if not to_dungeon.has(peer):
|
||||||
|
to_dungeon.append(peer)
|
||||||
_:
|
_:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -71,11 +83,10 @@ func _dispatch_events(inst: Instance) -> void:
|
|||||||
for peer in inst.peers:
|
for peer in inst.peers:
|
||||||
Net.send_events(peer, payload)
|
Net.send_events(peer, payload)
|
||||||
|
|
||||||
for t in transfers:
|
for peer in to_lobby:
|
||||||
if t["to_lobby"]:
|
_send_to_lobby(peer)
|
||||||
_send_to_lobby(int(t["peer"]))
|
for peer in to_dungeon:
|
||||||
else:
|
_send_to_dungeon(peer)
|
||||||
_send_to_dungeon(int(t["peer"]))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Peer lifecycle ---------------------------------------------------------
|
# --- Peer lifecycle ---------------------------------------------------------
|
||||||
@@ -84,13 +95,42 @@ func on_peer_connected(peer_id: int) -> void:
|
|||||||
GameLog.info("server", "peer %d connected, awaiting hello" % peer_id)
|
GameLog.info("server", "peer %d connected, awaiting hello" % peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
## A drop inside a dungeon does NOT delete the player. The body is detached
|
||||||
|
## from the send list but kept in the world, where it channels the same
|
||||||
|
## one-second escape everyone else does and stays killable the whole time --
|
||||||
|
## so quitting the process is never a cheaper exit than pressing the button.
|
||||||
|
## Only once that channel resolves (_release_linkdead) is the peer forgotten.
|
||||||
|
## A drop in the hub has nothing to dodge, so it is immediate.
|
||||||
func on_peer_disconnected(peer_id: int) -> void:
|
func on_peer_disconnected(peer_id: int) -> void:
|
||||||
var inst := instance_of(peer_id)
|
var inst := instance_of(peer_id)
|
||||||
if inst != null:
|
if inst == null:
|
||||||
inst.remove_peer(peer_id)
|
_forget_peer(peer_id)
|
||||||
|
return
|
||||||
|
if inst.kind == Protocol.InstanceKind.DUNGEON:
|
||||||
|
inst.detach_peer(peer_id)
|
||||||
|
GameLog.info("server", "peer %d dropped in instance %d, channelling out"
|
||||||
|
% [peer_id, inst.id])
|
||||||
|
_broadcast_roster()
|
||||||
|
return
|
||||||
|
inst.remove_peer(peer_id)
|
||||||
|
_forget_peer(peer_id)
|
||||||
|
GameLog.info("server", "peer %d disconnected" % peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _forget_peer(peer_id: int) -> void:
|
||||||
peer_instance.erase(peer_id)
|
peer_instance.erase(peer_id)
|
||||||
peer_names.erase(peer_id)
|
peer_names.erase(peer_id)
|
||||||
GameLog.info("server", "peer %d disconnected" % peer_id)
|
_broadcast_roster()
|
||||||
|
|
||||||
|
|
||||||
|
## The linkdead body finished its channel (or died trying). Either way it leaves
|
||||||
|
## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are
|
||||||
|
## the same outcome, so it is simply removed.
|
||||||
|
func _release_linkdead(peer_id: int, inst: Instance) -> void:
|
||||||
|
inst.remove_peer(peer_id)
|
||||||
|
GameLog.info("server", "peer %d released from instance %d after drop"
|
||||||
|
% [peer_id, inst.id])
|
||||||
|
_forget_peer(peer_id)
|
||||||
|
|
||||||
|
|
||||||
func on_hello(peer_id: int, version: int, display_name: String) -> void:
|
func on_hello(peer_id: int, version: int, display_name: String) -> void:
|
||||||
@@ -109,6 +149,7 @@ func on_hello(peer_id: int, version: int, display_name: String) -> void:
|
|||||||
Net.send_welcome(peer_id)
|
Net.send_welcome(peer_id)
|
||||||
_place(peer_id, lobby)
|
_place(peer_id, lobby)
|
||||||
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
|
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
|
||||||
|
_broadcast_roster()
|
||||||
|
|
||||||
|
|
||||||
func on_input(peer_id: int, data: PackedByteArray) -> void:
|
func on_input(peer_id: int, data: PackedByteArray) -> void:
|
||||||
@@ -135,7 +176,8 @@ func _place(peer_id: int, inst: Instance) -> void:
|
|||||||
var backlog := _live_bullet_events(inst.world)
|
var backlog := _live_bullet_events(inst.world)
|
||||||
if not backlog.is_empty():
|
if not backlog.is_empty():
|
||||||
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
|
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
|
||||||
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world))
|
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world,
|
||||||
|
inst.exit_countdown_seconds()))
|
||||||
|
|
||||||
|
|
||||||
func _transfer(peer_id: int, to: Instance) -> void:
|
func _transfer(peer_id: int, to: Instance) -> void:
|
||||||
@@ -143,9 +185,19 @@ func _transfer(peer_id: int, to: Instance) -> void:
|
|||||||
if from != null:
|
if from != null:
|
||||||
from.remove_peer(peer_id)
|
from.remove_peer(peer_id)
|
||||||
_place(peer_id, to)
|
_place(peer_id, to)
|
||||||
|
_broadcast_roster()
|
||||||
|
|
||||||
|
|
||||||
|
## Where everything that ends a dungeon run converges: the escape channel, a
|
||||||
|
## downed player asking out, and a linkdead body finishing its channel.
|
||||||
func _send_to_lobby(peer_id: int) -> void:
|
func _send_to_lobby(peer_id: int) -> void:
|
||||||
|
var from := instance_of(peer_id)
|
||||||
|
if from != null:
|
||||||
|
var p: SimPlayer = from.world.players.get(peer_id)
|
||||||
|
if p != null and p.linkdead:
|
||||||
|
_release_linkdead(peer_id, from)
|
||||||
|
return
|
||||||
|
|
||||||
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
|
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
|
||||||
_transfer(peer_id, lobby)
|
_transfer(peer_id, lobby)
|
||||||
|
|
||||||
@@ -173,6 +225,33 @@ func _close_dungeon(id: int) -> void:
|
|||||||
GameLog.info("server", "closed dungeon instance %d" % id)
|
GameLog.info("server", "closed dungeon instance %d" % id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Roster -----------------------------------------------------------------
|
||||||
|
|
||||||
|
## Tell everyone who is online and where they are, so the hub can show that a
|
||||||
|
## dungeon is already running before you walk into the portal.
|
||||||
|
##
|
||||||
|
## Sent on membership changes only, not per tick -- it is the one message that
|
||||||
|
## carries names, and nothing about it is time critical.
|
||||||
|
func _broadcast_roster() -> void:
|
||||||
|
var entries: Array[Dictionary] = []
|
||||||
|
for peer in peer_names:
|
||||||
|
var inst := instance_of(peer)
|
||||||
|
if inst == null:
|
||||||
|
continue
|
||||||
|
var p: SimPlayer = inst.world.players.get(peer)
|
||||||
|
entries.append({
|
||||||
|
"peer": peer,
|
||||||
|
"name": peer_names[peer],
|
||||||
|
"kind": int(inst.kind),
|
||||||
|
"instance": inst.id,
|
||||||
|
"alive": p != null and p.alive,
|
||||||
|
})
|
||||||
|
var payload := NetCodec.encode_roster(entries)
|
||||||
|
for inst in instances.values():
|
||||||
|
for peer in inst.peers:
|
||||||
|
Net.send_roster(peer, payload)
|
||||||
|
|
||||||
|
|
||||||
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
|
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
|
||||||
var out: Array[Dictionary] = []
|
var out: Array[Dictionary] = []
|
||||||
for i in world.pool.high_water:
|
for i in world.pool.high_water:
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ enum Type {
|
|||||||
PLAYER_HIT, ## peer, damage, hp
|
PLAYER_HIT, ## peer, damage, hp
|
||||||
PLAYER_DIED, ## peer
|
PLAYER_DIED, ## peer
|
||||||
PLAYER_RESPAWNED, ## peer, pos
|
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_HIT, ## id, damage, hp
|
||||||
ENEMY_DIED, ## id
|
ENEMY_DIED, ## id
|
||||||
BOSS_PHASE, ## phase index
|
BOSS_PHASE, ## phase index
|
||||||
|
|||||||
+24
-4
@@ -11,7 +11,14 @@ var hp: int = SimConfig.PLAYER_MAX_HP
|
|||||||
var alive: bool = true
|
var alive: bool = true
|
||||||
var iframes: int = 0
|
var iframes: int = 0
|
||||||
var fire_cooldown: 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.
|
## Ticks the emergency escape has been held. 0 means not channelling.
|
||||||
var escape_ticks: int = 0
|
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)
|
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
|
pos = spawn
|
||||||
hp = SimConfig.PLAYER_MAX_HP
|
hp = SimConfig.PLAYER_MAX_HP
|
||||||
alive = true
|
alive = true
|
||||||
iframes = SimConfig.PLAYER_IFRAMES
|
iframes = 0
|
||||||
|
spawn_grace = grace
|
||||||
fire_cooldown = 0
|
fire_cooldown = 0
|
||||||
respawn_timer = 0
|
|
||||||
escape_ticks = 0
|
escape_ticks = 0
|
||||||
input_queue.clear()
|
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.
|
## Set on a lobby world so the interact button can open a dungeon.
|
||||||
var portal_enabled: bool = false
|
var portal_enabled: bool = false
|
||||||
var spawn_point := Vector2(0.0, 240.0)
|
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 _next_actor_id: int = 1
|
||||||
var _ctx := EmitContext.new()
|
var _ctx := EmitContext.new()
|
||||||
@@ -50,7 +53,7 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer:
|
|||||||
var p := SimPlayer.new()
|
var p := SimPlayer.new()
|
||||||
p.peer_id = peer_id
|
p.peer_id = peer_id
|
||||||
p.display_name = display_name
|
p.display_name = display_name
|
||||||
p.reset_for_instance(spawn_point)
|
p.reset_for_instance(spawn_point, spawn_grace_ticks)
|
||||||
players[peer_id] = p
|
players[peer_id] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
@@ -148,24 +151,25 @@ func _step_players() -> void:
|
|||||||
for p in players.values():
|
for p in players.values():
|
||||||
if p.iframes > 0:
|
if p.iframes > 0:
|
||||||
p.iframes -= 1
|
p.iframes -= 1
|
||||||
|
if p.spawn_grace > 0:
|
||||||
|
p.spawn_grace -= 1
|
||||||
if p.fire_cooldown > 0:
|
if p.fire_cooldown > 0:
|
||||||
p.fire_cooldown -= 1
|
p.fire_cooldown -= 1
|
||||||
|
|
||||||
|
var frame := _take_input(p)
|
||||||
|
|
||||||
if not p.alive:
|
if not p.alive:
|
||||||
p.respawn_timer -= 1
|
# No timed respawn: a downed player waits for the hub. Asking to go
|
||||||
if p.respawn_timer <= 0:
|
# is an input like any other, so a dead client cannot be revived by
|
||||||
p.alive = true
|
# anything except its own request reaching the server.
|
||||||
p.hp = SimConfig.PLAYER_MAX_HP
|
if frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead:
|
||||||
p.pos = spawn_point
|
events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
|
||||||
p.iframes = SimConfig.PLAYER_IFRAMES
|
|
||||||
events.append({"t": SimEvent.Type.PLAYER_RESPAWNED, "peer": p.peer_id, "pos": p.pos})
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
var frame := _take_input(p)
|
|
||||||
p.aim = frame.aim
|
p.aim = frame.aim
|
||||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
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)
|
_fire_player_shot(p)
|
||||||
|
|
||||||
_step_escape(p, frame)
|
_step_escape(p, frame)
|
||||||
@@ -196,7 +200,7 @@ func _fire_player_shot(p: SimPlayer) -> void:
|
|||||||
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
|
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
|
||||||
var dir := Vector2.RIGHT.rotated(p.aim)
|
var dir := Vector2.RIGHT.rotated(p.aim)
|
||||||
pool.spawn(
|
pool.spawn(
|
||||||
p.pos + dir * (SimConfig.PLAYER_RADIUS + 6.0),
|
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
|
||||||
dir * SimConfig.PLAYER_BULLET_SPEED,
|
dir * SimConfig.PLAYER_BULLET_SPEED,
|
||||||
SimConfig.PLAYER_BULLET_RADIUS,
|
SimConfig.PLAYER_BULLET_RADIUS,
|
||||||
SimConfig.PLAYER_BULLET_LIFETIME,
|
SimConfig.PLAYER_BULLET_LIFETIME,
|
||||||
@@ -206,7 +210,10 @@ func _fire_player_shot(p: SimPlayer) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _step_escape(p: SimPlayer, frame: InputFrame) -> 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:
|
if p.escape_ticks == 0:
|
||||||
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
|
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
|
||||||
p.escape_ticks += 1
|
p.escape_ticks += 1
|
||||||
@@ -317,7 +324,10 @@ func _resolve_bullet_hits() -> void:
|
|||||||
var br: float = pool.radius[i]
|
var br: float = pool.radius[i]
|
||||||
if pool.team[i] == SimConfig.TEAM_ENEMY:
|
if pool.team[i] == SimConfig.TEAM_ENEMY:
|
||||||
for p in players.values():
|
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
|
continue
|
||||||
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
|
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
|
||||||
_damage_player(p, pool.damage[i])
|
_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:
|
if not e.alive or e.def.contact_damage <= 0:
|
||||||
continue
|
continue
|
||||||
for p in players.values():
|
for p in players.values():
|
||||||
if not p.alive or p.iframes > 0:
|
if not p.alive or p.invulnerable():
|
||||||
continue
|
continue
|
||||||
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
|
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
|
||||||
_damage_player(p, e.def.contact_damage)
|
_damage_player(p, e.def.contact_damage)
|
||||||
@@ -358,16 +368,15 @@ func _kill_bullet(slot: int) -> void:
|
|||||||
pool.despawn(slot)
|
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:
|
func _damage_player(p: SimPlayer, amount: int) -> void:
|
||||||
p.hp = maxi(p.hp - amount, 0)
|
p.hp = maxi(p.hp - amount, 0)
|
||||||
p.iframes = SimConfig.PLAYER_IFRAMES
|
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})
|
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
|
||||||
if p.hp <= 0:
|
if p.hp <= 0:
|
||||||
p.alive = false
|
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})
|
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## In-game system menu, opened with Escape.
|
||||||
|
##
|
||||||
|
## Exists because without it there is no way out of a session short of killing
|
||||||
|
## the process -- and "kill the process" is exactly the behaviour the escape
|
||||||
|
## channel is designed to discourage, so leaving it as the only exit would work
|
||||||
|
## against the rest of the design.
|
||||||
|
|
||||||
|
signal resumed
|
||||||
|
signal return_to_hub_requested
|
||||||
|
signal disconnect_requested
|
||||||
|
|
||||||
|
var _panel: VBoxContainer
|
||||||
|
var _hub_button: Button
|
||||||
|
var _open: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 20
|
||||||
|
var root := Control.new()
|
||||||
|
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(root)
|
||||||
|
|
||||||
|
# Dim the game behind the menu so it is obvious the world is still running.
|
||||||
|
var scrim := ColorRect.new()
|
||||||
|
scrim.color = Color(0.0, 0.0, 0.0, 0.55)
|
||||||
|
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
root.add_child(scrim)
|
||||||
|
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
root.add_child(center)
|
||||||
|
|
||||||
|
_panel = VBoxContainer.new()
|
||||||
|
_panel.custom_minimum_size = Vector2(320.0, 0.0)
|
||||||
|
_panel.add_theme_constant_override("separation", 10)
|
||||||
|
center.add_child(_panel)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "PAUSED"
|
||||||
|
title.add_theme_font_size_override("font_size", 26)
|
||||||
|
_panel.add_child(title)
|
||||||
|
|
||||||
|
var note := Label.new()
|
||||||
|
note.text = "The world keeps running. You are not safe here."
|
||||||
|
note.add_theme_font_size_override("font_size", 12)
|
||||||
|
note.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
|
||||||
|
_panel.add_child(note)
|
||||||
|
|
||||||
|
_hub_button = _button("Return to hub", func() -> void:
|
||||||
|
return_to_hub_requested.emit()
|
||||||
|
close())
|
||||||
|
_button("Resume", func() -> void: close())
|
||||||
|
_button("Disconnect to menu", func() -> void:
|
||||||
|
disconnect_requested.emit()
|
||||||
|
close())
|
||||||
|
_button("Quit game", func() -> void: get_tree().quit())
|
||||||
|
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _button(text: String, on_press: Callable) -> Button:
|
||||||
|
var b := Button.new()
|
||||||
|
b.text = text
|
||||||
|
b.pressed.connect(on_press)
|
||||||
|
_panel.add_child(b)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("system_menu"):
|
||||||
|
toggle()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func toggle() -> void:
|
||||||
|
if _open:
|
||||||
|
close()
|
||||||
|
else:
|
||||||
|
open()
|
||||||
|
|
||||||
|
|
||||||
|
func open() -> void:
|
||||||
|
_open = true
|
||||||
|
visible = true
|
||||||
|
# Nothing is paused -- the server does not stop for one client's menu, so
|
||||||
|
# neither does the view. Only the mouse is freed.
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
_open = false
|
||||||
|
visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func is_open() -> bool:
|
||||||
|
return _open
|
||||||
|
|
||||||
|
|
||||||
|
## The hub button is meaningless when you are already in the hub.
|
||||||
|
func set_in_dungeon(in_dungeon: bool) -> void:
|
||||||
|
_hub_button.disabled = not in_dungeon
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://6j1nytv113kn
|
||||||
+55
-5
@@ -68,9 +68,11 @@ func _status_text() -> String:
|
|||||||
func _hint_text() -> String:
|
func _hint_text() -> String:
|
||||||
if client == null:
|
if client == null:
|
||||||
return ""
|
return ""
|
||||||
|
if not client.my_alive:
|
||||||
|
return "DOWN -- press E to return to the hub"
|
||||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
||||||
return "WASD move mouse aim LMB fire E on the ring to enter a dungeon"
|
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 escape to the lobby"
|
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu"
|
||||||
|
|
||||||
|
|
||||||
func _draw_hud() -> void:
|
func _draw_hud() -> void:
|
||||||
@@ -89,16 +91,64 @@ func _draw_hud() -> void:
|
|||||||
|
|
||||||
_draw_boss_bar()
|
_draw_boss_bar()
|
||||||
|
|
||||||
|
if client.my_spawn_grace:
|
||||||
|
_canvas.draw_string(ThemeDB.fallback_font,
|
||||||
|
origin + Vector2(BAR_W + 12.0, BAR_H),
|
||||||
|
"ARRIVING -- invulnerable, weapons cold",
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0))
|
||||||
|
|
||||||
|
_draw_cleared_countdown()
|
||||||
|
_draw_roster()
|
||||||
|
|
||||||
if not client.my_alive:
|
if not client.my_alive:
|
||||||
var size := _canvas.size
|
# Centred on the canvas, which is only correct because _canvas actually
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, size * 0.5 - Vector2(70.0, 0.0),
|
# has the viewport's size now -- see the anchor note in _ready().
|
||||||
"DOWN -- respawning", HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color(1.0, 0.4, 0.4))
|
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:
|
if _hit_flash > 0.0:
|
||||||
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
|
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
|
||||||
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
|
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
|
||||||
|
|
||||||
|
|
||||||
|
## Shown after the boss dies, so the victory lap has a visible clock on it.
|
||||||
|
func _draw_cleared_countdown() -> void:
|
||||||
|
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
|
||||||
|
return
|
||||||
|
var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown
|
||||||
|
_canvas.draw_string(ThemeDB.fallback_font,
|
||||||
|
Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0),
|
||||||
|
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75))
|
||||||
|
|
||||||
|
|
||||||
|
## Who else is online, and whether they are already in a dungeon. Only useful in
|
||||||
|
## the hub, which is the one place you are deciding whether to go in.
|
||||||
|
func _draw_roster() -> void:
|
||||||
|
if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty():
|
||||||
|
return
|
||||||
|
var x := _canvas.size.x - 240.0
|
||||||
|
var y := MARGIN + 4.0
|
||||||
|
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE",
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8))
|
||||||
|
y += 20.0
|
||||||
|
for entry in client.roster:
|
||||||
|
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
|
||||||
|
var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "hub"
|
||||||
|
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
|
||||||
|
if not entry["alive"]:
|
||||||
|
col = Color(0.85, 0.4, 0.4)
|
||||||
|
where += " (down)"
|
||||||
|
var me := " <- you" if int(entry["peer"]) == client.my_peer else ""
|
||||||
|
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y),
|
||||||
|
"%s -- %s%s" % [entry["name"], where, me],
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
|
||||||
|
y += 18.0
|
||||||
|
|
||||||
|
|
||||||
func _draw_boss_bar() -> void:
|
func _draw_boss_bar() -> void:
|
||||||
var b := client.boss_state()
|
var b := client.boss_state()
|
||||||
if b.is_empty() or client.boss_def == null:
|
if b.is_empty() or client.boss_def == null:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ extends Node2D
|
|||||||
|
|
||||||
@onready var world_view: Node2D = $WorldView
|
@onready var world_view: Node2D = $WorldView
|
||||||
@onready var hud: CanvasLayer = $HUD
|
@onready var hud: CanvasLayer = $HUD
|
||||||
|
@onready var menu: CanvasLayer = $GameMenu
|
||||||
|
|
||||||
var _bound: ClientRuntime = null
|
var _bound: ClientRuntime = null
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ var _bound: ClientRuntime = null
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
world_view.position = get_viewport_rect().size * 0.5
|
world_view.position = get_viewport_rect().size * 0.5
|
||||||
get_viewport().size_changed.connect(_recentre)
|
get_viewport().size_changed.connect(_recentre)
|
||||||
|
menu.return_to_hub_requested.connect(_on_return_to_hub)
|
||||||
|
menu.disconnect_requested.connect(_on_disconnect)
|
||||||
|
|
||||||
|
|
||||||
func _recentre() -> void:
|
func _recentre() -> void:
|
||||||
@@ -23,6 +26,20 @@ func _process(_delta: float) -> void:
|
|||||||
_bound = Net.client
|
_bound = Net.client
|
||||||
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
|
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
|
||||||
_bound.local_hit.connect(_on_local_hit)
|
_bound.local_hit.connect(_on_local_hit)
|
||||||
|
menu.set_in_dungeon(_bound != null
|
||||||
|
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
|
||||||
|
|
||||||
|
|
||||||
|
## Routed through the same held-escape channel the F key uses, rather than a
|
||||||
|
## direct "teleport me" message -- the server has no such message, and adding
|
||||||
|
## one would hand clients an instant, uninterruptible exit.
|
||||||
|
func _on_return_to_hub() -> void:
|
||||||
|
if _bound != null:
|
||||||
|
_bound.request_escape = true
|
||||||
|
|
||||||
|
|
||||||
|
func _on_disconnect() -> void:
|
||||||
|
Net.shutdown()
|
||||||
|
|
||||||
|
|
||||||
func _on_local_hit(_damage: int) -> void:
|
func _on_local_hit(_damage: int) -> void:
|
||||||
|
|||||||
+32
-2
@@ -11,6 +11,8 @@ const COL_LOCAL := Color(0.5, 1.0, 0.8)
|
|||||||
const COL_REMOTE := Color(0.55, 0.75, 1.0)
|
const COL_REMOTE := Color(0.55, 0.75, 1.0)
|
||||||
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
|
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
|
||||||
const COL_PORTAL := Color(0.5, 0.9, 1.0)
|
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 := [
|
const ENEMY_COLORS := [
|
||||||
Color(0.95, 0.55, 0.55), # drifter
|
Color(0.95, 0.55, 0.55), # drifter
|
||||||
Color(0.85, 0.7, 0.35), # turret
|
Color(0.85, 0.7, 0.35), # turret
|
||||||
@@ -71,10 +73,25 @@ func _draw_portal() -> void:
|
|||||||
func _draw_enemy(e: Dictionary) -> void:
|
func _draw_enemy(e: Dictionary) -> void:
|
||||||
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
|
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
|
||||||
var r: float = e["radius"]
|
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_circle(e["pos"], r, Color(col, 0.35))
|
||||||
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
|
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:
|
func _draw_boss() -> void:
|
||||||
var b := client.boss_state()
|
var b := client.boss_state()
|
||||||
if b.is_empty():
|
if b.is_empty():
|
||||||
@@ -87,10 +104,13 @@ func _draw_boss() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _draw_remote_player(p: Dictionary) -> void:
|
func _draw_remote_player(p: Dictionary) -> void:
|
||||||
var alive := (int(p["flags"]) & Protocol.F_ALIVE) != 0
|
var flags := int(p["flags"])
|
||||||
|
var alive := (flags & Protocol.F_ALIVE) != 0
|
||||||
var col := COL_REMOTE if alive else COL_DEAD
|
var col := COL_REMOTE if alive else COL_DEAD
|
||||||
_draw_ship(p["pos"], p["aim"], col, alive)
|
_draw_ship(p["pos"], p["aim"], col, alive)
|
||||||
if (int(p["flags"]) & Protocol.F_ESCAPING) != 0:
|
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)
|
_draw_escape_ring(p["pos"], float(p["escape"]), col)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,10 +119,20 @@ func _draw_local_player() -> void:
|
|||||||
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
|
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
|
||||||
return
|
return
|
||||||
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
|
_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:
|
if client.my_escaping:
|
||||||
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
|
_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
|
## 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
|
## 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
|
## mismatch is deliberate, not a placeholder: a bullet can visibly clip the
|
||||||
|
|||||||
@@ -67,9 +67,13 @@ func test_killing_the_boss_clears_the_instance() -> void:
|
|||||||
inst.world.boss.alive = false
|
inst.world.boss.alive = false
|
||||||
_step(5)
|
_step(5)
|
||||||
assert_eq(inst.state, Instance.State.CLEARED)
|
assert_eq(inst.state, Instance.State.CLEARED)
|
||||||
|
assert_almost_eq(inst.exit_countdown_seconds(),
|
||||||
|
SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE, 1,
|
||||||
|
"the party gets a visible countdown, not an instant boot")
|
||||||
# The exit timer has to run down, or the party would never be released.
|
# The exit timer has to run down, or the party would never be released.
|
||||||
_step(400)
|
_step(SimConfig.DUNGEON_CLEARED_EXIT_TICKS + 10)
|
||||||
assert_eq(inst.stage_delay, 0)
|
assert_eq(inst.stage_delay, 0)
|
||||||
|
assert_eq(inst.exit_countdown_seconds(), 0)
|
||||||
|
|
||||||
|
|
||||||
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
|
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ func test_escape_takes_the_full_channel_time() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_releasing_the_button_cancels_the_channel() -> void:
|
func test_releasing_the_button_cancels_the_channel() -> void:
|
||||||
_hold_escape(60)
|
# Deliberately short of ESCAPE_CHANNEL_TICKS: hold the full duration and the
|
||||||
|
# channel completes instead, which is a different test.
|
||||||
|
_hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS / 2)
|
||||||
assert_gt(world.players[PEER].escape_ticks, 0)
|
assert_gt(world.players[PEER].escape_ticks, 0)
|
||||||
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
|
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
|
||||||
world.queue_input(PEER, frames)
|
world.queue_input(PEER, frames)
|
||||||
@@ -42,18 +44,63 @@ func test_releasing_the_button_cancels_the_channel() -> void:
|
|||||||
assert_eq(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 1)
|
assert_eq(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 1)
|
||||||
|
|
||||||
|
|
||||||
func test_taking_damage_cancels_the_channel() -> void:
|
## The inverse of what this asserted originally. Interrupting on damage makes
|
||||||
_hold_escape(60)
|
## quitting the process strictly better than using the button, which turns the
|
||||||
|
## escape hatch into the exploit -- see SimConfig.ESCAPE_CHANNEL_TICKS.
|
||||||
|
func test_taking_damage_does_not_cancel_the_channel() -> void:
|
||||||
|
_hold_escape(20)
|
||||||
|
var before: int = world.players[PEER].escape_ticks
|
||||||
world.pool.spawn(world.players[PEER].pos, Vector2.ZERO, 6.0, 60, 10,
|
world.pool.spawn(world.players[PEER].pos, Vector2.ZERO, 6.0, 60, 10,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
|
_hold_escape(1)
|
||||||
|
assert_lt(world.players[PEER].hp, SimConfig.PLAYER_MAX_HP, "setup: should have been hit")
|
||||||
|
assert_eq(world.players[PEER].escape_ticks, before + 1,
|
||||||
|
"the channel must keep running while under fire")
|
||||||
|
assert_eq(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
## Dying is still an interruption -- it is the one thing the escape cannot beat,
|
||||||
|
## which is what keeps the one-second channel a real risk.
|
||||||
|
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()
|
world.step()
|
||||||
assert_eq(world.players[PEER].escape_ticks, 0,
|
assert_false(p.alive)
|
||||||
"escaping under fire has to be a real risk, not a free exit")
|
assert_eq(p.escape_ticks, 0)
|
||||||
assert_gt(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 0)
|
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
## A dropped connection channels out on exactly the same timer, so pulling the
|
||||||
|
## plug is never cheaper than pressing the button.
|
||||||
|
func test_a_linkdead_player_channels_out_without_input() -> void:
|
||||||
|
var p: SimPlayer = world.players[PEER]
|
||||||
|
p.linkdead = true
|
||||||
|
for _i in SimConfig.ESCAPE_CHANNEL_TICKS - 1:
|
||||||
|
world.step()
|
||||||
|
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 0,
|
||||||
|
"a disconnect must not be an instant exit")
|
||||||
|
world.step()
|
||||||
|
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
world.step()
|
||||||
|
assert_false(p.alive, "the whole point: disconnecting does not grant immunity")
|
||||||
|
|
||||||
|
|
||||||
func test_a_cancelled_channel_restarts_from_zero() -> void:
|
func test_a_cancelled_channel_restarts_from_zero() -> void:
|
||||||
_hold_escape(120)
|
_hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS / 2)
|
||||||
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
|
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
|
||||||
world.queue_input(PEER, frames)
|
world.queue_input(PEER, frames)
|
||||||
world.step()
|
world.step()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ func before_each() -> void:
|
|||||||
p.pos = Vector2(120.5, -64.25)
|
p.pos = Vector2(120.5, -64.25)
|
||||||
p.aim = 1.25
|
p.aim = 1.25
|
||||||
p.hp = 73
|
p.hp = 73
|
||||||
p.escape_ticks = 90
|
p.escape_ticks = 30
|
||||||
p.last_input_tick = 555
|
p.last_input_tick = 555
|
||||||
world.spawn_enemy(Content.turret(), Vector2(-200.0, 100.0))
|
world.spawn_enemy(Content.turret(), Vector2(-200.0, 100.0))
|
||||||
world.spawn_boss(Content.warden())
|
world.spawn_boss(Content.warden())
|
||||||
@@ -30,7 +30,7 @@ func test_snapshot_round_trips_player_state() -> void:
|
|||||||
assert_eq(int(rec["last_input_tick"]), 555)
|
assert_eq(int(rec["last_input_tick"]), 555)
|
||||||
assert_true((int(rec["flags"]) & Protocol.F_ALIVE) != 0)
|
assert_true((int(rec["flags"]) & Protocol.F_ALIVE) != 0)
|
||||||
assert_true((int(rec["flags"]) & Protocol.F_ESCAPING) != 0)
|
assert_true((int(rec["flags"]) & Protocol.F_ESCAPING) != 0)
|
||||||
assert_almost_eq(float(rec["escape"]), 90.0 / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.01)
|
assert_almost_eq(float(rec["escape"]), 30.0 / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.01)
|
||||||
|
|
||||||
|
|
||||||
func test_snapshot_carries_enemy_radius_for_late_joiners() -> void:
|
func test_snapshot_carries_enemy_radius_for_late_joiners() -> void:
|
||||||
@@ -105,3 +105,59 @@ func test_truncated_input_packet_is_rejected_not_read_past() -> void:
|
|||||||
|
|
||||||
func test_empty_input_packet_is_safe() -> void:
|
func test_empty_input_packet_is_safe() -> void:
|
||||||
assert_eq(NetCodec.decode_inputs(PackedByteArray()).size(), 0)
|
assert_eq(NetCodec.decode_inputs(PackedByteArray()).size(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Roster -----------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_roster_round_trips() -> void:
|
||||||
|
var entries: Array[Dictionary] = [
|
||||||
|
{"peer": 7, "name": "ada", "kind": Protocol.InstanceKind.LOBBY,
|
||||||
|
"instance": 1, "alive": true},
|
||||||
|
{"peer": 9, "name": "grace", "kind": Protocol.InstanceKind.DUNGEON,
|
||||||
|
"instance": 4, "alive": false},
|
||||||
|
]
|
||||||
|
var out := NetCodec.decode_roster(NetCodec.encode_roster(entries))
|
||||||
|
assert_eq(out.size(), 2)
|
||||||
|
assert_eq(int(out[0]["peer"]), 7)
|
||||||
|
assert_eq(String(out[0]["name"]), "ada")
|
||||||
|
assert_eq(int(out[0]["kind"]), Protocol.InstanceKind.LOBBY)
|
||||||
|
assert_true(out[0]["alive"])
|
||||||
|
assert_eq(String(out[1]["name"]), "grace")
|
||||||
|
assert_eq(int(out[1]["kind"]), Protocol.InstanceKind.DUNGEON,
|
||||||
|
"the hub needs to know someone is already inside")
|
||||||
|
assert_eq(int(out[1]["instance"]), 4)
|
||||||
|
assert_false(out[1]["alive"])
|
||||||
|
|
||||||
|
|
||||||
|
func test_roster_survives_unicode_names() -> void:
|
||||||
|
var entries: Array[Dictionary] = [
|
||||||
|
{"peer": 1, "name": "ゆき", "kind": 0, "instance": 1, "alive": true}]
|
||||||
|
var out := NetCodec.decode_roster(NetCodec.encode_roster(entries))
|
||||||
|
assert_eq(String(out[0]["name"]), "ゆき")
|
||||||
|
|
||||||
|
|
||||||
|
func test_empty_roster_is_safe() -> void:
|
||||||
|
assert_eq(NetCodec.decode_roster(PackedByteArray()).size(), 0)
|
||||||
|
assert_eq(NetCodec.decode_roster(NetCodec.encode_roster([] as Array[Dictionary])).size(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Snapshot extras --------------------------------------------------------
|
||||||
|
|
||||||
|
func test_snapshot_carries_spawn_grace_and_linkdead_flags() -> void:
|
||||||
|
var p: SimPlayer = world.players[42]
|
||||||
|
p.spawn_grace = 30
|
||||||
|
p.linkdead = true
|
||||||
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
||||||
|
var flags := int(snap["players"][0]["flags"])
|
||||||
|
assert_true((flags & Protocol.F_SPAWN_GRACE) != 0)
|
||||||
|
assert_true((flags & Protocol.F_LINKDEAD) != 0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_cleared_countdown_round_trips() -> void:
|
||||||
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world, 17))
|
||||||
|
assert_eq(int(snap["cleared_countdown"]), 17)
|
||||||
|
|
||||||
|
|
||||||
|
func test_countdown_defaults_to_not_applicable() -> void:
|
||||||
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
||||||
|
assert_eq(int(snap["cleared_countdown"]), Protocol.COUNTDOWN_NONE)
|
||||||
|
|||||||
@@ -122,17 +122,70 @@ func test_a_replica_world_never_resolves_a_hit() -> void:
|
|||||||
"only the server decides damage; a client replica must never apply it")
|
"only the server decides damage; a client replica must never apply it")
|
||||||
|
|
||||||
|
|
||||||
func test_player_death_and_respawn() -> void:
|
func _kill_player() -> SimPlayer:
|
||||||
var p: SimPlayer = world.players[PEER]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.pos = Vector2.ZERO
|
p.pos = Vector2.ZERO
|
||||||
p.hp = 5
|
p.hp = 5
|
||||||
p.iframes = 0
|
p.iframes = 0
|
||||||
|
p.spawn_grace = 0
|
||||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
world.step()
|
world.step()
|
||||||
assert_false(p.alive)
|
assert_false(p.alive, "setup: the player should be down")
|
||||||
for _i in SimConfig.PLAYER_RESPAWN_DELAY + 1:
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
func _events_of(type: int) -> Array:
|
||||||
|
return world.events.filter(func(e: Dictionary) -> bool: return int(e["t"]) == type)
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_downed_player_stays_down_without_input() -> void:
|
||||||
|
var p := _kill_player()
|
||||||
|
world.drain_events()
|
||||||
|
for _i in 600:
|
||||||
world.step()
|
world.step()
|
||||||
assert_true(p.alive)
|
assert_false(p.alive, "there is no timed respawn -- death waits for the player")
|
||||||
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP)
|
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0)
|
||||||
assert_eq(p.pos, world.spawn_point)
|
|
||||||
|
|
||||||
|
func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void:
|
||||||
|
_kill_player()
|
||||||
|
world.drain_events()
|
||||||
|
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
||||||
|
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 1,
|
||||||
|
"the request is an event for the instance layer, not a local revive")
|
||||||
|
# Crucially the world does NOT revive the player itself: only the server's
|
||||||
|
# instance layer can, by moving them to the hub.
|
||||||
|
assert_false(world.players[PEER].alive)
|
||||||
|
|
||||||
|
|
||||||
|
func test_respawn_request_never_reaches_the_client() -> void:
|
||||||
|
_kill_player()
|
||||||
|
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
||||||
|
var packet := NetCodec.decode_events(NetCodec.encode_events(world.tick, world.events))
|
||||||
|
for ev: Dictionary in packet["events"]:
|
||||||
|
assert_ne(int(ev["t"]), SimEvent.Type.RESPAWN_REQUESTED,
|
||||||
|
"where a dead player goes is the server's decision")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
|
||||||
|
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP, "arrival protection must absorb the hit")
|
||||||
|
assert_eq(world.pool.live_count, 1,
|
||||||
|
"only the enemy bullet: a protected player cannot shoot either")
|
||||||
|
|
||||||
|
|
||||||
|
func test_spawn_grace_expires() -> void:
|
||||||
|
var p: SimPlayer = world.players[PEER]
|
||||||
|
p.spawn_grace = 5
|
||||||
|
_drive(6)
|
||||||
|
assert_eq(p.spawn_grace, 0)
|
||||||
|
assert_false(p.invulnerable())
|
||||||
|
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
|
||||||
|
assert_eq(world.pool.live_count, 1, "the gun comes back once grace ends")
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
extends Node
|
||||||
|
## Measures the gap between where the client *draws* the local player and where
|
||||||
|
## the server actually has it -- the number that decides whether bullets appear
|
||||||
|
## to leave the ship's nose or trail out of its back.
|
||||||
|
##
|
||||||
|
## godot --headless --path . res://tools/diag_prediction.tscn
|
||||||
|
##
|
||||||
|
## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual
|
||||||
|
## Net loopback), not a mock, so node process order is exactly production's.
|
||||||
|
|
||||||
|
var _ticks: int = 0
|
||||||
|
var _worst: float = 0.0
|
||||||
|
var _sum: float = 0.0
|
||||||
|
var _samples: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameOpts.bot_client = true
|
||||||
|
GameOpts.player_name = "diag"
|
||||||
|
if Net.host(27300) != OK:
|
||||||
|
push_error("could not host")
|
||||||
|
get_tree().quit(1)
|
||||||
|
return
|
||||||
|
Net.start_local_client()
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
_ticks += 1
|
||||||
|
var srv := Net.server
|
||||||
|
var cli := Net.client
|
||||||
|
if srv == null or cli == null:
|
||||||
|
return
|
||||||
|
var inst := srv.instance_of(Net.LOCAL_PEER)
|
||||||
|
if inst == null or not inst.world.players.has(Net.LOCAL_PEER):
|
||||||
|
return
|
||||||
|
var sp: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
||||||
|
var delta_px := sp.pos.distance_to(cli.predicted_pos)
|
||||||
|
|
||||||
|
# Skip the first few ticks while the handshake settles.
|
||||||
|
if _ticks > 20:
|
||||||
|
_worst = maxf(_worst, delta_px)
|
||||||
|
_sum += delta_px
|
||||||
|
_samples += 1
|
||||||
|
|
||||||
|
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()),
|
||||||
|
delta_px, sp.last_input_tick, cli.input_tick, sp.input_queue.size()])
|
||||||
|
|
||||||
|
if _ticks >= 300:
|
||||||
|
print("---")
|
||||||
|
print("GAP worst=%.2fpx mean=%.2fpx over %d samples" % [_worst, _sum / maxf(_samples, 1), _samples])
|
||||||
|
print("player speed is %.0f u/s = %.2f px per tick" % [
|
||||||
|
SimConfig.PLAYER_SPEED, SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA])
|
||||||
|
print("muzzle offset = %.1fpx, drawn ship radius = %.1fpx, hitbox = %.1fpx" % [
|
||||||
|
SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS,
|
||||||
|
SimConfig.PLAYER_RADIUS])
|
||||||
|
Net.shutdown()
|
||||||
|
get_tree().quit(0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bcsb8u10uk0o1
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tools/diag_prediction.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="DiagPrediction" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
@@ -19,6 +19,7 @@ func _init() -> void:
|
|||||||
_action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)])
|
_action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)])
|
||||||
_action("emergency_escape", [_key(KEY_F)])
|
_action("emergency_escape", [_key(KEY_F)])
|
||||||
_action("interact", [_key(KEY_E)])
|
_action("interact", [_key(KEY_E)])
|
||||||
|
_action("system_menu", [_key(KEY_ESCAPE)])
|
||||||
var err := ProjectSettings.save()
|
var err := ProjectSettings.save()
|
||||||
print("input map written, err=%d" % err)
|
print("input map written, err=%d" % err)
|
||||||
quit(0 if err == OK else 1)
|
quit(0 if err == OK else 1)
|
||||||
|
|||||||
+30
-3
@@ -17,8 +17,11 @@ SERVER_TICKS="${SERVER_TICKS:-2000}"
|
|||||||
CLIENT_TICKS="${CLIENT_TICKS:-1700}"
|
CLIENT_TICKS="${CLIENT_TICKS:-1700}"
|
||||||
OUT="$(mktemp -d -t transcience-smoke-XXXXXX)"
|
OUT="$(mktemp -d -t transcience-smoke-XXXXXX)"
|
||||||
|
|
||||||
|
PIDS=()
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill %1 %2 %3 2>/dev/null
|
for pid in "${PIDS[@]:-}"; do
|
||||||
|
kill "$pid" 2>/dev/null
|
||||||
|
done
|
||||||
wait 2>/dev/null
|
wait 2>/dev/null
|
||||||
if [[ "${KEEP:-0}" == "1" ]]; then
|
if [[ "${KEEP:-0}" == "1" ]]; then
|
||||||
echo "logs kept in $OUT"
|
echo "logs kept in $OUT"
|
||||||
@@ -31,6 +34,7 @@ trap cleanup EXIT
|
|||||||
echo "smoke: server on port $PORT, logs in $OUT"
|
echo "smoke: server on port $PORT, logs in $OUT"
|
||||||
"$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
|
"$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
|
||||||
> "$OUT/server.log" 2>&1 &
|
> "$OUT/server.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
for _ in $(seq 1 60); do
|
for _ in $(seq 1 60); do
|
||||||
grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break
|
grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break
|
||||||
@@ -43,11 +47,31 @@ fi
|
|||||||
for n in 1 2; do
|
for n in 1 2; do
|
||||||
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
||||||
--name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
|
--name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
sleep 0.4
|
sleep 0.4
|
||||||
done
|
done
|
||||||
|
|
||||||
wait %2 %3 2>/dev/null
|
# A third bot that gets SIGKILLed the moment it is inside a dungeon. This is the
|
||||||
wait %1 2>/dev/null
|
# anti-disconnect-cheese path: the server must keep its body in the world,
|
||||||
|
# channel it out over the same one second the escape button costs, and only then
|
||||||
|
# forget the peer -- never delete it instantly on socket close.
|
||||||
|
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
||||||
|
--name "dropbot" --autoquit "$CLIENT_TICKS" > "$OUT/dropbot.log" 2>&1 &
|
||||||
|
DROP_PID=$!
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null && break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
if grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null; then
|
||||||
|
# Reaped here so bash does not print its own "Killed" job-control line; the
|
||||||
|
# kill is the point of the test, not a failure.
|
||||||
|
{ kill -9 "$DROP_PID"; wait "$DROP_PID"; } 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " WARN dropbot never reached a dungeon; drop assertions will fail"
|
||||||
|
PIDS+=("$DROP_PID")
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait 2>/dev/null
|
||||||
|
|
||||||
fails=0
|
fails=0
|
||||||
check() { # check <label> <file> <pattern>
|
check() { # check <label> <file> <pattern>
|
||||||
@@ -77,6 +101,9 @@ check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
|
|||||||
check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
|
check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
|
||||||
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
|
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
|
||||||
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
|
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
|
||||||
|
check "a hard drop is channelled, not instant" \
|
||||||
|
"$OUT/server.log" "dropped in instance [0-9]+, channelling out"
|
||||||
|
check "the dropped body is released" "$OUT/server.log" "released from instance [0-9]+ after drop"
|
||||||
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||||
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user