c4beeae38f
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
84 lines
2.5 KiB
GDScript
84 lines
2.5 KiB
GDScript
extends SceneTree
|
|
## Headless performance probe for the simulation alone -- no transport, no
|
|
## rendering. Answers the only question that decides whether the design holds:
|
|
## how many ticks per second can one dedicated server sustain per instance, and
|
|
## how big does the bullet field get?
|
|
##
|
|
## godot --headless --path . --script tools/bench.gd
|
|
## godot --headless --path . --script tools/bench.gd -- --players 4 --ticks 5400
|
|
|
|
var players: int = 4
|
|
var ticks: int = 3600
|
|
|
|
|
|
func _init() -> void:
|
|
_parse()
|
|
print("bench: %d players, %d ticks (%.1f s of play)"
|
|
% [players, ticks, float(ticks) / float(SimConfig.TICK_RATE)])
|
|
_run("boss fight", _make_boss_world())
|
|
_run("trash wave", _make_wave_world())
|
|
quit(0)
|
|
|
|
|
|
func _parse() -> void:
|
|
var argv := OS.get_cmdline_user_args()
|
|
var i := 0
|
|
while i < argv.size():
|
|
match argv[i]:
|
|
"--players":
|
|
i += 1
|
|
players = int(argv[i])
|
|
"--ticks":
|
|
i += 1
|
|
ticks = int(argv[i])
|
|
i += 1
|
|
|
|
|
|
func _add_players(world: SimWorld) -> void:
|
|
for n in players:
|
|
var p := world.add_player(n + 2, "bench%d" % n)
|
|
p.pos = Vector2(-200.0 + 100.0 * float(n), 180.0)
|
|
|
|
|
|
func _make_boss_world() -> SimWorld:
|
|
var world := SimWorld.new(1234)
|
|
_add_players(world)
|
|
world.spawn_boss(Content.warden())
|
|
return world
|
|
|
|
|
|
func _make_wave_world() -> SimWorld:
|
|
var world := SimWorld.new(4321)
|
|
_add_players(world)
|
|
for i in 6:
|
|
world.spawn_enemy(Content.turret(), Vector2(-400.0 + 160.0 * float(i), -220.0), i * 20)
|
|
for i in 4:
|
|
world.spawn_enemy(Content.drifter(), Vector2(-240.0 + 160.0 * float(i), -80.0), i * 25)
|
|
return world
|
|
|
|
|
|
func _run(label: String, world: SimWorld) -> void:
|
|
var peak := 0
|
|
var total := 0
|
|
var start := Time.get_ticks_usec()
|
|
for t in ticks:
|
|
for peer in world.players:
|
|
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1,
|
|
Vector2(cos(float(t) * 0.02), sin(float(t) * 0.031)),
|
|
float(t) * 0.05, InputFrame.BTN_FIRE)]
|
|
world.queue_input(peer, frames)
|
|
world.step()
|
|
world.drain_events()
|
|
if world.boss != null:
|
|
# Sweep hp downwards instead of letting the boss die, so the run
|
|
# covers every phase including the heaviest one.
|
|
world.boss.hp = int(float(world.boss.def.max_hp)
|
|
* (0.95 - 0.9 * float(t) / float(ticks)))
|
|
peak = maxi(peak, world.pool.live_count)
|
|
total += world.pool.live_count
|
|
var elapsed := float(Time.get_ticks_usec() - start) / 1_000_000.0
|
|
var per_tick_ms := elapsed / float(ticks) * 1000.0
|
|
print(" %-12s %7.3f ms/tick peak %4d bullets avg %4d headroom x%.1f"
|
|
% [label, per_tick_ms, peak, total / ticks,
|
|
(1000.0 / float(SimConfig.TICK_RATE)) / maxf(per_tick_ms, 0.0001)])
|