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():