From b50597b7578331fe76590f963ebeaec3985d6959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 23 Aug 2026 19:23:35 +0200 Subject: [PATCH] Garrison laser vessels and gas bombers, on a shared ability seam #105 shipped with interceptors as the only garrisonable vessel, and the reason was a design fault rather than a decision: firing the laser and the gas bomb lived in the player's fleet controllers, so a stationed laser would have held its position and never shot. The shot is now a thing in the world rather than something a controller draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam and ArtilleryShell already had on the enemy side - and they outlive the vessel that fired them. Above them sits one seam, on VesselCooldownFillComponent: ability_range(), ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a bomb are now the same shape of thing to whoever pulls the trigger, so the garrison AI never names an ability - the test asserts that literally, by reading the AI's own source. A third armed vessel needs to implement the seam and nothing else. Ammunition is the CALLER's business, deliberately: the fleet spends from its hold, a garrison from its outpost's store, and neither concern belongs in the component. A garrison with an empty magazine holds fire rather than falling back on the fleet's hold. Outpost storage: 50 gas per stationed bomber and 50 energy per stationed laser, off the VESSEL definitions, so an outpost never learns which vessel needs what. It is a capacity, not a stock - hub transporters fill it exactly as they fill a gas burner's buffer, because building_logistics_deficit now takes the larger of the definition's static buffer and the garrison's magazine. Every other resource is capped at zero: an outpost is not a depot, and ore parked at one has nothing to do there and no transporter coming for it. Also restores two properties the Godot editor dropped from GasBurnerBuildingDefinition.tres when it re-saved the file: collected_outputs and capabilities. Without the first, nothing ever collects the burner's energy - test_gas_burner caught it. Co-Authored-By: Claude Opus 5 --- buildings/GasBurnerBuildingDefinition.tres | 9 +- controllers/GasBombController.gd | 123 ++++------ controllers/LaserController.gd | 145 +++-------- core/GameState.gd | 88 ++++++- tests/test_garrison_abilities.gd | 271 +++++++++++++++++++++ vessels/GarrisonField.gd.uid | 1 + vessels/GasBomberVesselDefinition.tres | 2 + vessels/LaserVesselDefinition.tres | 2 + vessels/VesselCooldownFillComponent.gd | 29 +++ vessels/VesselDefinition.gd | 7 + vessels/VesselGarrisonComponent.gd | 43 +++- vessels/VesselGarrisonComponent.gd.uid | 1 + vessels/VesselGasBomberComponent.gd | 22 ++ vessels/VesselLaserComponent.gd | 23 ++ vfx/GasBombEffect.gd | 107 ++++++++ vfx/LaserBeamEffect.gd | 139 +++++++++++ 16 files changed, 806 insertions(+), 206 deletions(-) create mode 100644 tests/test_garrison_abilities.gd create mode 100644 vessels/GarrisonField.gd.uid create mode 100644 vessels/VesselGarrisonComponent.gd.uid create mode 100644 vfx/GasBombEffect.gd create mode 100644 vfx/LaserBeamEffect.gd diff --git a/buildings/GasBurnerBuildingDefinition.tres b/buildings/GasBurnerBuildingDefinition.tres index 0f7d813..4f2fd22 100644 --- a/buildings/GasBurnerBuildingDefinition.tres +++ b/buildings/GasBurnerBuildingDefinition.tres @@ -1,7 +1,8 @@ -[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3] -[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"] +[gd_resource type="Resource" script_class="BuildingDefinition" format=3 uid="uid://cia6vbju65sbe"] + +[ext_resource type="Script" uid="uid://d10s0frtxiw62" path="res://buildings/BuildingDefinition.gd" id="1"] [ext_resource type="PackedScene" path="res://buildings/GasBurnerBuildingVisual.tscn" id="2"] -[ext_resource type="Script" path="res://buildings/BuildingLogisticsProfile.gd" id="3"] +[ext_resource type="Script" uid="uid://cydp18abylrgd" path="res://buildings/BuildingLogisticsProfile.gd" id="3"] [sub_resource type="Resource" id="Resource_logistics"] script = ExtResource("3") @@ -27,8 +28,6 @@ capabilities = PackedStringArray("gas_burner") logistics = SubResource("Resource_logistics") map_color = Color(0.95, 0.35, 0.15, 1) map_sides = 5 -map_radius = 7.0 -map_phase = 0.0 menu_button_name = "GasBurnerButton" menu_label_name = "GasBurnerLabel" menu_cost_name = "GasBurnerCost" diff --git a/controllers/GasBombController.gd b/controllers/GasBombController.gd index f4fc9d3..8642713 100644 --- a/controllers/GasBombController.gd +++ b/controllers/GasBombController.gd @@ -1,16 +1,26 @@ class_name GasBombController extends "res://controllers/AimedFleetAbilityController.gd" -const BOMB_COLOR := Color(0.96, 0.22, 0.72) -const TARGET_COLOR := Color(0.78, 0.32, 1.0) -const MAX_RANGE := 950.0 -const AOE_RADIUS := 180.0 -const DRAG_RADIUS := 260.0 -const PROJECTILE_SPEED := 720.0 -const DAMAGE := 3 -const COOLDOWN := 5.0 -const GAS_PER_SHOT: int = 5 +# The player's end of the gas bomb: the button, the aim, the fleet's +# magazine. The SHOT itself is a GasBombEffect in the world (see that file), +# lifted out of here for the same reason the beam was lifted out of +# LaserController - a garrisoned bomber fires the same bomb from a different +# trigger, and none of this controller is involved in that. +const BOMB_EFFECT = preload("res://vfx/GasBombEffect.gd") +const BOMB_COLOR := BOMB_EFFECT.BOMB_COLOR +const TARGET_COLOR := Color(0.78, 0.32, 1.0) +const MAX_RANGE := VesselGasBomberComponent.MAX_RANGE +const AOE_RADIUS := BOMB_EFFECT.AOE_RADIUS +const DRAG_RADIUS := 260.0 +const DAMAGE := VesselGasBomberComponent.BLAST_DAMAGE +const COOLDOWN := VesselGasBomberComponent.SHOT_COOLDOWN +const GAS_PER_SHOT: int = VesselGasBomberComponent.GAS_PER_SHOT + +# The bombs this controller has thrown that are still alive - in flight or +# still blooming. Split into the two lists the rest of the game already asks +# about rather than one, so "is a bomb still travelling" stays a question +# with a cheap answer. var projectiles: Array = [] var explosions: Array = [] var last_hit_count: int = 0 @@ -22,7 +32,8 @@ var explosion_time: float: get: var longest: float = 0.0 for explosion in explosions: - longest = maxf(longest, float(explosion.time)) + if is_instance_valid(explosion): + longest = maxf(longest, float(explosion.explosion_time)) return longest @@ -48,78 +59,46 @@ func _consume_shot(component: Node) -> bool: func _perform_shot(ship: Node2D, _component: Node) -> void: - projectiles.append({ - "position": ship.global_position, - "target": aim_target, - "trail": PackedVector2Array([ship.global_position]), - }) + # The cooldown was already spent by _consume_shot() above, so this + # launches the effect directly rather than going through fire_ability() + # and spending it twice. + var effect: Node2D = BOMB_EFFECT.launch(get_tree().root, ship.global_position, + aim_target, DAMAGE) + effect.struck.connect(_on_bomb_struck) + projectiles.append(effect) + + +func _on_bomb_struck(hit_count: int) -> void: + last_hit_count = hit_count func _next_ready_bomber() -> Node2D: return _next_ready_ship() -func _advance_effects(delta: float) -> void: - for i in range(projectiles.size() - 1, -1, -1): - var projectile: Dictionary = projectiles[i] - var distance: float = (projectile.position as Vector2).distance_to(projectile.target) - if distance <= PROJECTILE_SPEED * delta: - projectile.position = projectile.target - _explode(projectile.target) - projectiles.remove_at(i) +func _advance_effects(_delta: float) -> void: + # The effects fly, explode and free themselves. This only re-sorts them + # into "still travelling" and "still blooming", and forgets the gone. + var flying: Array = [] + var blooming: Array = [] + for effect in projectiles + explosions: + if not is_instance_valid(effect): + continue + if effect.in_flight(): + flying.append(effect) else: - projectile.position = (projectile.position as Vector2).move_toward( - projectile.target, PROJECTILE_SPEED * delta) - var trail: PackedVector2Array = projectile.trail - trail.append(projectile.position) - while trail.size() > 12: - trail.remove_at(0) - projectile.trail = trail - for i in range(explosions.size() - 1, -1, -1): - var explosion: Dictionary = explosions[i] - explosion.time = maxf(float(explosion.time) - delta, 0.0) - if float(explosion.time) <= 0.0: - explosions.remove_at(i) - - -func _explode(at: Vector2) -> void: - explosions.append({"position": at, "time": 0.8}) - last_hit_count = 0 - # Asteroids too - a blast that clears everything around it but leaves the - # rocks untouched would need explaining, and they take damage the same way. - for enemy in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids"): - if is_instance_valid(enemy) and enemy is Node2D \ - and enemy.global_position.distance_to(at) <= AOE_RADIUS: - enemy.take_damage(DAMAGE) - last_hit_count += 1 + blooming.append(effect) + projectiles = flying + explosions = blooming func _draw() -> void: - if aiming: - NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8) - _draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8) - _draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0) - for projectile in projectiles: - var trail: PackedVector2Array = projectile.trail - if trail.size() >= 2: - for i in range(1, trail.size()): - var alpha: float = float(i) / trail.size() - draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65), - 4.0 + alpha * 4.0, true) - draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35)) - NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0) - for explosion in explosions: - var progress: float = 1.0 - float(explosion.time) / 0.8 - var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) - draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) - _draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress) - for i in range(7): - var angle: float = TAU * float(i) / 7.0 + progress - var cloud_at: Vector2 = explosion.position \ - + Vector2(cos(angle), sin(angle)) * radius * 0.55 - draw_circle(cloud_at, 18.0 + 14.0 * progress, - Color(BOMB_COLOR, (1.0 - progress) * 0.22)) + # Only the aim guide. Each bomb draws itself, in its own node. + if not aiming: + return + NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8) + _draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8) + _draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0) func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void: diff --git a/controllers/LaserController.gd b/controllers/LaserController.gd index 799b7ce..3470864 100644 --- a/controllers/LaserController.gd +++ b/controllers/LaserController.gd @@ -1,39 +1,26 @@ class_name LaserController extends "res://controllers/AimedFleetAbilityController.gd" -const BEAM_COLOR := Color(0.3, 0.9, 1.0) -const TARGET_COLOR := Color(0.6, 0.95, 1.0) -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 -# #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 - which is the sabre look the report asked -# for and also the only part of the shot the eye can follow. +# The player's end of the laser: the button, the aim, the fleet's magazine. +# The SHOT itself is a LaserBeamEffect in the world (see that file) - it was +# lifted out of here so a garrisoned laser vessel could fire the same beam +# from a different trigger without this controller being involved at all. # -# Long enough for the extension to read as movement rather than as a -# stutter, still short enough to be gone before the 4s cooldown is up. -const FLASH_DURATION := 0.34 -# The fraction of that life spent extending. The rest holds and decays. -const IGNITE_FRACTION := 0.3 -# The white-hot rod down the middle of the coloured glow. Drawn INSIDE the -# beam's own width, never wider: the drawn beam is deliberately the same -# span as the hit test (see BEAM_WIDTH_SCALE above), and a halo painted past -# that would put the "it visibly missed" complaint back the other way round. -const CORE_SCALE := 0.42 -const CORE_COLOR := Color(0.85, 0.99, 1.0) -# The leading cap, and the flare left at the emitter. Both are radii, both -# stay within the beam's half width so nothing suggests a wider reach. -const TIP_RADIUS := BEAM_HALF_WIDTH * 0.55 -const MUZZLE_RADIUS := BEAM_HALF_WIDTH * 0.8 -const ENERGY_PER_SHOT: int = 8 -# By path, not by class name - see the note at the top of MuzzleSparkVFX. -const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") +# What is left here is everything that is genuinely about the player firing: +# where they aimed, what it costs the fleet hold, and the guide line drawn +# while they aim it. +const BEAM_EFFECT = preload("res://vfx/LaserBeamEffect.gd") +const BEAM_COLOR := BEAM_EFFECT.BEAM_COLOR +const TARGET_COLOR := Color(0.6, 0.95, 1.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 + +# The beams this controller has fired and that are still alive. Kept so the +# fleet's own shots can be reasoned about - "is anything still on screen" for +# the redraw, and the tests' view of what a shot did - without this becoming +# the owner of them again. var beams: Array = [] var last_hit_count: int = 0 @@ -72,101 +59,37 @@ func _consume_shot(component: Node) -> bool: func _perform_shot(ship: Node2D, component: Node) -> void: + # The cooldown was already spent by _consume_shot() above, so this fires + # the effect directly rather than going through fire_ability() and + # spending it twice. var direction: Vector2 = aim_target - _aim_origin() direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP - var beam_to: Vector2 = ship.global_position + direction * component.beam_range - _strike(ship.global_position, beam_to, direction, int(component.damage)) - # #90: something happens at the hull, not just out in space. The ordinary - # gun already does this (#87); the laser fired in complete silence at its - # own end, which is most of why a shot read as a line appearing rather - # than as a vessel firing. - MUZZLE_SPARK.spawn(get_tree().root, ship.global_position, direction, BEAM_COLOR) + var effect: Node2D = BEAM_EFFECT.fire(get_tree().root, ship.global_position, + direction, component.beam_range, int(component.damage)) + beams.append(effect) + last_hit_count = int(effect.hit_count) func _next_ready_laser_ship() -> Node2D: return _next_ready_ship() -func _advance_effects(delta: float) -> void: - for i in range(beams.size() - 1, -1, -1): - var beam: Dictionary = beams[i] - beam.time = maxf(float(beam.time) - delta, 0.0) - if float(beam.time) <= 0.0: - beams.remove_at(i) +func _advance_effects(_delta: float) -> void: + # The effects animate and free themselves; this only forgets the ones that + # have gone. + beams = beams.filter(func(beam): return is_instance_valid(beam)) -func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: - # The damage below is dealt HERE, in full, along the whole beam, at the - # instant of the shot. The extension in _draw is the picture of that, not - # a travelling projectile - a beam that had to arrive would be a change - # to how the weapon plays, which #90 did not ask for. - beams.append({ - "from": from, "to": to, "time": FLASH_DURATION, - # Per beam, so two lasers firing together do not flicker in lockstep. - "phase": randf() * TAU, - }) - last_hit_count = 0 - var range_along: float = from.distance_to(to) - # Asteroids share the enemies' take_damage() contract, so the beam sweeps - # both groups in one pass. Shooting a path clear through a field is a - # perfectly good use of a laser, and the ore it leaves behind is the point. - for enemy in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids"): - if not is_instance_valid(enemy) or not (enemy is Node2D): - continue - var to_enemy: Vector2 = enemy.global_position - from - var along: float = to_enemy.dot(direction) - if along < 0.0 or along > range_along: - continue - var perpendicular: float = (to_enemy - direction * along).length() - if perpendicular <= BEAM_HALF_WIDTH: - enemy.take_damage(damage) - last_hit_count += 1 +func beam_extension(beam: Node2D) -> float: + return beam.extension() -# How far along its length a beam is currently drawn. Cubic ease-out, so it -# leaves the ship fast and settles into full reach rather than crawling. -# Public because it is the whole of #90's "it comes out of the ship" and is -# worth asserting on directly. -func beam_extension(beam: Dictionary) -> float: - var progress: float = 1.0 - float(beam.time) / FLASH_DURATION - var out: float = clampf(progress / IGNITE_FRACTION, 0.0, 1.0) - return 1.0 - pow(1.0 - out, 3.0) - - -func beam_tip(beam: Dictionary) -> Vector2: - return Vector2(beam.from).lerp(Vector2(beam.to), beam_extension(beam)) +func beam_tip(beam: Node2D) -> Vector2: + return beam.tip() func _draw() -> void: + # Only the aim guide. Each beam draws itself, in its own node. if aiming: NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6) - for beam in beams: - var progress: float = 1.0 - float(beam.time) / FLASH_DURATION - # Full brightness while it extends, then away. Squared so it holds - # its light and drops at the end, instead of dimming from the first - # frame the way a plain linear fade did. - var decay: float = clampf((progress - IGNITE_FRACTION) / (1.0 - IGNITE_FRACTION), 0.0, 1.0) - var alpha: float = 1.0 - decay * decay - # A live blade hums. Small enough to be texture rather than a blink. - var flicker: float = 1.0 + 0.09 * sin(float(beam.time) * 70.0 + float(beam.phase)) - var tip: Vector2 = beam_tip(beam) - var from: Vector2 = beam.from - - NeonDraw.draw_glowing_line(self, from, tip, BEAM_COLOR, alpha, - BEAM_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, tip, CORE_COLOR, alpha, - BEAM_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 - NeonDraw.draw_glowing_circle(self, tip, 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) - if muzzle > 1.0: - NeonDraw.draw_glowing_circle(self, from, muzzle, CORE_COLOR, - alpha * (1.0 - progress)) diff --git a/core/GameState.gd b/core/GameState.gd index 5582079..8ee5ed4 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -221,6 +221,19 @@ func _inventory_for_record(record: Dictionary) -> RefCounted: if inventory == null: inventory = INVENTORY.new() record.inventory = inventory + # An outpost holds exactly what its garrison can use, and the garrison + # changes. Re-applied on every access rather than when the crew changes, + # because there is no single place a crew change goes through - a vessel + # arriving, one being released and one being shot down are three. + if is_outpost(record): + # Every resource gets a ceiling, not only the ones a magazine wants: + # an outpost is not a depot, and ore parked at one has nothing to do + # there and no transporter coming for it. + var capacities: Dictionary = {} + var magazines: Dictionary = garrison_magazines(int(record.id)) + for resource_type in resource_types(): + capacities[resource_type] = int(magazines.get(resource_type, 0)) + inventory.resource_capacities = capacities # Compatibility migration for state made before #33. Erase each legacy # field once absorbed so it cannot drift out of step with the inventory. if record.has("stock"): @@ -787,13 +800,14 @@ const GARRISON_BASE_SLOTS: int = 2 # rather than as a list of types, so a new armed vessel is eligible the day # it is added and an unarmed one never is by accident. # -# "weapon" only, and the laser deliberately left out (#105). A garrison -# vessel has to fight WITHOUT the player, and firing the laser lives in -# LaserController - a fleet ability driven by the laser button - rather than -# in VesselLaserComponent, which only tracks its cooldown and ammunition. A -# garrisoned laser vessel would therefore hold its position and never shoot. -# Offering a vessel that cannot fight is worse than a short list. -const GARRISON_CAPABILITIES: PackedStringArray = ["weapon"] +# #105 shipped with "weapon" only, because firing the laser and the gas bomb +# lived in the player's controllers rather than in the vessels' own +# components - a garrisoned laser would have held its position and never +# shot. Both effects now live in the world as LaserBeamEffect/GasBombEffect +# and both components expose the same fire_ability() seam, so a garrison can +# pull either trigger and the list is no longer a apology for a missing +# feature. +const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser", "gas_bomber"] func outpost_slot_capacity() -> int: @@ -876,6 +890,56 @@ func garrison_crew(id: int) -> Array: return b.garrison_crew +# --- what a garrison keeps beside it --------------------------------------- +# +# An outpost stores ammunition for the vessels actually stationed there: 50 +# gas for each gas bomber, 50 energy for each laser. The numbers come off the +# VESSEL definitions, so the outpost never learns which vessel needs what - +# add an armed vessel with a magazine and its outpost storage follows. +# +# This is a CAPACITY, not a stock. Hub transporters fill it the same way they +# fill a gas burner's buffer (see building_logistics_deficit), and the +# garrison AI spends from it. + +func garrison_magazine_capacity(id: int, resource_type: String) -> int: + var total: int = 0 + for vessel_type in garrison_crew(id): + var definition: Resource = vessel_definition(String(vessel_type)) + if definition == null: + continue + if String(definition.garrison_ammo_resource) != resource_type: + continue + total += int(definition.garrison_ammo_capacity) + return total + + +# Every resource this outpost wants held, as {resource_type: capacity}. +func garrison_magazines(id: int) -> Dictionary: + var out: Dictionary = {} + for vessel_type in garrison_crew(id): + var definition: Resource = vessel_definition(String(vessel_type)) + if definition == null: + continue + var resource_type: String = String(definition.garrison_ammo_resource) + if resource_type == "" or int(definition.garrison_ammo_capacity) <= 0: + continue + out[resource_type] = int(out.get(resource_type, 0)) \ + + int(definition.garrison_ammo_capacity) + return out + + +# Spends from the outpost's own store. Returns false when it cannot pay in +# full - a half-priced shot would be worse than no shot. +func spend_garrison_ammo(id: int, resource_type: String, amount: int) -> bool: + if amount <= 0: + return true + var inventory: RefCounted = building_inventory(id) + if inventory == null or inventory.amount(resource_type) < amount: + return false + inventory.withdraw(resource_type, amount) + return true + + func garrison_crew_of(id: int, vessel_type: String) -> int: var n: int = 0 for crewed in garrison_crew(id): @@ -1862,10 +1926,14 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted: func building_logistics_deficit(id: int, resource_type: String) -> int: var building: Dictionary = find_building(id) + var target: int = 0 var profile: Resource = building_logistics_profile(building) - if profile == null: - return 0 - var target: int = int(profile.input_buffers.get(resource_type, 0)) + if profile != null: + target = int(profile.input_buffers.get(resource_type, 0)) + # An outpost has no logistics profile at all - what it wants held depends + # on who is stationed there, which is not something a definition can + # state. Taking the larger of the two keeps the static case untouched. + target = maxi(target, garrison_magazine_capacity(id, resource_type)) return maxi(0, target - building_resource(id, resource_type)) diff --git a/tests/test_garrison_abilities.gd b/tests/test_garrison_abilities.gd new file mode 100644 index 0000000..3d62221 --- /dev/null +++ b/tests/test_garrison_abilities.gd @@ -0,0 +1,271 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_garrison_abilities.gd +# +# Laser vessels and gas bombers can be garrisoned, and fight from an outpost +# using the outpost's own ammunition. +# +# #105 shipped with interceptors only, because firing the laser and the bomb +# lived in the player's controllers rather than in the vessels' components - +# a garrisoned laser would have held its position and never shot. Both +# effects now live in the world (LaserBeamEffect / GasBombEffect) and both +# components expose the same fire_ability() seam, so the garrison AI pulls +# either trigger without knowing which it is. +# +# Covers: +# A. both are garrisonable, and the unarmed vessels still are not +# B. the outpost's magazine is 50 per vessel of the right resource, and it +# is the CREW that decides it - two bombers want 100 gas +# C. it cannot be overfilled, by a transporter or by hand +# D. an outpost short of ammunition asks for it, so hub logistics fill it +# E. a garrisoned laser fires on an enemy, damages it, and pays out of the +# OUTPOST's store rather than the fleet's hold +# F. with an empty store it holds fire - no free shots +# G. a garrisoned gas bomber lands a bomb, and pays in gas +# H. one abstraction, not two: the AI never names an ability + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var outpost_id: int = 0 +var guard: Node2D +var enemy: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var energy_before: int = 0 +var gas_before: int = 0 +var hp_before: int = 0 +var fleet_energy_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append("forward_bases") + game_state.vessel_levels["unlock_laser"] = 1 + game_state.vessel_levels["unlock_gas_bomber"] = 1 + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("GarrisonField") + + +func _fail(message: String) -> bool: + print("TEST_GARRISON_ABILITIES: FAIL (%s)" % message) + quit(1) + return true + + +func _dummy(at: Vector2) -> Node2D: + # Pinned and effectively immortal: the point is whether the garrison + # SHOOTS, and an enemy that died to the first hit would end the + # observation before the amount spent could be read. + var dummy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + dummy.enemy_id = "chaser" + dummy.position = at + main.add_child(dummy) + dummy.speed = 0.0 + dummy.hp = 100000 + return dummy + + +func _station(vessel_type: String) -> void: + game_state.set_garrison_desired(outpost_id, vessel_type, + game_state.garrison_desired_of(outpost_id, vessel_type) + 1) + game_state.garrison_crew(outpost_id).append(vessel_type) + + +func _state_cases() -> bool: + # --- A. who may hold a position ------------------------------------- + var eligible: PackedStringArray = game_state.garrison_vessel_types() + for armed in [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, + GameState.VESSEL_GAS_BOMBER]: + if not (armed in eligible): + return _fail("%s cannot be garrisoned" % armed) + for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER, + GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER]: + if game_state.vessel_can_garrison(unarmed): + return _fail("%s can be garrisoned - it does not attack" % unarmed) + print("garrisonable: %s" % str(eligible)) + + # --- B. the magazine follows the crew -------------------------------- + outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(2000.0, 0.0)) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 0: + return _fail("an empty outpost already wants gas") + _station(GameState.VESSEL_GAS_BOMBER) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 50: + return _fail("one bomber asks for %d gas, expected 50" \ + % game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS)) + _station(GameState.VESSEL_GAS_BOMBER) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 100: + return _fail("two bombers ask for %d gas, expected 100" \ + % game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS)) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY) != 0: + return _fail("bombers ask for energy they cannot use") + _station(GameState.VESSEL_LASER) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY) != 50: + return _fail("one laser asks for %d energy, expected 50" \ + % game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY)) + print("2 bombers + 1 laser -> 100 gas, 50 energy") + + # --- C. it cannot be overfilled -------------------------------------- + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_GAS, 400) + if game_state.building_resource(outpost_id, GameState.RESOURCE_GAS) != 100: + return _fail("400 gas into a 100-gas outpost left %d" \ + % game_state.building_resource(outpost_id, GameState.RESOURCE_GAS)) + # An interceptor needs no magazine, so it must not create one. + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_ORE, 50) + if game_state.building_resource(outpost_id, GameState.RESOURCE_ORE) != 0: + return _fail("the outpost accepted ore nothing stationed there can use") + print("capped at 100 gas, and refuses what nobody there can use") + + # --- D. and it asks for what it is short of -------------------------- + game_state.building_inventory(outpost_id).set_amount(GameState.RESOURCE_GAS, 20) + var wanted: int = game_state.building_requested_resource(outpost_id, GameState.RESOURCE_GAS) + if wanted != 80: + return _fail("an outpost holding 20 of 100 gas asks for %d" % wanted) + print("holding 20 of 100, it asks hub logistics for the other 80") + return false + + +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) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + if _state_cases(): + return true + + # A fresh outpost with a single laser, in the world this time. + game_state.buildings.clear() + main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2.ZERO) + outpost_id = int(game_state.buildings[-1].id) + _station(GameState.VESSEL_LASER) + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_ENERGY, 50) + # The fleet hold is deliberately full of energy: if the garrison + # spent from THERE, this is what would move. + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 100) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + var here: Array = field.ships_at(outpost_id) + if here.size() != 1: + return _fail("the laser outpost put %d vessels in the world" % here.size()) + guard = here[0] + if guard.get_component("laser") == null: + return _fail("the stationed vessel has no laser component") + enemy = _dummy(Vector2(320.0, 0.0)) + hp_before = enemy.hp + energy_before = game_state.building_resource(outpost_id, GameState.RESOURCE_ENERGY) + fleet_energy_before = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + elapsed = 0.0 + stage = 2 + return false + 2: + # --- E. it fires, hits, and pays from the outpost ------------- + if elapsed < 2.0: + return false + if not is_instance_valid(enemy): + return _fail("the target dummy is gone") + if enemy.hp >= hp_before: + return _fail("a garrisoned laser never fired on an enemy 320px away") + var spent: int = energy_before - game_state.building_resource( + outpost_id, GameState.RESOURCE_ENERGY) + if spent <= 0: + return _fail("the laser fired without spending any energy") + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != fleet_energy_before: + return _fail("the garrison spent %d energy out of the FLEET's hold" \ + % (fleet_energy_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY))) + print("garrisoned laser: %d damage dealt, %d energy taken from the outpost, fleet hold untouched" \ + % [hp_before - enemy.hp, spent]) + + # --- F. an empty store holds fire --------------------------- + game_state.building_inventory(outpost_id).set_amount(GameState.RESOURCE_ENERGY, 0) + hp_before = enemy.hp + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 2.0: + return false + if enemy.hp < hp_before: + return _fail("the laser kept firing with an empty magazine") + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != fleet_energy_before: + return _fail("an empty outpost fell back on the fleet's hold") + print("out of energy: it holds fire rather than taking the fleet's") + + # --- G. the bomber, through the same seam -------------------- + enemy.queue_free() + game_state.buildings.clear() + main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(6000.0, 0.0)) + outpost_id = int(game_state.buildings[-1].id) + _station(GameState.VESSEL_GAS_BOMBER) + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_GAS, 50) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.4: + return false + var crew: Array = field.ships_at(outpost_id) + if crew.size() != 1: + return _fail("the bomber outpost put %d vessels in the world" % crew.size()) + guard = crew[0] + if guard.get_component("gas_bomber") == null: + return _fail("the stationed vessel has no gas bomber component") + enemy = _dummy(Vector2(6000.0, 320.0)) + hp_before = enemy.hp + gas_before = game_state.building_resource(outpost_id, GameState.RESOURCE_GAS) + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 3.0: + return false + if not is_instance_valid(enemy): + return _fail("the bomber's target is gone") + if enemy.hp >= hp_before: + return _fail("a garrisoned gas bomber never landed a bomb") + var gas_spent: int = gas_before - game_state.building_resource( + outpost_id, GameState.RESOURCE_GAS) + if gas_spent <= 0: + return _fail("the bomb was thrown without spending gas") + print("garrisoned bomber: %d damage dealt, %d gas taken from the outpost" \ + % [hp_before - enemy.hp, gas_spent]) + + # --- H. the AI names no ability ------------------------------ + var source: String = FileAccess.get_file_as_string( + "res://vessels/VesselGarrisonComponent.gd") + for named in ["laser", "gas_bomber", "LaserBeam", "GasBomb"]: + if named in source: + return _fail("the garrison AI mentions \"%s\" - the seam is not doing its job" \ + % named) + print("the garrison AI never names an ability - it asks components what they can do") + + print("TEST_GARRISON_ABILITIES: PASS") + quit(0) + return true + return false diff --git a/vessels/GarrisonField.gd.uid b/vessels/GarrisonField.gd.uid new file mode 100644 index 0000000..056124c --- /dev/null +++ b/vessels/GarrisonField.gd.uid @@ -0,0 +1 @@ +uid://twdm4fm0wovs diff --git a/vessels/GasBomberVesselDefinition.tres b/vessels/GasBomberVesselDefinition.tres index 7ec8383..a43f850 100644 --- a/vessels/GasBomberVesselDefinition.tres +++ b/vessels/GasBomberVesselDefinition.tres @@ -25,3 +25,5 @@ unlock_cost = 0 unlock_resource_costs = {"gas": 30} research_point_cost = 1 unlock_icon = 31 +garrison_ammo_resource = "gas" +garrison_ammo_capacity = 50 diff --git a/vessels/LaserVesselDefinition.tres b/vessels/LaserVesselDefinition.tres index db53c39..76a840d 100644 --- a/vessels/LaserVesselDefinition.tres +++ b/vessels/LaserVesselDefinition.tres @@ -28,3 +28,5 @@ unlock_cost = 0 unlock_resource_costs = {"energy": 40} research_point_cost = 1 unlock_icon = -1 +garrison_ammo_resource = "energy" +garrison_ammo_capacity = 50 diff --git a/vessels/VesselCooldownFillComponent.gd b/vessels/VesselCooldownFillComponent.gd index f1c1b36..c72294b 100644 --- a/vessels/VesselCooldownFillComponent.gd +++ b/vessels/VesselCooldownFillComponent.gd @@ -41,6 +41,35 @@ func tick(delta: float) -> void: queue_redraw() +# --- the ability seam ------------------------------------------------------ +# +# Everything a caller needs to fire this vessel's ability WITHOUT knowing +# which ability it is. Both the player's controllers and the outpost garrison +# AI go through these, so a beam and a bomb are the same shape of thing to +# whoever pulls the trigger, and adding a third ability means implementing +# these rather than editing the AI. +# +# `ability_ammo_*` describe the magazine, not where it comes from: the fleet +# spends from its hold, a garrison from its outpost's store, and neither +# concern belongs down here. + +func has_ability() -> bool: + return ability_range() > 0.0 + + +func ability_range() -> float: return 0.0 +func ability_ammo_resource() -> String: return "" +func ability_ammo_cost() -> int: return 0 +func ability_cooldown() -> float: return cooldown_duration + + +# Spends the cooldown and puts the effect in the world. Returns false when the +# ability was not ready - ammunition is the CALLER's business, because only +# the caller knows which store it is spending from. +func fire_ability(_world: Node, _origin: Vector2, _at: Vector2) -> bool: + return false + + func cooldown_progress() -> float: return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) diff --git a/vessels/VesselDefinition.gd b/vessels/VesselDefinition.gd index 3abb992..ba2a988 100644 --- a/vessels/VesselDefinition.gd +++ b/vessels/VesselDefinition.gd @@ -24,6 +24,13 @@ extends Resource Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16), ]) @export var fleet_eligible: bool = false +# What one of these keeps beside it when stationed at an outpost, and which +# resource that is. Data here rather than a table in the outpost, so a new +# armed vessel brings its own magazine with it and the outpost never has to +# learn about it. 0 means it needs no ammunition to fight - the interceptor's +# gun is the case that does. +@export var garrison_ammo_resource: String = "" +@export var garrison_ammo_capacity: int = 0 @export var capabilities: PackedStringArray = [] @export var component_scenes: Array[PackedScene] = [] @export var base_stats: Dictionary = {} diff --git a/vessels/VesselGarrisonComponent.gd b/vessels/VesselGarrisonComponent.gd index d910ed3..d2e5365 100644 --- a/vessels/VesselGarrisonComponent.gd +++ b/vessels/VesselGarrisonComponent.gd @@ -86,17 +86,44 @@ func _engage(ship: Node2D, target: Node2D, delta: float) -> void: # snap the vessel back to whatever bearing it left on. _angle += orbit_speed * delta - var weapon: Node = ship.get_component("weapon") if ship.has_method("get_component") else null - if weapon == null: - return - if distance <= weapon.fire_range: - weapon.set_fire_direction(to_target) - else: - weapon.set_fire_direction(Vector2.ZERO) + _shoot(ship, target, distance, to_target) + + +# Two kinds of armament, one rule: point the gun, or fire the ability. Which +# one this vessel has is a question about its components, not about its type, +# so a third armed vessel needs nothing here. +func _shoot(ship: Node2D, target: Node2D, distance: float, to_target: Vector2) -> void: + var weapon: Node = _component(ship, "weapon") + if weapon != null: + # The gun is a held trigger: it is aimed every tick and stops the + # moment the direction is cleared. + weapon.set_fire_direction(to_target if distance <= weapon.fire_range else Vector2.ZERO) + for component in ship.get_children(): + if not component.has_method("fire_ability") or not component.has_ability(): + continue + if not component.is_ready() or distance > component.ability_range(): + continue + # The magazine is the OUTPOST's, not the fleet's hold. A garrison + # spends what was flown out to it, which is the whole point of the + # outpost holding a store at all - and if the store is empty the + # vessel holds its fire rather than firing free shots. + var resource_type: String = component.ability_ammo_resource() + var cost: int = component.ability_ammo_cost() + if not GameState.spend_garrison_ammo(outpost_id, resource_type, cost): + continue + if not component.fire_ability(get_tree().root, ship.global_position, + target.global_position): + # It refused after being paid - put the ammunition back rather + # than burning it on a shot that never happened. + GameState.building_inventory(outpost_id).deposit(resource_type, cost) + + +func _component(ship: Node2D, capability: String) -> Node: + return ship.get_component(capability) if ship.has_method("get_component") else null func _hold_fire(ship: Node2D) -> void: - var weapon: Node = ship.get_component("weapon") if ship.has_method("get_component") else null + var weapon: Node = _component(ship, "weapon") if weapon != null: weapon.set_fire_direction(Vector2.ZERO) diff --git a/vessels/VesselGarrisonComponent.gd.uid b/vessels/VesselGarrisonComponent.gd.uid new file mode 100644 index 0000000..1b51da5 --- /dev/null +++ b/vessels/VesselGarrisonComponent.gd.uid @@ -0,0 +1 @@ +uid://c3xwl4xetfdyn diff --git a/vessels/VesselGasBomberComponent.gd b/vessels/VesselGasBomberComponent.gd index b46bc33..d9a0ede 100644 --- a/vessels/VesselGasBomberComponent.gd +++ b/vessels/VesselGasBomberComponent.gd @@ -11,6 +11,28 @@ func _init() -> void: cooldown_duration = 5.0 +const BOMB_EFFECT = preload("res://vfx/GasBombEffect.gd") +const GAS_PER_SHOT: int = 5 +const MAX_RANGE: float = 950.0 +const BLAST_DAMAGE: int = 3 +const SHOT_COOLDOWN: float = 5.0 + + +func ability_range() -> float: return MAX_RANGE +func ability_ammo_resource() -> String: return GameState.RESOURCE_GAS +func ability_ammo_cost() -> int: return GAS_PER_SHOT +func ability_cooldown() -> float: return SHOT_COOLDOWN + + +func fire_ability(world: Node, origin: Vector2, at: Vector2) -> bool: + if not consume_shot(SHOT_COOLDOWN): + return false + # Unlike the beam, the bomb goes to the POINT it was aimed at, and it has + # to travel there - which is what makes it dodgeable. + BOMB_EFFECT.launch(world, origin, at, BLAST_DAMAGE) + return true + + func consume_shot(duration: float) -> bool: return _begin_cooldown(duration) diff --git a/vessels/VesselLaserComponent.gd b/vessels/VesselLaserComponent.gd index 278c32a..aa03d12 100644 --- a/vessels/VesselLaserComponent.gd +++ b/vessels/VesselLaserComponent.gd @@ -22,6 +22,29 @@ func apply_stats() -> void: queue_redraw() +# #90/#103 follow-up: the beam is a LaserBeamEffect in the world, not +# something the fleet's controller draws. See the class comment there. +const BEAM_EFFECT = preload("res://vfx/LaserBeamEffect.gd") +# What one shot costs, and how much a garrisoned laser keeps beside it. Data +# on the component rather than in the controller: a garrison spends the same +# ammunition through a different trigger. +const ENERGY_PER_SHOT: int = 8 + + +func ability_range() -> float: return beam_range +func ability_ammo_resource() -> String: return GameState.RESOURCE_ENERGY +func ability_ammo_cost() -> int: return ENERGY_PER_SHOT + + +func fire_ability(world: Node, origin: Vector2, at: Vector2) -> bool: + if not consume_shot(): + 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) + return true + + func consume_shot() -> bool: return _begin_cooldown(cooldown_duration) diff --git a/vfx/GasBombEffect.gd b/vfx/GasBombEffect.gd new file mode 100644 index 0000000..a2758f2 --- /dev/null +++ b/vfx/GasBombEffect.gd @@ -0,0 +1,107 @@ +class_name GasBombEffect +extends Node2D + +# The gas bomb itself: the flight, the blast and the picture of both, with +# nothing in it about who threw it. +# +# Lifted out of GasBombController for the same reason LaserBeamEffect was +# lifted out of LaserController - the shot was something only the FLEET could +# do, and a garrisoned bomber needs the same shot from a different trigger +# and a different magazine. Added to the tree root, self-animating, +# self-freeing, and it outlives the vessel that threw it. + +signal struck(hit_count: int) + +const BOMB_COLOR := Color(0.96, 0.22, 0.72) +const AOE_RADIUS := 180.0 +const PROJECTILE_SPEED := 720.0 +const EXPLOSION_DURATION := 0.8 + +var target: Vector2 = Vector2.ZERO +var damage: int = 3 +var trail: PackedVector2Array = PackedVector2Array() +var exploded: bool = false +var explosion_time: float = 0.0 +var hit_count: int = 0 + + +static func launch(world: Node, origin: Vector2, at: Vector2, + blast_damage: int = 3) -> GasBombEffect: + var effect := GasBombEffect.new() + effect.position = origin + effect.target = at + effect.damage = blast_damage + effect.trail = PackedVector2Array([origin]) + world.add_child(effect) + return effect + + +func in_flight() -> bool: + return not exploded + + +func _process(delta: float) -> void: + if not exploded: + _advance_flight(delta) + queue_redraw() + return + explosion_time = maxf(explosion_time - delta, 0.0) + queue_redraw() + if explosion_time <= 0.0: + queue_free() + + +func _advance_flight(delta: float) -> void: + var distance: float = position.distance_to(target) + if distance <= PROJECTILE_SPEED * delta: + position = target + _explode() + return + position = position.move_toward(target, PROJECTILE_SPEED * delta) + trail.append(position) + while trail.size() > 12: + trail.remove_at(0) + + +func _explode() -> void: + exploded = true + explosion_time = EXPLOSION_DURATION + hit_count = 0 + # Asteroids too - a blast that clears everything around it but leaves the + # rocks untouched would need explaining, and they take damage the same way. + for enemy in get_tree().get_nodes_in_group("enemies") \ + + get_tree().get_nodes_in_group("asteroids"): + if is_instance_valid(enemy) and enemy is Node2D \ + and enemy.global_position.distance_to(target) <= AOE_RADIUS: + enemy.take_damage(damage) + hit_count += 1 + struck.emit(hit_count) + + +func _draw() -> void: + # Drawn in this node's own space while it flies (it moves), and around the + # blast point once it lands (it does not). + if not exploded: + if trail.size() >= 2: + for i in range(1, trail.size()): + var alpha: float = float(i) / trail.size() + draw_line(trail[i - 1] - position, trail[i] - position, + Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true) + draw_circle(Vector2.ZERO, 12.0, Color(BOMB_COLOR, 0.35)) + NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 7.0, BOMB_COLOR, 1.0) + return + var progress: float = 1.0 - explosion_time / EXPLOSION_DURATION + var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) + draw_circle(Vector2.ZERO, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) + draw_ring(Vector2.ZERO, radius, BOMB_COLOR, 1.0 - progress) + for i in range(7): + var angle: float = TAU * float(i) / 7.0 + progress + var cloud_at: Vector2 = Vector2(cos(angle), sin(angle)) * radius * 0.55 + draw_circle(cloud_at, 18.0 + 14.0 * progress, + Color(BOMB_COLOR, (1.0 - progress) * 0.22)) + + +func draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void: + draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) + draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) + draw_arc(center, radius, 0.0, TAU, 64, Color(1.0, 0.86, 0.96, 0.9 * alpha), 2.0, true) diff --git a/vfx/LaserBeamEffect.gd b/vfx/LaserBeamEffect.gd new file mode 100644 index 0000000..d530230 --- /dev/null +++ b/vfx/LaserBeamEffect.gd @@ -0,0 +1,139 @@ +class_name LaserBeamEffect +extends Node2D + +# The laser shot itself: the strike and the picture of it, with nothing in it +# about who fired or why. +# +# This used to live inside LaserController, which meant the beam was a thing +# only the FLEET could do - the player's laser button, the fleet's hold, the +# fleet's aim. Garrisoned vessels (#103 follow-up) need the same shot with a +# different trigger and a different magazine, so the effect moved out here. +# Same shape as EnemyBigLaserBeam and ArtilleryShell on the enemy side: added +# to the tree root, self-animating, self-freeing, and it outlives the vessel +# that fired it. +# +# The damage below is dealt at the INSTANT of the shot, in full, along the +# whole beam. The extension in _draw is the picture of that, not a travelling +# projectile - a beam that had to arrive would be a change to how the weapon +# plays. + +signal struck(hit_count: int) + +const BEAM_COLOR := Color(0.3, 0.9, 1.0) +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 +# #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. +const FLASH_DURATION := 0.34 +const IGNITE_FRACTION := 0.3 +# The white-hot rod down the middle of the coloured glow. Drawn INSIDE the +# beam's own width, never wider: the drawn beam is deliberately the same span +# as the hit test, and a halo painted past that would put the "it visibly +# 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 +# By path, not by class name - see the note at the top of MuzzleSparkVFX. +const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") + +var from: Vector2 = Vector2.ZERO +var to: Vector2 = Vector2.ZERO +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 + + +static func fire(world: Node, origin: Vector2, direction: Vector2, + beam_range: float, damage: int) -> LaserBeamEffect: + var effect := LaserBeamEffect.new() + var heading: Vector2 = direction.normalized() if direction.length() > 0.001 else Vector2.UP + effect.from = origin + effect.to = origin + heading * beam_range + effect.phase = randf() * TAU + world.add_child(effect) + effect._strike(heading, damage) + # #90: something happens at the hull, not just out in space. The ordinary + # gun already does this (#87); the laser fired in complete silence at its + # own end, which is most of why a shot read as a line appearing rather + # than as a vessel firing. + MUZZLE_SPARK.spawn(world, origin, heading, BEAM_COLOR) + return effect + + +func _strike(direction: Vector2, damage: int) -> void: + hit_count = 0 + var range_along: float = from.distance_to(to) + # Asteroids share the enemies' take_damage() contract, so the beam sweeps + # both groups in one pass. Shooting a path clear through a field is a + # perfectly good use of a laser, and the ore it leaves behind is the point. + for enemy in get_tree().get_nodes_in_group("enemies") \ + + get_tree().get_nodes_in_group("asteroids"): + if not is_instance_valid(enemy) or not (enemy is Node2D): + continue + var to_enemy: Vector2 = enemy.global_position - from + var along: float = to_enemy.dot(direction) + if along < 0.0 or along > range_along: + continue + var perpendicular: float = (to_enemy - direction * along).length() + if perpendicular <= BEAM_HALF_WIDTH: + enemy.take_damage(damage) + hit_count += 1 + struck.emit(hit_count) + queue_redraw() + + +func _process(delta: float) -> void: + time = maxf(time - delta, 0.0) + queue_redraw() + if time <= 0.0: + queue_free() + + +# How far along its length the beam is currently drawn. Cubic ease-out, so it +# leaves the ship fast and settles into full reach rather than crawling. +# Public because it is the whole of #90's "it comes out of the ship". +func extension() -> float: + var progress: float = 1.0 - time / FLASH_DURATION + var out: float = clampf(progress / IGNITE_FRACTION, 0.0, 1.0) + return 1.0 - pow(1.0 - out, 3.0) + + +func tip() -> Vector2: + return from.lerp(to, extension()) + + +func _draw() -> void: + var progress: float = 1.0 - time / FLASH_DURATION + # Full brightness while it extends, then away. Squared so it holds its + # light and drops at the end, instead of dimming from the first frame the + # way a plain linear fade did. + var decay: float = clampf((progress - IGNITE_FRACTION) / (1.0 - IGNITE_FRACTION), 0.0, 1.0) + var alpha: float = 1.0 - decay * decay + # A live blade hums. Small enough to be texture rather than a blink. + var flicker: float = 1.0 + 0.09 * sin(time * 70.0 + phase) + var at: Vector2 = tip() + + NeonDraw.draw_glowing_line(self, from, at, BEAM_COLOR, alpha, + BEAM_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) + # The leading cap, brightest while the blade is still running out. + var cap: float = TIP_RADIUS * (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) + if muzzle > 1.0: + NeonDraw.draw_glowing_circle(self, from, muzzle, CORE_COLOR, + alpha * (1.0 - progress))