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:
parent
bbb1d4b0c5
commit
ed46cd77e6
18 changed files with 858 additions and 64 deletions
49
tests/_laser_aim_preview.gd
Normal file
49
tests/_laser_aim_preview.gd
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test - #58's aim-state screenshot: two laser ships (one
|
||||
# ready, one rearming), aiming with the beam preview drawn and enemies
|
||||
# placed along its line.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_laser_aim_preview.tscn /tmp/laser.png 20 720x1280
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.transporters.clear()
|
||||
GameState.vessel_levels["unlock_laser"] = 1
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("BackgroundLayer").visible = false
|
||||
main.get_node("AsteroidField").visible = false
|
||||
main.get_node("PlanetField").visible = false
|
||||
main.get_node("BuildingField").visible = false
|
||||
main.get_node("EnemySpawner").set_process(false)
|
||||
main.get_node("AsteroidField").set_process(false)
|
||||
var fleet: Node = main.get_node("Fleet")
|
||||
fleet.spawn_ships(4, Vector2.ZERO, PackedStringArray([
|
||||
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
|
||||
GameState.VESSEL_LASER, GameState.VESSEL_LASER]))
|
||||
var skill: Node = main.get_node("LaserController")
|
||||
skill._process(0.0)
|
||||
# Show both per-ship states in one frame: one laser ship rearming, the
|
||||
# other ready and supplying the shot.
|
||||
var rearming: Node = fleet.ships[2].get_component("laser")
|
||||
rearming.consume_shot()
|
||||
rearming.tick(rearming.cooldown_duration * 0.5)
|
||||
main.get_node("Camera2D").zoom = Vector2(1.8, 1.8)
|
||||
var button: Control = main.get_node("UI/LaserButton")
|
||||
var press := InputEventScreenTouch.new()
|
||||
press.index = 9
|
||||
press.position = button.global_position + button.size * 0.5
|
||||
press.pressed = true
|
||||
skill.handle_touch(press)
|
||||
var drag := InputEventScreenDrag.new()
|
||||
drag.index = 9
|
||||
drag.position = press.position + Vector2(-245.0, -235.0)
|
||||
skill.handle_drag(drag)
|
||||
for t in [0.35, 0.6]:
|
||||
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
||||
enemy.position = fleet.get_centroid().lerp(skill.aim_target, t)
|
||||
main.add_child(enemy)
|
||||
enemy.speed = 0.0 # after add_child - _ready() sets it from the definition
|
||||
6
tests/_laser_aim_preview.tscn
Normal file
6
tests/_laser_aim_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_laser_aim_preview.gd" id="1"]
|
||||
|
||||
[node name="LaserAimPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
193
tests/test_laser_vessel.gd
Normal file
193
tests/test_laser_vessel.gd
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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
|
||||
var skill: LaserController
|
||||
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
|
||||
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 \
|
||||
+ Vector2(200.0, LaserController.BEAM_HALF_WIDTH + 60.0)
|
||||
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
|
||||
|
|
@ -23,7 +23,7 @@ func _process(delta: float) -> bool:
|
|||
return false
|
||||
var definitions: Array = VESSEL_DEFINITIONS.all()
|
||||
var fleet_definitions: Array = VESSEL_DEFINITIONS.fleet()
|
||||
if fleet_definitions.size() != 4 \
|
||||
if fleet_definitions.size() != 5 \
|
||||
or fleet_definitions.any(func(definition): return definition.has_capability("transport")):
|
||||
return _fail("logistics transporters leaked into the combat-fleet catalog")
|
||||
if ships.size() != definitions.size():
|
||||
|
|
|
|||
Loading…
Reference in a new issue