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:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbdqtks6mqgr1
|
||||
Reference in New Issue
Block a user