Files
claude c4beeae38f 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
2026-09-03 16:03:57 +02:00

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