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