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,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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd7p18su7nbw2
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3p045w4nf6jo
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://c27tahmytk5vj
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cav5jlkxsy6qm
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5664w2onh7wx
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://igr031g85v5q
|
||||
@@ -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)]
|
||||
@@ -0,0 +1 @@
|
||||
uid://beq5lnuth5prk
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbax6i5kwsr5w
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqs0ihl1uh8b0
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://qjogi5l7e1ab
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://o5amgfbx7fkq
|
||||
Reference in New Issue
Block a user