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
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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue