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.

View file

@ -17,11 +17,22 @@ var building_type: String:
# #99: drawn only once the structure has actually been hit. A full bar over
# every building would be permanent clutter saying nothing, and the fleet
# would be flying over a field of meters in peacetime.
const HEALTH_BAR_WIDTH: float = 74.0
const HEALTH_BAR_HEIGHT: float = 7.0
const HEALTH_BAR_OFFSET: float = -78.0
const HEALTH_FULL_COLOR: Color = Color(0.45, 0.9, 0.55)
const HEALTH_HURT_COLOR: Color = Color(1.0, 0.35, 0.25)
# #215 made it bigger and brighter. It stays a DAMAGE-ONLY bar: the reason
# above still holds, and a field of full meters in peacetime is clutter, not
# visibility. What was actually hard to see was the bar when it was there -
# 74x7 of muted colour against a lit structure, at the zoom the game is
# usually played at.
const HEALTH_BAR_WIDTH: float = 104.0
const HEALTH_BAR_HEIGHT: float = 14.0
const HEALTH_BAR_OFFSET: float = -88.0
# The dark plate the bar sits on, drawn past its edges so the meter reads
# against the structure's own glow instead of competing with it.
const HEALTH_BAR_PAD: float = 3.0
const HEALTH_FULL_COLOR: Color = Color(0.5, 1.0, 0.55)
const HEALTH_HURT_COLOR: Color = Color(1.0, 0.28, 0.2)
const HEALTH_PLATE_COLOR: Color = Color(0.02, 0.02, 0.06, 0.92)
const HEALTH_FRAME_COLOR: Color = Color(0.85, 0.96, 1.0, 0.95)
const HEALTH_FRAME_WIDTH: float = 2.0
var _drawn_health: float = 1.0
@ -63,12 +74,23 @@ func _draw() -> void:
var health: float = health_fraction()
var left: float = -HEALTH_BAR_WIDTH * 0.5
var top: float = HEALTH_BAR_OFFSET
var fill_color: Color = HEALTH_HURT_COLOR.lerp(HEALTH_FULL_COLOR, health)
draw_rect(Rect2(left - HEALTH_BAR_PAD, top - HEALTH_BAR_PAD,
HEALTH_BAR_WIDTH + HEALTH_BAR_PAD * 2.0,
HEALTH_BAR_HEIGHT + HEALTH_BAR_PAD * 2.0), HEALTH_PLATE_COLOR)
# The empty part is not left blank: a dim wash of the fill colour shows
# how much has GONE, which is the thing being reported.
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT),
Color(0.02, 0.02, 0.06, 0.85))
Color(fill_color.r, fill_color.g, fill_color.b, 0.16))
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH * health, HEALTH_BAR_HEIGHT),
HEALTH_HURT_COLOR.lerp(HEALTH_FULL_COLOR, health))
fill_color)
# A bright square cap on the fill's leading edge, so the level itself has
# an edge to catch rather than only the colour changing.
if health > 0.001:
draw_rect(Rect2(left + HEALTH_BAR_WIDTH * health - HEALTH_FRAME_WIDTH,
top, HEALTH_FRAME_WIDTH, HEALTH_BAR_HEIGHT), HEALTH_FRAME_COLOR)
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT),
Color(0.55, 0.78, 0.86, 0.55), false, 1.0)
HEALTH_FRAME_COLOR, false, HEALTH_FRAME_WIDTH)
func available_actions() -> PackedStringArray:

View file

@ -7,6 +7,8 @@ extends "res://controllers/AimedFleetAbilityController.gd"
# second hull adds a second independently recharging dash.
const DASH_COLOR := Color(0.85, 0.95, 0.15)
# The unupgraded reach, and what the definition declares. #205 made it a
# vessel stat; _aim_max_range() below is the distance actually in force.
const DASH_DISTANCE: float = 520.0
const ENERGY_PER_DASH: int = 15
const TRAIL_SECONDS: float = 0.45
@ -21,13 +23,18 @@ func _component_id() -> String: return "distorder"
func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY
func _ammo_cost() -> int: return ENERGY_PER_DASH
func _aim_padding() -> float: return 36.0
func _aim_max_range() -> float: return DASH_DISTANCE
# Off the PROPULSER that will actually surge, falling back to the definition
# when none is ready - the same shape LaserController._aim_max_range() uses,
# and for the same reason: the guide must not promise reach the fleet has not
# researched.
func _aim_max_range() -> float:
return GameState.vessel_stat(GameState.VESSEL_DISTORDER, "dash_distance")
func _has_effect_visuals() -> bool: return trail_time > 0.0 or fleet.is_dashing()
func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2:
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
return origin + direction * DASH_DISTANCE
return origin + direction * _aim_max_range()
func _consume_shot(component: Node) -> bool:
@ -40,7 +47,7 @@ func _perform_shot(_ship: Node2D, _component: Node) -> void:
return
trail_from = fleet.get_centroid()
trail_time = TRAIL_SECONDS
fleet.dash(direction, DASH_DISTANCE)
fleet.dash(direction, _aim_max_range())
func _advance_effects(delta: float) -> void:

View file

@ -27,7 +27,6 @@ const DRAG_RADIUS: float = 260.0
# it on the edge of the screen, which is the half that matters when the point
# of dragging further is to reach past what is already close.
const FRAMING_FACTOR: float = 2.0
const BEAM_HALF_WIDTH := BEAM_EFFECT.BEAM_HALF_WIDTH
const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION
const ENERGY_PER_SHOT: int = VesselLaserComponent.ENERGY_PER_SHOT
@ -44,10 +43,22 @@ func _status_path() -> NodePath: return "../UI/LaserStatus"
func _component_id() -> String: return "laser"
func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY
func _ammo_cost() -> int: return ENERGY_PER_SHOT
func _aim_padding() -> float: return BEAM_HALF_WIDTH
func _aim_padding() -> float: return _aim_half_width()
func _has_effect_visuals() -> bool: return not beams.is_empty()
# The width the next shot will cut, off the hull that will cut it. Same shape
# and same reason as _aim_max_range() below: #204 made this upgradeable, so a
# constant here would draw last research's beam.
func _aim_half_width() -> float:
var ship: Node2D = _next_ready_ship()
if ship != null:
var component: Node = ship.get_component(_component_id())
if component != null:
return component.beam_half_width
return GameState.vessel_stat(GameState.VESSEL_LASER, "beam_half_width")
func _aim_max_range() -> float:
# Falls back to the VESSEL DEFINITION's own beam_range, not to a copy of
# the number. This used to hardcode 950.0 in both branches, which silently
@ -108,7 +119,8 @@ func _perform_shot(ship: Node2D, component: Node) -> void:
var direction: Vector2 = aim_target - ship.global_position
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
var effect: Node2D = BEAM_EFFECT.fire(get_tree().root, ship.global_position,
direction, component.beam_range, int(component.damage))
direction, component.beam_range, int(component.damage),
component.beam_half_width)
beams.append(effect)
last_hit_count = int(effect.hit_count)
@ -160,14 +172,14 @@ func _draw() -> void:
# units puts the camera far enough out that a mark measured in world units
# would be a few pixels of nothing at the moment it matters most.
var across: Vector2 = far_end.direction_to(origin).orthogonal() \
* _screen_units(END_TICK_PIXELS)
* maxf(_aim_half_width(), _screen_units(END_TICK_PIXELS))
NeonDraw.draw_glowing_line(self, far_end - across, far_end + across,
TARGET_COLOR, 0.7, 0.5)
# What the assist picked, when that is nearer than the far end. The line
# still runs past it - everything behind it on the same line is hit too.
if origin.distance_to(aim_target) < reach - 1.0:
NeonDraw.draw_glowing_circle(self, aim_target,
maxf(BEAM_HALF_WIDTH, _screen_units(TARGET_RING_PIXELS)),
maxf(_aim_half_width(), _screen_units(TARGET_RING_PIXELS)),
TARGET_COLOR, 0.7)

View file

