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.
2026-08-16 17:29:53 +02:00
|
|
|
extends SceneTree
|
|
|
|
|
|
|
|
|
|
# Headless gameplay test: run with
|
|
|
|
|
# godot --headless -s res://tests/test_laser_vessel.gd
|
|
|
|
|
# Forgejo #58: a manually aimed instant beam, modelled closely on the gas
|
|
|
|
|
# bomber's press-drag-release skill shot (GasBombController/
|
|
|
|
|
# VesselGasBomberComponent) but firing the instant the touch releases -
|
|
|
|
|
# there is no projectile travel time to wait out.
|
|
|
|
|
#
|
|
|
|
|
# Covers:
|
|
|
|
|
# A. the control is absent with no laser ship in the fleet, and appears
|
|
|
|
|
# the moment one joins
|
|
|
|
|
# B. the beam damages every enemy along its line, within beam_half_width,
|
|
|
|
|
# and nothing off it or beyond its range
|
|
|
|
|
# C. cooldown is per SHIP, not per fleet: two laser ships fire two shots
|
|
|
|
|
# before the control disables (the exact bug fixed in eb79efd for
|
|
|
|
|
# bombers)
|
|
|
|
|
# D. the camera zooms out while aiming and restores afterward
|
|
|
|
|
|
|
|
|
|
var game_state: Node
|
|
|
|
|
var main: Node
|
|
|
|
|
var fleet: Node
|
2026-08-16 19:11:31 +02:00
|
|
|
var skill: Node
|
|
|
|
|
# Mirrors LaserController.BEAM_HALF_WIDTH's value rather than referencing
|
|
|
|
|
# the class constant directly - a bare ClassName.CONST reference elsewhere
|
|
|
|
|
# in a test script has been observed to corrupt method resolution for the
|
|
|
|
|
# WHOLE script. See tests/test_gas_bomber_cooldowns.gd's identical fix (#60).
|
|
|
|
|
const BEAM_HALF_WIDTH: float = 22.0
|
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.
2026-08-16 17:29:53 +02:00
|
|
|
var on_line_enemies: Array = []
|
|
|
|
|
var off_line_enemy: Node
|
|
|
|
|
var beyond_range_enemy: Node
|
|
|
|
|
var first_shooter: Node2D
|
|
|
|
|
var second_shooter: Node2D
|
|
|
|
|
var stage: int = 0
|
|
|
|
|
var elapsed: float = 0.0
|
|
|
|
|
var original_zoom: float = 2.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _initialize() -> void:
|
|
|
|
|
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.reset_progression()
|
|
|
|
|
# Direct unlock, same shortcut unlock_vessel() itself uses internally -
|
|
|
|
|
# this ticket is about the laser's own behaviour, not re-proving the
|
|
|
|
|
# ship-lab research flow (already covered generically elsewhere).
|
|
|
|
|
game_state.vessel_levels["unlock_laser"] = 1
|
2026-08-16 19:11:31 +02:00
|
|
|
# #61: shots draw energy from the fleet hold now - stock enough for two
|
|
|
|
|
# fires. Set BEFORE the first spawn_ships() call (inside Main.tscn's own
|
|
|
|
|
# _ready(), triggered by instantiate() below): the fleet hold has no
|
|
|
|
|
# capacity cap yet at this point (#56's cap is only computed once a
|
|
|
|
|
# fleet composition exists), so this deposit can't be clamped by the
|
|
|
|
|
# soon-to-exist all-laser fleet having zero "cargo" capability.
|
|
|
|
|
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 20)
|
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.
2026-08-16 17:29:53 +02:00
|
|
|
main = load("res://Main.tscn").instantiate()
|
|
|
|
|
root.add_child(main)
|
|
|
|
|
fleet = main.get_node("Fleet")
|
|
|
|
|
skill = main.get_node("LaserController")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
|
|
|
elapsed += delta
|
|
|
|
|
match stage:
|
|
|
|
|
0:
|
|
|
|
|
if elapsed < 0.2:
|
|
|
|
|
return false
|
|
|
|
|
main.get_node("EnemySpawner").set_process(false)
|
|
|
|
|
main.get_node("AsteroidField").set_process(false)
|
|
|
|
|
|
|
|
|
|
# --- A. absent, then appears -------------------------------------
|
|
|
|
|
if main.get_node("UI/LaserButton").visible:
|
|
|
|
|
return _fail_now("laser control appeared without a laser ship")
|
|
|
|
|
|
|
|
|
|
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
|
|
|
|
|
GameState.VESSEL_LASER, GameState.VESSEL_LASER]))
|
|
|
|
|
main.get_node("Camera2D").zoom = Vector2(original_zoom, original_zoom)
|
|
|
|
|
skill._process(0.0)
|
|
|
|
|
if not main.get_node("UI/LaserButton").visible:
|
|
|
|
|
return _fail_now("a laser ship in the fleet did not reveal the laser control")
|
|
|
|
|
print("control hidden with no laser ship, visible once one joins")
|
|
|
|
|
|
|
|
|
|
# The beam fires FROM the specific ship LaserController picks, not
|
|
|
|
|
# the fleet centroid (same discrepancy GasBombController already
|
|
|
|
|
# has) - enemies are placed relative to that ship's own position,
|
|
|
|
|
# not the centroid, so a non-zero formation offset can't put them
|
|
|
|
|
# off the real beam line.
|
|
|
|
|
first_shooter = skill._next_ready_laser_ship()
|
|
|
|
|
if first_shooter == null:
|
|
|
|
|
return _fail_now("no laser ship is ready right after spawning")
|
|
|
|
|
|
|
|
|
|
var press := InputEventScreenTouch.new()
|
|
|
|
|
press.index = 7
|
|
|
|
|
var laser_button: Control = main.get_node("UI/LaserButton")
|
|
|
|
|
press.position = laser_button.global_position + laser_button.size * 0.5
|
|
|
|
|
press.pressed = true
|
|
|
|
|
main._handle_touch(press)
|
|
|
|
|
if not skill.aiming:
|
|
|
|
|
return _fail_now("laser control did not begin aiming")
|
|
|
|
|
|
|
|
|
|
# --- D. zoom-out while aiming --------------------------------
|
|
|
|
|
var drag := InputEventScreenDrag.new()
|
|
|
|
|
drag.index = 7
|
|
|
|
|
drag.position = press.position + Vector2(300.0, 0.0)
|
|
|
|
|
main._handle_drag(drag)
|
|
|
|
|
if main.get_node("Camera2D").zoom.x >= original_zoom:
|
|
|
|
|
return _fail_now("aiming did not zoom out")
|
|
|
|
|
print("aiming zoomed the camera out")
|
|
|
|
|
|
|
|
|
|
# --- B. targets: two on the line, one off it, one past range ---
|
|
|
|
|
for offset in [Vector2(200.0, 0.0), Vector2(500.0, 0.0)]:
|
|
|
|
|
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
|
|
|
|
enemy.position = first_shooter.global_position + offset
|
|
|
|
|
main.add_child(enemy)
|
|
|
|
|
enemy.speed = 0.0
|
|
|
|
|
on_line_enemies.append(enemy)
|
|
|
|
|
|
|
|
|
|
off_line_enemy = load("res://Enemy.tscn").instantiate()
|
|
|
|
|
off_line_enemy.position = first_shooter.global_position \
|
2026-08-16 19:11:31 +02:00
|
|
|
+ Vector2(200.0, BEAM_HALF_WIDTH + 60.0)
|
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.
2026-08-16 17:29:53 +02:00
|
|
|
main.add_child(off_line_enemy)
|
|
|
|
|
off_line_enemy.speed = 0.0
|
|
|
|
|
|
|
|
|
|
beyond_range_enemy = load("res://Enemy.tscn").instantiate()
|
|
|
|
|
beyond_range_enemy.position = first_shooter.global_position + Vector2(2000.0, 0.0)
|
|
|
|
|
main.add_child(beyond_range_enemy)
|
|
|
|
|
beyond_range_enemy.speed = 0.0
|
|
|
|
|
|
|
|
|
|
var release := InputEventScreenTouch.new()
|
|
|
|
|
release.index = 7
|
|
|
|
|
release.position = drag.position
|
|
|
|
|
release.pressed = false
|
|
|
|
|
main._handle_touch(release)
|
|
|
|
|
|
|
|
|
|
# Instant beam: the hit already landed by the time _handle_touch
|
|
|
|
|
# returns, no travel time to wait out like the bomb's projectile.
|
|
|
|
|
if skill.last_hit_count != 2:
|
|
|
|
|
return _fail_now("beam hit %d enemies, expected 2" % skill.last_hit_count)
|
|
|
|
|
for enemy in on_line_enemies:
|
|
|
|
|
if not is_instance_valid(enemy) or enemy.hp >= enemy.max_hp:
|
|
|
|
|
return _fail_now("an enemy on the beam line was not damaged")
|
|
|
|
|
if not is_instance_valid(off_line_enemy) or off_line_enemy.hp != off_line_enemy.max_hp:
|
|
|
|
|
return _fail_now("the beam damaged an enemy off its line")
|
|
|
|
|
if not is_instance_valid(beyond_range_enemy) or beyond_range_enemy.hp != beyond_range_enemy.max_hp:
|
|
|
|
|
return _fail_now("the beam damaged an enemy beyond its range")
|
|
|
|
|
print("beam damaged exactly the 2 enemies on its line within range")
|
|
|
|
|
|
|
|
|
|
# --- C. cooldown is per ship, not per fleet ---------------------
|
|
|
|
|
if not main.get_node("UI/LaserButton").enabled:
|
|
|
|
|
return _fail_now("firing one laser ship disabled the control with a second ship still ready")
|
|
|
|
|
print("one ship fired; control stays enabled with the second ready")
|
|
|
|
|
|
|
|
|
|
stage = 1
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
return false
|
|
|
|
|
1:
|
|
|
|
|
if elapsed < 0.3:
|
|
|
|
|
return false
|
|
|
|
|
second_shooter = skill._next_ready_laser_ship()
|
|
|
|
|
if second_shooter == null or second_shooter == first_shooter:
|
|
|
|
|
return _fail_now("the second shot was not offered to the other ship")
|
|
|
|
|
|
|
|
|
|
var press2 := InputEventScreenTouch.new()
|
|
|
|
|
press2.index = 8
|
|
|
|
|
var laser_button: Control = main.get_node("UI/LaserButton")
|
|
|
|
|
press2.position = laser_button.global_position + laser_button.size * 0.5
|
|
|
|
|
press2.pressed = true
|
|
|
|
|
main._handle_touch(press2)
|
|
|
|
|
if not skill.aiming:
|
|
|
|
|
return _fail_now("laser control did not re-open aiming for the second shot")
|
|
|
|
|
var drag2 := InputEventScreenDrag.new()
|
|
|
|
|
drag2.index = 8
|
|
|
|
|
drag2.position = press2.position + Vector2(0.0, 300.0)
|
|
|
|
|
main._handle_drag(drag2)
|
|
|
|
|
var release2 := InputEventScreenTouch.new()
|
|
|
|
|
release2.index = 8
|
|
|
|
|
release2.position = drag2.position
|
|
|
|
|
release2.pressed = false
|
|
|
|
|
main._handle_touch(release2)
|
|
|
|
|
|
|
|
|
|
if main.get_node("UI/LaserButton").enabled:
|
|
|
|
|
return _fail_now("both laser ships fired but the control is still enabled")
|
|
|
|
|
print("second shot fired from the other ship - two ships, two shots, now disabled")
|
|
|
|
|
|
|
|
|
|
stage = 2
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
return false
|
|
|
|
|
2:
|
|
|
|
|
# --- D continued: zoom restores after aiming ends ---------------
|
|
|
|
|
if elapsed < 1.0:
|
|
|
|
|
return false
|
|
|
|
|
if absf(main.get_node("Camera2D").zoom.x - original_zoom) > 0.03:
|
|
|
|
|
return _fail_now("camera did not restore its pre-aim zoom")
|
|
|
|
|
print("camera zoom restored")
|
|
|
|
|
print("TEST_LASER_VESSEL: PASS")
|
|
|
|
|
quit(0)
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _fail_now(message: String) -> bool:
|
|
|
|
|
print("TEST_LASER_VESSEL: FAIL (%s)" % message)
|
|
|
|
|
quit(1)
|
|
|
|
|
return true
|