e1f9fa8096
ci / verify (push) Successful in 45s
A failed connection left the connect buttons disabled forever. _show_menu() returned early when a menu already existed, so the set_busy(false) that re-enables them never ran on the way back from _abort_connect -- the player was locked out of both joining and hosting with no way out but a restart. Also guards against a second attempt stacking on an in-flight one. Contact damage is gone as a concept: nothing in this game hurts you by touching it, because every threat should be a bullet you can see and dodge. The Stalker walked at you and dealt contact damage and nothing else, so it now carries a point-blank shotgun instead -- five pellets, 62 degrees, 18-tick lifetime for about 78px of reach, so it still has to close and leaves nothing lingering. tests/unit/test_content.gd asserts every hostile enemy has an emitter, so a new enemy cannot quietly reintroduce the mechanic. The menu's "Disconnect" DOES get the anti-cheese protection -- verified, not assumed. It calls Net.shutdown(), the socket closes, and the server takes the same linkdead path as a SIGKILL, because the channel is keyed on the socket closing rather than on how it closed. Made permanent: a --leave-after bot flag and two smoke assertions covering the polite exit alongside the hard kill, so a future "clean leave" message that skipped the channel would fail a test. The menu now says so out loud in a dungeon -- the mechanic only reads as fair if the cost is known before clicking. 103 tests, 12 smoke assertions; check.sh, test.sh and smoke.sh all pass.
319 lines
7.9 KiB
GDScript
319 lines
7.9 KiB
GDScript
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
|