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:
2026-09-03 16:03:57 +02:00
commit c4beeae38f
385 changed files with 28725 additions and 0 deletions
+47
View File
@@ -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))