@ -211,6 +211,11 @@ var explored_chunks: Dictionary = {}
# The scanner's latest unconquered destination. Unlike explored_chunks this
# is one intentional navigation target, shared by the map and flight HUD.
var scanner_target: Dictionary = {}
# #207: where the prologue's convoy is headed. Set by IntroDirector while the
# World Ship is still something to fly TOWARD, and cleared the moment the
# breach takes that away. Kept beside the scanner's target because the HUD
# asks one question - "where am I going" - and only ever wants one answer.
var intro_destination: Dictionary = {}
# The four ordinary destinations teach the scanner enough signatures to
# identify the hostile boss world. Kept explicit so adding a sixth authored
@ -300,8 +305,29 @@ func withdraw_fleet_resource(resource_type: String, amount: int) -> int:
# deposits into the building's OWN per-record inventory, not bank_inventory
# itself (only building_resource()/building_inventory() special-case "bank"
# capability to redirect there).
# #210: anything that reaches the fleet's own hold is heard - a mined rock, a
# transporter's delivery, a load lifted off a planet. The bank is deliberately
# silent: that is a number moving in a building the player is not standing in.
const PICKUP_SOUND_INTERVAL_MS: int = 90
var _pickup_sound_at: int = 0
func deposit_fleet_resource(resource_type: String, amount: int) -> int:
return fleet_inventory.deposit(resource_type, amount)
var accepted: int = fleet_inventory.deposit(resource_type, amount)
if accepted > 0:
_play_pickup_sound()
return accepted
# One unit at a time is how mining arrives, so a blip per unit would be a
# machine-gun. A burst inside the window is heard once; anything slower than
# it stays a separate sound.
func _play_pickup_sound() -> void:
var now: int = Time.get_ticks_msec()
if now - _pickup_sound_at < PICKUP_SOUND_INTERVAL_MS:
return
_pickup_sound_at = now
Sfx.play(get_tree(), Sfx.PICKUP)
func deposit_bank_resource(resource_type: String, amount: int) -> int:
@ -620,6 +646,32 @@ const SKILL_TREE: Array = [
"icon": 2,
"desc": "Permanent: +2 vessels in the fleet.",
},
{
# #206 carries the branch on to twenty. The step size keeps growing
# for the reason the +2 tiers were introduced: ten more single-vessel
# nodes would be a very long chain for a lane that only ever does one
# thing. These three cost late-game money and are meant to be the
# thing a finished economy is finally FOR.
"id": "task_force", "name": "Task Force Doctrine", "cost": 520, "requires": "grand_fleet",
"costs": {"ore": 520, "gas": 180, "energy": 130, "dark_matter": 35},
"x": 0.82, "fleet_bonus": 3, "research_points": 8,
"icon": 2,
"desc": "Permanent: +3 vessels in the fleet.",
},
{
"id": "star_legion", "name": "Star Legion Doctrine", "cost": 820, "requires": "task_force",
"costs": {"ore": 820, "gas": 280, "energy": 200, "dark_matter": 60},
"x": 0.82, "fleet_bonus": 3, "research_points": 10,
"icon": 2,
"desc": "Permanent: +3 vessels in the fleet.",
},
{
"id": "void_armada", "name": "Void Armada Doctrine", "cost": 1300, "requires": "star_legion",
"costs": {"ore": 1300, "gas": 440, "energy": 320, "dark_matter": 100},
"x": 0.82, "fleet_bonus": 4, "research_points": 14,
"icon": 2,
"desc": "Permanent: +4 vessels in the fleet. The fleet reaches twenty.",
},
]
# How many SLOTS the fleet has before any doctrine is researched. Distinct
@ -802,6 +854,7 @@ func reset_new_game() -> void:
dev_mode = false
explored_chunks.clear()
scanner_target.clear()
intro_destination.clear()
bank_inventory = INVENTORY.new()
fleet_inventory = INVENTORY.new()
buildings.clear()
@ -915,6 +968,15 @@ func _accept_scanner_target(data: Dictionary) -> Dictionary:
return data
# What the flight HUD should point at. The prologue's destination wins while
# it exists: during those beats the fleet has no scanner and exactly one place
# to be, and afterwards there is no World Ship left to point at.
func active_guidance_target() -> Dictionary:
if intro_active:
return intro_destination
return active_scanner_target()
func active_scanner_target() -> Dictionary:
if scanner_target.is_empty() \
or is_planet_conquered(String(scanner_target.get("planet_id", ""))):
@ -2505,13 +2567,18 @@ func get_mothership_position() -> Variant:
# capacity upgrade is researched, but they are defined up front so the wedge
# keeps a deliberate shape as it grows rather than having positions invented
# at unlock time.
# Ten slots, because max_fleet_size() caps itself on this array's length -
# Twenty slots, because max_fleet_size() caps itself on this array's length -
# so the wedge has to be long enough for every doctrine to be worth buying.
# The last pair continues the V rather than starting a new shape.
# Each further pair continues the same V rather than starting a new shape:
# the rows step 30 out and 40 back, which is the rule the first ten already
# followed, so the wedge that arrives at twenty is the one that left at three.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
Vector2(-120, 112), Vector2(120, 112),
Vector2(-150, 152), Vector2(150, 152), Vector2(-180, 192), Vector2(180, 192),
Vector2(-210, 232), Vector2(210, 232), Vector2(-240, 272), Vector2(240, 272),
Vector2(-270, 312), Vector2(270, 312),
]
var formation: Array = DEFAULT_FORMATION.duplicate()
@ -2727,8 +2794,11 @@ func _advance_distorder_drain(delta: float, boost_in_use: bool = true) -> void:
# Normalized against the CURRENT ceiling, so "full boost" always costs
# ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it -
# a bigger fleet buys more speed, not cheaper speed.
# #205: the per-second figure is the Propulser's own stat now, so a Flux
# Regulator makes sustained boost cheaper. ENERGY_DRAIN_PER_SECOND remains
# what the definition declares, and what this falls back to.
var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \
* ENERGY_DRAIN_PER_SECOND
* vessel_stat(VESSEL_DISTORDER, "boost_drain")
_distorder_drain_progress += rate * delta
var whole: int = int(floor(_distorder_drain_progress))
if whole <= 0:

View file

@ -0,0 +1,34 @@
extends Node2D
# Preview, not a test - #215's health bars, through the real game path. A bar
# is only drawn on a DAMAGED structure, so this hurts each one by a different
# amount: full, most, half, and nearly gone, left to right.
#
# bash tests/screenshot.sh res://tests/_building_health_preview.tscn shots/building_health.png 3 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.intro_active = false
GameState.reset_formation()
var placed: Array = [
[GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0), 0.0],
[GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0), 0.25],
[GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0), 0.5],
[GameState.BUILDING_SHIP_LAB, Vector2(820.0, 0.0), 0.85],
]
for entry in placed:
var id: int = GameState.add_building(String(entry[0]), Vector2(entry[1]))
var hurt: float = float(entry[2])
if hurt > 0.0:
GameState.damage_building(id,
int(GameState.building_max_hp(String(entry[0])) * hurt))
var main: Node = load("res://world/Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
main.get_node("Fleet").move_to(Vector2(240.0, 260.0))
var cam: Camera2D = main.get_node("Camera2D")
cam.zoom = Vector2(0.78, 0.78)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_building_health_preview.gd" id="1"]
[node name="BuildingHealthPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,21 @@
extends Node2D
# Preview, not a test - #207's destination marker during the prologue. The
# convoy is flying north toward a World Ship 16000 units away that is never
# on screen, so this renders the RUN beat, where the player is flying and
# the marker is the only thing saying where.
#
# bash tests/screenshot.sh res://tests/_intro_marker_preview.tscn shots/intro_marker.png 3 720x1280
func _ready() -> void:
GameState.reset_new_game()
var main: Node = load("res://world/Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
# BRIEF has already run inside Main's _ready(), which is what spawns the
# convoy and names the destination; step on to the beat where the convoy
# is actually under way.
var director: Node = main.get_node("IntroDirector")
director._begin(1)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_intro_marker_preview.gd" id="1"]
[node name="IntroMarkerPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -21,6 +21,8 @@ func _ready() -> void:
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
var build: Node = main.get_node("BuildController")
# The tree lives on the menu, not on the controller - reaching through
# build.skill_tree stopped resolving at some point and this preview had
# been rendering an unopened menu ever since.
build.skill_menu.visible = true
build.skill_tree.selected_id = "shipyards"
build._refresh_skill_menu("shipyards")
build.skill_menu.refresh("shipyards")

View file

@ -19,7 +19,8 @@ extends SceneTree
# E. the tree cannot promise a slot the formation has no room for - the
# invariant that made extra doctrines silently worthless
const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup", "grand_fleet"]
const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup",
"grand_fleet", "task_force", "star_legion", "void_armada"]
var game_state: Node
var scene: Node
@ -98,6 +99,11 @@ func _process(delta: float) -> bool:
if expected > GameState.DEFAULT_FORMATION.size():
return _fail("the doctrines promise %d slots but DEFAULT_FORMATION only defines %d - the last ones buy nothing" \
% [expected, GameState.DEFAULT_FORMATION.size()])
# #206 named the destination, so it is pinned rather than left to
# whatever the chain happens to add up to.
if expected != 20:
return _fail("the doctrine chain reaches %d vessels, not the 20 it is meant to" \
% expected)
print("full doctrine chain reaches %d slots, with %d formation positions defined" \
% [expected, GameState.DEFAULT_FORMATION.size()])

View file

@ -288,6 +288,41 @@ func _process(delta: float) -> bool:
return _fail_now("dragging further did not reach past the nearer enemy")
print("drag depth selects between a near and a far target in the same cone")
# --- G. #204: the Collimator widens what one shot catches -------
# The upgrade used to add range to a beam that already crossed the
# screen; it adds WIDTH now, so this checks the thing it widens is
# the hit test and not only the picture.
for placed in [near_enemy, far_enemy, out_of_cone]:
if is_instance_valid(placed):
placed.free()
var laser: Node = first_shooter.get_component("laser")
var narrow_width: float = laser.beam_half_width
game_state.vessel_levels["lsr_width"] = 3
laser.apply_stats()
if not is_equal_approx(laser.beam_half_width, narrow_width + 66.0):
return _fail_now("three Collimators gave a half-width of %.0f, expected %.0f" \
% [laser.beam_half_width, narrow_width + 66.0])
# Off the line by more than the base beam covers and less than the
# researched one does, so the same shot answers differently.
var flanker: Node = load("res://enemies/Enemy.tscn").instantiate()
flanker.position = first_shooter.global_position \
+ Vector2(600.0, narrow_width + 60.0)
main.add_child(flanker)
flanker.speed = 0.0
var beam_script: Resource = load("res://vfx/LaserBeamEffect.gd")
var narrow_shot: Node2D = beam_script.fire(root, first_shooter.global_position,
Vector2.RIGHT, 2900.0, 1, narrow_width)
if narrow_shot.hit_count != 0:
return _fail_now("the unresearched beam already reached the flanking enemy")
var wide_shot: Node2D = beam_script.fire(root, first_shooter.global_position,
Vector2.RIGHT, 2900.0, 1, laser.beam_half_width)
if wide_shot.hit_count != 1:
return _fail_now("the widened beam hit %d enemies, expected the flanker" \
% wide_shot.hit_count)
print("Beam Collimator: %.0f -> %.0f half-width, and the wider beam catches what the narrow one missed" \
% [narrow_width, laser.beam_half_width])
print("TEST_LASER_VESSEL: PASS")
quit(0)
return true

View file

@ -0,0 +1,133 @@
extends SceneTree
# Forgejo #205: the Energy Propulser had no upgrades at all - it was the one
# fleet hull whose ship lab offered nothing. It now carries three, and this
# checks each of them reaches the thing it is supposed to change rather than
# only reading well in the menu.
#
# Covers:
# A. Warp Coils lengthen the dash, and the controller aims that far
# B. Surge Capacitor shortens the recharge, floored by minimum_stats
# C. Flux Regulator makes boosted cruise cheaper to sustain
# D. an unupgraded Propulser is exactly what it was before
const BASE_DASH_DISTANCE: float = 520.0
const BASE_DASH_COOLDOWN: float = 5.0
const BASE_BOOST_DRAIN: float = 2.0
const COOLDOWN_FLOOR: float = 2.0
# Where three Surge Capacitors actually land. Above the floor, deliberately:
# the floor is a guard on the definition, not a target the branch reaches.
const UPGRADED_COOLDOWN: float = 2.3
var state: Node
var main: Node
var fleet: Node
var dash: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
state = root.get_node("GameState")
state.reset_new_game()
state.intro_active = false
state.reset_quests()
state.vessel_levels["unlock_distorder"] = 1
state.fleet_inventory.total_capacity = -1
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
dash = main.get_node("DashController")
func _stat(name: String) -> float:
return state.vessel_stat(GameState.VESSEL_DISTORDER, name)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.3:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
# --- D. nothing researched, nothing changed -------------------
if not is_equal_approx(_stat("dash_distance"), BASE_DASH_DISTANCE) \
or not is_equal_approx(_stat("dash_cooldown"), BASE_DASH_COOLDOWN) \
or not is_equal_approx(_stat("boost_drain"), BASE_BOOST_DRAIN):
return _fail("an unresearched Propulser does not match its own definition")
print("unupgraded Propulser: %.0f dash, %.1fs recharge, %.1f energy/s" \
% [_stat("dash_distance"), _stat("dash_cooldown"), _stat("boost_drain")])
# --- A. Warp Coils --------------------------------------------
state.vessel_levels["dst_dash_range"] = 3
if not is_equal_approx(_stat("dash_distance"), BASE_DASH_DISTANCE + 420.0):
return _fail("three Warp Coils gave %.0f dash distance, expected %.0f" \
% [_stat("dash_distance"), BASE_DASH_DISTANCE + 420.0])
# The controller must aim as far as the fleet can actually surge,
# or the guide promises reach the dash does not have.
if not is_equal_approx(dash._aim_max_range(), _stat("dash_distance")):
return _fail("the dash guide reaches %.0f while the dash carries %.0f" \
% [dash._aim_max_range(), _stat("dash_distance")])
print("Warp Coils: dash and its guide both reach %.0f" % _stat("dash_distance"))
# --- C. Flux Regulator ----------------------------------------
state.vessel_levels["dst_drain"] = 3
var expected_drain: float = BASE_BOOST_DRAIN * pow(0.75, 3)
if not is_equal_approx(_stat("boost_drain"), expected_drain):
return _fail("three Flux Regulators drain %.3f/s, expected %.3f" \
% [_stat("boost_drain"), expected_drain])
print("Flux Regulator: sustained boost costs %.2f energy/s" % _stat("boost_drain"))
# --- B. Surge Capacitor, and its floor ------------------------
state.vessel_levels["dst_dash_cooldown"] = 2
if not is_equal_approx(_stat("dash_cooldown"), BASE_DASH_COOLDOWN - 1.8):
return _fail("two Surge Capacitors left a %.2fs recharge, expected %.2f" \
% [_stat("dash_cooldown"), BASE_DASH_COOLDOWN - 1.8])
state.vessel_levels["dst_dash_cooldown"] = 3
if not is_equal_approx(_stat("dash_cooldown"), UPGRADED_COOLDOWN):
return _fail("three Surge Capacitors left a %.2fs recharge, expected %.2f" \
% [_stat("dash_cooldown"), UPGRADED_COOLDOWN])
# Past the branch's own maximum, to prove minimum_stats actually
# holds. Nothing in the game can buy this level; raising max_level
# later would walk straight into the floor, and this says where it
# is rather than leaving it to be discovered.
state.vessel_levels["dst_dash_cooldown"] = 6
if not is_equal_approx(_stat("dash_cooldown"), COOLDOWN_FLOOR):
return _fail("the recharge passed its %.1fs floor, reaching %.2f" \
% [COOLDOWN_FLOOR, _stat("dash_cooldown")])
state.vessel_levels["dst_dash_cooldown"] = 3
print("Surge Capacitor: %.1fs recharge at full research, floored at %.1f" \
% [UPGRADED_COOLDOWN, COOLDOWN_FLOOR])
# A hull built AFTER the research carries the researched recharge:
# apply_stats() is what puts a definition on a component, and a
# stat that never reaches the component upgrades nothing.
fleet.spawn_ships(1, Vector2.ZERO,
PackedStringArray([GameState.VESSEL_DISTORDER]))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
var component: Node = fleet.ships[0].get_component("distorder")
if component == null:
return _fail("the spawned Propulser has no distorder component")
if not is_equal_approx(component.cooldown_duration, UPGRADED_COOLDOWN):
return _fail("the hull recharges in %.2fs while its definition says %.2f" \
% [component.cooldown_duration, UPGRADED_COOLDOWN])
print("the researched recharge reaches the hull itself")
print("TEST_PROPULSER_UPGRADES: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_PROPULSER_UPGRADES: FAIL (%s)" % message)
quit(1)
return true

View file

@ -179,6 +179,55 @@ func _process(delta: float) -> bool:
return _fail("a structure came down in silence")
print("a structure coming down is heard")
# --- G. the things #210/#213/#214 asked to be able to hear -----
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var rock: Node2D = load("res://world/Asteroid.tscn").instantiate()
scene.add_child(rock)
rock.shatter(false)
if _sounding() == 0:
return _fail("an asteroid broke apart in silence")
print("a destroyed asteroid is heard")
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var bomb: Node2D = load("res://vfx/GasBombEffect.gd").launch(
scene, Vector2.ZERO, Vector2(300.0, 0.0), 6)
if _sounding() != 0:
return _fail("the gas bomb made its noise on the throw, not the blast")
bomb._explode()
if _sounding() == 0:
return _fail("a gas bomb bloomed in silence")
print("a gas bomb is heard when it blooms, and not before")
# The hold has to be able to take the deposit, or there is no
# arrival to hear.
game_state.fleet_inventory.total_capacity = -1
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
if game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1) != 1:
return _fail("the fleet hold refused a single rock")
var after_first: int = _sounding()
if after_first == 0:
return _fail("a rock reached the fleet hold in silence")
# Mining lands one unit at a time, so the window is the whole of
# what stops this being a machine-gun.
game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1)
game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1)
if _sounding() != after_first:
return _fail("a burst of pickups opened %d voices, not the %d one blip needs" \
% [_sounding(), after_first])
print("a rock arriving in the hold is heard, and a burst of them only once")
# The bank is a number moving in a building the player is not in.
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
game_state._pickup_sound_at = 0
game_state.deposit_bank_resource(GameState.RESOURCE_ORE, 5)
if _sounding() != 0:
return _fail("a deposit into the bank made a pickup noise")
print("banked resources stay silent")
print("TEST_SFX: PASS")
quit(0)
return true

