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 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 19:23:35 +02:00
commit b50597b757
16 changed files with 809 additions and 209 deletions

View file

@ -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"

View file

@ -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:

View file

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

View file

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

View file

@ -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.<instance member> 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

View file

@ -0,0 +1 @@
uid://twdm4fm0wovs

View file

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

View file

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

View file

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

View file

@ -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 = {}

View file

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

View file

@ -0,0 +1 @@
uid://c3xwl4xetfdyn

View file

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

View file

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

107
vfx/GasBombEffect.gd Normal file
View file

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

139
vfx/LaserBeamEffect.gd Normal file
View file

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