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