View file

@ -4,6 +4,12 @@ extends Control
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
const EDGE_MARGIN: float = 34.0
# The scene sets process_mode = ALWAYS. A narration line stops the tree
# (NarrationBanner, #128) and the prologue is nearly all narration, so a
# pausable marker would be absent for most of the run it exists to guide
# (#207). Nothing here simulates anything - it reads a target and points at
# it - so running while the game is stopped costs nothing and stops the
# marker blinking out every time somebody speaks.
@onready var indicator: Control = $Indicator
@onready var target_label: Label = $TargetLabel
@ -16,8 +22,11 @@ func _ready() -> void:
func _process(_delta: float) -> void:
var target: Dictionary = GameState.active_scanner_target()
if target.is_empty() or GameState.intro_active or _modal_is_open():
# #207: the prologue's destination comes through the same marker, so this
# no longer hides for the intro - GameState decides which target is live,
# and an empty answer is what hides it.
var target: Dictionary = GameState.active_guidance_target()
if target.is_empty() or _modal_is_open():
visible = false
return
if fleet == null or not is_instance_valid(fleet):
@ -39,8 +48,11 @@ func _process(_delta: float) -> void:
if toward_target.length_squared() > 0.01:
indicator.rotation = toward_target.angle() + PI / 2.0
# A scanned planet colours the arrow with its own map colour. The World
# Ship is not a planet and has no definition, so it keeps the indicator's
# authored colour rather than being forced into one.
var definition: Resource = PLANET_DEFINITIONS.get_definition(
String(target.definition_id))
String(target.get("definition_id", "")))
if definition != null:
indicator.glow_color = definition.map_color
indicator.queue_redraw()

