From 0fb8f9300b4ca13291d65cdb3f89010171adb3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 21 Aug 2026 01:29:21 +0200 Subject: [PATCH] Twin-stick combat: aim and fire with a second stick, MOBA-style ability ring The fleet's guns picked their own targets and fired on their own, so combat happened whether or not the player did anything - there was nothing to aim and nothing to decide, and the right half of the screen held nothing at all. Firing is now an action. VesselWeaponComponent no longer seeks a target; it shoots along a heading, and the only thing that supplies one is the new right-hand stick via FireStickController. A fleet arriving from another scene starts with its guns silent, which is the correct resting state. A subtle dashed guide runs from the fleet along the aimed heading while the stick is held, out to the guns' OWN range - read from a weapon that is actually aboard, so the line can never promise reach the fleet does not have. Layout: the two sticks take the bottom corners, the pivot pair is centred between them at the bottom edge, and the laser and gas bomb sit on an arc up and left of the fire stick where a thumb already is. Those four are now round - IconButton grew a round_frame mode, and the in-world action controls use it while panel and menu buttons stay square, so the two kinds of button read as different kinds of thing. Worth recording, because it cost a screenshot to find: a script-declared property set BEFORE the `script = ExtResource(...)` line in a .tscn is silently dropped. Godot's parser has no idea the property exists yet. The round frames were all correctly set in both scenes and none of them applied. Also fixed researtch_lab_garrison.tres, which was authored under the void profile - that is the black hole surface, so a lab garrison was spawning on black holes. Suite 110/110. test_enemy_combat's whole premise was auto-fire; it now holds the real stick toward the enemy rather than poking weapon components, so it exercises the path the player uses. test_fire_stick is new and covers the change itself: an idle stick leaves a target parked well inside range untouched, which is precisely what the old gun would never have done. Co-Authored-By: Claude Opus 5 --- FireStickController.gd | 93 +++++++++++ FireStickController.gd.uid | 1 + GameController.gd | 9 ++ IconButton.gd | 43 ++++-- Main.tscn | 112 ++++++++------ PlanetSetups/researtch_lab_garrison.tres | 2 +- PlanetSurface.tscn | 120 +++++++++------ VesselWeaponComponent.gd | 49 +++--- tests/_hud_preview.gd | 35 +++++ tests/_hud_preview.gd.uid | 1 + tests/_hud_preview.tscn | 6 + tests/test_enemy_combat.gd | 31 +++- tests/test_fire_stick.gd | 188 +++++++++++++++++++++++ tests/test_fire_stick.gd.uid | 1 + tests/test_setup_file_identity.gd.uid | 1 + 15 files changed, 561 insertions(+), 131 deletions(-) create mode 100644 FireStickController.gd create mode 100644 FireStickController.gd.uid create mode 100644 tests/_hud_preview.gd create mode 100644 tests/_hud_preview.gd.uid create mode 100644 tests/_hud_preview.tscn create mode 100644 tests/test_fire_stick.gd create mode 100644 tests/test_fire_stick.gd.uid create mode 100644 tests/test_setup_file_identity.gd.uid diff --git a/FireStickController.gd b/FireStickController.gd new file mode 100644 index 0000000..6ed57db --- /dev/null +++ b/FireStickController.gd @@ -0,0 +1,93 @@ +extends Node2D + +# Points every armed vessel's gun wherever the right-hand stick is pushed. +# +# The fleet's guns used to seek their own targets and fire on their own. That +# made combat something that happened TO the player rather than something they +# did, and left the right half of the screen with nothing on it. Aiming is now +# the second stick, and this is the only thing that tells a weapon to fire. +# +# Deliberately a scene node rather than state on the fleet or in GameState: +# it is input plumbing, it belongs to whichever scene is showing the stick, +# and a fleet that moves between scenes should arrive with its guns silent +# until the local stick says otherwise. + +# The aim guide: a dotted line from the fleet along the heading being fired, +# out to the guns' own range. Deliberately faint and broken rather than a +# solid beam - it has to answer "where will this go and how far" at a glance +# without competing with the shots themselves for attention. +const GUIDE_DASH: float = 14.0 +const GUIDE_GAP: float = 16.0 +const GUIDE_WIDTH: float = 2.0 +const GUIDE_COLOR: Color = Color(1.0, 0.55, 0.38, 0.28) + +@onready var fleet: Node2D = get_node("../Fleet") +# Optional so a scene without a fire stick (a preview, a test harness) still +# runs - the guns simply stay quiet, which is the correct resting state. +@onready var stick: Node = get_node_or_null("../UI/FireStick") + +var aim_direction: Vector2 = Vector2.ZERO + + +func _process(_delta: float) -> void: + var direction: Vector2 = stick.direction if stick != null else Vector2.ZERO + aim(direction) + # The guide starts at the fleet, which is usually moving, so this redraws + # while aiming rather than only when the heading changes. Once the stick is + # released it redraws once more to clear the line, then stops. + var was_aiming: bool = aim_direction != Vector2.ZERO + aim_direction = direction + if direction != Vector2.ZERO or was_aiming: + queue_redraw() + + +# Also the entry point for anything driving the guns without a stick - tests +# aim through this rather than reaching into each weapon component, so what +# they exercise is the same path the stick uses. +func aim(direction: Vector2) -> void: + if fleet == null: + return + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var weapon: Node = ship.get_component("weapon") + if weapon != null: + weapon.set_fire_direction(direction) + + +# How far the guide reaches: the actual range of a gun that is actually +# aboard, so the line never promises reach the fleet does not have. +func guide_range() -> float: + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var weapon: Node = ship.get_component("weapon") + if weapon != null: + return float(weapon.fire_range) + return 0.0 + + +func _draw() -> void: + if aim_direction == Vector2.ZERO or fleet == null or fleet.ships.is_empty(): + return + var reach: float = guide_range() + if reach <= 0.0: + return + var origin: Vector2 = fleet.get_centroid() + var heading: Vector2 = aim_direction.normalized() + var travelled: float = GUIDE_GAP + while travelled < reach: + var dash_end: float = minf(travelled + GUIDE_DASH, reach) + draw_line(origin + heading * travelled, origin + heading * dash_end, + GUIDE_COLOR, GUIDE_WIDTH, true) + travelled = dash_end + GUIDE_GAP + + +func is_firing() -> bool: + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var weapon: Node = ship.get_component("weapon") + if weapon != null and weapon.is_firing(): + return true + return false diff --git a/FireStickController.gd.uid b/FireStickController.gd.uid new file mode 100644 index 0000000..5380340 --- /dev/null +++ b/FireStickController.gd.uid @@ -0,0 +1 @@ +uid://dpfqioif8n7rr diff --git a/GameController.gd b/GameController.gd index b0659c7..f8f5ea7 100644 --- a/GameController.gd +++ b/GameController.gd @@ -18,6 +18,9 @@ extends Node2D @onready var fleet: Node2D = $Fleet # Optional: scenes without a joystick (none currently) still work. @onready var joystick: TouchJoystick = get_node_or_null("UI/Joystick") +# The right-hand stick: aims and fires. Optional for the same reason as the +# movement stick - a scene without one still runs, with the guns silent. +@onready var fire_stick: TouchJoystick = get_node_or_null("UI/FireStick") # Navigation section. Optional so a scene without it still works. @onready var pivot_cw_button: IconButton = get_node_or_null("UI/PivotCWButton") @onready var pivot_ccw_button: IconButton = get_node_or_null("UI/PivotCCWButton") @@ -299,6 +302,10 @@ func _handle_touch(event: InputEventScreenTouch) -> void: return if joystick != null and joystick.visible and joystick.handle_touch(event): return + # Order between the two sticks does not matter: each only claims touches + # that begin inside its own rect, and their rects do not overlap. + if fire_stick != null and fire_stick.visible and fire_stick.handle_touch(event): + return if event.pressed: touches[event.index] = event.position @@ -323,6 +330,8 @@ func _handle_drag(event: InputEventScreenDrag) -> void: return if joystick != null and joystick.visible and joystick.handle_drag(event): return + if fire_stick != null and fire_stick.visible and fire_stick.handle_drag(event): + return if not touches.has(event.index): return touches[event.index] = event.position diff --git a/IconButton.gd b/IconButton.gd index b3789e8..81183be 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -136,6 +136,33 @@ func _gui_input(event: InputEvent) -> void: # delivers releases keeps it. @export var fire_on_press: bool = OS.has_feature("web") +# Round rather than square. The in-world action controls - the abilities +# arranged around the fire stick, and the pivot pair - read as a different +# kind of thing from the square panel and menu buttons, and a round target is +# what a thumb reaching for an ability expects. Purely how the frame is drawn; +# the hit area is still the whole rect. +@export var round_frame: bool = false + + +# The frame outline, inset by `inset` pixels on every side. Round buttons trace +# a circle through the same inset box, so a round and a square button of the +# same size present the same footprint. +func _frame_points(inset: float) -> PackedVector2Array: + if not round_frame: + return PackedVector2Array([ + Vector2(inset, inset), + Vector2(size.x - inset, inset), + Vector2(size.x - inset, size.y - inset), + Vector2(inset, size.y - inset), + ]) + var centre: Vector2 = size / 2.0 + var radius: Vector2 = centre - Vector2(inset, inset) + var points: PackedVector2Array = PackedVector2Array() + for i in range(32): + var angle: float = TAU * float(i) / 32.0 + points.append(centre + Vector2(cos(angle) * radius.x, sin(angle) * radius.y)) + return points + func _handle_press(is_pressed: bool, at_position: Vector2) -> void: if is_pressed: @@ -167,23 +194,13 @@ func _draw() -> void: var pulse: float = 1.0 if pressed_down else 0.85 var margin: float = 4.0 * s - var square: PackedVector2Array = PackedVector2Array([ - Vector2(margin, margin), - Vector2(rect_size.x - margin, margin), - Vector2(rect_size.x - margin, rect_size.y - margin), - Vector2(margin, rect_size.y - margin), - ]) - NeonDraw.draw_glowing_polygon(self, square, _active_color(), pulse, core_color) + NeonDraw.draw_glowing_polygon(self, _frame_points(margin), _active_color(), pulse, core_color) if highlighted: # The inset frame reads as a selection focus ring. It deliberately uses # the bright core rather than the button's glow colour, so selecting a # blue shield or pink transporter does not change what that colour means. - var inset: float = 10.0 * s - var focus: PackedVector2Array = PackedVector2Array([ - Vector2(inset, inset), Vector2(rect_size.x - inset, inset), - Vector2(rect_size.x - inset, rect_size.y - inset), Vector2(inset, rect_size.y - inset), - ]) - NeonDraw.draw_glowing_polygon(self, focus, core_color, 1.0, core_color, 0.72 * s) + NeonDraw.draw_glowing_polygon(self, _frame_points(10.0 * s), core_color, 1.0, + core_color, 0.72 * s) var center: Vector2 = rect_size / 2.0 match icon: diff --git a/Main.tscn b/Main.tscn index fa6be78..acd8fb2 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=29 format=3] +[gd_scene load_steps=30 format=3] [ext_resource type="Script" path="res://Starfield.gd" id="1"] [ext_resource type="Script" path="res://Fleet.gd" id="2"] @@ -24,6 +24,7 @@ [ext_resource type="Script" path="res://ResourceBar.gd" id="24"] [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://FireStickController.gd" id="fire_ctrl"] [ext_resource type="Script" path="res://GasBombController.gd" id="29"] [ext_resource type="Script" path="res://LaserController.gd" id="30"] [ext_resource type="Script" path="res://SpeedFader.gd" id="31"] @@ -58,6 +59,9 @@ script = ExtResource("2") [node name="GasBombController" type="Node2D" parent="."] script = ExtResource("29") +[node name="FireStickController" type="Node2D" parent="."] +script = ExtResource("fire_ctrl") + [node name="LaserController" type="Node2D" parent="."] script = ExtResource("30") @@ -214,13 +218,27 @@ text = "WASD / arrows move Q / E pivot wheel zoom" [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 +script = ExtResource("13") anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = 24 -offset_right = 424 -offset_top = -424 -offset_bottom = -24 +offset_left = 12 +offset_right = 252 +offset_top = -330 +offset_bottom = -80 + +[node name="FireStick" type="Control" parent="UI"] +mouse_filter = 2 script = ExtResource("13") +glow_color = Color(1, 0.45, 0.3, 1) +core_color = Color(1, 1, 1, 1) +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -252 +offset_right = -12 +offset_top = -330 +offset_bottom = -80 [node name="SpeedFader" type="Control" parent="UI"] visible = false @@ -249,90 +267,94 @@ text = "x1.0" [node name="LaserButton" type="Control" parent="UI"] visible = false mouse_filter = 2 +script = ExtResource("10") +round_frame = true +icon = 36 +glow_color = Color(0.3, 0.9, 1, 1) 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) +offset_left = -196 +offset_right = -100 +offset_top = -492 +offset_bottom = -396 [node name="PivotCCWButton" type="Control" parent="UI"] mouse_filter = 2 +script = ExtResource("10") +round_frame = true +icon = 7 anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -344 -offset_right = -196 -offset_top = -172 -offset_bottom = -24 -script = ExtResource("10") -icon = 7 +offset_left = -468 +offset_right = -364 +offset_top = -130 +offset_bottom = -26 [node name="GasBombButton" type="Control" parent="UI"] visible = false mouse_filter = 2 +script = ExtResource("10") +round_frame = true +icon = 32 +glow_color = Color(0.96, 0.22, 0.72, 1) anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -172 -offset_right = -24 -offset_top = -344 -offset_bottom = -196 -script = ExtResource("10") -icon = 32 -glow_color = Color(0.96, 0.22, 0.72, 1) +offset_left = -324 +offset_right = -228 +offset_top = -424 +offset_bottom = -328 [node name="GasBombStatus" type="Label" parent="UI"] mouse_filter = 2 -anchor_left = 1.0 -anchor_right = 1.0 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = -344 -offset_right = -24 -offset_top = -480 -offset_bottom = -430 theme_override_font_sizes/font_size = 18 theme_override_colors/font_color = Color(0.96, 0.22, 0.72, 1) autowrap_mode = 2 horizontal_alignment = 1 text = "" - -[node name="LaserStatus" type="Label" parent="UI"] -mouse_filter = 2 anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -344 +offset_left = -420 offset_right = -24 -offset_top = -540 -offset_bottom = -490 +offset_top = -560 +offset_bottom = -512 + +[node name="LaserStatus" type="Label" parent="UI"] +mouse_filter = 2 theme_override_font_sizes/font_size = 18 theme_override_colors/font_color = Color(0.3, 0.9, 1, 1) autowrap_mode = 2 horizontal_alignment = 1 text = "" - -[node name="PivotCWButton" type="Control" parent="UI"] -mouse_filter = 2 anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -172 +offset_left = -420 offset_right = -24 -offset_top = -172 -offset_bottom = -24 +offset_top = -616 +offset_bottom = -568 + +[node name="PivotCWButton" type="Control" parent="UI"] +mouse_filter = 2 script = ExtResource("10") +round_frame = true icon = 6 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -356 +offset_right = -252 +offset_top = -130 +offset_bottom = -26 [node name="WorldMap" type="Control" parent="UI" groups=["modal_menus"]] visible = false diff --git a/PlanetSetups/researtch_lab_garrison.tres b/PlanetSetups/researtch_lab_garrison.tres index c9b9938..fbd237b 100644 --- a/PlanetSetups/researtch_lab_garrison.tres +++ b/PlanetSetups/researtch_lab_garrison.tres @@ -40,7 +40,7 @@ position = Vector2(-977.8174, -470.0476) script = ExtResource("2_oxc3b") setup_id = "researtch_lab_garrison" display_name = "Researtch Lab Garrison" -surface_profile_id = "void" +surface_profile_id = "research_lab" capture_points = PackedVector2Array(-450, -60, 460, -40, 0, 480) enemy_spawns = Array[Resource]([SubResource("Resource_p34gm"), SubResource("Resource_oxc3b"), SubResource("Resource_ujs3j"), SubResource("Resource_4h5ny"), SubResource("Resource_1djs2"), SubResource("Resource_isvqt"), SubResource("Resource_ff4yc")]) research_point_reward = 1 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index e31fc4f..89805e0 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="PackedScene" path="res://SurfaceBackground.tscn" id="3"] @@ -7,6 +7,7 @@ [ext_resource type="Script" path="res://IconButton.gd" id="6"] [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://FireStickController.gd" id="fire_ctrl"] [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://SpeedFader.gd" id="17"] @@ -25,6 +26,9 @@ ship_core_color = Color(0.02, 0.09, 0.15, 1) [node name="GasBombController" type="Node2D" parent="."] script = ExtResource("14") +[node name="FireStickController" type="Node2D" parent="."] +script = ExtResource("fire_ctrl") + [node name="LaserController" type="Node2D" parent="."] script = ExtResource("16") @@ -130,15 +134,29 @@ text = "WASD / arrows move Q / E pivot wheel zoom" [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = 24 -offset_right = 424 -offset_top = -424 -offset_bottom = -24 script = ExtResource("7") glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 12 +offset_right = 252 +offset_top = -330 +offset_bottom = -80 + +[node name="FireStick" type="Control" parent="UI"] +mouse_filter = 2 +script = ExtResource("7") +glow_color = Color(0.62, 0.16, 0.08, 1) +core_color = Color(0.15, 0.03, 0.02, 1) +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -252 +offset_right = -12 +offset_top = -330 +offset_bottom = -80 [node name="SpeedFader" type="Control" parent="UI"] visible = false @@ -169,96 +187,100 @@ text = "x1.0" [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") +round_frame = true 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 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -344 -offset_right = -196 -offset_top = -172 -offset_bottom = -24 +offset_left = -196 +offset_right = -100 +offset_top = -492 +offset_bottom = -396 + +[node name="PivotCCWButton" type="Control" parent="UI"] +mouse_filter = 2 script = ExtResource("6") +round_frame = true icon = 7 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -468 +offset_right = -364 +offset_top = -130 +offset_bottom = -26 [node name="GasBombButton" 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 = -172 -offset_right = -24 -offset_top = -344 -offset_bottom = -196 script = ExtResource("6") +round_frame = true icon = 32 glow_color = Color(0.82, 0.08, 0.48, 1) core_color = Color(0.02, 0.09, 0.15, 1) - -[node name="GasBombStatus" type="Label" parent="UI"] -mouse_filter = 2 anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -344 -offset_right = -24 -offset_top = -480 -offset_bottom = -430 +offset_left = -324 +offset_right = -228 +offset_top = -424 +offset_bottom = -328 + +[node name="GasBombStatus" type="Label" parent="UI"] +mouse_filter = 2 theme_override_font_sizes/font_size = 18 theme_override_colors/font_color = Color(0.82, 0.08, 0.48, 1) autowrap_mode = 2 horizontal_alignment = 1 text = "" - -[node name="LaserStatus" type="Label" parent="UI"] -mouse_filter = 2 anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -344 +offset_left = -420 offset_right = -24 -offset_top = -540 -offset_bottom = -490 +offset_top = -560 +offset_bottom = -512 + +[node name="LaserStatus" type="Label" parent="UI"] +mouse_filter = 2 theme_override_font_sizes/font_size = 18 theme_override_colors/font_color = Color(0.2, 0.6, 0.68, 1) autowrap_mode = 2 horizontal_alignment = 1 text = "" - -[node name="PivotCWButton" type="Control" parent="UI"] -mouse_filter = 2 anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -172 +offset_left = -420 offset_right = -24 -offset_top = -172 -offset_bottom = -24 +offset_top = -616 +offset_bottom = -568 + +[node name="PivotCWButton" type="Control" parent="UI"] +mouse_filter = 2 script = ExtResource("6") +round_frame = true icon = 6 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -356 +offset_right = -252 +offset_top = -130 +offset_bottom = -26 [node name="FadeOverlay" type="CanvasLayer" parent="."] layer = 10 diff --git a/VesselWeaponComponent.gd b/VesselWeaponComponent.gd index 1bc6855..1ba49e9 100644 --- a/VesselWeaponComponent.gd +++ b/VesselWeaponComponent.gd @@ -1,11 +1,25 @@ class_name VesselWeaponComponent extends Node +# The fleet's gun. It does NOT pick its own targets: the player aims it with +# the fire stick, and it shoots along whatever heading that stick reports for +# as long as it is held. +# +# It used to seek the nearest enemy in range and fire on its own, which meant +# combat happened whether or not the player did anything - there was nothing +# to aim, and nothing to decide. Firing is now an action like any other, and +# the right-hand stick is what performs it. +# +# `fire_direction` is set every frame by FireStickController from the stick's +# own reading, and cleared the moment it is released. Zero means "not firing", +# which is the resting state. + var capability_id: String = "weapon" var fire_range: float var fire_cooldown: float var projectile_damage: int var fire_timer: float = 0.0 +var fire_direction: Vector2 = Vector2.ZERO var projectile_scene: PackedScene = preload("res://Projectile.tscn") @@ -17,31 +31,28 @@ func apply_stats() -> void: projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage")) +# Called by FireStickController. A zero vector stops the gun. +func set_fire_direction(direction: Vector2) -> void: + fire_direction = direction if direction.length() > 0.001 else Vector2.ZERO + + +func is_firing() -> bool: + return fire_direction != Vector2.ZERO + + func tick(delta: float) -> void: + # The cooldown runs down whether or not the trigger is held, so tapping + # the stick in a new direction is not punished by a timer that only + # started when the stick moved. fire_timer -= delta - if fire_timer > 0.0: + if fire_direction == Vector2.ZERO or fire_timer > 0.0: return var ship: Node2D = get_parent() - var target: Node2D = _find_nearest_enemy(ship) - if target == null: - fire_timer = 0.25 - return var projectile: Node2D = projectile_scene.instantiate() projectile.position = ship.position projectile.damage = projectile_damage ship.get_tree().root.add_child(projectile) - projectile.launch(target.position - ship.position) + # Aimed by DIRECTION, not at a target: launch() takes a displacement, so + # the gun's own range decides how far the shot is sent. + projectile.launch(fire_direction.normalized() * fire_range) fire_timer = fire_cooldown - - -func _find_nearest_enemy(ship: Node2D) -> Node2D: - var nearest: Node2D = null - var best_distance: float = fire_range - for enemy in ship.get_tree().get_nodes_in_group("enemies"): - if not is_instance_valid(enemy): - continue - var distance: float = ship.position.distance_to(enemy.position) - if distance <= best_distance: - best_distance = distance - nearest = enemy - return nearest diff --git a/tests/_hud_preview.gd b/tests/_hud_preview.gd new file mode 100644 index 0000000..04f0fb8 --- /dev/null +++ b/tests/_hud_preview.gd @@ -0,0 +1,35 @@ +extends Node2D + +# Preview, not a test - the full combat HUD with every ability control +# visible, so the arrangement around the fire stick can be looked at. +# +# bash tests/screenshot.sh res://tests/_hud_preview.tscn /tmp/hud.png 30 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.vessel_levels["unlock_laser"] = 1 + GameState.vessel_levels["unlock_gas_bomber"] = 1 + GameState.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 200) + GameState.deposit_fleet_resource(GameState.RESOURCE_GAS, 200) + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + var fleet: Node = main.get_node("Fleet") + fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, GameState.VESSEL_GAS_BOMBER])) + main.get_node("LaserController")._process(0.0) + main.get_node("GasBombController")._process(0.0) + # Push the fire stick, so its knob shows deflected the way it looks in use. + var stick: Control = main.get_node("UI/FireStick") + var press := InputEventScreenTouch.new() + press.index = 4 + press.pressed = true + press.position = stick.global_position + stick.size * 0.5 + stick.handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = 4 + drag.position = press.position + Vector2(46.0, -52.0) + stick.handle_drag(drag) diff --git a/tests/_hud_preview.gd.uid b/tests/_hud_preview.gd.uid new file mode 100644 index 0000000..ca16e1f --- /dev/null +++ b/tests/_hud_preview.gd.uid @@ -0,0 +1 @@ +uid://ddyyllwj5g76j diff --git a/tests/_hud_preview.tscn b/tests/_hud_preview.tscn new file mode 100644 index 0000000..a1c60b9 --- /dev/null +++ b/tests/_hud_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_hud_preview.gd" id="1"] + +[node name="HudPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_enemy_combat.gd b/tests/test_enemy_combat.gd index aa1951e..13ef609 100644 --- a/tests/test_enemy_combat.gd +++ b/tests/test_enemy_combat.gd @@ -2,9 +2,15 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_enemy_combat.gd -# Verifies the fleet auto-fires on an enemy within range and kills it after -# a few hits, without the enemy ever having to touch a ship, and that a -# coherent red light-burst explosion VFX spawns at the moment it dies. +# Verifies the fleet kills an enemy it is AIMED at, without the enemy ever +# having to touch a ship, and that a coherent red light-burst explosion VFX +# spawns at the moment it dies. +# +# The fleet used to fire on its own and this test only had to wait. Firing is +# now the right-hand stick, so the test holds it toward the enemy - through +# the real control, not by poking the weapon components, so what it exercises +# is the path the player uses. See tests/test_fire_stick.gd for the aiming +# behaviour itself. const TEST_DURATION: float = 3.0 const SPAWN_OFFSET: Vector2 = Vector2(220, 0) # inside fire_range, outside collision radius @@ -43,13 +49,14 @@ func _physics_process(delta: float) -> bool: enemy = enemy_scene.instantiate() enemy.position = fleet.get_centroid() + SPAWN_OFFSET main_scene.add_child(enemy) + _hold_fire_toward(SPAWN_OFFSET) started = true return false elapsed += delta if not is_instance_valid(enemy): - _finish(true, "enemy destroyed by fleet fire after %.2fs" % elapsed) + _finish(true, "enemy destroyed by aimed fleet fire after %.2fs" % elapsed) return true if elapsed >= TEST_DURATION: @@ -59,6 +66,22 @@ func _physics_process(delta: float) -> bool: return false +# Presses the fire stick and holds it on the given heading. The stick keeps +# reporting that direction until it is released, so this is called once. +func _hold_fire_toward(heading: Vector2) -> void: + var stick: Control = main_scene.get_node("UI/FireStick") + var centre: Vector2 = stick.global_position + stick.size * 0.5 + var press := InputEventScreenTouch.new() + press.index = 6 + press.pressed = true + press.position = centre + stick.handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = 6 + drag.position = centre + heading.normalized() * stick.max_radius + stick.handle_drag(drag) + + func _finish(ok: bool, message: String) -> void: print("ships_remaining=%d" % fleet.ships.size()) print(message) diff --git a/tests/test_fire_stick.gd b/tests/test_fire_stick.gd new file mode 100644 index 0000000..ca80aa9 --- /dev/null +++ b/tests/test_fire_stick.gd @@ -0,0 +1,188 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_fire_stick.gd +# The fleet's guns used to pick their own targets and fire on their own, so +# combat happened whether or not the player did anything. Firing is now an +# action: the right-hand stick aims it, and nothing else fires it. +# +# Covers: +# A. an idle stick means an idle gun - an enemy parked well inside fire +# range takes no shots at all. This is the behaviour that CHANGED, so it +# is checked against a target the old auto-aim would certainly have hit +# B. pushing the stick fires, and the shots travel along the stick's +# heading rather than at whatever happens to be nearest +# C. releasing the stick stops the gun +# D. the stick claims only touches inside its own rect, so the right half +# of the screen does not swallow pinches and taps meant for the world +# E. the dotted aim guide is drawn only while aiming, and reaches exactly as +# far as the guns actually shoot - a guide that outruns the gun would be +# worse than none + +const FIRE_DIRECTION := Vector2(1.0, 0.0) + +var game_state: Node +var main: Node +var fleet: Node +var fire_stick: Control +var controller: Node +var decoy: Node +var stage: int = 0 +var elapsed: float = 0.0 +var shots_while_idle: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + main = load("res://Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + fire_stick = main.get_node("UI/FireStick") + controller = main.get_node("FireStickController") + + +func _projectile_count() -> int: + return get_nodes_in_group("player_projectiles").size() + + +func _push_stick(direction: Vector2) -> void: + var centre: Vector2 = fire_stick.global_position + fire_stick.size * 0.5 + var press := InputEventScreenTouch.new() + press.index = 3 + press.pressed = true + press.position = centre + if not fire_stick.handle_touch(press): + push_error("the fire stick refused a touch at its own centre") + var drag := InputEventScreenDrag.new() + drag.index = 3 + drag.position = centre + direction.normalized() * fire_stick.max_radius + fire_stick.handle_drag(drag) + + +func _release_stick() -> void: + var release := InputEventScreenTouch.new() + release.index = 3 + release.pressed = false + release.position = fire_stick.global_position + fire_stick.size * 0.5 + fire_stick.handle_touch(release) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + for projectile in get_nodes_in_group("player_projectiles"): + projectile.free() + + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + var ship: Node2D = fleet.ships[0] + var weapon: Node = ship.get_component("weapon") + if weapon == null: + return _fail("the default vessel has no weapon component to test") + # Parked at a third of fire range, dead ahead: the old auto-aiming + # gun would have opened up on this immediately. + decoy = load("res://Enemy.tscn").instantiate() + decoy.position = ship.global_position + Vector2(weapon.fire_range * 0.33, 0.0) + main.add_child(decoy) + decoy.speed = 0.0 + print("a target sits %.0f away, well inside the gun's %.0f range" \ + % [ship.global_position.distance_to(decoy.position), weapon.fire_range]) + stage = 1 + elapsed = 0.0 + return false + 1: + # --- A. idle stick, idle gun ----------------------------------- + shots_while_idle = maxi(shots_while_idle, _projectile_count()) + if elapsed < 1.5: + return false + if shots_while_idle != 0: + return _fail("the fleet fired %d shot(s) with the stick untouched - the gun is still aiming itself" \ + % shots_while_idle) + print("1.5s with the stick untouched: no shots fired") + + _push_stick(FIRE_DIRECTION) + if fire_stick.direction.normalized().distance_to(FIRE_DIRECTION) > 0.01: + return _fail("pushing the stick reported direction %s, expected %s" \ + % [fire_stick.direction, FIRE_DIRECTION]) + stage = 2 + elapsed = 0.0 + return false + 2: + # --- B. it fires, along the stick ------------------------------ + if elapsed < 0.6: + return false + var projectiles: Array = get_nodes_in_group("player_projectiles") + if projectiles.is_empty(): + return _fail("pushing the fire stick did not fire anything") + for projectile in projectiles: + var heading: Vector2 = projectile.velocity.normalized() + if heading.distance_to(FIRE_DIRECTION) > 0.05: + return _fail("a shot travelled along %s, but the stick was pushed %s" \ + % [heading, FIRE_DIRECTION]) + print("%d shot(s) fired, every one along the stick's heading" % projectiles.size()) + + # --- E. the aim guide ------------------------------------------ + if controller.aim_direction == Vector2.ZERO: + return _fail("the aim guide has no heading while the stick is held") + if controller.aim_direction.normalized().distance_to(FIRE_DIRECTION) > 0.05: + return _fail("the guide points along %s but the stick was pushed %s" \ + % [controller.aim_direction.normalized(), FIRE_DIRECTION]) + var weapon: Node = fleet.ships[0].get_component("weapon") + if not is_equal_approx(controller.guide_range(), weapon.fire_range): + return _fail("the guide reaches %.0f but the gun shoots %.0f" \ + % [controller.guide_range(), weapon.fire_range]) + print("aim guide drawn along the stick, reaching the gun's own %.0f range" % weapon.fire_range) + + _release_stick() + if fire_stick.direction != Vector2.ZERO: + return _fail("releasing the stick left it reporting %s" % fire_stick.direction) + # One frame for the controller to push the release through to the + # weapons, then clear the sky so what follows is unambiguous. + controller._process(0.0) + for projectile in get_nodes_in_group("player_projectiles"): + projectile.free() + stage = 3 + elapsed = 0.0 + return false + 3: + # --- C. released means stopped --------------------------------- + if _projectile_count() != 0: + return _fail("the gun kept firing after the stick was released") + if elapsed < 1.2: + return false + if controller.is_firing(): + return _fail("a weapon still reports itself firing after release") + if controller.aim_direction != Vector2.ZERO: + return _fail("the aim guide is still drawn after the stick was released") + print("1.2s after release: nothing fired") + + # --- D. the stick claims only its own rect --------------------- + var outside := InputEventScreenTouch.new() + outside.index = 5 + outside.pressed = true + outside.position = fire_stick.global_position - Vector2(40.0, 40.0) + if fire_stick.handle_touch(outside): + return _fail("the fire stick claimed a touch outside its own rect, which would swallow world gestures") + print("a touch outside the stick's rect falls through") + + print("TEST_FIRE_STICK: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_FIRE_STICK: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_fire_stick.gd.uid b/tests/test_fire_stick.gd.uid new file mode 100644 index 0000000..31647c5 --- /dev/null +++ b/tests/test_fire_stick.gd.uid @@ -0,0 +1 @@ +uid://da87mqssstsx6 diff --git a/tests/test_setup_file_identity.gd.uid b/tests/test_setup_file_identity.gd.uid new file mode 100644 index 0000000..84d24d5 --- /dev/null +++ b/tests/test_setup_file_identity.gd.uid @@ -0,0 +1 @@ +uid://cvw1mjejsu7pv