Add laser ship vessel and its skill shot (#58)

A manually aimed instant beam, modelled on the gas bomber's press-drag-
release aiming (GasBombController/VesselGasBomberComponent) but firing
the moment the touch releases - no projectile travel time. Damage,
beam_range and cooldown are resolved per ship from base_stats/upgrades
(VesselLaserComponent.apply_stats(), the same pattern
VesselWeaponComponent uses) rather than hardcoded in the controller, so
the new damage/range/cooldown ship-lab upgrades actually reach them.
Cooldown lives on the component (per ship), not the controller (per
fleet) - two laser ships give two shots, the exact bug already fixed
for bombers in eb79efd.

LaserController.gd is reused unmodified by both Main.tscn and
PlanetSurface.tscn (relative ../Fleet, ../Camera2D, ../UI/LaserButton
paths, matching GasBombController), so it works identically in space
and on a landed surface. Wired into GameController's touch/drag
first-refusal chain alongside the bomb controller.

The crafter, hangar and ship-lab menus were already generic over
VesselDefinitions.fleet()/.all() - only the per-vessel Main.tscn nodes
are hand-placed, not the logic - so this repacks those rows to
5/5/6 columns and extends BuildController's onready arrays. No new
signal handlers were needed. Also fixes test_vessel_composition.gd's
hardcoded fleet-catalog size again (4 -> 5) now that laser is a fifth
fleet-eligible vessel.

Note for later tickets: a brand-new class_name script (LaserController)
resolves fine as a static type when its own file is the godot -s entry
point, but the identical type hint fails to parse ("Could not find
type") when the script holding it is only reached indirectly - e.g.
Main.tscn loaded from inside a _screenshot_tool.gd preview. Untyped
Node + duck-typed calls sidesteps it; GameController.gd's
laser_controller field documents this in place.
This commit is contained in:
Loïc Wisniewski 2026-08-16 17:29:53 +02:00
commit ed46cd77e6
18 changed files with 858 additions and 64 deletions

100
VesselLaserComponent.gd Normal file
View file

@ -0,0 +1,100 @@
class_name VesselLaserComponent
extends Node2D
# Same shape as VesselGasBomberComponent (per-ship cooldown, ready/cooling
# hull fill) - #58's own note: two laser vessels must give two shots, so
# this lives on the SHIP, not the fleet-wide LaserController.
#
# Unlike the gas bomber, damage/beam_range/cooldown are resolved from the
# vessel's own base_stats/upgrades (apply_stats(), the same pattern
# VesselWeaponComponent uses) rather than hardcoded in the controller, so
# ship-lab upgrades actually reach them.
var capability_id := "laser"
var damage: int = 2
var beam_range: float = 950.0
var cooldown_duration: float = 4.0
var cooldown_left: float = 0.0
const READY_COLOR := Color(0.3, 0.9, 1.0)
const COOLING_COLOR := Color(0.08, 0.28, 0.34)
func setup() -> void:
# Keep the ship's normal neon outline above the charge fill.
show_behind_parent = true
queue_redraw()
func apply_stats() -> void:
var ship: Node = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
damage = int(state.vessel_stat(ship.vessel_type, "damage"))
beam_range = state.vessel_stat(ship.vessel_type, "beam_range")
cooldown_duration = state.vessel_stat(ship.vessel_type, "beam_cooldown")
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func consume_shot() -> bool:
if not is_ready():
return false
cooldown_left = maxf(cooldown_duration, 0.001)
queue_redraw()
return true
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array:
if points.size() < 3 or progress <= 0.001:
return PackedVector2Array()
if progress >= 0.999:
return points.duplicate()
var top := INF
var bottom := -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
var clipped := PackedVector2Array()
var previous: Vector2 = points[-1]
var previous_inside := previous.y >= fill_y
for current in points:
var current_inside := current.y >= fill_y
if current_inside != previous_inside:
var amount: float = (fill_y - previous.y) / (current.y - previous.y)
clipped.append(previous.lerp(current, amount))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
func _draw() -> void:
var parent_ship: Node = get_parent()
if parent_ship == null or parent_ship.definition == null:
return
var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE
var hull: PackedVector2Array = parent_ship.definition.hull_points
if is_ready():
draw_colored_polygon(hull, Color(READY_COLOR, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75)
return
draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58))
var filled := _filled_hull(hull, cooldown_progress())
if filled.size() >= 3:
draw_colored_polygon(filled, Color(READY_COLOR, 0.68))