View file

@ -6,6 +6,7 @@
[node name="ScannerGuidance" type="Control"]
visible = false
mouse_filter = 2
process_mode = 3
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "dst_dash_cooldown"
display_name = "Surge Capacitor"
description = "The dash recharges 0.9s sooner per level."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 20
additive_modifiers = {"dash_cooldown": -0.9}

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "dst_dash_range"
display_name = "Warp Coils"
description = "+140 dash distance per level."
base_cost = 35
max_level = 3
research_point_cost = 1
icon = 17
additive_modifiers = {"dash_distance": 140.0}

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "dst_drain"
display_name = "Flux Regulator"
description = "Boosted cruise burns 25% less energy per second, per level."
base_cost = 40
max_level = 3
research_point_cost = 1
icon = 22
multiplicative_modifiers = {"boost_drain": 0.75}

View file

@ -1,6 +1,9 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=3 format=3]
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselDistorderComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://vessels/DistorderDashCooldownUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://vessels/DistorderDashRangeUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/DistorderDrainUpgrade.tres" id="5"]
[resource]
script = ExtResource("1")
definition_id = "distorder"
@ -15,7 +18,9 @@ hull_points = PackedVector2Array(0, -26, 9, -8, 13, 6, 18, 20, 0, 12, -18, 20, -
fleet_eligible = true
capabilities = PackedStringArray("distorder")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 645.0, "acceleration": 1845.0, "delivery_speed": 180.0}
base_stats = {"max_speed": 645.0, "acceleration": 1845.0, "delivery_speed": 180.0, "dash_cooldown": 5.0, "dash_distance": 520.0, "boost_drain": 2.0}
minimum_stats = {"dash_cooldown": 2.0, "boost_drain": 0.4}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")])
unlock_id = "unlock_distorder"
unlock_name = "Unlock Energy Propulser"
unlock_description = "Research a propulsion hull that burns energy to drive the whole fleet faster and grants an aimed fleet dash."

