diff --git a/audio/Sfx.gd b/audio/Sfx.gd index 6da2c4b..04be32c 100644 --- a/audio/Sfx.gd +++ b/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() diff --git a/audio/make_sfx.py b/audio/make_sfx.py index 957cea1..88c1302 100644 --- a/audio/make_sfx.py +++ b/audio/make_sfx.py @@ -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()) diff --git a/audio/sfx_asteroid_break.wav b/audio/sfx_asteroid_break.wav new file mode 100644 index 0000000..ec1f982 Binary files /dev/null and b/audio/sfx_asteroid_break.wav differ diff --git a/audio/sfx_black_hole.wav b/audio/sfx_black_hole.wav new file mode 100644 index 0000000..8f85bb0 Binary files /dev/null and b/audio/sfx_black_hole.wav differ diff --git a/audio/sfx_gas_blast.wav b/audio/sfx_gas_blast.wav new file mode 100644 index 0000000..263a68a Binary files /dev/null and b/audio/sfx_gas_blast.wav differ diff --git a/audio/sfx_pickup.wav b/audio/sfx_pickup.wav new file mode 100644 index 0000000..7702c90 Binary files /dev/null and b/audio/sfx_pickup.wav differ diff --git a/buildings/Building.gd b/buildings/Building.gd index 196fe52..dd7fa75 100644 --- a/buildings/Building.gd +++ b/buildings/Building.gd @@ -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: diff --git a/controllers/DashController.gd b/controllers/DashController.gd index 9951423..a3a0894 100644 --- a/controllers/DashController.gd +++ b/controllers/DashController.gd @@ -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: diff --git a/controllers/LaserController.gd b/controllers/LaserController.gd index 9119f8f..6102d80 100644 --- a/controllers/LaserController.gd +++ b/controllers/LaserController.gd @@ -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) diff --git a/core/GameState.gd b/core/GameState.gd index 2e63272..d819507 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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: diff --git a/tests/_building_health_preview.gd b/tests/_building_health_preview.gd new file mode 100644 index 0000000..8e2dd16 --- /dev/null +++ b/tests/_building_health_preview.gd @@ -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) diff --git a/tests/_building_health_preview.tscn b/tests/_building_health_preview.tscn new file mode 100644 index 0000000..4f122b9 --- /dev/null +++ b/tests/_building_health_preview.tscn @@ -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") diff --git a/tests/_intro_marker_preview.gd b/tests/_intro_marker_preview.gd new file mode 100644 index 0000000..34787c2 --- /dev/null +++ b/tests/_intro_marker_preview.gd @@ -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) diff --git a/tests/_intro_marker_preview.tscn b/tests/_intro_marker_preview.tscn new file mode 100644 index 0000000..7abe27d --- /dev/null +++ b/tests/_intro_marker_preview.tscn @@ -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") diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd index 76285f4..480ec59 100644 --- a/tests/_skill_menu_preview.gd +++ b/tests/_skill_menu_preview.gd @@ -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") diff --git a/tests/test_fleet_growth.gd b/tests/test_fleet_growth.gd index 1bc3ab0..4d66987 100644 --- a/tests/test_fleet_growth.gd +++ b/tests/test_fleet_growth.gd @@ -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()]) diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd index 95b7619..253b3e2 100644 --- a/tests/test_laser_vessel.gd +++ b/tests/test_laser_vessel.gd @@ -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 diff --git a/tests/test_propulser_upgrades.gd b/tests/test_propulser_upgrades.gd new file mode 100644 index 0000000..cda8522 --- /dev/null +++ b/tests/test_propulser_upgrades.gd @@ -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 diff --git a/tests/test_sfx.gd b/tests/test_sfx.gd index e69e254..ea90a10 100644 --- a/tests/test_sfx.gd +++ b/tests/test_sfx.gd @@ -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 diff --git a/ui/ScannerGuidance.gd b/ui/ScannerGuidance.gd index 32e256e..e2accff 100644 --- a/ui/ScannerGuidance.gd +++ b/ui/ScannerGuidance.gd @@ -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() diff --git a/ui/ScannerGuidance.tscn b/ui/ScannerGuidance.tscn index 20aacbb..311c108 100644 --- a/ui/ScannerGuidance.tscn +++ b/ui/ScannerGuidance.tscn @@ -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") diff --git a/vessels/DistorderDashCooldownUpgrade.tres b/vessels/DistorderDashCooldownUpgrade.tres new file mode 100644 index 0000000..193bb24 --- /dev/null +++ b/vessels/DistorderDashCooldownUpgrade.tres @@ -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} diff --git a/vessels/DistorderDashRangeUpgrade.tres b/vessels/DistorderDashRangeUpgrade.tres new file mode 100644 index 0000000..51305b2 --- /dev/null +++ b/vessels/DistorderDashRangeUpgrade.tres @@ -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} diff --git a/vessels/DistorderDrainUpgrade.tres b/vessels/DistorderDrainUpgrade.tres new file mode 100644 index 0000000..195d888 --- /dev/null +++ b/vessels/DistorderDrainUpgrade.tres @@ -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} diff --git a/vessels/DistorderVesselDefinition.tres b/vessels/DistorderVesselDefinition.tres index fc9f890..8b58159 100644 --- a/vessels/DistorderVesselDefinition.tres +++ b/vessels/DistorderVesselDefinition.tres @@ -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." diff --git a/vessels/LaserVesselDefinition.tres b/vessels/LaserVesselDefinition.tres index f76690d..67ca7d9 100644 --- a/vessels/LaserVesselDefinition.tres +++ b/vessels/LaserVesselDefinition.tres @@ -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" diff --git a/vessels/LaserRangeUpgrade.tres b/vessels/LaserWidthUpgrade.tres similarity index 63% rename from vessels/LaserRangeUpgrade.tres rename to vessels/LaserWidthUpgrade.tres index 6c7c06d..b0f6e2b 100644 --- a/vessels/LaserRangeUpgrade.tres +++ b/vessels/LaserWidthUpgrade.tres @@ -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} diff --git a/vessels/VesselDistorderComponent.gd b/vessels/VesselDistorderComponent.gd index 5fe5acd..6ed57d2 100644 --- a/vessels/VesselDistorderComponent.gd +++ b/vessels/VesselDistorderComponent.gd @@ -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 diff --git a/vessels/VesselLaserComponent.gd b/vessels/VesselLaserComponent.gd index a34541b..1026255 100644 --- a/vessels/VesselLaserComponent.gd +++ b/vessels/VesselLaserComponent.gd @@ -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 diff --git a/vfx/GasBombEffect.gd b/vfx/GasBombEffect.gd index a33ff7e..f8982a8 100644 --- a/vfx/GasBombEffect.gd +++ b/vfx/GasBombEffect.gd @@ -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 diff --git a/vfx/LaserBeamEffect.gd b/vfx/LaserBeamEffect.gd index f6716ba..430e285 100644 --- a/vfx/LaserBeamEffect.gd +++ b/vfx/LaserBeamEffect.gd @@ -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)) diff --git a/world/Asteroid.gd b/world/Asteroid.gd index a21a7e5..caca93a 100644 --- a/world/Asteroid.gd +++ b/world/Asteroid.gd @@ -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() diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index bf8eb7d..db2178b 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -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()