Files
transcience/src/content/content.gd
T
claude e1f9fa8096
ci / verify (push) Successful in 45s
Unlock menu after failed join; remove contact damage; cover clean disconnects
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.
2026-09-03 19:37:26 +02:00

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