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

@ -66,6 +66,10 @@ func _advance_flight(delta: float) -> void:
func _explode() -> void:
# #214: at the bloom, not at the launch - the throw is silent and the
# arrival is the event, the same way the beam's own noise sits at the
# muzzle rather than at the button.
Sfx.play(get_tree(), Sfx.GAS_BLAST)
exploded = true
explosion_time = EXPLOSION_DURATION
hit_count = 0

View file

@ -22,13 +22,18 @@ const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
signal struck(hit_count: int)
const BEAM_COLOR := Color(0.3, 0.9, 1.0)
# The width an unupgraded emitter cuts, and what the definition declares.
# #204 made it a vessel stat, so the width in force is `half_width` below;
# this is only what a beam fired without one falls back to.
const BEAM_HALF_WIDTH := 38.0
# The DRAWN beam is scaled from the hit width rather than tuned separately.
# The two were independent numbers and had drifted apart: the beam struck
# anything within 22 units of the line while its outer glow spanned about 11,
# so it killed things it visibly missed. NeonDraw's widest layer is 18px at
# scale 1, so this makes the glow span exactly what the beam hits.
const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0
# NeonDraw's widest layer is 18px at scale 1, so dividing by it makes the
# glow span exactly what the beam hits. The DRAWN beam is scaled from the hit
# width rather than tuned separately: the two were once independent numbers
# and had drifted apart, so the beam struck anything within 22 units of the
# line while its outer glow spanned about 11, killing things it visibly
# missed. Kept a ratio rather than a constant now that the width can grow -
# a wider beam has to LOOK wider, or the drift comes back with every level.
const NEON_WIDEST_LAYER := 18.0
# #90: the beam was one flat line that blinked on at full length and faded.
# It now IGNITES - it runs out of the ship over the first slice of its life,
# holds white-hot, and falls away.
@ -40,8 +45,9 @@ const IGNITE_FRACTION := 0.3
# missed" complaint back the other way round.
const CORE_SCALE := 0.42
const CORE_COLOR := Color(0.85, 0.99, 1.0)
const TIP_RADIUS := BEAM_HALF_WIDTH * 0.55
const MUZZLE_RADIUS := BEAM_HALF_WIDTH * 0.8
# Both ends of the beam, as fractions of its own width.
const TIP_FRACTION := 0.55
const MUZZLE_FRACTION := 0.8
# By path, not by class name - see the note at the top of MuzzleSparkVFX.
const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd")
@ -51,11 +57,15 @@ var time: float = FLASH_DURATION
# Per beam, so two lasers firing together do not flicker in lockstep.
var phase: float = 0.0
var hit_count: int = 0
# How far either side of the line this beam strikes, and how wide it is drawn.
var half_width: float = BEAM_HALF_WIDTH
static func fire(world: Node, origin: Vector2, direction: Vector2,
beam_range: float, damage: int) -> LaserBeamEffect:
beam_range: float, damage: int,
beam_half_width: float = BEAM_HALF_WIDTH) -> LaserBeamEffect:
var effect := LaserBeamEffect.new()
effect.half_width = maxf(beam_half_width, 1.0)
var heading: Vector2 = direction.normalized() if direction.length() > 0.001 else Vector2.UP
effect.from = origin
effect.to = origin + heading * beam_range
@ -83,7 +93,7 @@ func _strike(direction: Vector2, damage: int) -> void:
if along < 0.0 or along > range_along:
continue
var perpendicular: float = (to_enemy - direction * along).length()
if perpendicular <= BEAM_HALF_WIDTH:
if perpendicular <= half_width:
enemy.take_damage(damage)
hit_count += 1
struck.emit(hit_count)
@ -121,19 +131,20 @@ func _draw() -> void:
var flicker: float = 1.0 + 0.09 * sin(time * 70.0 + phase)
var at: Vector2 = tip()
var width_scale: float = half_width * 2.0 / NEON_WIDEST_LAYER
NeonDraw.draw_glowing_line(self, from, at, BEAM_COLOR, alpha,
BEAM_WIDTH_SCALE * flicker * (1.0 - decay * 0.45))
width_scale * flicker * (1.0 - decay * 0.45))
# The white-hot rod, over the same span at a fraction of the width. This
# is where the extra light comes from: brighter through the middle, not
# broader across.
NeonDraw.draw_glowing_line(self, from, at, CORE_COLOR, alpha,
BEAM_WIDTH_SCALE * CORE_SCALE * flicker)
width_scale * CORE_SCALE * flicker)
# The leading cap, brightest while the blade is still running out.
var cap: float = TIP_RADIUS * (0.7 + 0.3 * (1.0 - decay)) * flicker
var cap: float = half_width * TIP_FRACTION * (0.7 + 0.3 * (1.0 - decay)) * flicker
NeonDraw.draw_glowing_circle(self, at, cap, CORE_COLOR, alpha)
# And the flare left at the emitter, which fades faster than the beam so
# the hull does not keep glowing after the shot is over.
var muzzle: float = MUZZLE_RADIUS * (1.0 - progress)
var muzzle: float = half_width * MUZZLE_FRACTION * (1.0 - progress)
if muzzle > 1.0:
NeonDraw.draw_glowing_circle(self, from, muzzle, CORE_COLOR,
alpha * (1.0 - progress))