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:
@@ -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
|
||||
Reference in New Issue
Block a user