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,299 @@
|
||||
class_name Content
|
||||
extends RefCounted
|
||||
## All game content, built in code.
|
||||
##
|
||||
## Content is GDScript rather than .tres because it is the shape that reviews
|
||||
## well: a boss is a readable diff, there are no resource UIDs to churn, and a
|
||||
## test can build one without touching the filesystem. `tools/export_content.gd`
|
||||
## writes .tres copies into res://resources/ for anyone who prefers to tune
|
||||
## numbers in the editor inspector -- but this file stays the source of truth.
|
||||
|
||||
const ENEMY_DRIFTER := &"drifter"
|
||||
const ENEMY_TURRET := &"turret"
|
||||
const ENEMY_STALKER := &"stalker"
|
||||
const ENEMY_DUMMY := &"dummy"
|
||||
const BOSS_WARDEN := &"warden"
|
||||
|
||||
|
||||
static func enemy(id: StringName) -> EnemyDef:
|
||||
match id:
|
||||
ENEMY_DRIFTER: return drifter()
|
||||
ENEMY_TURRET: return turret()
|
||||
ENEMY_STALKER: return stalker()
|
||||
ENEMY_DUMMY: return dummy()
|
||||
push_error("unknown enemy id: %s" % id)
|
||||
return drifter()
|
||||
|
||||
|
||||
static func boss(id: StringName) -> BossDef:
|
||||
match id:
|
||||
BOSS_WARDEN: return warden()
|
||||
push_error("unknown boss id: %s" % id)
|
||||
return warden()
|
||||
|
||||
|
||||
# --- Enemies ----------------------------------------------------------------
|
||||
|
||||
## Reads as: floats across the arena, lobs a slow three-shot fan at you.
|
||||
static func drifter() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DRIFTER
|
||||
d.display_name = "Drifter"
|
||||
d.max_hp = 40
|
||||
d.radius = 14.0
|
||||
d.contact_damage = 8
|
||||
d.move = EnemyDef.Move.DRIFT
|
||||
d.speed = 55.0
|
||||
d.visual = 0
|
||||
d.pattern_loop_ticks = 120
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
fan.start_tick = 0
|
||||
fan.interval = 120
|
||||
fan.count = 3
|
||||
fan.spread_deg = 20.0
|
||||
fan.speed = 150.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 10
|
||||
fan.lifetime = 240
|
||||
fan.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [fan]
|
||||
return d
|
||||
|
||||
|
||||
## Never moves, fires a wide ring on a slow beat. Punishes crowding.
|
||||
static func turret() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_TURRET
|
||||
d.display_name = "Turret"
|
||||
d.max_hp = 70
|
||||
d.radius = 16.0
|
||||
d.contact_damage = 0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.speed = 0.0
|
||||
d.visual = 1
|
||||
d.pattern_loop_ticks = 150
|
||||
|
||||
var ring := RingEmitter.new()
|
||||
ring.start_tick = 0
|
||||
ring.interval = 150
|
||||
ring.count = 10
|
||||
ring.spin_per_shot_deg = 18.0
|
||||
ring.speed = 130.0
|
||||
ring.radius = 7.0
|
||||
ring.damage = 12
|
||||
ring.lifetime = 300
|
||||
ring.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [ring]
|
||||
return d
|
||||
|
||||
|
||||
## Walks at you and does nothing else. The pressure enemy.
|
||||
static func stalker() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_STALKER
|
||||
d.display_name = "Stalker"
|
||||
d.max_hp = 30
|
||||
d.radius = 12.0
|
||||
d.contact_damage = 16
|
||||
d.move = EnemyDef.Move.APPROACH
|
||||
d.speed = 95.0
|
||||
d.retarget_interval = 20
|
||||
d.visual = 2
|
||||
return d
|
||||
|
||||
|
||||
## Lobby target dummy: inert, tough, so players can feel out the gun.
|
||||
static func dummy() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DUMMY
|
||||
d.display_name = "Target Dummy"
|
||||
d.max_hp = 100000
|
||||
d.radius = 20.0
|
||||
d.contact_damage = 0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.visual = 3
|
||||
return d
|
||||
|
||||
|
||||
# --- Boss -------------------------------------------------------------------
|
||||
|
||||
## The Warden of the Fold: stationary, four phases, each layering one more idea
|
||||
## on the last. Adding a second boss means writing another function like this
|
||||
## one -- the simulation has no per-boss branches.
|
||||
static func warden() -> BossDef:
|
||||
var b := BossDef.new()
|
||||
b.id = BOSS_WARDEN
|
||||
b.display_name = "Warden of the Fold"
|
||||
b.max_hp = 3600
|
||||
b.radius = 42.0
|
||||
b.stationary = true
|
||||
b.spawn_pos = Vector2(0.0, -150.0)
|
||||
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
|
||||
return b
|
||||
|
||||
|
||||
## Phase 1 -- one idea at a time: a lazily spinning ring you walk around, with
|
||||
## an aimed fan that stops you from standing still.
|
||||
static func _warden_p1() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Opening Fold"
|
||||
p.enter_at_hp_fraction = 1.0
|
||||
p.loop_ticks = 480
|
||||
p.telegraph_ticks = 60
|
||||
|
||||
var ring := RingEmitter.new()
|
||||
ring.interval = 45
|
||||
ring.count = 14
|
||||
ring.spin_per_shot_deg = 9.0
|
||||
ring.speed = 135.0
|
||||
ring.radius = 8.0
|
||||
ring.damage = 14
|
||||
ring.lifetime = 420
|
||||
ring.muzzle_offset = 46.0
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
fan.start_tick = 120
|
||||
fan.interval = 150
|
||||
fan.count = 5
|
||||
fan.spread_deg = 26.0
|
||||
fan.speed = 210.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 46.0
|
||||
|
||||
p.emitters = [ring, fan]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 2 -- adds a moving safe lane: the spiral fills space while a wall with
|
||||
## a sliding gap forces a committed dodge.
|
||||
static func _warden_p2() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Sliding Lattice"
|
||||
p.enter_at_hp_fraction = 0.72
|
||||
p.loop_ticks = 600
|
||||
p.telegraph_ticks = 50
|
||||
|
||||
var spiral := RingEmitter.new()
|
||||
spiral.interval = 9
|
||||
spiral.count = 4
|
||||
spiral.spin_per_shot_deg = 23.0
|
||||
spiral.speed = 120.0
|
||||
spiral.radius = 7.0
|
||||
spiral.damage = 12
|
||||
spiral.lifetime = 480
|
||||
spiral.muzzle_offset = 46.0
|
||||
|
||||
var wall := WallGapEmitter.new()
|
||||
wall.start_tick = 90
|
||||
wall.interval = 170
|
||||
wall.direction = 0
|
||||
wall.count = 20
|
||||
wall.gap_width = 3
|
||||
wall.gap_step = 7
|
||||
wall.speed = 175.0
|
||||
wall.radius = 8.0
|
||||
wall.damage = 16
|
||||
wall.lifetime = 300
|
||||
wall.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
p.emitters = [spiral, wall]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 3 -- adds rotation you have to track: two sweeping arms that reverse,
|
||||
## plus jittered aimed fire so the safe pocket is never exactly where it was.
|
||||
static func _warden_p3() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Reaping Arms"
|
||||
p.enter_at_hp_fraction = 0.42
|
||||
p.loop_ticks = 720
|
||||
p.telegraph_ticks = 45
|
||||
p.damage_taken_mult = 1.15
|
||||
|
||||
var arms := ArcSweepEmitter.new()
|
||||
arms.interval = 5
|
||||
arms.arms = 3
|
||||
arms.bullets_per_arm = 2
|
||||
arms.arm_spacing = 26.0
|
||||
arms.sweep_deg = 95.0
|
||||
arms.sweep_period = 5.0
|
||||
arms.speed = 165.0
|
||||
arms.radius = 7.0
|
||||
arms.damage = 13
|
||||
arms.lifetime = 400
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
fan.start_tick = 60
|
||||
fan.interval = 110
|
||||
fan.count = 7
|
||||
fan.spread_deg = 40.0
|
||||
fan.jitter_deg = 3.0
|
||||
fan.speed = 195.0
|
||||
fan.edge_speed_bonus = 0.25
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
var counter_ring := RingEmitter.new()
|
||||
counter_ring.start_tick = 240
|
||||
counter_ring.end_tick = 600
|
||||
counter_ring.interval = 60
|
||||
counter_ring.count = 18
|
||||
counter_ring.spin_per_shot_deg = -14.0
|
||||
counter_ring.speed = 105.0
|
||||
counter_ring.radius = 7.0
|
||||
counter_ring.damage = 12
|
||||
counter_ring.lifetime = 420
|
||||
|
||||
p.emitters = [arms, fan, counter_ring]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 4 -- everything at once, plus curving bullets that close the pockets
|
||||
## the earlier phases taught you to use.
|
||||
static func _warden_p4() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Collapse"
|
||||
p.enter_at_hp_fraction = 0.16
|
||||
p.loop_ticks = 420
|
||||
p.telegraph_ticks = 60
|
||||
p.damage_taken_mult = 1.3
|
||||
|
||||
var curve := RingEmitter.new()
|
||||
curve.interval = 11
|
||||
curve.count = 5
|
||||
curve.spin_per_shot_deg = 31.0
|
||||
curve.speed = 130.0
|
||||
curve.turn_deg = 0.55
|
||||
curve.radius = 7.0
|
||||
curve.damage = 15
|
||||
curve.lifetime = 400
|
||||
curve.muzzle_offset = 46.0
|
||||
|
||||
var walls := WallGapEmitter.new()
|
||||
walls.start_tick = 40
|
||||
walls.interval = 130
|
||||
walls.direction = 2
|
||||
walls.count = 16
|
||||
walls.gap_width = 3
|
||||
walls.gap_step = 5
|
||||
walls.speed = 200.0
|
||||
walls.radius = 8.0
|
||||
walls.damage = 18
|
||||
walls.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
var snipe := AimedSpreadEmitter.new()
|
||||
snipe.start_tick = 100
|
||||
snipe.interval = 80
|
||||
snipe.count = 3
|
||||
snipe.spread_deg = 10.0
|
||||
snipe.speed = 300.0
|
||||
snipe.radius = 5.0
|
||||
snipe.damage = 16
|
||||
snipe.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
p.emitters = [curve, walls, snipe]
|
||||
return p
|
||||
Reference in New Issue
Block a user