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
+147
View File
@@ -0,0 +1,147 @@
class_name Instance
extends RefCounted
## One live world on the server: the shared lobby hub, or one dungeon run for a
## party. Instances are cheap -- a [SimWorld] and a peer list -- so a single
## server process hosts the hub plus every concurrent dungeon.
enum State {
## Dungeon is open for players to join from the hub.
FORMING,
## Fight in progress.
ACTIVE,
## Boss dead; players are being returned to the hub.
CLEARED,
}
var id: int = 0
var kind: Protocol.InstanceKind = Protocol.InstanceKind.LOBBY
var world: SimWorld
var peers: Array[int] = []
var state: State = State.ACTIVE
var age: int = 0
var seed_value: int = 0
## Dungeon progression. -1 is the pre-fight breather.
var stage: int = -1
var stage_delay: int = 0
var boss_id: StringName = &""
static func make_lobby(instance_id: int) -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.LOBBY
inst.seed_value = 1
inst.world = SimWorld.new(inst.seed_value)
inst.world.portal_enabled = true
inst.world.spawn_point = Vector2(0.0, 120.0)
inst.state = State.ACTIVE
# A single inert dummy so players can feel out the gun before committing to
# a run. It has no emitters and no contact damage.
inst.world.spawn_enemy(Content.dummy(), Vector2(-220.0, -40.0))
return inst
static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.DUNGEON
inst.seed_value = dungeon_seed
inst.world = SimWorld.new(dungeon_seed)
inst.world.spawn_point = Vector2(0.0, 260.0)
inst.boss_id = Content.BOSS_WARDEN
inst.state = State.FORMING
if GameOpts.boss_rush:
# Dev switch: the next stage advance lands on the boss.
inst.stage = 1
return inst
func add_peer(peer_id: int, display_name: String) -> void:
if not peers.has(peer_id):
peers.append(peer_id)
world.add_player(peer_id, display_name)
func remove_peer(peer_id: int) -> void:
peers.erase(peer_id)
world.remove_player(peer_id)
func is_empty() -> bool:
return peers.is_empty()
func accepts_new_party_member() -> bool:
return kind == Protocol.InstanceKind.DUNGEON \
and state == State.FORMING \
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
func step() -> void:
age += 1
world.step()
if kind == Protocol.InstanceKind.DUNGEON:
_step_dungeon()
## Straight-line progression: two trash waves, then the boss. Waves gate on
## "everything dead" rather than a timer so a slow party is never overrun.
func _step_dungeon() -> void:
# The delay has to tick down before the CLEARED check, or a cleared dungeon
# would sit on its exit timer forever and never release its party.
if stage_delay > 0:
stage_delay -= 1
return
if state == State.CLEARED:
return
if state == State.FORMING:
if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX:
state = State.ACTIVE
# stage is left as make_dungeon set it -- resetting it here would
# silently undo the --boss-rush dev switch.
stage_delay = 90
return
if stage >= 0 and _live_enemy_count() > 0:
return
if stage == 2:
if world.boss != null and world.boss.alive:
return
state = State.CLEARED
stage_delay = 300
return
stage += 1
stage_delay = 60
match stage:
0: _spawn_wave_one()
1: _spawn_wave_two()
2:
world.spawn_boss(Content.boss(boss_id))
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
GameLog.debug("instance", "instance %d entered stage %d" % [id, stage])
func _live_enemy_count() -> int:
var n := 0
for e in world.enemies.values():
if e.alive:
n += 1
return n
func _spawn_wave_one() -> void:
for i in 3:
world.spawn_enemy(Content.drifter(), Vector2(-320.0 + 320.0 * float(i), -120.0), i * 40)
world.spawn_enemy(Content.turret(), Vector2(-420.0, -220.0), 0)
world.spawn_enemy(Content.turret(), Vector2(420.0, -220.0), 75)
func _spawn_wave_two() -> void:
for i in 4:
world.spawn_enemy(Content.stalker(), Vector2(-300.0 + 200.0 * float(i), -260.0), i * 15)
for i in 3:
world.spawn_enemy(Content.turret(), Vector2(-380.0 + 380.0 * float(i), -60.0), i * 50)
world.spawn_enemy(Content.drifter(), Vector2(0.0, -300.0), 20)
+1
View File
@@ -0,0 +1 @@
uid://bbdqtks6mqgr1