View file

@ -2,7 +2,7 @@
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselLaserComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://vessels/LaserDamageUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://vessels/LaserRangeUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/LaserWidthUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/LaserCooldownUpgrade.tres" id="5"]
[resource]
script = ExtResource("1")
@ -18,7 +18,7 @@ hull_points = PackedVector2Array(0, -26, -9, 14, -4, 10, 4, 10, 9, 14)
fleet_eligible = true
capabilities = PackedStringArray("laser")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 660.0, "acceleration": 1890.0, "delivery_speed": 180.0, "damage": 6.0, "beam_range": 2900.0, "beam_cooldown": 4.0}
base_stats = {"max_speed": 660.0, "acceleration": 1890.0, "delivery_speed": 180.0, "damage": 6.0, "beam_range": 2900.0, "beam_cooldown": 4.0, "beam_half_width": 38.0}
minimum_stats = {"beam_cooldown": 1.5}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")])
unlock_id = "unlock_laser"

View file

@ -2,11 +2,11 @@
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "lsr_range"
upgrade_id = "lsr_width"
display_name = "Beam Collimator"
description = "+300 beam range per level."
description = "+22 beam width per level - the beam cuts a wider swathe and catches more in one shot."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 17
additive_modifiers = {"beam_range": 300.0}
additive_modifiers = {"beam_half_width": 22.0}

