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:
parent
2239235993
commit
78836882af
33 changed files with 716 additions and 52 deletions
31
audio/Sfx.gd
31
audio/Sfx.gd
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
BIN
audio/sfx_asteroid_break.wav
Normal file
BIN
audio/sfx_asteroid_break.wav
Normal file
Binary file not shown.
BIN
audio/sfx_black_hole.wav
Normal file
BIN
audio/sfx_black_hole.wav
Normal file
Binary file not shown.
BIN
audio/sfx_gas_blast.wav
Normal file
BIN
audio/sfx_gas_blast.wav
Normal file
Binary file not shown.
BIN
audio/sfx_pickup.wav
Normal file
BIN
audio/sfx_pickup.wav
Normal file
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
34
tests/_building_health_preview.gd
Normal file
34
tests/_building_health_preview.gd
Normal 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)
|
||||
6
tests/_building_health_preview.tscn
Normal file
6
tests/_building_health_preview.tscn
Normal 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")
|
||||
21
tests/_intro_marker_preview.gd
Normal file
21
tests/_intro_marker_preview.gd
Normal 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)
|
||||
6
tests/_intro_marker_preview.tscn
Normal file
6
tests/_intro_marker_preview.tscn
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
133
tests/test_propulser_upgrades.gd
Normal file
133
tests/test_propulser_upgrades.gd
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
12
vessels/DistorderDashCooldownUpgrade.tres
Normal file
12
vessels/DistorderDashCooldownUpgrade.tres
Normal 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}
|
||||
12
vessels/DistorderDashRangeUpgrade.tres
Normal file
12
vessels/DistorderDashRangeUpgrade.tres
Normal 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}
|
||||
12
vessels/DistorderDrainUpgrade.tres
Normal file
12
vessels/DistorderDrainUpgrade.tres
Normal 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}
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue