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.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.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 fires a point-blank burst. The pressure enemy. ## ## It used to deal contact damage and nothing else. Nothing in this game hurts ## you by touching you -- every threat is a bullet you can see coming and dodge, ## which is the whole contract of the genre. So the Stalker's melee is a ## shotgun with a deliberately tiny lifetime: ~78px of travel, which only ## threatens once it has closed the distance, and leaves nothing lingering in ## the arena afterwards. 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.move = EnemyDef.Move.APPROACH d.speed = 95.0 d.retarget_interval = 20 d.visual = 2 d.pattern_loop_ticks = 45 var lunge := AimedSpreadEmitter.new() lunge.start_tick = 0 lunge.interval = 45 lunge.count = 5 lunge.spread_deg = 62.0 lunge.speed = 260.0 lunge.radius = 6.0 lunge.damage = 14 # 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to- # player distance at any range you would call "not point blank". lunge.lifetime = 18 lunge.muzzle_offset = 10.0 lunge.kind = SimConfig.KIND_HEAVY d.emitters = [lunge] 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.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