Initial commit: Transcience MVP

Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).

- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
  physics server), ~0.24ms/tick at peak load -- runs headless for free and
  drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
  events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
  enemies and bosses -- a new boss is data in src/content/content.gd, no
  simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
  format
- Lobby hub with a portal into on-demand dungeon instances; one process
  hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
  ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
2026-09-03 16:03:57 +02:00
commit c4beeae38f
385 changed files with 28725 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
class_name BossDef
extends Resource
## A whole boss. Adding a boss to the game is writing one of these plus its
## emitter list -- there is no per-boss code path anywhere in the simulation.
@export var id: StringName = &"boss"
@export var display_name: String = "Boss"
@export var max_hp: int = 4000
@export var radius: float = 44.0
## Stationary bosses are the MVP shape; the field exists so a later boss can
## move without changing the runtime.
@export var stationary: bool = true
@export var spawn_pos := Vector2(0.0, -140.0)
@export var phases: Array[BossPhase] = []
## Index of the phase that matches [param hp_fraction]. Later entries win, so a
## boss at 0.2 hp picks the lowest-threshold phase that still covers it.
func phase_index_for(hp_fraction: float) -> int:
var chosen := 0
for i in phases.size():
if hp_fraction <= phases[i].enter_at_hp_fraction:
chosen = i
return chosen
+1
View File
@@ -0,0 +1 @@
uid://cakbi458wdkrr
+18
View File
@@ -0,0 +1,18 @@
class_name BossPhase
extends Resource
## One stage of a boss fight: a looping timeline of emitters, entered when the
## boss drops below [member enter_at_hp_fraction].
@export var name: String = "Phase"
## The phase becomes active once hp/max_hp is at or below this. Phases are
## evaluated in array order, so list them from 1.0 downwards.
@export_range(0.0, 1.0, 0.01) var enter_at_hp_fraction: float = 1.0
## The phase timeline wraps at this many ticks. Emitter [member start_tick] and
## [member end_tick] are relative to the start of each loop.
@export var loop_ticks: int = 600
## Ticks of no fire after entering the phase, so the transition is readable.
@export var telegraph_ticks: int = 45
## Damage taken while in this phase is scaled by this. Use < 1.0 for armoured
## phases rather than adding hit points, so the fight length stays predictable.
@export var damage_taken_mult: float = 1.0
@export var emitters: Array[BulletEmitter] = []
+1
View File
@@ -0,0 +1 @@
uid://dfub6sufpvw1n
+36
View File
@@ -0,0 +1,36 @@
class_name EnemyDef
extends Resource
## Data-only description of a trash enemy. Behaviour is picked from a small set
## of readable, predictable movements -- players should be able to learn an
## enemy in one encounter.
enum Move {
## Never moves. Pure turret.
STATIC,
## Slides along a fixed heading, bouncing off the arena edges.
DRIFT,
## Circles its spawn point at a fixed radius.
ORBIT,
## Walks straight at the nearest player, slowly.
APPROACH,
## Keeps a preferred distance from the nearest player.
STRAFE,
}
@export var id: StringName = &"drone"
@export var display_name: String = "Drone"
@export var max_hp: int = 40
@export var radius: float = 14.0
@export var contact_damage: int = 10
@export var move: Move = Move.DRIFT
@export var speed: float = 60.0
## ORBIT radius, or STRAFE preferred distance.
@export var move_param: float = 120.0
## Ticks between direction re-evaluations for APPROACH/STRAFE, so enemies read
## as deliberate rather than twitchy.
@export var retarget_interval: int = 30
## Index the client renderer uses to pick a shape/colour.
@export var visual: int = 0
@export var emitters: Array[BulletEmitter] = []
## The emitter timeline wraps at this many ticks.
@export var pattern_loop_ticks: int = 240
+1
View File
@@ -0,0 +1 @@
uid://njtjcrwckton
+249
View File
@@ -0,0 +1,249 @@
extends Node
## Transport and RPC surface. This is the only autoload in the project.
##
## It has to be an autoload because Godot routes an RPC by node path: the sender
## and receiver must agree on where the node lives, and `/root/Net` is the one
## path that exists identically on a dedicated server and on every client.
##
## The naming rule here is load-bearing: `c_*` is client -> server, `s_*` is
## server -> client. The `s_*` methods are annotated "authority", so the engine
## itself drops any attempt by a client to fake one.
signal state_changed(new_state: State)
enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
## Peer id the engine gives the host. On a listen server this is also a real
## player, which is why every send goes through the helpers below.
const LOCAL_PEER := 1
var state: State = State.OFFLINE
var server: ServerRuntime = null
var client: ClientRuntime = null
var last_error: String = ""
func _set_state(s: State) -> void:
state = s
state_changed.emit(s)
# --- Lifecycle --------------------------------------------------------------
func host(port: int) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, Protocol.MAX_CLIENTS, Protocol.CHANNEL_COUNT)
if err != OK:
last_error = "could not bind port %d (error %d)" % [port, err]
GameLog.error("net", last_error)
_set_state(State.FAILED)
return err
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
server = ServerRuntime.new()
server.name = "Server"
add_child(server)
_set_state(State.ONLINE)
GameLog.info("net", "listening on %d" % port)
return OK
func join(address: String, port: int) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port, Protocol.CHANNEL_COUNT)
if err != OK:
last_error = "could not reach %s:%d (error %d)" % [address, port, err]
GameLog.error("net", last_error)
_set_state(State.FAILED)
return err
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_connect_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
# Nothing is simulated until the server says hello back; on_welcome turns
# the physics process on.
client.set_physics_process(false)
_set_state(State.CONNECTING)
GameLog.info("net", "connecting to %s:%d" % [address, port])
return OK
func shutdown() -> void:
if multiplayer.multiplayer_peer != null \
and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
for sig in [multiplayer.peer_connected, multiplayer.peer_disconnected,
multiplayer.connected_to_server, multiplayer.connection_failed,
multiplayer.server_disconnected]:
for c in sig.get_connections():
sig.disconnect(c["callable"])
if server != null:
server.queue_free()
server = null
if client != null:
client.queue_free()
client = null
_set_state(State.OFFLINE)
func kick(peer_id: int) -> void:
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
func is_server() -> bool:
return server != null
# --- Transport signals ------------------------------------------------------
func _on_peer_connected(peer_id: int) -> void:
if server != null:
server.on_peer_connected(peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
if server != null:
server.on_peer_disconnected(peer_id)
func _on_connected() -> void:
_set_state(State.ONLINE)
c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name)
func _on_connect_failed() -> void:
last_error = "connection refused"
GameLog.error("net", last_error)
_set_state(State.FAILED)
func _on_server_disconnected() -> void:
last_error = "server closed the connection"
GameLog.warn("net", last_error)
shutdown()
## Start a client inside the hosting process. The result is a listen server:
## the host plays through exactly the same code path as a remote player, sending
## input and learning outcomes from snapshots, with the transport short-circuited
## to a function call. No simulation code knows the difference.
func start_local_client() -> void:
if server == null:
GameLog.error("net", "start_local_client called with no server")
return
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name)
func _is_local(peer_id: int) -> bool:
return peer_id == LOCAL_PEER and client != null
# --- Send helpers -----------------------------------------------------------
# Every server -> client message goes through one of these rather than calling
# rpc_id directly, so the listen server's own player is reachable too.
func send_welcome(peer_id: int) -> void:
if _is_local(peer_id):
client.on_welcome(peer_id)
else:
s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION)
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
boss_id: String, spawn: Vector2) -> void:
if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn)
func send_snapshot(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_snapshot(data)
else:
s_snapshot.rpc_id(peer_id, data)
func send_events(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_events(data)
else:
s_events.rpc_id(peer_id, data)
func send_reject(peer_id: int, reason: String) -> void:
if _is_local(peer_id):
GameLog.error("net", "local client rejected: %s" % reason)
else:
s_reject.rpc_id(peer_id, reason)
func send_input(data: PackedByteArray) -> void:
if server != null:
server.on_input(LOCAL_PEER, data) # listen server: no transport at all
elif state == State.ONLINE and client != null:
c_input.rpc_id(1, data)
# --- Client -> server -------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable", 1)
func c_hello(version: int, display_name: String) -> void:
if server == null:
return
server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
func c_input(data: PackedByteArray) -> void:
if server == null:
return
server.on_input(multiplayer.get_remote_sender_id(), data)
# --- Server -> client -------------------------------------------------------
@rpc("authority", "call_remote", "reliable", 1)
func s_welcome(peer_id: int, _version: int) -> void:
if client == null:
return
client.on_welcome(peer_id)
client.set_physics_process(true)
@rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
if client == null:
return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
@rpc("authority", "call_remote", "reliable", 1)
func s_reject(reason: String) -> void:
last_error = reason
GameLog.error("net", "rejected by server: %s" % reason)
_set_state(State.FAILED)
@rpc("authority", "call_remote", "unreliable", 2)
func s_snapshot(data: PackedByteArray) -> void:
if client != null:
client.on_snapshot(data)
@rpc("authority", "call_remote", "reliable", 3)
func s_events(data: PackedByteArray) -> void:
if client != null:
client.on_events(data)
+1
View File
@@ -0,0 +1 @@
uid://8itnd1lucfeu
+299
View File
@@ -0,0 +1,299 @@
class_name Content
extends RefCounted
## All game content, built in code.
##
## Content is GDScript rather than .tres because it is the shape that reviews
## well: a boss is a readable diff, there are no resource UIDs to churn, and a
## test can build one without touching the filesystem. `tools/export_content.gd`
## writes .tres copies into res://resources/ for anyone who prefers to tune
## numbers in the editor inspector -- but this file stays the source of truth.
const ENEMY_DRIFTER := &"drifter"
const ENEMY_TURRET := &"turret"
const ENEMY_STALKER := &"stalker"
const ENEMY_DUMMY := &"dummy"
const BOSS_WARDEN := &"warden"
static func enemy(id: StringName) -> EnemyDef:
match id:
ENEMY_DRIFTER: return drifter()
ENEMY_TURRET: return turret()
ENEMY_STALKER: return stalker()
ENEMY_DUMMY: return dummy()
push_error("unknown enemy id: %s" % id)
return drifter()
static func boss(id: StringName) -> BossDef:
match id:
BOSS_WARDEN: return warden()
push_error("unknown boss id: %s" % id)
return warden()
# --- Enemies ----------------------------------------------------------------
## Reads as: floats across the arena, lobs a slow three-shot fan at you.
static func drifter() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DRIFTER
d.display_name = "Drifter"
d.max_hp = 40
d.radius = 14.0
d.contact_damage = 8
d.move = EnemyDef.Move.DRIFT
d.speed = 55.0
d.visual = 0
d.pattern_loop_ticks = 120
var fan := AimedSpreadEmitter.new()
fan.start_tick = 0
fan.interval = 120
fan.count = 3
fan.spread_deg = 20.0
fan.speed = 150.0
fan.radius = 6.0
fan.damage = 10
fan.lifetime = 240
fan.kind = SimConfig.KIND_ORB
d.emitters = [fan]
return d
## Never moves, fires a wide ring on a slow beat. Punishes crowding.
static func turret() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_TURRET
d.display_name = "Turret"
d.max_hp = 70
d.radius = 16.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC
d.speed = 0.0
d.visual = 1
d.pattern_loop_ticks = 150
var ring := RingEmitter.new()
ring.start_tick = 0
ring.interval = 150
ring.count = 10
ring.spin_per_shot_deg = 18.0
ring.speed = 130.0
ring.radius = 7.0
ring.damage = 12
ring.lifetime = 300
ring.kind = SimConfig.KIND_ORB
d.emitters = [ring]
return d
## Walks at you and does nothing else. The pressure enemy.
static func stalker() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_STALKER
d.display_name = "Stalker"
d.max_hp = 30
d.radius = 12.0
d.contact_damage = 16
d.move = EnemyDef.Move.APPROACH
d.speed = 95.0
d.retarget_interval = 20
d.visual = 2
return d
## Lobby target dummy: inert, tough, so players can feel out the gun.
static func dummy() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DUMMY
d.display_name = "Target Dummy"
d.max_hp = 100000
d.radius = 20.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC
d.visual = 3
return d
# --- Boss -------------------------------------------------------------------
## The Warden of the Fold: stationary, four phases, each layering one more idea
## on the last. Adding a second boss means writing another function like this
## one -- the simulation has no per-boss branches.
static func warden() -> BossDef:
var b := BossDef.new()
b.id = BOSS_WARDEN
b.display_name = "Warden of the Fold"
b.max_hp = 3600
b.radius = 42.0
b.stationary = true
b.spawn_pos = Vector2(0.0, -150.0)
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
return b
## Phase 1 -- one idea at a time: a lazily spinning ring you walk around, with
## an aimed fan that stops you from standing still.
static func _warden_p1() -> BossPhase:
var p := BossPhase.new()
p.name = "Opening Fold"
p.enter_at_hp_fraction = 1.0
p.loop_ticks = 480
p.telegraph_ticks = 60
var ring := RingEmitter.new()
ring.interval = 45
ring.count = 14
ring.spin_per_shot_deg = 9.0
ring.speed = 135.0
ring.radius = 8.0
ring.damage = 14
ring.lifetime = 420
ring.muzzle_offset = 46.0
var fan := AimedSpreadEmitter.new()
fan.start_tick = 120
fan.interval = 150
fan.count = 5
fan.spread_deg = 26.0
fan.speed = 210.0
fan.radius = 6.0
fan.damage = 12
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 46.0
p.emitters = [ring, fan]
return p
## Phase 2 -- adds a moving safe lane: the spiral fills space while a wall with
## a sliding gap forces a committed dodge.
static func _warden_p2() -> BossPhase:
var p := BossPhase.new()
p.name = "Sliding Lattice"
p.enter_at_hp_fraction = 0.72
p.loop_ticks = 600
p.telegraph_ticks = 50
var spiral := RingEmitter.new()
spiral.interval = 9
spiral.count = 4
spiral.spin_per_shot_deg = 23.0
spiral.speed = 120.0
spiral.radius = 7.0
spiral.damage = 12
spiral.lifetime = 480
spiral.muzzle_offset = 46.0
var wall := WallGapEmitter.new()
wall.start_tick = 90
wall.interval = 170
wall.direction = 0
wall.count = 20
wall.gap_width = 3
wall.gap_step = 7
wall.speed = 175.0
wall.radius = 8.0
wall.damage = 16
wall.lifetime = 300
wall.kind = SimConfig.KIND_HEAVY
p.emitters = [spiral, wall]
return p
## Phase 3 -- adds rotation you have to track: two sweeping arms that reverse,
## plus jittered aimed fire so the safe pocket is never exactly where it was.
static func _warden_p3() -> BossPhase:
var p := BossPhase.new()
p.name = "Reaping Arms"
p.enter_at_hp_fraction = 0.42
p.loop_ticks = 720
p.telegraph_ticks = 45
p.damage_taken_mult = 1.15
var arms := ArcSweepEmitter.new()
arms.interval = 5
arms.arms = 3
arms.bullets_per_arm = 2
arms.arm_spacing = 26.0
arms.sweep_deg = 95.0
arms.sweep_period = 5.0
arms.speed = 165.0
arms.radius = 7.0
arms.damage = 13
arms.lifetime = 400
var fan := AimedSpreadEmitter.new()
fan.start_tick = 60
fan.interval = 110
fan.count = 7
fan.spread_deg = 40.0
fan.jitter_deg = 3.0
fan.speed = 195.0
fan.edge_speed_bonus = 0.25
fan.radius = 6.0
fan.damage = 12
fan.kind = SimConfig.KIND_NEEDLE
var counter_ring := RingEmitter.new()
counter_ring.start_tick = 240
counter_ring.end_tick = 600
counter_ring.interval = 60
counter_ring.count = 18
counter_ring.spin_per_shot_deg = -14.0
counter_ring.speed = 105.0
counter_ring.radius = 7.0
counter_ring.damage = 12
counter_ring.lifetime = 420
p.emitters = [arms, fan, counter_ring]
return p
## Phase 4 -- everything at once, plus curving bullets that close the pockets
## the earlier phases taught you to use.
static func _warden_p4() -> BossPhase:
var p := BossPhase.new()
p.name = "Collapse"
p.enter_at_hp_fraction = 0.16
p.loop_ticks = 420
p.telegraph_ticks = 60
p.damage_taken_mult = 1.3
var curve := RingEmitter.new()
curve.interval = 11
curve.count = 5
curve.spin_per_shot_deg = 31.0
curve.speed = 130.0
curve.turn_deg = 0.55
curve.radius = 7.0
curve.damage = 15
curve.lifetime = 400
curve.muzzle_offset = 46.0
var walls := WallGapEmitter.new()
walls.start_tick = 40
walls.interval = 130
walls.direction = 2
walls.count = 16
walls.gap_width = 3
walls.gap_step = 5
walls.speed = 200.0
walls.radius = 8.0
walls.damage = 18
walls.kind = SimConfig.KIND_HEAVY
var snipe := AimedSpreadEmitter.new()
snipe.start_tick = 100
snipe.interval = 80
snipe.count = 3
snipe.spread_deg = 10.0
snipe.speed = 300.0
snipe.radius = 5.0
snipe.damage = 16
snipe.kind = SimConfig.KIND_NEEDLE
p.emitters = [curve, walls, snipe]
return p
+1
View File
@@ -0,0 +1 @@
uid://dv03hinp1g3ce
+37
View File
@@ -0,0 +1,37 @@
class_name GameLog
extends RefCounted
## Tagged logging that survives headless runs.
##
## Static rather than an autoload so it works from unit tests and from
## `--script` tool runs, where autoload identifiers are not registered at all.
## The dedicated server has no console UI, so every line carries the role that
## produced it -- that is what makes a captured server log readable later.
enum Level { DEBUG, INFO, WARN, ERROR }
## Raise to WARN on a busy server; DEBUG is very chatty at 60 Hz.
static var min_level: Level = Level.INFO
static var role_tag: String = "?"
static func _fmt(level: Level, tag: String, msg: String) -> String:
return "[%s][%s][%s] %s" % [Level.keys()[level], role_tag, tag, msg]
static func debug(tag: String, msg: String) -> void:
if min_level <= Level.DEBUG:
print(_fmt(Level.DEBUG, tag, msg))
static func info(tag: String, msg: String) -> void:
if min_level <= Level.INFO:
print(_fmt(Level.INFO, tag, msg))
static func warn(tag: String, msg: String) -> void:
if min_level <= Level.WARN:
print(_fmt(Level.WARN, tag, msg))
static func error(tag: String, msg: String) -> void:
printerr(_fmt(Level.ERROR, tag, msg))
+1
View File
@@ -0,0 +1 @@
uid://dlmfc7pqn6p4s
+67
View File
@@ -0,0 +1,67 @@
class_name GameOpts
extends RefCounted
## Process-wide options parsed from the command line.
##
## These are per-machine settings and must never affect the simulation -- if a
## value would change how the world evolves, it belongs in [SimConfig] so server
## and client cannot disagree about it.
static var is_server: bool = false
static var port: int = Protocol.DEFAULT_PORT
static var host_address: String = "127.0.0.1"
static var player_name: String = "player"
## Replaces keyboard input with a scripted loop so CI can play the game with no
## display. See `tools/smoke.sh`.
static var bot_client: bool = false
## Quit after this many physics ticks. 0 = run forever.
static var autoquit_ticks: int = 0
## Skip the menu and connect straight away. Implied by --bot.
static var autojoin: bool = false
## Skip the menu and start a listen server (host + local player).
static var listen: bool = false
## Server-side dev switch: new dungeons open straight onto the boss, skipping
## the trash waves. For iterating on a pattern without clearing two waves first.
static var boss_rush: bool = false
static var parsed: bool = false
static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
if argv.is_empty():
argv = OS.get_cmdline_user_args()
var i := 0
while i < argv.size():
match argv[i]:
"--server":
is_server = true
"--bot":
bot_client = true
autojoin = true
"--join":
autojoin = true
"--listen":
listen = true
"--boss-rush":
boss_rush = true
"--port":
i += 1
if i < argv.size():
port = int(argv[i])
"--host":
i += 1
if i < argv.size():
host_address = argv[i]
"--name":
i += 1
if i < argv.size():
player_name = argv[i]
"--autoquit":
i += 1
if i < argv.size():
autoquit_ticks = int(argv[i])
"--verbose":
GameLog.min_level = GameLog.Level.DEBUG
"--quiet":
GameLog.min_level = GameLog.Level.WARN
i += 1
GameLog.role_tag = "server" if is_server else "client"
parsed = true
+1
View File
@@ -0,0 +1 @@
uid://cneth7xd6rb68
+31
View File
@@ -0,0 +1,31 @@
class_name Movement
extends RefCounted
## Pure movement helpers. These run on the server (authoritative) and on the
## client (prediction). They must stay side-effect free so a client can replay
## them over a history of inputs and land on the same result as the server.
## Clamp a raw analogue/keyboard vector to a unit disc.
static func sanitize_move(raw: Vector2) -> Vector2:
if raw.length_squared() > 1.0:
return raw.normalized()
return raw
## One tick of player locomotion. Returns the new position.
static func step_player(pos: Vector2, move: Vector2, speed: float, bounds: Vector2) -> Vector2:
var next := pos + sanitize_move(move) * speed * SimConfig.TICK_DELTA
next.x = clampf(next.x, -bounds.x, bounds.x)
next.y = clampf(next.y, -bounds.y, bounds.y)
return next
## Circle-vs-circle overlap test used for every hit in the game.
static func circles_overlap(a: Vector2, ar: float, b: Vector2, br: float) -> bool:
var r := ar + br
return a.distance_squared_to(b) <= r * r
## True when a point has drifted outside the arena plus the cull margin.
static func outside_arena(p: Vector2, margin: float = SimConfig.BULLET_CULL_MARGIN) -> bool:
return absf(p.x) > SimConfig.ARENA_HALF.x + margin \
or absf(p.y) > SimConfig.ARENA_HALF.y + margin
+1
View File
@@ -0,0 +1 @@
uid://upjfa2xdgjct
+64
View File
@@ -0,0 +1,64 @@
class_name SimConfig
extends RefCounted
## Tuning constants shared verbatim by the server simulation and the client
## replica. Nothing here may differ between the two builds -- if a value needs
## to differ, it belongs in [GameConfig], not here.
# --- Time -------------------------------------------------------------------
const TICK_RATE := 60
const TICK_DELTA := 1.0 / 60.0
## Server sends a snapshot every N ticks (60 / 3 = 20 Hz).
const SNAPSHOT_INTERVAL := 3
## How far behind the newest snapshot the client renders remote actors.
const INTERPOLATION_DELAY_TICKS := 6
# --- Arena ------------------------------------------------------------------
const ARENA_HALF := Vector2(620.0, 340.0)
## Bullets are culled once they leave the arena by this margin.
const BULLET_CULL_MARGIN := 64.0
# --- Player -----------------------------------------------------------------
const PLAYER_SPEED := 240.0
const PLAYER_RADIUS := 9.0
const PLAYER_MAX_HP := 100
const PLAYER_FIRE_COOLDOWN := 7 # ticks
const PLAYER_BULLET_SPEED := 620.0
const PLAYER_BULLET_RADIUS := 4.0
const PLAYER_BULLET_LIFETIME := 90 # ticks
const PLAYER_BULLET_DAMAGE := 6
const PLAYER_IFRAMES := 36 # ticks of invulnerability after a hit
const PLAYER_RESPAWN_DELAY := 180 # ticks
# --- Anti-cheat guards ------------------------------------------------------
## Inputs older than this (relative to the newest accepted) are discarded.
const INPUT_MAX_AGE := 30
## Inputs claiming to be further ahead than this of the server tick are clamped.
const INPUT_MAX_LEAD := 12
## Hard ceiling on inputs consumed from one peer in a single tick.
const INPUT_MAX_PER_TICK := 4
# --- Emergency escape -------------------------------------------------------
const ESCAPE_CHANNEL_TICKS := 180 # 3 seconds
## Taking damage while channelling cancels the escape.
const ESCAPE_BREAK_ON_DAMAGE := true
# --- Bullets ----------------------------------------------------------------
const MAX_BULLETS := 4096
const TEAM_PLAYER := 0
const TEAM_ENEMY := 1
# --- Bullet visual kinds (index into the renderer's atlas) ------------------
const KIND_PLAYER_SHOT := 0
const KIND_ORB := 1
const KIND_NEEDLE := 2
const KIND_HEAVY := 3
# --- Instances --------------------------------------------------------------
const LOBBY_INSTANCE_ID := 1
const DUNGEON_PARTY_MAX := 4
## How long a forming dungeon waits for more players before it locks.
const DUNGEON_FORMING_TICKS := 300
# --- Portal -----------------------------------------------------------------
const PORTAL_POS := Vector2(0.0, -220.0)
const PORTAL_RADIUS := 60.0
+1
View File
@@ -0,0 +1 @@
uid://msxyswmkdty1
+19
View File
@@ -0,0 +1,19 @@
[gd_scene load_steps=5 format=3]
[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/bullet_renderer.gd" id="3"]
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
[node name="Game" type="Node2D"]
script = ExtResource("1")
[node name="WorldView" type="Node2D" parent="."]
position = Vector2(640, 360)
script = ExtResource("2")
[node name="Bullets" type="MultiMeshInstance2D" parent="WorldView"]
script = ExtResource("3")
[node name="HUD" type="CanvasLayer" parent="."]
script = ExtResource("4")
+147
View File
@@ -0,0 +1,147 @@
class_name Instance
extends RefCounted
## One live world on the server: the shared lobby hub, or one dungeon run for a
## party. Instances are cheap -- a [SimWorld] and a peer list -- so a single
## server process hosts the hub plus every concurrent dungeon.
enum State {
## Dungeon is open for players to join from the hub.
FORMING,
## Fight in progress.
ACTIVE,
## Boss dead; players are being returned to the hub.
CLEARED,
}
var id: int = 0
var kind: Protocol.InstanceKind = Protocol.InstanceKind.LOBBY
var world: SimWorld
var peers: Array[int] = []
var state: State = State.ACTIVE
var age: int = 0
var seed_value: int = 0
## Dungeon progression. -1 is the pre-fight breather.
var stage: int = -1
var stage_delay: int = 0
var boss_id: StringName = &""
static func make_lobby(instance_id: int) -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.LOBBY
inst.seed_value = 1
inst.world = SimWorld.new(inst.seed_value)
inst.world.portal_enabled = true
inst.world.spawn_point = Vector2(0.0, 120.0)
inst.state = State.ACTIVE
# A single inert dummy so players can feel out the gun before committing to
# a run. It has no emitters and no contact damage.
inst.world.spawn_enemy(Content.dummy(), Vector2(-220.0, -40.0))
return inst
static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.DUNGEON
inst.seed_value = dungeon_seed
inst.world = SimWorld.new(dungeon_seed)
inst.world.spawn_point = Vector2(0.0, 260.0)
inst.boss_id = Content.BOSS_WARDEN
inst.state = State.FORMING
if GameOpts.boss_rush:
# Dev switch: the next stage advance lands on the boss.
inst.stage = 1
return inst
func add_peer(peer_id: int, display_name: String) -> void:
if not peers.has(peer_id):
peers.append(peer_id)
world.add_player(peer_id, display_name)
func remove_peer(peer_id: int) -> void:
peers.erase(peer_id)
world.remove_player(peer_id)
func is_empty() -> bool:
return peers.is_empty()
func accepts_new_party_member() -> bool:
return kind == Protocol.InstanceKind.DUNGEON \
and state == State.FORMING \
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
func step() -> void:
age += 1
world.step()
if kind == Protocol.InstanceKind.DUNGEON:
_step_dungeon()
## Straight-line progression: two trash waves, then the boss. Waves gate on
## "everything dead" rather than a timer so a slow party is never overrun.
func _step_dungeon() -> void:
# The delay has to tick down before the CLEARED check, or a cleared dungeon
# would sit on its exit timer forever and never release its party.
if stage_delay > 0:
stage_delay -= 1
return
if state == State.CLEARED:
return
if state == State.FORMING:
if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX:
state = State.ACTIVE
# stage is left as make_dungeon set it -- resetting it here would
# silently undo the --boss-rush dev switch.
stage_delay = 90
return
if stage >= 0 and _live_enemy_count() > 0:
return
if stage == 2:
if world.boss != null and world.boss.alive:
return
state = State.CLEARED
stage_delay = 300
return
stage += 1
stage_delay = 60
match stage:
0: _spawn_wave_one()
1: _spawn_wave_two()
2:
world.spawn_boss(Content.boss(boss_id))
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
GameLog.debug("instance", "instance %d entered stage %d" % [id, stage])
func _live_enemy_count() -> int:
var n := 0
for e in world.enemies.values():
if e.alive:
n += 1
return n
func _spawn_wave_one() -> void:
for i in 3:
world.spawn_enemy(Content.drifter(), Vector2(-320.0 + 320.0 * float(i), -120.0), i * 40)
world.spawn_enemy(Content.turret(), Vector2(-420.0, -220.0), 0)
world.spawn_enemy(Content.turret(), Vector2(420.0, -220.0), 75)
func _spawn_wave_two() -> void:
for i in 4:
world.spawn_enemy(Content.stalker(), Vector2(-300.0 + 200.0 * float(i), -260.0), i * 15)
for i in 3:
world.spawn_enemy(Content.turret(), Vector2(-380.0 + 380.0 * float(i), -60.0), i * 50)
world.spawn_enemy(Content.drifter(), Vector2(0.0, -300.0), 20)
+1
View File
@@ -0,0 +1 @@
uid://bbdqtks6mqgr1
+112
View File
@@ -0,0 +1,112 @@
extends Node
## Process entry point. One executable is both the dedicated server and the
## client -- which one it becomes is decided here from the command line, so a
## server build is just `--headless -- --server` rather than a separate export.
const GAME_SCENE := preload("res://src/game.tscn")
var _menu: Control = null
var _game: Node = null
var _ticks: int = 0
func _ready() -> void:
GameOpts.parse()
# The simulation is written against a fixed 60 Hz tick. Assert it in code
# rather than trusting project.godot, which drops settings equal to the
# engine default and would silently desync server and client if that
# default ever changed.
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
Net.state_changed.connect(_on_net_state)
if GameOpts.is_server:
_run_dedicated_server()
elif GameOpts.listen:
_host_and_play(GameOpts.port)
elif GameOpts.autojoin:
_join(GameOpts.host_address, GameOpts.port)
else:
_show_menu()
func _physics_process(_delta: float) -> void:
_ticks += 1
if GameOpts.autoquit_ticks > 0 and _ticks >= GameOpts.autoquit_ticks:
GameLog.info("main", "autoquit after %d ticks" % _ticks)
Net.shutdown()
get_tree().quit(0)
func _run_dedicated_server() -> void:
if Net.host(GameOpts.port) != OK:
GameLog.error("main", "server failed to start: %s" % Net.last_error)
get_tree().quit(1)
return
# A dedicated server has no view, and headless mode has no display to draw
# to. Leaving the scene empty keeps the process at simulation cost only.
# Nothing to render, so do not let the main loop spin faster than the tick.
Engine.max_fps = SimConfig.TICK_RATE
GameLog.info("main", "SERVER_READY port=%d" % GameOpts.port)
func _show_menu() -> void:
if _menu != null:
return
_clear_game()
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
add_child(_menu)
func _join(address: String, port: int) -> void:
GameOpts.host_address = address
GameOpts.port = port
if Net.join(address, port) != OK:
_menu_status(Net.last_error)
return
_enter_game()
## Listen server: one process runs the authoritative world and a local client
## connected over loopback. The client takes no shortcuts because of it -- it
## still only sends input and still only learns outcomes from snapshots.
func _host_and_play(port: int) -> void:
if Net.host(port) != OK:
_menu_status(Net.last_error)
return
GameLog.role_tag = "listen"
Net.start_local_client()
_enter_game()
func _enter_game() -> void:
if _menu != null:
_menu.queue_free()
_menu = null
if _game == null:
_game = GAME_SCENE.instantiate()
add_child(_game)
func _clear_game() -> void:
if _game != null:
_game.queue_free()
_game = null
func _menu_status(text: String) -> void:
if _menu != null:
_menu.set_status(text)
func _on_net_state(state: Net.State) -> void:
match state:
Net.State.FAILED:
_show_menu()
_menu_status(Net.last_error)
Net.State.OFFLINE:
if not GameOpts.is_server:
_show_menu()
_:
pass
+1
View File
@@ -0,0 +1 @@
uid://4y8xrnuafwd8
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/main.gd" id="1"]
[node name="Main" type="Node2D"]
script = ExtResource("1")
+252
View File
@@ -0,0 +1,252 @@
class_name ClientRuntime
extends Node
## The client half: sample input, predict the local player, interpolate everyone
## else, and replay the bullets the server told us about.
##
## The client owns exactly one thing -- where it *thinks* the local player is,
## so the stick feels instant. Every consequence (damage, death, escape, loot)
## comes back from the server and overwrites whatever the client believed.
signal instance_changed
signal hud_dirty
signal local_hit(damage: int)
var my_peer: int = 0
var instance_id: int = 0
var instance_kind: Protocol.InstanceKind = Protocol.InstanceKind.LOBBY
var boss_def: BossDef = null
## Replica world. [member SimWorld.authoritative] is false, so it integrates
## bullets and nothing else.
var world := SimWorld.new()
## Local estimate of the server's tick, used to age incoming bullets.
var server_tick_est: int = 0
var input_tick: int = 0
var predicted_pos := Vector2.ZERO
var aim: float = 0.0
var pending: Array[InputFrame] = []
# Authoritative mirror of the local player.
var my_hp: int = SimConfig.PLAYER_MAX_HP
var my_alive: bool = true
var my_escape: float = 0.0
var my_escaping: bool = false
var snap_prev: Dictionary = {}
var snap_curr: Dictionary = {}
var _interp: float = 0.0
var _bot_tick: int = 0
func _ready() -> void:
world.authoritative = false
set_physics_process(true)
func _physics_process(delta: float) -> void:
server_tick_est += 1
input_tick += 1
var frame := _sample_input()
pending.append(frame)
# Only enough history to cover the worst reconciliation window.
while pending.size() > SimConfig.INPUT_MAX_AGE:
pending.pop_front()
if my_alive:
predicted_pos = Movement.step_player(predicted_pos, frame.move,
SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
# Send the last few frames every tick. Inputs are unreliable-ordered, so the
# redundancy is what covers a dropped packet without a retransmit stall.
var redundant: Array[InputFrame] = []
var from := maxi(pending.size() - 3, 0)
for i in range(from, pending.size()):
redundant.append(pending[i])
Net.send_input(NetCodec.encode_inputs(redundant))
world.step()
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
# --- Input ------------------------------------------------------------------
func _sample_input() -> InputFrame:
if GameOpts.bot_client:
return _bot_input()
var move := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5
var to_mouse := mouse - predicted_pos
if to_mouse.length_squared() > 1.0:
aim = to_mouse.angle()
var buttons := 0
if Input.is_action_pressed("fire"):
buttons |= InputFrame.BTN_FIRE
if Input.is_action_pressed("emergency_escape"):
buttons |= InputFrame.BTN_ESCAPE
if Input.is_action_pressed("interact"):
buttons |= InputFrame.BTN_INTERACT
return InputFrame.make(input_tick, move, aim, buttons)
## Scripted input so `tools/smoke.sh` can play the game with no display: orbit
## the arena, fire constantly, take the portal, then punch out with the escape.
func _bot_input() -> InputFrame:
_bot_tick += 1
var t := float(_bot_tick) * SimConfig.TICK_DELTA
var move := Vector2(cos(t * 0.9), sin(t * 1.3))
aim = t * 2.1
var buttons := InputFrame.BTN_FIRE
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands.
move = (SimConfig.PORTAL_POS - predicted_pos).normalized()
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
buttons |= InputFrame.BTN_ESCAPE
return InputFrame.make(input_tick, move, aim, buttons)
# --- Server messages --------------------------------------------------------
func on_welcome(peer_id: int) -> void:
my_peer = peer_id
set_physics_process(true)
GameLog.info("client", "welcome, peer id %d" % peer_id)
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
instance_id = id
instance_kind = kind as Protocol.InstanceKind
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
world.pool.clear()
snap_prev = {}
snap_curr = {}
predicted_pos = spawn
pending.clear()
server_tick_est = server_tick
input_tick = server_tick + 8
my_alive = true
my_hp = SimConfig.PLAYER_MAX_HP
my_escape = 0.0
my_escaping = false
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit()
hud_dirty.emit()
func on_snapshot(data: PackedByteArray) -> void:
var snap := NetCodec.decode_snapshot(data)
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
return # stale or duplicate; unreliable channel, newest wins
snap_prev = snap_curr
snap_curr = snap
_interp = 0.0
var tick := int(snap["tick"])
if server_tick_est < tick or server_tick_est > tick + 12:
server_tick_est = tick
# Keep the client roughly one buffer ahead of the server so inputs arrive
# just before they are needed rather than late.
var lead := input_tick - tick
if lead < 2 or lead > 16:
input_tick = tick + 8
for rec: Dictionary in snap["players"]:
if int(rec["peer"]) == my_peer:
_reconcile(rec)
break
## Rewind to the server's position, replay every input it has not seen yet, and
## land where the client should actually be right now.
func _reconcile(rec: Dictionary) -> void:
my_hp = int(rec["hp"])
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
my_escape = float(rec["escape"])
hud_dirty.emit()
var acked := int(rec["last_input_tick"])
while not pending.is_empty() and pending[0].tick <= acked:
pending.pop_front()
var p: Vector2 = rec["pos"]
if my_alive:
for f in pending:
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
var error := predicted_pos.distance_to(p)
if error > 24.0:
predicted_pos = p # real divergence: take the server's word
elif error > 0.5:
predicted_pos = predicted_pos.lerp(p, 0.3) # smooth out jitter
func on_events(data: PackedByteArray) -> void:
var packet := NetCodec.decode_events(data)
var catchup := clampi(server_tick_est - int(packet["tick"]), 0, 30)
for ev: Dictionary in packet["events"]:
match int(ev["t"]):
SimEvent.Type.BULLET_SPAWN:
var slot := world.pool.spawn(ev["pos"], ev["vel"], ev["r"], ev["life"], 0,
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
if slot >= 0 and catchup > 0:
world.pool.advance_slot(slot, catchup)
SimEvent.Type.BULLET_DESPAWN:
world.apply_event(ev)
SimEvent.Type.PLAYER_HIT:
if int(ev["peer"]) == my_peer:
my_hp = int(ev["hp"])
local_hit.emit(int(ev["dmg"]))
hud_dirty.emit()
SimEvent.Type.PLAYER_DIED:
if int(ev["peer"]) == my_peer:
my_alive = false
hud_dirty.emit()
SimEvent.Type.PLAYER_RESPAWNED:
if int(ev["peer"]) == my_peer:
my_alive = true
predicted_pos = ev["pos"]
pending.clear()
hud_dirty.emit()
_:
pass
# --- View queries -----------------------------------------------------------
## Remote players, interpolated between the last two snapshots. The local player
## is excluded -- the view draws [member predicted_pos] for that one.
func remote_players() -> Array[Dictionary]:
return _interpolated("players", "peer", [my_peer])
func enemies() -> Array[Dictionary]:
return _interpolated("enemies", "id", [])
func boss_state() -> Dictionary:
if snap_curr.is_empty() or snap_curr.get("boss") == null:
return {}
return snap_curr["boss"]
func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]:
var out: Array[Dictionary] = []
if snap_curr.is_empty():
return out
var prev_by_id := {}
if not snap_prev.is_empty():
for r: Dictionary in snap_prev[list_key]:
prev_by_id[r[id_key]] = r
for r: Dictionary in snap_curr[list_key]:
if exclude.has(r[id_key]):
continue
var rec: Dictionary = r.duplicate()
var old: Variant = prev_by_id.get(r[id_key])
if old != null:
rec["pos"] = (old["pos"] as Vector2).lerp(r["pos"], _interp)
out.append(rec)
return out
+1
View File
@@ -0,0 +1 @@
uid://2hlhlktdlix
+234
View File
@@ -0,0 +1,234 @@
class_name NetCodec
extends RefCounted
## Binary encoders for the two server -> client streams.
##
## Snapshots are lossy and unreliable: only what is needed to draw and predict.
## Events are exact and reliable: things a client can never re-derive, above all
## bullet spawns and the despawns caused by a hit.
# --- Snapshot ---------------------------------------------------------------
static func encode_snapshot(world: SimWorld) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u32(world.tick)
b.put_u8(mini(world.players.size(), 255))
for p in world.players.values():
b.put_u32(p.peer_id)
b.put_float(p.pos.x)
b.put_float(p.pos.y)
b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536))
b.put_u16(clampi(p.hp, 0, 65535))
var flags := 0
if p.alive:
flags |= Protocol.F_ALIVE
if p.iframes > 0:
flags |= Protocol.F_INVULN
if p.escape_ticks > 0:
flags |= Protocol.F_ESCAPING
b.put_u8(flags)
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
# Echoed so the owning client knows how far to rewind when reconciling.
b.put_u32(p.last_input_tick)
var live_enemies: Array[SimEnemy] = []
for e in world.enemies.values():
if e.alive:
live_enemies.append(e)
b.put_u16(mini(live_enemies.size(), 65535))
for e in live_enemies:
b.put_u32(e.id)
b.put_float(e.pos.x)
b.put_float(e.pos.y)
b.put_u16(clampi(e.hp, 0, 65535))
# Radius and visual travel with the snapshot so a client that joins
# mid-fight can draw an enemy without any extra handshake.
b.put_u8(clampi(roundi(e.def.radius * 2.0), 0, 255))
b.put_u8(clampi(e.def.visual, 0, 255))
var has_boss := world.boss != null and world.boss.alive
b.put_u8(1 if has_boss else 0)
if has_boss:
b.put_u32(world.boss.id)
b.put_float(world.boss.pos.x)
b.put_float(world.boss.pos.y)
b.put_u32(maxi(world.boss.hp, 0))
b.put_u8(clampi(world.boss.phase_index, 0, 255))
return b.data_array
static func decode_snapshot(data: PackedByteArray) -> Dictionary:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var snap := {"tick": b.get_u32(), "players": [], "enemies": [], "boss": null}
var pcount := b.get_u8()
for _i in pcount:
snap["players"].append({
"peer": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"aim": float(b.get_u16()) / 65536.0 * TAU,
"hp": b.get_u16(),
"flags": b.get_u8(),
"escape": float(b.get_u8()) / 255.0,
"last_input_tick": b.get_u32(),
})
var ecount := b.get_u16()
for _i in ecount:
snap["enemies"].append({
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"hp": b.get_u16(),
"radius": float(b.get_u8()) * 0.5,
"visual": b.get_u8(),
})
if b.get_u8() == 1:
snap["boss"] = {
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"hp": b.get_u32(),
"phase": b.get_u8(),
}
return snap
# --- Events -----------------------------------------------------------------
## Events the client never sees; the instance layer consumes them server-side.
const SERVER_ONLY := [SimEvent.Type.PORTAL_USED, SimEvent.Type.ESCAPE_COMPLETED]
## [param server_tick] rides along so the client can fast-forward a bullet by
## however many ticks the packet spent in flight, instead of popping it in at
## the muzzle a round-trip late.
static func encode_events(server_tick: int, events: Array[Dictionary]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u32(server_tick)
var count := 0
var body := StreamPeerBuffer.new()
body.big_endian = false
for ev in events:
var t := int(ev["t"])
if SERVER_ONLY.has(t):
continue
body.put_u8(t)
match t:
SimEvent.Type.BULLET_SPAWN:
body.put_u32(ev["uid"])
body.put_float(ev["pos"].x)
body.put_float(ev["pos"].y)
body.put_float(ev["vel"].x)
body.put_float(ev["vel"].y)
body.put_float(ev["r"])
body.put_u16(clampi(int(ev["life"]), 0, 65535))
body.put_u8(int(ev["kind"]))
body.put_u8(int(ev["team"]))
body.put_float(ev["accel"])
body.put_float(ev["turn"])
SimEvent.Type.BULLET_DESPAWN:
body.put_u32(ev["uid"])
SimEvent.Type.PLAYER_HIT:
body.put_u32(ev["peer"])
body.put_u16(clampi(int(ev["dmg"]), 0, 65535))
body.put_u16(clampi(int(ev["hp"]), 0, 65535))
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED:
body.put_u32(ev["peer"])
SimEvent.Type.PLAYER_RESPAWNED:
body.put_u32(ev["peer"])
body.put_float(ev["pos"].x)
body.put_float(ev["pos"].y)
SimEvent.Type.ENEMY_HIT:
body.put_u32(ev["id"])
body.put_u16(clampi(int(ev["dmg"]), 0, 65535))
body.put_u32(maxi(int(ev["hp"]), 0))
SimEvent.Type.ENEMY_DIED:
body.put_u32(ev["id"])
SimEvent.Type.BOSS_PHASE:
body.put_u8(clampi(int(ev["phase"]), 0, 255))
SimEvent.Type.BOSS_DIED:
pass
count += 1
b.put_u16(count)
b.put_data(body.data_array)
return b.data_array
## Returns { "tick": int, "events": Array[Dictionary] }.
static func decode_events(data: PackedByteArray) -> Dictionary:
var out: Array[Dictionary] = []
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var server_tick := b.get_u32()
var count := b.get_u16()
for _i in count:
var t := b.get_u8()
var ev := {"t": t}
match t:
SimEvent.Type.BULLET_SPAWN:
ev["uid"] = b.get_u32()
ev["pos"] = Vector2(b.get_float(), b.get_float())
ev["vel"] = Vector2(b.get_float(), b.get_float())
ev["r"] = b.get_float()
ev["life"] = b.get_u16()
ev["kind"] = b.get_u8()
ev["team"] = b.get_u8()
ev["accel"] = b.get_float()
ev["turn"] = b.get_float()
SimEvent.Type.BULLET_DESPAWN:
ev["uid"] = b.get_u32()
SimEvent.Type.PLAYER_HIT:
ev["peer"] = b.get_u32()
ev["dmg"] = b.get_u16()
ev["hp"] = b.get_u16()
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED:
ev["peer"] = b.get_u32()
SimEvent.Type.PLAYER_RESPAWNED:
ev["peer"] = b.get_u32()
ev["pos"] = Vector2(b.get_float(), b.get_float())
SimEvent.Type.ENEMY_HIT:
ev["id"] = b.get_u32()
ev["dmg"] = b.get_u16()
ev["hp"] = b.get_u32()
SimEvent.Type.ENEMY_DIED:
ev["id"] = b.get_u32()
SimEvent.Type.BOSS_PHASE:
ev["phase"] = b.get_u8()
SimEvent.Type.BOSS_DIED:
pass
out.append(ev)
return {"tick": server_tick, "events": out}
# --- Input ------------------------------------------------------------------
static func encode_inputs(frames: Array[InputFrame]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(mini(frames.size(), 255))
for f in frames:
f.write(b)
return b.data_array
static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]:
var out: Array[InputFrame] = []
if data.size() < 1:
return out
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var count := b.get_u8()
# A malformed or hostile packet must not make the server read past the end.
if data.size() < 1 + count * InputFrame.SIZE:
return out
for _i in count:
out.append(InputFrame.read(b))
return out
+1
View File
@@ -0,0 +1 @@
uid://er7seioedeo4
+24
View File
@@ -0,0 +1,24 @@
class_name Protocol
extends RefCounted
## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the
## server refuses mismatched clients at handshake rather than desyncing later.
const VERSION := 1
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
## ENet channels. Separating them stops a burst of reliable bullet events from
## head-of-line blocking the unreliable snapshot stream.
const CH_CONTROL := 1 ## handshake, instance transitions -- reliable
const CH_SNAPSHOT := 2 ## world state -- unreliable, newest wins
const CH_EVENTS := 3 ## bullet spawns/despawns, hits -- reliable ordered
const CH_INPUT := 4 ## client -> server intent -- unreliable ordered
## Must be identical on both ends, and larger than the highest channel above.
const CHANNEL_COUNT := 8
enum InstanceKind { LOBBY, DUNGEON }
## Player flags packed into the snapshot's per-player byte.
const F_ALIVE := 1
const F_INVULN := 2
const F_ESCAPING := 4
+1
View File
@@ -0,0 +1 @@
uid://dao4v3ubbg0yt
+193
View File
@@ -0,0 +1,193 @@
class_name ServerRuntime
extends Node
## The dedicated server. Owns every instance, ticks them all at the physics
## rate, and is the only place in the codebase allowed to decide what happened.
##
## Clients send intent and nothing else (see [method SimWorld.queue_input]), so
## there is no client message that can move a player, deal damage, cancel a hit
## or shorten an escape channel. Refusing to accept those messages at all is a
## stronger guarantee than validating them after the fact.
var instances: Dictionary[int, Instance] = {}
var peer_instance: Dictionary[int, int] = {}
var peer_names: Dictionary[int, String] = {}
var lobby: Instance
var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID
var _snapshot_phase: int = 0
func _ready() -> void:
lobby = Instance.make_lobby(_take_instance_id())
instances[lobby.id] = lobby
GameLog.info("server", "lobby instance %d up" % lobby.id)
func _take_instance_id() -> int:
var id := _next_instance_id
_next_instance_id += 1
return id
func _physics_process(_delta: float) -> void:
_snapshot_phase += 1
var send_snapshot := _snapshot_phase % SimConfig.SNAPSHOT_INTERVAL == 0
var closing: Array[int] = []
for inst in instances.values():
inst.step()
_dispatch_events(inst)
if send_snapshot and not inst.peers.is_empty():
var snap := NetCodec.encode_snapshot(inst.world)
for peer in inst.peers:
Net.send_snapshot(peer, snap)
if inst.kind == Protocol.InstanceKind.DUNGEON \
and inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
closing.append(inst.id)
elif inst.kind == Protocol.InstanceKind.DUNGEON and inst.is_empty() and inst.age > 60:
closing.append(inst.id)
for id in closing:
_close_dungeon(id)
## Split the tick's events into the ones clients need and the ones only the
## server acts on (escape completion, portal use).
func _dispatch_events(inst: Instance) -> void:
var events := inst.world.drain_events()
if events.is_empty():
return
var transfers: Array[Dictionary] = []
for ev in events:
match int(ev["t"]):
SimEvent.Type.ESCAPE_COMPLETED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": true})
SimEvent.Type.PORTAL_USED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": false})
_:
pass
var payload := NetCodec.encode_events(inst.world.tick, events)
for peer in inst.peers:
Net.send_events(peer, payload)
for t in transfers:
if t["to_lobby"]:
_send_to_lobby(int(t["peer"]))
else:
_send_to_dungeon(int(t["peer"]))
# --- Peer lifecycle ---------------------------------------------------------
func on_peer_connected(peer_id: int) -> void:
GameLog.info("server", "peer %d connected, awaiting hello" % peer_id)
func on_peer_disconnected(peer_id: int) -> void:
var inst := instance_of(peer_id)
if inst != null:
inst.remove_peer(peer_id)
peer_instance.erase(peer_id)
peer_names.erase(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
func on_hello(peer_id: int, version: int, display_name: String) -> void:
if peer_names.has(peer_id):
return # a second hello from the same peer is either a bug or an attack
if version != Protocol.VERSION:
GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION])
Net.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
Net.kick(peer_id)
return
# Never trust a client-supplied string for anything but display.
var clean := display_name.strip_edges().substr(0, 24)
if clean.is_empty():
clean = "player%d" % peer_id
peer_names[peer_id] = clean
Net.send_welcome(peer_id)
_place(peer_id, lobby)
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
func on_input(peer_id: int, data: PackedByteArray) -> void:
var inst := instance_of(peer_id)
if inst == null:
return
inst.world.queue_input(peer_id, NetCodec.decode_inputs(data))
func instance_of(peer_id: int) -> Instance:
var id: int = peer_instance.get(peer_id, 0)
return instances.get(id)
# --- Transfers --------------------------------------------------------------
func _place(peer_id: int, inst: Instance) -> void:
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
peer_instance[peer_id] = inst.id
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
String(inst.boss_id), inst.world.spawn_point)
# A player arriving mid-fight has no idea what is already in the air, so
# replay the live bullets as spawn events before the next snapshot lands.
var backlog := _live_bullet_events(inst.world)
if not backlog.is_empty():
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world))
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
from.remove_peer(peer_id)
_place(peer_id, to)
func _send_to_lobby(peer_id: int) -> void:
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
_transfer(peer_id, lobby)
func _send_to_dungeon(peer_id: int) -> void:
var target: Instance = null
for inst in instances.values():
if inst.accepts_new_party_member():
target = inst
break
if target == null:
target = Instance.make_dungeon(_take_instance_id(), randi())
instances[target.id] = target
GameLog.info("server", "opened dungeon instance %d" % target.id)
_transfer(peer_id, target)
func _close_dungeon(id: int) -> void:
var inst: Instance = instances.get(id)
if inst == null:
return
for peer in inst.peers.duplicate():
_transfer(peer, lobby)
instances.erase(id)
GameLog.info("server", "closed dungeon instance %d" % id)
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for i in world.pool.high_water:
if world.pool.alive[i] == 0:
continue
out.append({
"t": SimEvent.Type.BULLET_SPAWN,
"uid": world.pool.uid[i],
"pos": world.pool.pos[i],
"vel": world.pool.vel[i],
"r": world.pool.radius[i],
"life": world.pool.life[i],
"kind": world.pool.kind[i],
"team": world.pool.team[i],
"accel": world.pool.accel[i],
"turn": world.pool.turn[i],
})
return out
+1
View File
@@ -0,0 +1 @@
uid://cfysfvk62ik58
+161
View File
@@ -0,0 +1,161 @@
class_name BulletPool
extends RefCounted
## Bullets are plain data in parallel arrays, never nodes. A bullet-hell spawns
## thousands of them per second; one Node2D + Area2D each would dominate both
## the frame budget and the network. Everything here is integrated by hand so
## the server can run it headless and a unit test can run it with no SceneTree.
var pos := PackedVector2Array()
var vel := PackedVector2Array()
var radius := PackedFloat32Array()
## Scalar acceleration along the current heading, units/sec^2.
var accel := PackedFloat32Array()
## Heading change per tick, radians. Non-zero makes the bullet curve.
var turn := PackedFloat32Array()
var life := PackedInt32Array()
var damage := PackedInt32Array()
## Network identity. Stable for the bullet's whole life, 0 in a free slot.
var uid := PackedInt32Array()
var team := PackedByteArray()
var kind := PackedByteArray()
var alive := PackedByteArray()
var live_count := 0
## Exclusive upper bound over slots that have ever been used, so the hot loops
## do not walk the whole 4096-slot table while the arena is nearly empty.
var high_water := 0
var _free := PackedInt32Array()
var _next_uid := 1
## Slots spawned since the last [method clear_spawn_log]. The server turns this
## into BULLET_SPAWN events; the client replica ignores it because its bullets
## arrive from the wire already.
var spawn_log := PackedInt32Array()
func _init() -> void:
var n := SimConfig.MAX_BULLETS
pos.resize(n)
vel.resize(n)
radius.resize(n)
accel.resize(n)
turn.resize(n)
life.resize(n)
damage.resize(n)
uid.resize(n)
team.resize(n)
kind.resize(n)
alive.resize(n)
func clear() -> void:
for i in high_water:
alive[i] = 0
uid[i] = 0
_free.clear()
spawn_log.clear()
live_count = 0
high_water = 0
## Allocate a slot. Returns the slot index, or -1 when the pool is saturated.
## [param forced_uid] is used by the client replica so its bullets carry the
## same identity the server assigned.
func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
bullet_team: int, bullet_kind: int, bullet_accel: float = 0.0,
bullet_turn: float = 0.0, forced_uid: int = 0) -> int:
var slot := -1
if not _free.is_empty():
slot = _free[_free.size() - 1]
_free.remove_at(_free.size() - 1)
elif high_water < SimConfig.MAX_BULLETS:
slot = high_water
high_water += 1
else:
return -1
pos[slot] = p
vel[slot] = v
radius[slot] = r
accel[slot] = bullet_accel
turn[slot] = bullet_turn
life[slot] = lifetime
damage[slot] = dmg
team[slot] = bullet_team
kind[slot] = bullet_kind
alive[slot] = 1
if forced_uid != 0:
uid[slot] = forced_uid
else:
uid[slot] = _next_uid
_next_uid += 1
live_count += 1
spawn_log.append(slot)
return slot
func clear_spawn_log() -> void:
spawn_log.clear()
func despawn(slot: int) -> void:
if alive[slot] == 0:
return
alive[slot] = 0
uid[slot] = 0
live_count -= 1
_free.append(slot)
func find_by_uid(target_uid: int) -> int:
for i in high_water:
if alive[i] == 1 and uid[i] == target_uid:
return i
return -1
## Advance every live bullet one tick. Bullets that expire or leave the arena
## die here on both server and client without any network traffic, because both
## sides run this identical integration.
func step() -> void:
var dt := SimConfig.TICK_DELTA
for i in high_water:
if alive[i] == 0:
continue
var v := vel[i]
if turn[i] != 0.0:
v = v.rotated(turn[i])
if accel[i] != 0.0:
var speed := v.length()
if speed > 0.0001:
v = v * ((speed + accel[i] * dt) / speed)
vel[i] = v
var p: Vector2 = pos[i] + v * dt
pos[i] = p
life[i] -= 1
if life[i] <= 0 or Movement.outside_arena(p):
despawn(i)
## Fast-forward a single bullet. The client uses this so a bullet that spawned
## on the server N ticks ago appears where it should be now instead of popping
## in at the muzzle a round-trip late.
func advance_slot(slot: int, ticks: int) -> void:
var dt := SimConfig.TICK_DELTA
for _i in ticks:
if alive[slot] == 0:
return
var v := vel[slot]
if turn[slot] != 0.0:
v = v.rotated(turn[slot])
if accel[slot] != 0.0:
var speed := v.length()
if speed > 0.0001:
v = v * ((speed + accel[slot] * dt) / speed)
vel[slot] = v
pos[slot] = pos[slot] + v * dt
life[slot] -= 1
if life[slot] <= 0 or Movement.outside_arena(pos[slot]):
despawn(slot)
return
+1
View File
@@ -0,0 +1 @@
uid://bd7p18su7nbw2
+48
View File
@@ -0,0 +1,48 @@
class_name InputFrame
extends RefCounted
## One tick of player intent. This is the only thing a client is allowed to tell
## the server about its own state: no positions, no hits, no damage.
const BTN_FIRE := 1
const BTN_ESCAPE := 2
const BTN_INTERACT := 4
## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons.
const SIZE := 9
var tick: int = 0
var move := Vector2.ZERO
var aim: float = 0.0
var buttons: int = 0
static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int) -> InputFrame:
var f := InputFrame.new()
f.tick = p_tick
f.move = p_move
f.aim = p_aim
f.buttons = p_buttons
return f
func pressed(bit: int) -> bool:
return (buttons & bit) != 0
func write(buf: StreamPeerBuffer) -> void:
buf.put_u32(tick)
# Quantised to a signed byte. The server re-clamps to the unit disc anyway,
# so a hand-crafted packet cannot buy extra speed here.
buf.put_8(clampi(roundi(move.x * 100.0), -100, 100))
buf.put_8(clampi(roundi(move.y * 100.0), -100, 100))
buf.put_u16(wrapi(roundi(aim / TAU * 65536.0), 0, 65536))
buf.put_u8(buttons & 0xFF)
static func read(buf: StreamPeerBuffer) -> InputFrame:
var f := InputFrame.new()
f.tick = buf.get_u32()
f.move = Vector2(float(buf.get_8()) / 100.0, float(buf.get_8()) / 100.0)
f.aim = float(buf.get_u16()) / 65536.0 * TAU
f.buttons = buf.get_u8()
return f
+1
View File
@@ -0,0 +1 @@
uid://c3p045w4nf6jo
+27
View File
@@ -0,0 +1,27 @@
class_name AimedSpreadEmitter
extends BulletEmitter
## A fan aimed at the nearest player. This is the emitter that punishes standing
## still, so most patterns pair one of these with a geometric emitter.
@export var count: int = 5
@export var spread_deg: float = 24.0
## Random angular jitter. Uses the world RNG, which is seeded per instance so a
## replay of the same seed produces the same fight.
@export var jitter_deg: float = 0.0
@export var muzzle_offset: float = 24.0
## Extra speed given to the outermost bullets, which curves the fan forward.
@export var edge_speed_bonus: float = 0.0
func fire(ctx: EmitContext) -> void:
if count <= 0:
return
var aim := ctx.aim_angle()
var spread := deg_to_rad(spread_deg)
for i in count:
var t := 0.0 if count == 1 else (float(i) / float(count - 1)) - 0.5
var a := aim + spread * t
if jitter_deg > 0.0 and ctx.rng != null:
a += deg_to_rad(ctx.rng.randf_range(-jitter_deg, jitter_deg))
var scale := 1.0 + edge_speed_bonus * absf(t) * 2.0
emit_shot(ctx, a, ctx.origin + Vector2.RIGHT.rotated(aim) * muzzle_offset, scale)
@@ -0,0 +1 @@
uid://b4njg402onjdw
+31
View File
@@ -0,0 +1,31 @@
class_name ArcSweepEmitter
extends BulletEmitter
## A laser-like arm that sweeps back and forth. Dense enough to force movement,
## slow enough to read. Pairs well with a ring that fills the space behind it.
@export var arms: int = 2
@export var bullets_per_arm: int = 3
## Distance between bullets along one arm.
@export var arm_spacing: float = 22.0
## Degrees the arm travels per second at the sweep extremes.
@export var sweep_deg: float = 70.0
## Seconds for one full back-and-forth.
@export var sweep_period: float = 4.0
@export var base_angle_deg: float = 0.0
@export var muzzle_offset: float = 26.0
func fire(ctx: EmitContext) -> void:
if arms <= 0 or bullets_per_arm <= 0:
return
var t := float(ctx.local_tick) * SimConfig.TICK_DELTA
var phase := sin(TAU * t / maxf(sweep_period, 0.001))
var base := deg_to_rad(base_angle_deg + sweep_deg * phase)
for arm in arms:
var a := base + TAU * float(arm) / float(arms)
var heading := Vector2.RIGHT.rotated(a)
for j in bullets_per_arm:
var from := ctx.origin + heading * (muzzle_offset + arm_spacing * float(j))
# Outer bullets start further along, so give them matching speed to
# keep the arm straight instead of letting it bow.
emit_shot(ctx, a, from, 1.0 + 0.08 * float(j))
@@ -0,0 +1 @@
uid://bxgv7yc1b7rqx
+60
View File
@@ -0,0 +1,60 @@
@abstract
class_name BulletEmitter
extends Resource
## One firing behaviour on a timeline. A boss phase or an enemy is just a list
## of these, which is the whole "format that adapts to other bosses": authoring
## a new boss means writing a new list, never new code.
@export_group("Timeline")
## First tick of the owner's phase at which this emitter is armed.
@export var start_tick: int = 0
## Last armed tick, or -1 to stay armed for the whole phase.
@export var end_tick: int = -1
## Ticks between shots.
@export var interval: int = 30
@export_group("Bullet")
@export var speed: float = 150.0
## Units/sec^2 along the heading. Negative values make bullets stall and drift.
@export var accel: float = 0.0
## Degrees of heading change per tick. Non-zero produces curving bullets.
@export var turn_deg: float = 0.0
@export var radius: float = 7.0
@export var lifetime: int = 300
@export var damage: int = 12
@export var kind: int = SimConfig.KIND_ORB
func is_armed(local_tick: int) -> bool:
if local_tick < start_tick:
return false
if end_tick >= 0 and local_tick > end_tick:
return false
return true
func should_fire(local_tick: int) -> bool:
if not is_armed(local_tick):
return false
if interval <= 0:
return false
return (local_tick - start_tick) % interval == 0
## How many shots this emitter has fired by [param local_tick], used to drive
## per-shot spin without the emitter holding mutable state (emitters are shared
## resources -- two bosses of the same kind must not stomp each other).
func shot_index_at(local_tick: int) -> int:
if interval <= 0:
return 0
return int(floor(float(local_tick - start_tick) / float(interval)))
@abstract func fire(ctx: EmitContext) -> void
## Shared spawn path so every emitter produces bullets with identical physics.
func emit_shot(ctx: EmitContext, angle: float, from: Vector2, speed_scale: float = 1.0) -> void:
var v := Vector2.RIGHT.rotated(angle) * speed * speed_scale
ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind,
accel, deg_to_rad(turn_deg))
+1
View File
@@ -0,0 +1 @@
uid://c27tahmytk5vj
+23
View File
@@ -0,0 +1,23 @@
class_name EmitContext
extends RefCounted
## Everything an emitter is allowed to see. Keeping this narrow is what lets a
## unit test fire a boss pattern into a bare [BulletPool] with no world at all.
var pool: BulletPool
## Where the shots come from (the emitting actor's position).
var origin := Vector2.ZERO
## Nearest live player, used by aimed emitters. Falls back to [member origin]
## plus a down vector when nobody is alive.
var target := Vector2.ZERO
var has_target := false
## Ticks since the emitting actor entered its current phase.
var local_tick := 0
## How many times this emitter has fired in this phase. Drives spin/step.
var shot_index := 0
var rng: RandomNumberGenerator
func aim_angle() -> float:
if not has_target:
return PI * 0.5
return (target - origin).angle()
+1
View File
@@ -0,0 +1 @@
uid://cav5jlkxsy6qm
+29
View File
@@ -0,0 +1,29 @@
class_name RingEmitter
extends BulletEmitter
## Evenly spaced bullets around a full or partial circle. Give it a non-zero
## [member spin_per_shot_deg] and consecutive rings become a spiral.
@export var count: int = 12
## Total angle the bullets are spread over. 360 is a closed ring.
@export var arc_deg: float = 360.0
@export var base_angle_deg: float = 0.0
## Added to the base angle on every shot.
@export var spin_per_shot_deg: float = 7.0
## Spawn distance from the emitter, so big bosses do not fire from their centre.
@export var muzzle_offset: float = 24.0
## Aim the ring's base angle at the nearest player instead of using world space.
@export var aim_at_target: bool = false
func fire(ctx: EmitContext) -> void:
if count <= 0:
return
var base := deg_to_rad(base_angle_deg + spin_per_shot_deg * float(ctx.shot_index))
if aim_at_target:
base += ctx.aim_angle()
var arc := deg_to_rad(arc_deg)
# A closed ring must not place two bullets on top of each other at the seam.
var divisor := count if is_equal_approx(arc_deg, 360.0) else maxi(count - 1, 1)
for i in count:
var a := base + arc * (float(i) / float(divisor))
emit_shot(ctx, a, ctx.origin + Vector2.RIGHT.rotated(a) * muzzle_offset)
+1
View File
@@ -0,0 +1 @@
uid://c5664w2onh7wx
+47
View File
@@ -0,0 +1,47 @@
class_name WallGapEmitter
extends BulletEmitter
## A curtain of bullets sweeping across the arena with a survivable gap. The gap
## moves by [member gap_step] every shot so players cannot camp one lane.
## 0 = travels down, 1 = up, 2 = right, 3 = left.
@export_enum("Down", "Up", "Right", "Left") var direction: int = 0
@export var count: int = 18
## Slots left empty. Width 2-3 is tight but fair at default bullet radius.
@export var gap_width: int = 3
@export var gap_index: int = 6
## How far the gap slides each shot. Coprime-ish values feel least predictable.
@export var gap_step: int = 5
## Randomise the gap instead of stepping it.
@export var randomize_gap: bool = false
func _axis() -> Dictionary:
match direction:
1: return {"dir": Vector2.UP, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
2: return {"dir": Vector2.RIGHT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
3: return {"dir": Vector2.LEFT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
_: return {"dir": Vector2.DOWN, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
func fire(ctx: EmitContext) -> void:
if count <= 0:
return
var ax := _axis()
var dir: Vector2 = ax["dir"]
var along: Vector2 = ax["along"]
var extent: float = ax["extent"]
var edge: float = ax["edge"]
var gap := gap_index
if randomize_gap and ctx.rng != null:
gap = ctx.rng.randi_range(0, maxi(count - gap_width, 0))
else:
gap = posmod(gap_index + gap_step * ctx.shot_index, maxi(count - gap_width + 1, 1))
var start := -dir * (edge + 8.0)
var angle := dir.angle()
for i in count:
if i >= gap and i < gap + gap_width:
continue
var t := (float(i) / float(maxi(count - 1, 1))) * 2.0 - 1.0
emit_shot(ctx, angle, start + along * (t * extent))
+1
View File
@@ -0,0 +1 @@
uid://igr031g85v5q
+25
View File
@@ -0,0 +1,25 @@
class_name SimBoss
extends RefCounted
## A boss instance. All fight-specific behaviour lives in its [BossDef]; this
## class only advances the phase timeline.
var id: int = 0
var def: BossDef
var pos := Vector2.ZERO
var hp: int = 1
var alive: bool = true
var phase_index: int = 0
## Ticks since entering the current phase.
var phase_tick: int = 0
func hp_fraction() -> float:
if def == null or def.max_hp <= 0:
return 0.0
return float(hp) / float(def.max_hp)
func current_phase() -> BossPhase:
if def == null or def.phases.is_empty():
return null
return def.phases[clampi(phase_index, 0, def.phases.size() - 1)]
+1
View File
@@ -0,0 +1 @@
uid://beq5lnuth5prk
+23
View File
@@ -0,0 +1,23 @@
class_name SimEnemy
extends RefCounted
## A trash enemy instance. Movement is deliberately legible -- every behaviour
## here is a closed-form function of tick and spawn point, so a player can learn
## it, and so a test can assert on it.
var id: int = 0
var def: EnemyDef
var pos := Vector2.ZERO
var home := Vector2.ZERO
var heading := Vector2.RIGHT
var hp: int = 1
var alive: bool = true
var local_tick: int = 0
## Staggers identical enemies so a pack does not fire in lockstep.
var phase_offset: int = 0
var target_dir := Vector2.ZERO
func hp_fraction() -> float:
if def == null or def.max_hp <= 0:
return 0.0
return float(hp) / float(def.max_hp)
+1
View File
@@ -0,0 +1 @@
uid://cbax6i5kwsr5w
+22
View File
@@ -0,0 +1,22 @@
class_name SimEvent
extends RefCounted
## Discrete things the authoritative simulation decided. The server drains this
## list every tick and ships it to clients on a reliable channel; clients apply
## them to their replica. Continuous state (positions, hp) goes in snapshots
## instead -- events are only for things a client can never re-derive.
enum Type {
BULLET_SPAWN, ## uid, pos, vel, radius, life, kind, team, accel, turn
BULLET_DESPAWN, ## uid -- early death only; expiry is derived on both sides
PLAYER_HIT, ## peer, damage, hp
PLAYER_DIED, ## peer
PLAYER_RESPAWNED, ## peer, pos
ENEMY_HIT, ## id, damage, hp
ENEMY_DIED, ## id
BOSS_PHASE, ## phase index
BOSS_DIED,
ESCAPE_STARTED, ## peer
ESCAPE_CANCELLED, ## peer
ESCAPE_COMPLETED, ## peer -- the instance layer acts on this
PORTAL_USED, ## peer -- the instance layer acts on this
}
+1
View File
@@ -0,0 +1 @@
uid://bqs0ihl1uh8b0
+41
View File
@@ -0,0 +1,41 @@
class_name SimPlayer
extends RefCounted
## Authoritative player state. Lives only inside a [SimWorld]; the client holds
## a mirror of it for rendering and prediction.
var peer_id: int = 0
var display_name: String = "player"
var pos := Vector2.ZERO
var aim: float = 0.0
var hp: int = SimConfig.PLAYER_MAX_HP
var alive: bool = true
var iframes: int = 0
var fire_cooldown: int = 0
var respawn_timer: int = 0
## Ticks the emergency escape has been held. 0 means not channelling.
var escape_ticks: int = 0
## Newest input tick the server has consumed. Echoed back in snapshots so the
## client knows how far to rewind when reconciling.
var last_input_tick: int = 0
var input_queue: Array[InputFrame] = []
## Repeated when the queue runs dry, so a dropped packet coasts instead of
## stuttering. Capped by [member starved_ticks].
var held_input: InputFrame = InputFrame.new()
var starved_ticks: int = 0
func escape_progress() -> float:
return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0)
func reset_for_instance(spawn: Vector2) -> void:
pos = spawn
hp = SimConfig.PLAYER_MAX_HP
alive = true
iframes = SimConfig.PLAYER_IFRAMES
fire_cooldown = 0
respawn_timer = 0
escape_ticks = 0
input_queue.clear()
+1
View File
@@ -0,0 +1 @@
uid://qjogi5l7e1ab
+430
View File
@@ -0,0 +1,430 @@
class_name SimWorld
extends RefCounted
## The whole game simulation for one instance (a lobby or one dungeon run).
##
## It is a plain RefCounted with no nodes, no physics server and no rendering,
## which buys three things: the dedicated server runs it headless at negligible
## cost, a unit test can drive a thousand ticks in milliseconds, and the client
## can run the exact same class in replica mode for prediction.
##
## Authority: [member authoritative] is true on the server only. In replica mode
## the world never runs AI, never fires emitters and never resolves a hit -- it
## only integrates bullets it was told about and holds actor state for drawing.
## That is the whole anti-cheat story: a client physically has no code path that
## can decide it dealt or avoided damage.
var tick: int = 0
var authoritative: bool = true
var pool := BulletPool.new()
var rng := RandomNumberGenerator.new()
var players: Dictionary[int, SimPlayer] = {}
var enemies: Dictionary[int, SimEnemy] = {}
var boss: SimBoss = null
## Drained by the owner every tick. See [SimEvent].
var events: Array[Dictionary] = []
## Set on a lobby world so the interact button can open a dungeon.
var portal_enabled: bool = false
var spawn_point := Vector2(0.0, 240.0)
var _next_actor_id: int = 1
var _ctx := EmitContext.new()
func _init(seed_value: int = 0) -> void:
rng.seed = seed_value
_ctx.rng = rng
func next_actor_id() -> int:
var id := _next_actor_id
_next_actor_id += 1
return id
# --- Population -------------------------------------------------------------
func add_player(peer_id: int, display_name: String) -> SimPlayer:
var p := SimPlayer.new()
p.peer_id = peer_id
p.display_name = display_name
p.reset_for_instance(spawn_point)
players[peer_id] = p
return p
func remove_player(peer_id: int) -> void:
players.erase(peer_id)
func spawn_enemy(def: EnemyDef, at: Vector2, phase_offset: int = 0) -> SimEnemy:
var e := SimEnemy.new()
e.id = next_actor_id()
e.def = def
e.pos = at
e.home = at
e.hp = def.max_hp
e.phase_offset = phase_offset
e.heading = Vector2.RIGHT.rotated(rng.randf() * TAU)
enemies[e.id] = e
return e
func spawn_boss(def: BossDef) -> SimBoss:
var b := SimBoss.new()
b.id = next_actor_id()
b.def = def
b.pos = def.spawn_pos
b.hp = def.max_hp
boss = b
return b
func alive_player_count() -> int:
var n := 0
for p in players.values():
if p.alive:
n += 1
return n
## Nearest living player to [param from]. Aimed emitters and chasing enemies use
## this; it is the only targeting primitive in the game.
func nearest_player(from: Vector2) -> SimPlayer:
var best: SimPlayer = null
var best_d := INF
for p in players.values():
if not p.alive:
continue
var d := from.distance_squared_to(p.pos)
if d < best_d:
best_d = d
best = p
return best
# --- Input ------------------------------------------------------------------
## Accept a batch of client inputs. Everything questionable is dropped here
## rather than deeper in, so there is a single place to audit.
func queue_input(peer_id: int, frames: Array[InputFrame]) -> void:
var p: SimPlayer = players.get(peer_id)
if p == null:
return
for f in frames:
if f.tick <= p.last_input_tick:
continue # replay or out-of-order duplicate
if f.tick < tick - SimConfig.INPUT_MAX_AGE:
continue # too old to matter
if f.tick > tick + SimConfig.INPUT_MAX_LEAD:
continue # client claiming to be far in the future
if p.input_queue.size() >= SimConfig.INPUT_MAX_AGE:
p.input_queue.pop_front() # flood guard
p.input_queue.append(f)
p.input_queue.sort_custom(func(a: InputFrame, b: InputFrame) -> bool: return a.tick < b.tick)
# --- Tick -------------------------------------------------------------------
func step() -> void:
tick += 1
if authoritative:
pool.clear_spawn_log()
_step_players()
_step_enemies()
_step_boss()
pool.step()
_resolve_bullet_hits()
_resolve_contact_damage()
_emit_spawn_events()
else:
# Replica: bullets only. Actor state arrives in snapshots.
pool.step()
func _step_players() -> void:
for p in players.values():
if p.iframes > 0:
p.iframes -= 1
if p.fire_cooldown > 0:
p.fire_cooldown -= 1
if not p.alive:
p.respawn_timer -= 1
if p.respawn_timer <= 0:
p.alive = true
p.hp = SimConfig.PLAYER_MAX_HP
p.pos = spawn_point
p.iframes = SimConfig.PLAYER_IFRAMES
events.append({"t": SimEvent.Type.PLAYER_RESPAWNED, "peer": p.peer_id, "pos": p.pos})
continue
var frame := _take_input(p)
p.aim = frame.aim
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
if frame.pressed(InputFrame.BTN_FIRE) and p.fire_cooldown == 0:
_fire_player_shot(p)
_step_escape(p, frame)
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
if p.pos.distance_to(SimConfig.PORTAL_POS) <= SimConfig.PORTAL_RADIUS:
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
## Pull the next input for this player, or coast on the last one. Coasting is
## capped so a client that stops sending cannot keep moving forever.
func _take_input(p: SimPlayer) -> InputFrame:
if not p.input_queue.is_empty():
var f: InputFrame = p.input_queue.pop_front()
p.last_input_tick = f.tick
p.held_input = f
p.starved_ticks = 0
return f
p.starved_ticks += 1
if p.starved_ticks > SimConfig.INPUT_MAX_AGE:
var idle := InputFrame.new()
idle.aim = p.held_input.aim
p.held_input = idle
return p.held_input
func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
var dir := Vector2.RIGHT.rotated(p.aim)
pool.spawn(
p.pos + dir * (SimConfig.PLAYER_RADIUS + 6.0),
dir * SimConfig.PLAYER_BULLET_SPEED,
SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME,
SimConfig.PLAYER_BULLET_DAMAGE,
SimConfig.TEAM_PLAYER,
SimConfig.KIND_PLAYER_SHOT)
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
if frame.pressed(InputFrame.BTN_ESCAPE):
if p.escape_ticks == 0:
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
p.escape_ticks += 1
if p.escape_ticks >= SimConfig.ESCAPE_CHANNEL_TICKS:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_COMPLETED, "peer": p.peer_id})
elif p.escape_ticks > 0:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
func _step_enemies() -> void:
for e in enemies.values():
if not e.alive:
continue
_move_enemy(e)
_run_emitters(e.def.emitters, e.pos,
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
e.local_tick += 1
func _move_enemy(e: SimEnemy) -> void:
var dt := SimConfig.TICK_DELTA
var speed: float = e.def.speed
match e.def.move:
EnemyDef.Move.STATIC:
pass
EnemyDef.Move.DRIFT:
var next := e.pos + e.heading * speed * dt
# Bounce off the arena so a drifter never leaves the fight.
if absf(next.x) > SimConfig.ARENA_HALF.x - e.def.radius:
e.heading.x = -e.heading.x
next.x = clampf(next.x, -SimConfig.ARENA_HALF.x + e.def.radius, SimConfig.ARENA_HALF.x - e.def.radius)
if absf(next.y) > SimConfig.ARENA_HALF.y - e.def.radius:
e.heading.y = -e.heading.y
next.y = clampf(next.y, -SimConfig.ARENA_HALF.y + e.def.radius, SimConfig.ARENA_HALF.y - e.def.radius)
e.pos = next
EnemyDef.Move.ORBIT:
var a := float(e.local_tick + e.phase_offset) * dt * (speed / maxf(e.def.move_param, 1.0))
e.pos = e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
EnemyDef.Move.APPROACH:
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
var t := nearest_player(e.pos)
e.target_dir = Vector2.ZERO if t == null else (t.pos - e.pos).normalized()
e.pos += e.target_dir * speed * dt
EnemyDef.Move.STRAFE:
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
var t := nearest_player(e.pos)
if t == null:
e.target_dir = Vector2.ZERO
else:
var to_player := t.pos - e.pos
var d := to_player.length()
var radial := 0.0
if d > e.def.move_param + 20.0:
radial = 1.0
elif d < e.def.move_param - 20.0:
radial = -1.0
var toward := Vector2.ZERO if d < 0.001 else to_player / d
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
e.pos += e.target_dir * speed * dt
e.pos.x = clampf(e.pos.x, -SimConfig.ARENA_HALF.x, SimConfig.ARENA_HALF.x)
e.pos.y = clampf(e.pos.y, -SimConfig.ARENA_HALF.y, SimConfig.ARENA_HALF.y)
func _step_boss() -> void:
if boss == null or not boss.alive or boss.def == null:
return
var wanted := boss.def.phase_index_for(boss.hp_fraction())
if wanted != boss.phase_index:
boss.phase_index = wanted
boss.phase_tick = 0
events.append({"t": SimEvent.Type.BOSS_PHASE, "phase": wanted})
var phase := boss.current_phase()
if phase == null:
return
if boss.phase_tick >= phase.telegraph_ticks:
var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1))
_run_emitters(phase.emitters, boss.pos, local)
boss.phase_tick += 1
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
## be dropped onto a trash mob and vice versa.
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int) -> void:
if emitters.is_empty():
return
var target := nearest_player(origin)
_ctx.pool = pool
_ctx.origin = origin
_ctx.local_tick = local_tick
_ctx.has_target = target != null
_ctx.target = origin + Vector2.DOWN * 200.0 if target == null else target.pos
for e in emitters:
if e == null or not e.should_fire(local_tick):
continue
_ctx.shot_index = e.shot_index_at(local_tick)
e.fire(_ctx)
# --- Hit resolution (server only) -------------------------------------------
func _resolve_bullet_hits() -> void:
for i in pool.high_water:
if pool.alive[i] == 0:
continue
var bp: Vector2 = pool.pos[i]
var br: float = pool.radius[i]
if pool.team[i] == SimConfig.TEAM_ENEMY:
for p in players.values():
if not p.alive or p.iframes > 0:
continue
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, pool.damage[i])
_kill_bullet(i)
break
else:
var consumed := false
if boss != null and boss.alive \
and Movement.circles_overlap(bp, br, boss.pos, boss.def.radius):
_damage_boss(pool.damage[i])
consumed = true
if not consumed:
for e in enemies.values():
if not e.alive:
continue
if Movement.circles_overlap(bp, br, e.pos, e.def.radius):
_damage_enemy(e, pool.damage[i])
consumed = true
break
if consumed:
_kill_bullet(i)
func _resolve_contact_damage() -> void:
for e in enemies.values():
if not e.alive or e.def.contact_damage <= 0:
continue
for p in players.values():
if not p.alive or p.iframes > 0:
continue
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, e.def.contact_damage)
## Bullets removed early must be announced -- clients cannot derive a hit.
func _kill_bullet(slot: int) -> void:
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": pool.uid[slot]})
pool.despawn(slot)
func _damage_player(p: SimPlayer, amount: int) -> void:
p.hp = maxi(p.hp - amount, 0)
p.iframes = SimConfig.PLAYER_IFRAMES
if SimConfig.ESCAPE_BREAK_ON_DAMAGE and p.escape_ticks > 0:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
if p.hp <= 0:
p.alive = false
p.respawn_timer = SimConfig.PLAYER_RESPAWN_DELAY
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
func _damage_enemy(e: SimEnemy, amount: int) -> void:
e.hp = maxi(e.hp - amount, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if e.hp <= 0:
e.alive = false
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id})
func _damage_boss(amount: int) -> void:
var phase := boss.current_phase()
var mult := 1.0 if phase == null else phase.damage_taken_mult
var applied := maxi(1, roundi(float(amount) * mult))
boss.hp = maxi(boss.hp - applied, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if boss.hp <= 0:
boss.alive = false
events.append({"t": SimEvent.Type.BOSS_DIED})
func _emit_spawn_events() -> void:
for slot in pool.spawn_log:
if pool.alive[slot] == 0:
continue # spawned and killed inside the same tick
events.append({
"t": SimEvent.Type.BULLET_SPAWN,
"uid": pool.uid[slot],
"pos": pool.pos[slot],
"vel": pool.vel[slot],
"r": pool.radius[slot],
"life": pool.life[slot],
"kind": pool.kind[slot],
"team": pool.team[slot],
"accel": pool.accel[slot],
"turn": pool.turn[slot],
})
func drain_events() -> Array[Dictionary]:
var out := events
events = []
return out
# --- Replica side -----------------------------------------------------------
## Apply a server event to a non-authoritative world.
func apply_event(ev: Dictionary) -> void:
match int(ev["t"]):
SimEvent.Type.BULLET_SPAWN:
pool.spawn(ev["pos"], ev["vel"], ev["r"], ev["life"], 0,
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
SimEvent.Type.BULLET_DESPAWN:
var slot := pool.find_by_uid(int(ev["uid"]))
if slot >= 0:
pool.despawn(slot)
_:
pass
+1
View File
@@ -0,0 +1 @@
uid://o5amgfbx7fkq
+114
View File
@@ -0,0 +1,114 @@
extends CanvasLayer
## Heads-up display. Built in code rather than as a scene because every element
## is data-driven -- there is no layout here a designer would want to drag.
const MARGIN := 24.0
const BAR_W := 260.0
const BAR_H := 16.0
var _canvas: Control
var _status: Label
var _hint: Label
var _hit_flash: float = 0.0
var client: ClientRuntime = null
func _ready() -> void:
layer = 10
_canvas = Control.new()
_canvas.set_anchors_preset(Control.PRESET_FULL_RECT)
_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
_canvas.draw.connect(_draw_hud)
add_child(_canvas)
_status = _make_label(Vector2(MARGIN, MARGIN))
_hint = _make_label(Vector2(MARGIN, 0.0))
_hint.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
_hint.position = Vector2(MARGIN, -56.0)
func _make_label(pos: Vector2) -> Label:
var l := Label.new()
l.position = pos
l.add_theme_font_size_override("font_size", 14)
l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95))
_canvas.add_child(l)
return l
func _process(delta: float) -> void:
client = Net.client
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
_status.text = _status_text()
_hint.text = _hint_text()
_canvas.queue_redraw()
func _status_text() -> String:
if client == null:
return "connecting..."
var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON"
return "%s instance %d hp %d/%d %d fps" % [
where, client.instance_id, client.my_hp, SimConfig.PLAYER_MAX_HP,
Engine.get_frames_per_second()]
func _hint_text() -> String:
if client == null:
return ""
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 hold F to escape to the lobby"
func _draw_hud() -> void:
if client == null:
return
var origin := Vector2(MARGIN, MARGIN + 28.0)
_bar(origin, float(client.my_hp) / float(SimConfig.PLAYER_MAX_HP),
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
if client.my_escaping:
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape,
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H + 8.0 + BAR_H),
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
_draw_boss_bar()
if not client.my_alive:
var size := _canvas.size
_canvas.draw_string(ThemeDB.fallback_font, size * 0.5 - Vector2(70.0, 0.0),
"DOWN -- respawning", HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color(1.0, 0.4, 0.4))
if _hit_flash > 0.0:
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
return
var w := 560.0
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1))
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), Color(0.95, 0.35, 0.45))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
_canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0),
"%s -- %s" % [client.boss_def.display_name, phase_name],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void:
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back)
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill)
func flash_hit() -> void:
_hit_flash = 1.0
+1
View File
@@ -0,0 +1 @@
uid://boq560lv7hkl0
+80
View File
@@ -0,0 +1,80 @@
extends Control
## Connect screen. Built in code: it is four controls, and a scene file for it
## would only be one more thing to keep in sync.
signal join_requested(address: String, port: int)
signal host_requested(port: int)
var _address: LineEdit
var _port: LineEdit
var _name: LineEdit
var _status: Label
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var panel := VBoxContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.custom_minimum_size = Vector2(360.0, 0.0)
panel.add_theme_constant_override("separation", 8)
panel.position = Vector2(-180.0, -160.0)
add_child(panel)
var title := Label.new()
title.text = "TRANSCIENCE"
title.add_theme_font_size_override("font_size", 32)
panel.add_child(title)
_name = _field(panel, "name", GameOpts.player_name)
_address = _field(panel, "server address", GameOpts.host_address)
_port = _field(panel, "port", str(GameOpts.port))
var join := Button.new()
join.text = "Join"
join.pressed.connect(_on_join)
panel.add_child(join)
var host := Button.new()
host.text = "Host and play (listen server)"
host.pressed.connect(_on_host)
panel.add_child(host)
var quit := Button.new()
quit.text = "Quit"
quit.pressed.connect(func() -> void: get_tree().quit())
panel.add_child(quit)
_status = Label.new()
_status.add_theme_color_override("font_color", Color(1.0, 0.55, 0.5))
panel.add_child(_status)
func _field(parent: Control, label: String, value: String) -> LineEdit:
var l := Label.new()
l.text = label
l.add_theme_font_size_override("font_size", 12)
parent.add_child(l)
var e := LineEdit.new()
e.text = value
parent.add_child(e)
return e
func set_status(text: String) -> void:
_status.text = text
func _commit_name() -> void:
var clean := _name.text.strip_edges()
if not clean.is_empty():
GameOpts.player_name = clean
func _on_join() -> void:
_commit_name()
join_requested.emit(_address.text.strip_edges(), int(_port.text))
func _on_host() -> void:
_commit_name()
host_requested.emit(int(_port.text))
+1
View File
@@ -0,0 +1 @@
uid://d30lplh710ji8
+60
View File
@@ -0,0 +1,60 @@
extends MultiMeshInstance2D
## Draws the whole bullet field in one draw call.
##
## A bullet hell routinely has a thousand bullets on screen. One Node2D each
## would cost more than the simulation does, so the renderer reads the client's
## [BulletPool] arrays directly and pushes them into a MultiMesh. The sprite is
## generated at runtime, so the project needs no art to run.
const TEXTURE_SIZE := 32
var _colors := {
SimConfig.KIND_PLAYER_SHOT: Color(0.45, 0.95, 1.0),
SimConfig.KIND_ORB: Color(1.0, 0.35, 0.75),
SimConfig.KIND_NEEDLE: Color(1.0, 0.85, 0.3),
SimConfig.KIND_HEAVY: Color(1.0, 0.45, 0.2),
}
func _ready() -> void:
texture = _make_dot_texture()
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
var quad := QuadMesh.new()
quad.size = Vector2.ONE
mm.mesh = quad
mm.instance_count = SimConfig.MAX_BULLETS
mm.visible_instance_count = 0
multimesh = mm
## A soft-edged disc with a bright core, so overlapping bullets stay readable.
func _make_dot_texture() -> ImageTexture:
var img := Image.create(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_RGBA8)
var centre := Vector2(TEXTURE_SIZE, TEXTURE_SIZE) * 0.5
for y in TEXTURE_SIZE:
for x in TEXTURE_SIZE:
var d := Vector2(x + 0.5, y + 0.5).distance_to(centre) / (TEXTURE_SIZE * 0.5)
var alpha := clampf(1.0 - d, 0.0, 1.0)
alpha = pow(alpha, 0.6)
var core := clampf(1.0 - d * 1.9, 0.0, 1.0)
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, alpha).lerp(Color.WHITE, core * 0.8))
return ImageTexture.create_from_image(img)
func render_pool(pool: BulletPool) -> void:
var mm := multimesh
var n := 0
for i in pool.high_water:
if pool.alive[i] == 0:
continue
if n >= SimConfig.MAX_BULLETS:
break
# The texture's visible disc is about 80% of the quad, so scale up a
# little to make the drawn radius match the radius used for hits.
var d: float = pool.radius[i] * 2.5
mm.set_instance_transform_2d(n, Transform2D(0.0, Vector2(d, d), 0.0, pool.pos[i]))
mm.set_instance_color(n, _colors.get(pool.kind[i], Color.WHITE))
n += 1
mm.visible_instance_count = n
+1
View File
@@ -0,0 +1 @@
uid://dmjyy62xxru42
+29
View File
@@ -0,0 +1,29 @@
extends Node2D
## Root of the playable client scene. Wires the view and HUD to whatever client
## runtime [Net] currently has, and survives that runtime being null (during
## connection) or replaced (on reconnect).
@onready var world_view: Node2D = $WorldView
@onready var hud: CanvasLayer = $HUD
var _bound: ClientRuntime = null
func _ready() -> void:
world_view.position = get_viewport_rect().size * 0.5
get_viewport().size_changed.connect(_recentre)
func _recentre() -> void:
world_view.position = get_viewport_rect().size * 0.5
func _process(_delta: float) -> void:
if Net.client != _bound:
_bound = Net.client
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
_bound.local_hit.connect(_on_local_hit)
func _on_local_hit(_damage: int) -> void:
hud.flash_hit()
+1
View File
@@ -0,0 +1 @@
uid://b0xkfsnpm1xe1
+119
View File
@@ -0,0 +1,119 @@
extends Node2D
## Draws everything that is not a bullet. There are only a handful of actors, so
## immediate-mode [method CanvasItem.draw_circle] is cheaper than keeping nodes
## in sync with a replicated world.
##
## The view is strictly read-only: it never advances the simulation and never
## decides anything. Every number it draws came from the server.
const COL_ARENA := Color(0.22, 0.24, 0.34)
const COL_LOCAL := Color(0.5, 1.0, 0.8)
const COL_REMOTE := Color(0.55, 0.75, 1.0)
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
const COL_PORTAL := Color(0.5, 0.9, 1.0)
const ENEMY_COLORS := [
Color(0.95, 0.55, 0.55), # drifter
Color(0.85, 0.7, 0.35), # turret
Color(0.9, 0.4, 0.9), # stalker
Color(0.5, 0.55, 0.6), # dummy
]
@onready var bullets: MultiMeshInstance2D = $Bullets
var client: ClientRuntime = null
func _process(_delta: float) -> void:
client = Net.client
if client != null:
bullets.render_pool(client.world.pool)
queue_redraw()
func _draw() -> void:
_draw_arena()
if client == null:
return
if client.instance_kind == Protocol.InstanceKind.LOBBY:
_draw_portal()
for e in client.enemies():
_draw_enemy(e)
_draw_boss()
for p in client.remote_players():
_draw_remote_player(p)
_draw_local_player()
func _draw_arena() -> void:
var h := SimConfig.ARENA_HALF
draw_rect(Rect2(-h, h * 2.0), COL_ARENA, false, 2.0)
# Faint grid, purely so movement reads against a background.
var step := 80.0
var faint := Color(COL_ARENA, 0.25)
var x := -h.x + step
while x < h.x:
draw_line(Vector2(x, -h.y), Vector2(x, h.y), faint, 1.0)
x += step
var y := -h.y + step
while y < h.y:
draw_line(Vector2(-h.x, y), Vector2(h.x, y), faint, 1.0)
y += step
func _draw_portal() -> void:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
draw_arc(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
draw_circle(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS * 0.25,
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
func _draw_enemy(e: Dictionary) -> void:
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
var r: float = e["radius"]
draw_circle(e["pos"], r, Color(col, 0.35))
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty():
return
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
var pos: Vector2 = b["pos"]
draw_circle(pos, r, Color(0.9, 0.3, 0.4, 0.25))
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5), 3.0)
draw_arc(pos, r + 10.0, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.25), 1.0)
func _draw_remote_player(p: Dictionary) -> void:
var alive := (int(p["flags"]) & Protocol.F_ALIVE) != 0
var col := COL_REMOTE if alive else COL_DEAD
_draw_ship(p["pos"], p["aim"], col, alive)
if (int(p["flags"]) & Protocol.F_ESCAPING) != 0:
_draw_escape_ring(p["pos"], float(p["escape"]), col)
func _draw_local_player() -> void:
if not client.my_alive:
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
return
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
if client.my_escaping:
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void:
var r := SimConfig.PLAYER_RADIUS
draw_circle(pos, r, Color(col, 0.4 if alive else 0.2))
draw_arc(pos, r, 0.0, TAU, 20, col, 2.0)
if alive:
var dir := Vector2.RIGHT.rotated(aim)
draw_line(pos + dir * r, pos + dir * (r + 12.0), col, 2.0)
## The escape channel is drawn on the player, not just in the HUD, so other
## players can see someone is about to leave and react.
func _draw_escape_ring(pos: Vector2, progress: float, col: Color) -> void:
draw_arc(pos, SimConfig.PLAYER_RADIUS + 8.0, -PI * 0.5,
-PI * 0.5 + TAU * progress, 32, Color(col, 0.9), 3.0)
+1
View File
@@ -0,0 +1 @@
uid://dhkx1oxbrhh32