View file

@ -8,6 +8,9 @@ extends "res://vessels/VesselCooldownFillComponent.gd"
var capability_id := "distorder"
# The value a Propulser carries before apply_stats() reads the definition,
# and what the definition itself declares. #205 made it upgradeable, so the
# recharge in force is cooldown_duration, never this.
const DASH_COOLDOWN: float = 5.0
const READY_COLOR := Color(0.85, 0.95, 0.15)
const COOLING_COLOR := Color(0.28, 0.31, 0.08)
@ -17,8 +20,15 @@ func _init() -> void:
cooldown_duration = DASH_COOLDOWN
func apply_stats() -> void:
var ship: Node = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
cooldown_duration = state.vessel_stat(ship.vessel_type, "dash_cooldown")
queue_redraw()
func consume_dash() -> bool:
return _begin_cooldown(DASH_COOLDOWN)
return _begin_cooldown(cooldown_duration)
func _ready_color() -> Color: return READY_COLOR

View file

@ -4,6 +4,7 @@ extends "res://vessels/VesselCooldownFillComponent.gd"
var capability_id := "laser"
var damage: int = 6
var beam_range: float = 2900.0
var beam_half_width: float = 38.0
const READY_COLOR := Color(0.3, 0.9, 1.0)
const COOLING_COLOR := Color(0.08, 0.28, 0.34)
@ -18,6 +19,7 @@ func apply_stats() -> void:
var state: Node = ship.get_tree().root.get_node("GameState")
damage = int(state.vessel_stat(ship.vessel_type, "damage"))
beam_range = state.vessel_stat(ship.vessel_type, "beam_range")
beam_half_width = state.vessel_stat(ship.vessel_type, "beam_half_width")
cooldown_duration = state.vessel_stat(ship.vessel_type, "beam_cooldown")
queue_redraw()
@ -41,7 +43,7 @@ func fire_ability(world: Node, origin: Vector2, at: Vector2) -> bool:
return false
var direction: Vector2 = at - origin
# A beam always reaches full range; the aim chooses its direction only.
BEAM_EFFECT.fire(world, origin, direction, beam_range, damage)
BEAM_EFFECT.fire(world, origin, direction, beam_range, damage, beam_half_width)
return true

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

View file

@ -68,6 +68,10 @@ func take_damage(amount: int) -> bool:
func shatter(drop_ore: bool) -> void:
AsteroidExplosionVFX.spawn(get_tree().root, position)
# #213. Here rather than in take_damage(), so a rock broken by a ramming
# hull is heard too - and NOT in AsteroidField's despawn, which frees
# rocks the player has simply flown away from.
Sfx.play(get_tree(), Sfx.ASTEROID_BREAK)
if drop_ore:
_drop_ore()
queue_free()

View file

@ -330,6 +330,13 @@ func _begin(next: int) -> void:
match beat:
Beat.BRIEF:
start_at = fleet.get_centroid()
# #207: the World Ship is 16000 units north and never on screen,
# so "straight ahead" was a line of dialogue with nothing behind
# it. The flight marker gives it a place.
GameState.intro_destination = {
"name": "World Ship",
"world_position": WORLD_SHIP_AT,
}
GameState.intro_fleet_size = CONVOY.size()
# #125: the convoy flies in its own spread-out box, escorts on
# the corners. spawn_ships seats vessel i in formation slot i,
@ -358,8 +365,15 @@ func _begin(next: int) -> void:
GameState.intro_locks_movement = true
breach_at = fleet.get_centroid() + Vector2(0.0, -900.0)
_open_breach()
# #208: on the beat that OPENS it, not the one that falls through
# it - the sound is what tells the player something has happened
# while the guide is still saying it has not.
Sfx.play(get_tree(), Sfx.BLACK_HOLE)
narration.say("Kestrel, your course is - something is opening ahead of you -", "CONVOY CONTROL")
Beat.VORTEX:
# Nothing to steer toward any more: the breach has the convoy, and
# what is on the other side is not the World Ship.
GameState.intro_destination = {}
vortex_time = 0.0
_ensure_survivor_hull()
narration.clear()