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 # Mirrored for the same reason, and it bites exactly as documented: a bare # DRAG_RADIUS here made every method call in this script # resolve against a plain Node2D. const DRAG_RADIUS: float = 260.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) # #219: fleet capacity is built now - one berth free, one per Fleet # Office. This test flies more than one vessel, so it needs the offices # standing before the fleet spawns into them. load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6) 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") if absf(first_shooter.get_component("laser").beam_range - 2900.0) > 0.1: return _fail_now("laser ship beam range was not doubled to 2900") # #201 tripled the beam's damage. Read off the COMPONENT rather # than off the definition, so a stat that stops reaching the hull # fails here instead of passing on the strength of its own file. if first_shooter.get_component("laser").damage != 6: return _fail_now("laser damage is %d, expected the tripled 6" \ % first_shooter.get_component("laser").damage) 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) # The aim view eases to its framing rather than cutting to it, so # the drag alone has not moved the camera yet. if not is_equal_approx(main.get_node("Camera2D").zoom.x, original_zoom): return _fail_now("the aim zoom jumped instead of easing") for _step in range(40): skill._process(0.05) if main.get_node("Camera2D").zoom.x >= original_zoom: return _fail_now("aiming did not zoom out") print("aiming eased the camera out instead of cutting to it") # #203: framed to twice the distance of the chosen target, so what # stands behind it is on screen too. The drag above is horizontal, # so the screen's narrow half is the binding one. var half_screen: Vector2 = skill.get_viewport_rect().size * 0.5 \ - Vector2(54.0, 54.0) var aimed_distance: float = skill._aim_origin().distance_to(skill.aim_target) var framed: float = (aimed_distance * 2.0 + BEAM_HALF_WIDTH) \ * main.get_node("Camera2D").zoom.x if framed > half_screen.x + 1.0: return _fail_now("framed %.0fpx of aim on %.0fpx of screen" \ % [framed, half_screen.x]) print("aiming framed twice the distance to the target") # --- 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(3400.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") # --- E. #201: the beam gets ordinary fire's aim helper ---------- # Checked on the resolution itself rather than by firing again: # both hulls are still cooling down, and what the ticket asks for # is exactly that a drag resolves onto a nearby enemy. for spent in on_line_enemies + [off_line_enemy, beyond_range_enemy]: if is_instance_valid(spent): spent.free() var origin: Vector2 = skill._aim_origin() var in_cone: Node = load("res://enemies/Enemy.tscn").instantiate() in_cone.position = origin + Vector2(800.0, 0.0).rotated(deg_to_rad(20.0)) main.add_child(in_cone) in_cone.speed = 0.0 # Nearer than the one in the cone, and deliberately outside it: # the assist must not treat "closest" as "aimed at". var out_of_cone: Node = load("res://enemies/Enemy.tscn").instantiate() out_of_cone.position = origin + Vector2(600.0, 0.0).rotated(deg_to_rad(60.0)) main.add_child(out_of_cone) out_of_cone.speed = 0.0 var assisted: Vector2 = skill._calculate_aim_target(Vector2(300.0, 0.0), origin) if assisted.distance_to(in_cone.global_position) > 1.0: return _fail_now("a drag past an enemy inside the assist cone did not snap onto it") print("aiming snapped onto the enemy inside the assist cone") in_cone.free() var unassisted: Vector2 = skill._calculate_aim_target(Vector2(300.0, 0.0), origin) if unassisted.distance_to(origin + Vector2(2900.0, 0.0)) > 1.0: return _fail_now("the aim bent toward an enemy outside the assist cone") print("an enemy outside the cone left the aim where it was pointed") # --- F. the drag names a DEPTH, so the aim can reach past a # nearer target instead of always settling on the closest one. var near_enemy: Node = load("res://enemies/Enemy.tscn").instantiate() near_enemy.position = origin + Vector2(700.0, 0.0) main.add_child(near_enemy) near_enemy.speed = 0.0 # Further out and off to one side, but still inside the assist # cone: without depth this one is unreachable, because the nearer # enemy wins the cone every time. var far_enemy: Node = load("res://enemies/Enemy.tscn").instantiate() far_enemy.position = origin + Vector2(2000.0, 0.0).rotated(deg_to_rad(10.0)) main.add_child(far_enemy) far_enemy.speed = 0.0 var near_drag := Vector2(700.0 / 2900.0 * DRAG_RADIUS, 0.0) var near_pick: Vector2 = skill._calculate_aim_target(near_drag, origin) if near_pick.distance_to(near_enemy.global_position) > 1.0: return _fail_now("a short drag did not settle on the nearer enemy") var far_drag := Vector2(2000.0 / 2900.0 * DRAG_RADIUS, 0.0) \ .rotated(deg_to_rad(10.0)) var far_pick: Vector2 = skill._calculate_aim_target(far_drag, origin) if far_pick.distance_to(far_enemy.global_position) > 1.0: return _fail_now("dragging further did not reach past the nearer enemy") print("drag depth selects between a near and a far target in the same cone") # --- G. #204: the Collimator widens what one shot catches ------- # The upgrade used to add range to a beam that already crossed the # screen; it adds WIDTH now, so this checks the thing it widens is # the hit test and not only the picture. for placed in [near_enemy, far_enemy, out_of_cone]: if is_instance_valid(placed): placed.free() var laser: Node = first_shooter.get_component("laser") var narrow_width: float = laser.beam_half_width game_state.vessel_levels["lsr_width"] = 3 laser.apply_stats() if not is_equal_approx(laser.beam_half_width, narrow_width + 66.0): return _fail_now("three Collimators gave a half-width of %.0f, expected %.0f" \ % [laser.beam_half_width, narrow_width + 66.0]) # Off the line by more than the base beam covers and less than the # researched one does, so the same shot answers differently. var flanker: Node = load("res://enemies/Enemy.tscn").instantiate() flanker.position = first_shooter.global_position \ + Vector2(600.0, narrow_width + 60.0) main.add_child(flanker) flanker.speed = 0.0 var beam_script: Resource = load("res://vfx/LaserBeamEffect.gd") var narrow_shot: Node2D = beam_script.fire(root, first_shooter.global_position, Vector2.RIGHT, 2900.0, 1, narrow_width) if narrow_shot.hit_count != 0: return _fail_now("the unresearched beam already reached the flanking enemy") var wide_shot: Node2D = beam_script.fire(root, first_shooter.global_position, Vector2.RIGHT, 2900.0, 1, laser.beam_half_width) if wide_shot.hit_count != 1: return _fail_now("the widened beam hit %d enemies, expected the flanker" \ % wide_shot.hit_count) print("Beam Collimator: %.0f -> %.0f half-width, and the wider beam catches what the narrow one missed" \ % [narrow_width, laser.beam_half_width]) 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