#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>
95 lines
3.6 KiB
GDScript
95 lines
3.6 KiB
GDScript
class_name LaserController
|
|
extends "res://controllers/AimedFleetAbilityController.gd"
|
|
|
|
# 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.
|
|
#
|
|
# 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
|
|
|
|
|
|
func _button_path() -> NodePath: return "../UI/LaserButton"
|
|
func _status_path() -> NodePath: return "../UI/LaserStatus"
|
|
func _component_id() -> String: return "laser"
|
|
func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY
|
|
func _ammo_cost() -> int: return ENERGY_PER_SHOT
|
|
func _aim_padding() -> float: return BEAM_HALF_WIDTH
|
|
func _has_effect_visuals() -> bool: return not beams.is_empty()
|
|
|
|
|
|
func _aim_max_range() -> float:
|
|
# Falls back to the VESSEL DEFINITION's own beam_range, not to a copy of
|
|
# the number. This used to hardcode 950.0 in both branches, which silently
|
|
# became a lie the moment the definition's range changed - the aim line
|
|
# would have been drawn to a different distance than the beam actually
|
|
# reaches, and only in the cases where the fallback fires.
|
|
var ship: Node2D = _next_ready_ship()
|
|
if ship != null:
|
|
var component: Node = ship.get_component(_component_id())
|
|
if component != null:
|
|
return component.beam_range
|
|
return GameState.vessel_stat(GameState.VESSEL_LASER, "beam_range")
|
|
|
|
|
|
func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2:
|
|
# A beam always reaches full range; dragging chooses its direction only.
|
|
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
|
|
return origin + direction * _aim_max_range()
|
|
|
|
|
|
func _consume_shot(component: Node) -> bool:
|
|
return component.consume_shot()
|
|
|
|
|
|
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 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:
|
|
# 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 beam_extension(beam: Node2D) -> float:
|
|
return beam.extension()
|
|
|
|
|
|
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)
|