Sharpen upgrades, mark the convoy's destination, and give the world sound

#204 the Beam Collimator widens the beam instead of adding range to one that
already crosses the screen; beam width becomes a per-shot vessel stat.
#205 the Energy Propulser gains three upgrades - dash recharge, dash reach,
and boosted-cruise drain - which turns three constants into vessel stats.
#206 three further doctrines carry the fleet from ten to twenty.
#207 the prologue's World Ship gets a marker, through the scanner's own
guidance arrow rather than a second one.
#208 #210 #213 #214 four new synthesised sounds: the breach opening, cargo
reaching the hold, a rock breaking, and a gas bomb blooming.
#215 building health bars are larger, plated and framed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-05 12:16:17 +02:00
commit 78836882af
33 changed files with 716 additions and 52 deletions

View file

@ -28,6 +28,11 @@ const ENEMY_DEATH: String = "enemy_death"
const BUILDING_DEATH: String = "building_death"
const DIALOG: String = "dialog"
const BOSS_SCREAM: String = "boss_scream"
# #210, #213, #214, #208.
const PICKUP: String = "pickup"
const ASTEROID_BREAK: String = "asteroid_break"
const GAS_BLAST: String = "gas_blast"
const BLACK_HOLE: String = "black_hole"
const SOUNDS: Dictionary = {
SHOT: "res://audio/sfx_shot.wav",
@ -36,6 +41,10 @@ const SOUNDS: Dictionary = {
BUILDING_DEATH: "res://audio/sfx_building_death.wav",
DIALOG: "res://audio/sfx_dialog.wav",
BOSS_SCREAM: "res://audio/sfx_boss_scream.wav",
PICKUP: "res://audio/sfx_pickup.wav",
ASTEROID_BREAK: "res://audio/sfx_asteroid_break.wav",
GAS_BLAST: "res://audio/sfx_gas_blast.wav",
BLACK_HOLE: "res://audio/sfx_black_hole.wav",
}
# Per-sound level. A shot is fired constantly and a death happens once, so
@ -47,12 +56,29 @@ const LEVELS: Dictionary = {
BUILDING_DEATH: -23.0,
DIALOG: -19.0,
BOSS_SCREAM: -13.0,
# Quieter than everything else by a clear margin: it is the only sound
# here that can happen several times a second for minutes at a time, and
# a pickup blip at shot volume would be the loudest thing in the game.
PICKUP: -32.0,
ASTEROID_BREAK: -24.0,
GAS_BLAST: -21.0,
# The prologue's one irreversible moment, and nothing else is playing
# under it.
BLACK_HOLE: -11.0,
}
# Shots vary a little in pitch so a stream of them does not turn into one
# flat machine-gun note. The deaths do not: they are events, and a wobbling
# explosion sounds broken rather than varied.
const SHOT_PITCH_SPREAD: float = 0.12
# Pickups repeat harder than shots do, so they vary too - but less, because a
# pickup is a short bright tone and a wide spread on one reads as wrong notes
# rather than as variety. Rocks also break in twos and threes.
const PITCH_SPREAD: Dictionary = {
SHOT: SHOT_PITCH_SPREAD,
PICKUP: 0.07,
ASTEROID_BREAK: 0.09,
}
static func _pool(tree: SceneTree) -> Node:
@ -99,7 +125,6 @@ static func play(tree: SceneTree, id: String) -> void:
return
voice.stream = stream
voice.volume_db = float(LEVELS.get(id, -10.0))
voice.pitch_scale = 1.0
if id == SHOT:
voice.pitch_scale = 1.0 + randf_range(-SHOT_PITCH_SPREAD, SHOT_PITCH_SPREAD)
var spread: float = float(PITCH_SPREAD.get(id, 0.0))
voice.pitch_scale = 1.0 if spread <= 0.0 else 1.0 + randf_range(-spread, spread)
voice.play()

View file

@ -183,6 +183,139 @@ def explosion(seconds, sub_from, sub_to, roar_seed, debris_level, drive):
return [_saturate(v, drive) * _attack(i, 0.001) for i, v in enumerate(mixed)]
def pickup(seconds=0.10):
"""A unit of cargo arriving in the hold (#210).
This one fires more often than everything else in the file put together
- every rock a drill turns out, every load a transporter lands - so it
is built to be missable. Short, quiet, and NARROW: two clean partials a
fifth apart with a couple of milliseconds of tick in front. No noise bed
and no sub, because those are what make a repeated sound tiring."""
n = int(RATE * seconds)
tick = [value * _decay(i, n, 22.0) * 0.30
for i, value in enumerate(_noise(n, 61, lambda t: 0.9 - 0.4 * t))]
tone = [0.0] * n
for hz, level in ((1180.0, 0.55), (1770.0, 0.22)):
phase = 0.0
for i in range(n):
phase += 2.0 * math.pi * hz / RATE
# Rings up and straight back down: an arrival, not a note held.
tone[i] += math.sin(phase) * level * _decay(i, n, 3.4)
mixed = _mix(tick, tone)
return [_saturate(value, 1.5) * _attack(i, 0.0012)
for i, value in enumerate(mixed)]
def asteroid_break(seconds=0.62):
"""A rock coming apart (#213).
Deliberately NOT the ship explosion with different numbers. Stone has no
fuel in it: there is a hard dry crack, a short gravel roar, and a fall of
fragments, with only enough low end to give it mass. The sub that makes a
vessel's death read as big is exactly what would make this read as an
explosion rather than a rock."""
n = int(RATE * seconds)
crack_n = int(n * 0.14)
crack = [value * _decay(i, crack_n, 6.5) * 0.95
for i, value in enumerate(_noise(crack_n, 71, lambda t: 1.0 - 0.35 * t))]
crack += [0.0] * (n - crack_n)
# Higher and drier than an explosion's roar - grit, not fire.
gravel = [value * _decay(i, n, 2.4) * 0.6
for i, value in enumerate(_noise(n, 73, lambda t: 0.55 * (1.0 - t) ** 1.1 + 0.05))]
thud = [value * _decay(i, n, 2.6) * 0.45
for i, value in enumerate(_sweep(n, 190.0, 70.0, 0.6))]
# Fragments, denser and shorter than an explosion's debris, and pitched
# up where small hard things live.
rng = random.Random(77)
shards = [0.0] * n
for _ in range(int(seconds * 90)):
at = rng.randint(int(n * 0.10), n - 1)
length = rng.randint(120, 620)
pitch = rng.uniform(420.0, 2100.0)
for k in range(min(length, n - at)):
shards[at + k] += (math.sin(2.0 * math.pi * pitch * k / RATE)
* (1.0 - k / length) ** 3.5
* 0.16 * rng.uniform(0.4, 1.0))
mixed = _mix(crack, gravel, thud, shards)
return [_saturate(value, 2.4) * _attack(i, 0.001)
for i, value in enumerate(mixed)]
def gas_blast(seconds=1.05):
"""A gas bomb blooming (#214).
The opposite shape to a hull exploding: almost no crack, a soft low
whoomph that swells rather than starting at full volume, and a long
hiss that outlives it as the cloud spreads. The swell is the point - it
is the sound of an area filling, and it matches a bloom the player
watches expand rather than a flash they see once."""
n = int(RATE * seconds)
# Rises over the first fifth, then falls away: a bloom, not a bang.
def bloom(i):
t = i / n
return min(1.0, t / 0.18) * (1.0 - t) ** 1.3
whoomph = [value * bloom(i) * 1.1
for i, value in enumerate(_sweep(n, 150.0, 46.0, 0.5))]
body = [value * bloom(i) * 0.85
for i, value in enumerate(_noise(n, 83, lambda t: 0.22 * (1.0 - t) + 0.02))]
# The cloud itself: bright, thin, and still going after the low end has
# gone. Cut off far later than the rest, which is what "it is still out
# there" sounds like.
hiss = [value * min(1.0, i / (RATE * 0.05)) * _decay(i, n, 1.05) * 0.42
for i, value in enumerate(_noise(n, 89, lambda t: 0.75 - 0.25 * t))]
mixed = _mix(whoomph, body, hiss)
return [_saturate(value, 2.2) * _attack(i, 0.004)
for i, value in enumerate(mixed)]
def black_hole(seconds=2.6):
"""The vortex opening in the prologue (#208).
Something tearing rather than detonating. A pair of tones sliding in
OPPOSITE directions - one climbing to a thin scream, one falling into
the sub - so the sound pulls apart in the middle, plus a rising roar
that swallows both. It has to hold under the beat's shake, so it swells
across most of its length instead of decaying from the first sample."""
n = int(RATE * seconds)
def swell(i, peak=0.72, power=1.6):
t = i / n
if t <= peak:
return (t / peak) ** 1.25
return ((1.0 - t) / (1.0 - peak)) ** power
# Up into a scream, and down into the floor. The gap between them opening
# is the whole idea.
tearing_up = [value * swell(i) * 0.34
for i, value in enumerate(_sweep(n, 240.0, 1650.0, 1.35))]
tearing_down = [value * swell(i) * 0.95
for i, value in enumerate(_sweep(n, 190.0, 28.0, 0.85))]
# The roar closes over the top of them, brightening as it goes.
roar = [value * swell(i, 0.8, 1.3) * 0.8
for i, value in enumerate(_noise(n, 101, lambda t: 0.05 + 0.5 * t ** 1.5))]
# A slow beat in the low end, so it churns instead of humming.
churn = [0.0] * n
for i in range(n):
churn[i] = math.sin(2.0 * math.pi * 37.0 * i / RATE) \
* math.sin(2.0 * math.pi * 4.3 * i / RATE) * swell(i) * 0.5
mixed = _mix(tearing_up, tearing_down, roar, churn)
return [_saturate(value, 2.8) * _attack(i, 0.02)
for i, value in enumerate(mixed)]
def boss_scream(seconds=1.65):
"""A huge synthetic creature waking: a rough falling throat tone with
an unstable upper rasp. Long enough to carry the reveal shake, without
@ -213,3 +346,7 @@ if __name__ == "__main__":
_write("sfx_ship_death", explosion(1.45, 260.0, 38.0, 5, 0.16, 2.6))
# A structure: longer, deeper, and it keeps coming down afterwards.
_write("sfx_building_death", explosion(2.3, 150.0, 24.0, 9, 0.24, 3.1))
_write("sfx_pickup", pickup())
_write("sfx_asteroid_break", asteroid_break())
_write("sfx_gas_blast", gas_blast())
_write("sfx_black_hole", black_hole())

Binary file not shown.

BIN
audio/sfx_black_hole.wav Normal file

Binary file not shown.

BIN
audio/sfx_gas_blast.wav Normal file

Binary file not shown.

BIN
audio/sfx_pickup.wav Normal file

Binary file not shown.