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: 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 = 38.0 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 # #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) main = load("res://world/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) # Asteroids are shootable now, and the laser and the bomb both # sweep them alongside enemies - so whatever the field already # spawned would be counted as a hit. Silencing the spawner is not # enough; what it placed before that has to go too. Same # clean-slate idiom as test_ship_lab.gd. for hazard in get_nodes_in_group("asteroids"): if is_instance_valid(hazard): hazard.free() # --- 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://enemies/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://enemies/Enemy.tscn").instantiate() off_line_enemy.position = first_shooter.global_position \ + Vector2(200.0, BEAM_HALF_WIDTH + 60.0) main.add_child(off_line_enemy) off_line_enemy.speed = 0.0 beyond_range_enemy = load("res://enemies/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