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

@ -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())