From ed46cd77e61974cbd3c53869d17b86dcc59cb56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 17:29:53 +0200 Subject: [PATCH] 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. --- BuildController.gd | 12 +- GameController.gd | 10 ++ GameState.gd | 1 + IconButton.gd | 27 ++++ LaserController.gd | 197 ++++++++++++++++++++++++++ LaserCooldownUpgrade.tres | 11 ++ LaserDamageUpgrade.tres | 11 ++ LaserRangeUpgrade.tres | 11 ++ LaserVesselDefinition.tres | 29 ++++ Main.tscn | 232 +++++++++++++++++++++++-------- PlanetSurface.tscn | 22 ++- VesselDefinitions.gd | 3 +- VesselLaserComponent.gd | 100 +++++++++++++ VesselLaserComponent.tscn | 6 + tests/_laser_aim_preview.gd | 49 +++++++ tests/_laser_aim_preview.tscn | 6 + tests/test_laser_vessel.gd | 193 +++++++++++++++++++++++++ tests/test_vessel_composition.gd | 2 +- 18 files changed, 858 insertions(+), 64 deletions(-) create mode 100644 LaserController.gd create mode 100644 LaserCooldownUpgrade.tres create mode 100644 LaserDamageUpgrade.tres create mode 100644 LaserRangeUpgrade.tres create mode 100644 LaserVesselDefinition.tres create mode 100644 VesselLaserComponent.gd create mode 100644 VesselLaserComponent.tscn create mode 100644 tests/_laser_aim_preview.gd create mode 100644 tests/_laser_aim_preview.tscn create mode 100644 tests/test_laser_vessel.gd diff --git a/BuildController.gd b/BuildController.gd index 96a905c..02de479 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -31,13 +31,13 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var lab_vessel_buttons: Array = [ get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"), - get_node("../UI/LabMenu/VesselE"), + get_node("../UI/LabMenu/VesselE"), get_node("../UI/LabMenu/VesselF"), ] @onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel") @onready var lab_vessel_labels: Array = [ get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"), get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"), - get_node("../UI/LabMenu/VesselELabel"), + get_node("../UI/LabMenu/VesselELabel"), get_node("../UI/LabMenu/VesselFLabel"), ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @@ -66,24 +66,28 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") get_node("../UI/HangarMenu/AvailableShield"), get_node("../UI/HangarMenu/AvailableGasBomber"), get_node("../UI/HangarMenu/AvailableCarrier"), + get_node("../UI/HangarMenu/AvailableLaser"), ] @onready var hangar_available_labels: Array = [ get_node("../UI/HangarMenu/AvailableInterceptorLabel"), get_node("../UI/HangarMenu/AvailableShieldLabel"), get_node("../UI/HangarMenu/AvailableGasBomberLabel"), get_node("../UI/HangarMenu/AvailableCarrierLabel"), + get_node("../UI/HangarMenu/AvailableLaserLabel"), ] @onready var hangar_fleet_slots: Array = [ get_node("../UI/HangarMenu/FleetInterceptor"), get_node("../UI/HangarMenu/FleetShield"), get_node("../UI/HangarMenu/FleetGasBomber"), get_node("../UI/HangarMenu/FleetCarrier"), + get_node("../UI/HangarMenu/FleetLaser"), ] @onready var hangar_fleet_labels: Array = [ get_node("../UI/HangarMenu/FleetInterceptorLabel"), get_node("../UI/HangarMenu/FleetShieldLabel"), get_node("../UI/HangarMenu/FleetGasBomberLabel"), get_node("../UI/HangarMenu/FleetCarrierLabel"), + get_node("../UI/HangarMenu/FleetLaserLabel"), ] var build_menu_entries: Array = [] @@ -111,11 +115,15 @@ var lab_building_id: int = 0 @onready var craft_gas_bomber_label: Label = get_node("../UI/CraftMenu/GasBomberLabel") @onready var craft_carrier_button: IconButton = get_node("../UI/CraftMenu/CarrierButton") @onready var craft_carrier_label: Label = get_node("../UI/CraftMenu/CarrierLabel") +@onready var craft_laser_button: IconButton = get_node("../UI/CraftMenu/LaserButton") +@onready var craft_laser_label: Label = get_node("../UI/CraftMenu/LaserLabel") @onready var craft_vessel_buttons: Array = [ craft_interceptor_button, craft_shield_button, craft_gas_bomber_button, craft_carrier_button, + craft_laser_button, ] @onready var craft_vessel_labels: Array = [ craft_interceptor_label, craft_shield_label, craft_gas_bomber_label, craft_carrier_label, + craft_laser_label, ] # --- the transfer panel (#24) --------------------------------------------- diff --git a/GameController.gd b/GameController.gd index 5e9b21f..e3cb346 100644 --- a/GameController.gd +++ b/GameController.gd @@ -22,6 +22,12 @@ extends Node2D @onready var pivot_cw_button: IconButton = get_node_or_null("UI/PivotCWButton") @onready var pivot_ccw_button: IconButton = get_node_or_null("UI/PivotCCWButton") @onready var gas_bomb_controller: GasBombController = get_node_or_null("GasBombController") +# Untyped on purpose: LaserController resolves fine as a static type when a +# script IS the -s entry point, but the same class_name reference 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 - for a class_name this new. Node + duck-typed calls sidesteps it. +@onready var laser_controller: Node = get_node_or_null("LaserController") var touches: Dictionary = {} var pinch_active: bool = false @@ -255,6 +261,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void: # else on screen. if gas_bomb_controller != null and gas_bomb_controller.handle_touch(event): return + if laser_controller != null and laser_controller.handle_touch(event): + return if _handle_pivot_touch(event): return if joystick != null and joystick.visible and joystick.handle_touch(event): @@ -277,6 +285,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void: func _handle_drag(event: InputEventScreenDrag) -> void: if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event): return + if laser_controller != null and laser_controller.handle_drag(event): + return if joystick != null and joystick.visible and joystick.handle_drag(event): return if not touches.has(event.index): diff --git a/GameState.gd b/GameState.gd index cfc0c2d..ced08da 100644 --- a/GameState.gd +++ b/GameState.gd @@ -464,6 +464,7 @@ const VESSEL_SHIELD: String = "shield" const VESSEL_GAS_BOMBER: String = "gas_bomber" const VESSEL_TRANSPORTER: String = "transporter" const VESSEL_CARRIER: String = "carrier" +const VESSEL_LASER: String = "laser" # Compatibility constants for callers and old tests. Runtime metadata is # resolved from VesselDefinition below. diff --git a/IconButton.gd b/IconButton.gd index 6a5e02f..62878a0 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -38,6 +38,9 @@ enum Icon { # Same append-only rule (#57): a wide hauler hull, distinct from every # vessel glyph above. CARRIER_VESSEL, + # #58: LASER_VESSEL is the ship glyph, LASER_SHOT the action button - + # same two-icon split as GAS_BOMBER/GAS_BOMB above. + LASER_VESSEL, LASER_SHOT, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -232,6 +235,10 @@ func _draw() -> void: _draw_gas_burner_icon(center, pulse, s) Icon.CARRIER_VESSEL: _draw_carrier(center, pulse, s) + Icon.LASER_VESSEL: + _draw_laser_vessel(center, pulse, s) + Icon.LASER_SHOT: + _draw_laser_shot(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -270,6 +277,26 @@ func _draw_carrier(center: Vector2, pulse: float, s: float) -> void: NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color) +func _draw_laser_vessel(center: Vector2, pulse: float, s: float) -> void: + # A slim dart rather than the interceptor's plain triangle - the notch at + # the nose reads as a fixed forward-facing emitter, not a hull point. + var hull := PackedVector2Array([ + center + Vector2(0, -13) * s, + center + Vector2(-8, 9) * s, + center + Vector2(-3, 6) * s, + center + Vector2(3, 6) * s, + center + Vector2(8, 9) * s, + ]) + NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color) + + +func _draw_laser_shot(center: Vector2, pulse: float, s: float) -> void: + var from: Vector2 = center + Vector2(-9, 9) * s + var to: Vector2 = center + Vector2(9, -9) * s + NeonDraw.draw_glowing_line(self, from, to, _active_color(), pulse, 1.1, core_color) + NeonDraw.draw_glowing_circle(self, to, 3.0 * s, _active_color(), pulse, core_color) + + func _draw_gas_bomber(center: Vector2, pulse: float, s: float) -> void: var hull := PackedVector2Array([ center + Vector2(0, -12) * s, diff --git a/LaserController.gd b/LaserController.gd new file mode 100644 index 0000000..78f8e81 --- /dev/null +++ b/LaserController.gd @@ -0,0 +1,197 @@ +class_name LaserController +extends Node2D + +# Same press-drag-release aiming plumbing as GasBombController, adapted for +# an instant beam rather than a lobbed, timed projectile: there is no travel +# time or explosion delay - the beam strikes the instant the shot fires, and +# only a brief flash lingers for feedback. Damage/range/cooldown are read +# straight off the firing ship's own VesselLaserComponent (already resolved +# from vessel_stat/upgrades) rather than hardcoded here, unlike the bomber's +# AOE_RADIUS/DAMAGE/COOLDOWN. +# +# Reused as-is by both Main.tscn and PlanetSurface.tscn (same relative +# ../Fleet, ../Camera2D, ../UI/LaserButton paths as GasBombController), so +# it works identically in space and on a landed surface with no branching. + +const BEAM_COLOR := Color(0.3, 0.9, 1.0) +const TARGET_COLOR := Color(0.6, 0.95, 1.0) +const BEAM_HALF_WIDTH := 22.0 +const DRAG_DEADZONE := 22.0 +const FLASH_DURATION := 0.22 + +@export var minimum_zoom: float = 0.4 +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") +@onready var button: IconButton = get_node("../UI/LaserButton") + +var aiming := false +var aim_touch := -1 +var aim_start_screen := Vector2.ZERO +var aim_screen := Vector2.ZERO +var aim_target := Vector2.ZERO +var saved_zoom := 1.0 +var restoring_zoom := false +var beams: Array = [] +var last_hit_count := 0 + + +func _ready() -> void: + button.visible = false + queue_redraw() + + +func _process(delta: float) -> void: + var had_visuals := aiming or not beams.is_empty() + button.visible = fleet.has_capability("laser") + button.enabled = _next_ready_laser_ship() != null + if not button.visible and aiming: + _cancel_aim() + + if restoring_zoom and not aiming: + var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) + camera.zoom = Vector2(z, z) + if is_equal_approx(z, saved_zoom): + restoring_zoom = false + + for i in range(beams.size() - 1, -1, -1): + var beam: Dictionary = beams[i] + beam.time = maxf(float(beam.time) - delta, 0.0) + if float(beam.time) <= 0.0: + beams.remove_at(i) + if aiming or not beams.is_empty() or had_visuals: + queue_redraw() + + +func can_begin_aim() -> bool: + return button.visible and button.enabled and not button._blocked_by_modal() \ + and not aiming + + +func handle_touch(event: InputEventScreenTouch) -> bool: + if event.pressed: + if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): + return false + aiming = true + aim_touch = event.index + aim_start_screen = event.position + aim_screen = event.position + saved_zoom = camera.zoom.x + restoring_zoom = false + button.pressed_down = true + button.queue_redraw() + _update_aim() + return true + if event.index != aim_touch: + return false + var should_fire := aim_start_screen.distance_to(aim_screen) >= DRAG_DEADZONE + if should_fire: + _fire() + else: + _cancel_aim() + return true + + +func handle_drag(event: InputEventScreenDrag) -> bool: + if not aiming or event.index != aim_touch: + return false + aim_screen = event.position + _update_aim() + return true + + +func _aim_origin() -> Vector2: + return fleet.get_centroid() + + +func _aim_max_range() -> float: + var ship: Node2D = _next_ready_laser_ship() + if ship == null: + return 950.0 + var component: Node = ship.get_component("laser") + return component.beam_range if component != null else 950.0 + + +func _update_aim() -> void: + # A beam always reaches its full range - only the DIRECTION is aimed, + # unlike the bomb's drag-controlled distance. + var drag := aim_screen - aim_start_screen + var origin: Vector2 = _aim_origin() + var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP + aim_target = origin + direction * _aim_max_range() + _update_aim_zoom(origin, aim_target) + queue_redraw() + + +func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: + var displacement := target - origin + var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) + var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + BEAM_HALF_WIDTH) + var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + BEAM_HALF_WIDTH) + var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom) + camera.zoom = Vector2(desired, desired) + + +func _finish_aim() -> void: + aiming = false + aim_touch = -1 + button.pressed_down = false + button.queue_redraw() + restoring_zoom = true + + +func _cancel_aim() -> void: + _finish_aim() + queue_redraw() + + +func _fire() -> void: + var shooter: Node2D = _next_ready_laser_ship() + if shooter == null: + _cancel_aim() + return + var component: Node = shooter.get_component("laser") + if not component.consume_shot(): + _cancel_aim() + return + var direction: Vector2 = (aim_target - _aim_origin()) + direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP + var beam_to: Vector2 = shooter.global_position + direction * component.beam_range + _strike(shooter.global_position, beam_to, direction, int(component.damage)) + _finish_aim() + button.enabled = _next_ready_laser_ship() != null + + +func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: + beams.append({"from": from, "to": to, "time": FLASH_DURATION}) + last_hit_count = 0 + var range_along: float = from.distance_to(to) + for enemy in get_tree().get_nodes_in_group("enemies"): + if not is_instance_valid(enemy) or not (enemy is Node2D): + continue + var to_enemy: Vector2 = enemy.global_position - from + var along: float = to_enemy.dot(direction) + if along < 0.0 or along > range_along: + continue + var perpendicular: float = (to_enemy - direction * along).length() + if perpendicular <= BEAM_HALF_WIDTH: + enemy.take_damage(damage) + last_hit_count += 1 + + +func _next_ready_laser_ship() -> Node2D: + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var component: Node = ship.get_component("laser") + if component != null and component.is_ready(): + return ship + return null + + +func _draw() -> void: + if aiming: + NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6) + for beam in beams: + var progress: float = 1.0 - float(beam.time) / FLASH_DURATION + var alpha: float = 1.0 - progress + NeonDraw.draw_glowing_line(self, beam.from, beam.to, BEAM_COLOR, alpha, 1.2 - progress * 0.6) diff --git a/LaserCooldownUpgrade.tres b/LaserCooldownUpgrade.tres new file mode 100644 index 0000000..71ad6aa --- /dev/null +++ b/LaserCooldownUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +upgrade_id = "lsr_cooldown" +display_name = "Capacitor Bank" +description = "Recharges 0.6s sooner per level." +base_cost = 30 +max_level = 3 +icon = 20 +additive_modifiers = {"beam_cooldown": -0.6} diff --git a/LaserDamageUpgrade.tres b/LaserDamageUpgrade.tres new file mode 100644 index 0000000..daa76ab --- /dev/null +++ b/LaserDamageUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +upgrade_id = "lsr_damage" +display_name = "Focusing Lens" +description = "+1 damage per shot." +base_cost = 30 +max_level = 3 +icon = 15 +additive_modifiers = {"damage": 1.0} diff --git a/LaserRangeUpgrade.tres b/LaserRangeUpgrade.tres new file mode 100644 index 0000000..447c58e --- /dev/null +++ b/LaserRangeUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +upgrade_id = "lsr_range" +display_name = "Beam Collimator" +description = "+150 beam range per level." +base_cost = 25 +max_level = 3 +icon = 17 +additive_modifiers = {"beam_range": 150.0} diff --git a/LaserVesselDefinition.tres b/LaserVesselDefinition.tres new file mode 100644 index 0000000..370a0d5 --- /dev/null +++ b/LaserVesselDefinition.tres @@ -0,0 +1,29 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3] +[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://VesselLaserComponent.tscn" id="2"] +[ext_resource type="Resource" path="res://LaserDamageUpgrade.tres" id="3"] +[ext_resource type="Resource" path="res://LaserRangeUpgrade.tres" id="4"] +[ext_resource type="Resource" path="res://LaserCooldownUpgrade.tres" id="5"] +[resource] +script = ExtResource("1") +definition_id = "laser" +display_name = "Laser Ship" +description = "Manually aimed beam vessel. No ammo, but the emitter needs to cool down between shots." +build_cost = 12 +build_resource_costs = {"ore": 12, "energy": 6} +build_time = 14.0 +icon = 35 +glow_color = Color(0.3, 0.9, 1, 1) +hull_points = PackedVector2Array(0, -26, -9, 14, -4, 10, 4, 10, 9, 14) +fleet_eligible = true +capabilities = PackedStringArray("laser") +component_scenes = Array[PackedScene]([ExtResource("2")]) +base_stats = {"max_speed": 440.0, "acceleration": 420.0, "delivery_speed": 180.0, "damage": 2.0, "beam_range": 950.0, "beam_cooldown": 4.0} +minimum_stats = {"beam_cooldown": 1.5} +upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")]) +unlock_id = "unlock_laser" +unlock_name = "Unlock Laser Ship" +unlock_description = "Research a directed-energy hull for the ship crafter - manually aimed, no ammo, cools down between shots." +unlock_cost = 0 +unlock_resource_costs = {"energy": 40} +unlock_icon = -1 diff --git a/Main.tscn b/Main.tscn index 663e18e..7eaf569 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=27 format=3] +[gd_scene load_steps=28 format=3] [ext_resource type="Script" path="res://Starfield.gd" id="1"] [ext_resource type="Script" path="res://Fleet.gd" id="2"] @@ -25,6 +25,7 @@ [ext_resource type="Script" path="res://VesselDeliveryField.gd" id="26"] [ext_resource type="Script" path="res://ResourceTransferList.gd" id="27"] [ext_resource type="Script" path="res://GasBombController.gd" id="29"] +[ext_resource type="Script" path="res://LaserController.gd" id="30"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -56,6 +57,9 @@ script = ExtResource("2") [node name="GasBombController" type="Node2D" parent="."] script = ExtResource("29") +[node name="LaserController" type="Node2D" parent="."] +script = ExtResource("30") + [node name="Camera2D" type="Camera2D" parent="."] position = Vector2(0, 0) zoom = Vector2(1, 1) @@ -217,6 +221,21 @@ offset_top = -424 offset_bottom = -24 script = ExtResource("13") +[node name="LaserButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -196 +offset_top = -344 +offset_bottom = -196 +script = ExtResource("10") +icon = 36 +glow_color = Color(0.3, 0.9, 1, 1) + [node name="PivotCCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 @@ -820,7 +839,7 @@ horizontal_alignment = 1 anchor_left = 0.5 anchor_right = 0.5 offset_left = -290 -offset_right = -178 +offset_right = -198 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -831,7 +850,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -138 +offset_right = -158 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -840,8 +859,8 @@ horizontal_alignment = 1 [node name="AvailableShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -134 -offset_right = -22 +offset_left = -168 +offset_right = -76 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -852,8 +871,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -174 -offset_right = 18 +offset_left = -208 +offset_right = -36 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -862,8 +881,8 @@ horizontal_alignment = 1 [node name="AvailableGasBomber" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 22 -offset_right = 134 +offset_left = -46 +offset_right = 46 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -874,8 +893,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -18 -offset_right = 174 +offset_left = -86 +offset_right = 86 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -884,8 +903,8 @@ horizontal_alignment = 1 [node name="AvailableCarrier" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 178 -offset_right = 290 +offset_left = 76 +offset_right = 168 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -896,7 +915,29 @@ glow_color = Color(1, 0.78, 0.3, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 138 +offset_left = 36 +offset_right = 208 +offset_top = 380 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="AvailableLaser" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 198 +offset_right = 290 +offset_top = 255 +offset_bottom = 375 +script = ExtResource("10") +icon = 35 +glow_color = Color(0.3, 0.9, 1, 1) + +[node name="AvailableLaserLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 158 offset_right = 330 offset_top = 380 offset_bottom = 460 @@ -916,7 +957,7 @@ horizontal_alignment = 1 anchor_left = 0.5 anchor_right = 0.5 offset_left = -290 -offset_right = -178 +offset_right = -198 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -927,7 +968,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -138 +offset_right = -158 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -936,8 +977,8 @@ horizontal_alignment = 1 [node name="FleetShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -134 -offset_right = -22 +offset_left = -168 +offset_right = -76 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -948,8 +989,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -174 -offset_right = 18 +offset_left = -208 +offset_right = -36 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -958,8 +999,8 @@ horizontal_alignment = 1 [node name="FleetGasBomber" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 22 -offset_right = 134 +offset_left = -46 +offset_right = 46 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -970,8 +1011,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -18 -offset_right = 174 +offset_left = -86 +offset_right = 86 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -980,8 +1021,8 @@ horizontal_alignment = 1 [node name="FleetCarrier" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 178 -offset_right = 290 +offset_left = 76 +offset_right = 168 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -992,7 +1033,29 @@ glow_color = Color(1, 0.78, 0.3, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 138 +offset_left = 36 +offset_right = 208 +offset_top = 690 +offset_bottom = 770 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="FleetLaser" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 198 +offset_right = 290 +offset_top = 565 +offset_bottom = 685 +script = ExtResource("10") +icon = 35 +glow_color = Color(0.3, 0.9, 1, 1) + +[node name="FleetLaserLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 158 offset_right = 330 offset_top = 690 offset_bottom = 770 @@ -1044,7 +1107,7 @@ script = ExtResource("24") anchor_left = 0.5 anchor_right = 0.5 offset_left = -300 -offset_right = -212 +offset_right = -230 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1054,8 +1117,8 @@ glow_color = Color(0.25, 0.85, 1, 1) [node name="VesselB" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -172 -offset_right = -84 +offset_left = -194 +offset_right = -124 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1065,8 +1128,8 @@ glow_color = Color(0.45, 0.8, 1, 1) [node name="VesselC" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -44 -offset_right = 44 +offset_left = -88 +offset_right = -18 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1076,8 +1139,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1) [node name="VesselD" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 84 -offset_right = 172 +offset_left = 18 +offset_right = 88 offset_top = 274 offset_bottom = 394 script = ExtResource("10") @@ -1087,14 +1150,25 @@ glow_color = Color(0.95, 0.55, 0.85, 1) [node name="VesselE" type="Control" parent="UI/LabMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = 212 -offset_right = 300 +offset_left = 124 +offset_right = 194 offset_top = 274 offset_bottom = 394 script = ExtResource("10") icon = 34 glow_color = Color(1, 0.78, 0.3, 1) +[node name="VesselF" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 230 +offset_right = 300 +offset_top = 274 +offset_bottom = 394 +script = ExtResource("10") +icon = 35 +glow_color = Color(0.3, 0.9, 1, 1) + [node name="VesselLabel" type="Label" parent="UI/LabMenu"] visible = false mouse_filter = 2 @@ -1112,7 +1186,7 @@ mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 offset_left = -330 -offset_right = -182 +offset_right = -200 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1123,8 +1197,8 @@ text = "Interceptor" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -202 -offset_right = -54 +offset_left = -224 +offset_right = -94 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1135,8 +1209,8 @@ text = "Shield" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -74 -offset_right = 74 +offset_left = -118 +offset_right = 12 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1147,8 +1221,8 @@ text = "Gas Bomber" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 54 -offset_right = 202 +offset_left = 12 +offset_right = 118 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -1159,14 +1233,26 @@ text = "Transporter" mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = 182 -offset_right = 330 +offset_left = 94 +offset_right = 224 offset_top = 400 offset_bottom = 460 theme_override_font_sizes/font_size = 22 horizontal_alignment = 1 text = "Carrier" +[node name="VesselFLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 200 +offset_right = 330 +offset_top = 400 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 +text = "Laser Ship" + [node name="VesselUpgradeSeparator" type="ColorRect" parent="UI/LabMenu"] mouse_filter = 2 anchor_right = 1.0 @@ -1412,7 +1498,7 @@ anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -310 -offset_right = -208 +offset_right = -232 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1425,7 +1511,7 @@ anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -330 -offset_right = -188 +offset_right = -212 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1437,8 +1523,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -174 -offset_right = -72 +offset_left = -202 +offset_right = -124 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1451,8 +1537,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -194 -offset_right = -52 +offset_left = -222 +offset_right = -104 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1465,8 +1551,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -38 -offset_right = 64 +offset_left = -94 +offset_right = -16 offset_top = -100 offset_bottom = 28 script = ExtResource("10") @@ -1479,8 +1565,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -58 -offset_right = 84 +offset_left = -114 +offset_right = 4 offset_top = 56 offset_bottom = 150 theme_override_font_sizes/font_size = 24 @@ -1493,8 +1579,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 98 -offset_right = 200 +offset_left = 14 +offset_right = 92 offset_top = -100 offset_bottom = 48 script = ExtResource("10") @@ -1507,8 +1593,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 78 -offset_right = 220 +offset_left = -6 +offset_right = 112 offset_top = 56 offset_bottom = 136 theme_override_font_sizes/font_size = 24 @@ -1516,6 +1602,34 @@ autowrap_mode = 2 text = "Carrier" horizontal_alignment = 1 +[node name="LaserButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 122 +offset_right = 200 +offset_top = -100 +offset_bottom = 48 +script = ExtResource("10") +icon = 35 +glow_color = Color(0.3, 0.9, 1, 1) + +[node name="LaserLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 102 +offset_right = 220 +offset_top = 56 +offset_bottom = 136 +theme_override_font_sizes/font_size = 24 +autowrap_mode = 2 +text = "Laser Ship" +horizontal_alignment = 1 + [node name="QueueLabel" type="Label" parent="UI/CraftMenu"] mouse_filter = 2 anchor_left = 1.0 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 3b8bd19..afdeb49 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=14 format=3] +[gd_scene load_steps=15 format=3] [ext_resource type="Script" path="res://GameController.gd" id="1"] [ext_resource type="Script" path="res://LunarBackground.gd" id="2"] @@ -9,6 +9,7 @@ [ext_resource type="Script" path="res://TouchJoystick.gd" id="7"] [ext_resource type="Script" path="res://ResourceReadout.gd" id="9"] [ext_resource type="Script" path="res://GasBombController.gd" id="14"] +[ext_resource type="Script" path="res://LaserController.gd" id="16"] [ext_resource type="Script" path="res://CaptureLabel.gd" id="15"] [sub_resource type="ShaderMaterial" id="1"] @@ -45,6 +46,9 @@ ship_core_color = Color(0.02, 0.09, 0.15, 1) [node name="GasBombController" type="Node2D" parent="."] script = ExtResource("14") +[node name="LaserController" type="Node2D" parent="."] +script = ExtResource("16") + [node name="Camera2D" type="Camera2D" parent="."] position = Vector2(0, 0) zoom = Vector2(1, 1) @@ -153,6 +157,22 @@ script = ExtResource("7") glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +[node name="LaserButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -196 +offset_top = -344 +offset_bottom = -196 +script = ExtResource("6") +icon = 36 +glow_color = Color(0.3, 0.9, 1, 1) +core_color = Color(0.02, 0.09, 0.15, 1) + [node name="PivotCCWButton" type="Control" parent="UI"] mouse_filter = 2 anchor_left = 1.0 diff --git a/VesselDefinitions.gd b/VesselDefinitions.gd index 92675a8..a037eb7 100644 --- a/VesselDefinitions.gd +++ b/VesselDefinitions.gd @@ -1,13 +1,14 @@ class_name VesselDefinitions extends RefCounted -const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier"] +const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser"] const DEFINITIONS: Dictionary = { "interceptor": preload("res://InterceptorVesselDefinition.tres"), "shield": preload("res://ShieldVesselDefinition.tres"), "gas_bomber": preload("res://GasBomberVesselDefinition.tres"), "transporter": preload("res://TransporterVesselDefinition.tres"), "carrier": preload("res://CarrierVesselDefinition.tres"), + "laser": preload("res://LaserVesselDefinition.tres"), } static func get_definition(definition_id: String) -> Resource: diff --git a/VesselLaserComponent.gd b/VesselLaserComponent.gd new file mode 100644 index 0000000..fb9379e --- /dev/null +++ b/VesselLaserComponent.gd @@ -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)) diff --git a/VesselLaserComponent.tscn b/VesselLaserComponent.tscn new file mode 100644 index 0000000..a7d44db --- /dev/null +++ b/VesselLaserComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://VesselLaserComponent.gd" id="1"] + +[node name="LaserComponent" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_laser_aim_preview.gd b/tests/_laser_aim_preview.gd new file mode 100644 index 0000000..1fba9a5 --- /dev/null +++ b/tests/_laser_aim_preview.gd @@ -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 diff --git a/tests/_laser_aim_preview.tscn b/tests/_laser_aim_preview.tscn new file mode 100644 index 0000000..671b33c --- /dev/null +++ b/tests/_laser_aim_preview.tscn @@ -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") diff --git a/tests/test_laser_vessel.gd b/tests/test_laser_vessel.gd new file mode 100644 index 0000000..4c3001e --- /dev/null +++ b/tests/test_laser_vessel.gd @@ -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 diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd index aaa425f..67bf6c3 100644 --- a/tests/test_vessel_composition.gd +++ b/tests/test_vessel_composition.gd @@ -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():