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:
parent
0c595d775d
commit
b50597b757
16 changed files with 809 additions and 209 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
271
tests/test_garrison_abilities.gd
Normal file
271
tests/test_garrison_abilities.gd
Normal 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
|
||||
1
vessels/GarrisonField.gd.uid
Normal file
1
vessels/GarrisonField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://twdm4fm0wovs
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
1
vessels/VesselGarrisonComponent.gd.uid
Normal file
1
vessels/VesselGarrisonComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c3xwl4xetfdyn
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
107
vfx/GasBombEffect.gd
Normal 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
139
vfx/LaserBeamEffect.gd
Normal 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))
|
||||
Loading…
Reference in a new issue