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
148 lines
3.6 KiB
GDScript
148 lines
3.6 KiB
GDScript
extends GutTest
|
|
## Emitters are the authoring surface for every enemy and boss, so their shapes
|
|
## are asserted directly rather than eyeballed in game.
|
|
|
|
var pool: BulletPool
|
|
var ctx: EmitContext
|
|
|
|
|
|
func before_each() -> void:
|
|
pool = BulletPool.new()
|
|
ctx = EmitContext.new()
|
|
ctx.pool = pool
|
|
ctx.origin = Vector2.ZERO
|
|
ctx.rng = RandomNumberGenerator.new()
|
|
ctx.rng.seed = 1
|
|
|
|
|
|
func _angles() -> Array[float]:
|
|
var out: Array[float] = []
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 1:
|
|
out.append(pool.vel[i].angle())
|
|
return out
|
|
|
|
|
|
func test_ring_emits_the_requested_count() -> void:
|
|
var e := RingEmitter.new()
|
|
e.count = 8
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 8)
|
|
|
|
|
|
func test_closed_ring_is_evenly_spaced_with_no_duplicate_at_the_seam() -> void:
|
|
var e := RingEmitter.new()
|
|
e.count = 8
|
|
e.arc_deg = 360.0
|
|
e.spin_per_shot_deg = 0.0
|
|
e.fire(ctx)
|
|
var angles := _angles()
|
|
angles.sort()
|
|
for i in range(1, angles.size()):
|
|
assert_almost_eq(angles[i] - angles[i - 1], TAU / 8.0, 0.001)
|
|
|
|
|
|
func test_spin_advances_with_shot_index() -> void:
|
|
var e := RingEmitter.new()
|
|
e.count = 1
|
|
e.spin_per_shot_deg = 30.0
|
|
e.fire(ctx)
|
|
var first := _angles()[0]
|
|
pool.clear()
|
|
ctx.shot_index = 1
|
|
e.fire(ctx)
|
|
assert_almost_eq(_angles()[0] - first, deg_to_rad(30.0), 0.001)
|
|
|
|
|
|
func test_should_fire_respects_interval_and_window() -> void:
|
|
var e := RingEmitter.new()
|
|
e.start_tick = 10
|
|
e.end_tick = 40
|
|
e.interval = 10
|
|
assert_false(e.should_fire(9), "not armed before start_tick")
|
|
assert_true(e.should_fire(10))
|
|
assert_false(e.should_fire(15))
|
|
assert_true(e.should_fire(30))
|
|
assert_false(e.should_fire(50), "not armed after end_tick")
|
|
|
|
|
|
func test_shot_index_matches_the_number_of_shots_so_far() -> void:
|
|
var e := RingEmitter.new()
|
|
e.start_tick = 10
|
|
e.interval = 10
|
|
assert_eq(e.shot_index_at(10), 0)
|
|
assert_eq(e.shot_index_at(30), 2)
|
|
|
|
|
|
func test_aimed_spread_centres_on_the_target() -> void:
|
|
var e := AimedSpreadEmitter.new()
|
|
e.count = 3
|
|
e.spread_deg = 30.0
|
|
ctx.has_target = true
|
|
ctx.target = Vector2(100.0, 0.0)
|
|
e.fire(ctx)
|
|
var angles := _angles()
|
|
angles.sort()
|
|
assert_eq(angles.size(), 3)
|
|
assert_almost_eq(angles[1], 0.0, 0.001, "the middle shot must point at the target")
|
|
assert_almost_eq(angles[2] - angles[0], deg_to_rad(30.0), 0.001)
|
|
|
|
|
|
func test_aimed_spread_falls_back_when_nobody_is_alive() -> void:
|
|
var e := AimedSpreadEmitter.new()
|
|
e.count = 1
|
|
ctx.has_target = false
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 1, "an emitter with no target must still be safe to fire")
|
|
|
|
|
|
func test_wall_leaves_a_gap_of_the_requested_width() -> void:
|
|
var e := WallGapEmitter.new()
|
|
e.count = 20
|
|
e.gap_width = 3
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 17)
|
|
|
|
|
|
func test_wall_gap_slides_between_shots() -> void:
|
|
var e := WallGapEmitter.new()
|
|
e.count = 20
|
|
e.gap_width = 3
|
|
e.gap_step = 5
|
|
e.fire(ctx)
|
|
var first_xs: Array = []
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 1:
|
|
first_xs.append(snappedf(pool.pos[i].x, 0.01))
|
|
pool.clear()
|
|
ctx.shot_index = 1
|
|
e.fire(ctx)
|
|
var second_xs: Array = []
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 1:
|
|
second_xs.append(snappedf(pool.pos[i].x, 0.01))
|
|
assert_ne(first_xs, second_xs, "the safe lane must move so it cannot be camped")
|
|
|
|
|
|
func test_arc_sweep_emits_every_arm() -> void:
|
|
var e := ArcSweepEmitter.new()
|
|
e.arms = 3
|
|
e.bullets_per_arm = 4
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 12)
|
|
|
|
|
|
func test_arc_sweep_angle_changes_over_the_phase() -> void:
|
|
var e := ArcSweepEmitter.new()
|
|
e.arms = 1
|
|
e.bullets_per_arm = 1
|
|
e.sweep_period = 4.0
|
|
e.sweep_deg = 90.0
|
|
ctx.local_tick = 0
|
|
e.fire(ctx)
|
|
var a0 := _angles()[0]
|
|
pool.clear()
|
|
ctx.local_tick = 30
|
|
e.fire(ctx)
|
|
assert_ne(snappedf(a0, 0.001), snappedf(_angles()[0], 0.001))
|