From baac11ae8b7ddd2c9ee3e0bcc437fa871cc9452a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 21 Aug 2026 18:49:47 +0200 Subject: [PATCH] Implement #80, #81, #82 and #83 #80 - desktop combat. The mouse aims, the left button fires, and the number keys pick the weapon: 1 the fleet's guns, 2 and up the abilities. The twin sticks are built for thumbs; on a desktop they mean dragging a virtual stick with a pointer that could simply BE the crosshair. The pointer path hands its heading to FireStickController rather than driving weapons itself, so exactly one thing in the scene aims a gun, and a held stick wins - a finger on it is a deliberate act, a mouse resting somewhere is not. Abilities gained fire_at(), which aims and fires in one call instead of pretending to be a press-drag-release touch. It is gated on the absence of a touchscreen, because Godot emulates a mouse button from every touch and without that a tap on a phone would fire the guns. The gate has an explicit override, and not for tidiness: headless reports a touchscreen, so with the automatic answer alone the aiming could never be exercised by a test and the only covered behaviour would be the guard. #81 - the bottom-left control hint is gone, from both scenes and from GameController. #82 - a chaser is destroyed by the impact when it reaches a vessel. It used to pass through the wreck untouched and line up another pass, which made a ramming enemy strictly better than any other. Data-driven per enemy (dies_on_contact), because a sniper that happens to drift into a ship has not spent itself doing it - and routed through take_damage, so it dies exactly as it dies to gunfire. The enemy is told about the impact BEFORE the ship resolves its own damage, since resolving it can free the ship. #83 - the research tree's buy button is gone; tapping a node twice buys it. Deliberately "the same node twice" rather than a timed double-tap: no window to miss on a slow phone, no accidental purchase while browsing, and the first tap always does something visible, so the second is never a blind commitment. The status line now says "Tap again to research" - with the button gone it is the only thing distinguishing an affordable node from an unaffordable one. test_enemy_collision asserted the exact behaviour #82 reversed, and now checks the ram costs both. Suite 117/117. Co-Authored-By: Claude Opus 5 --- AimedFleetAbilityController.gd | 19 ++++ BuildController.gd | 21 ++-- ChaserEnemyDefinition.tres | 1 + Enemy.gd | 10 ++ EnemyDefinition.gd | 5 + FireStickController.gd | 8 ++ GameController.gd | 5 - Main.tscn | 32 +----- MouseCombatController.gd | 123 ++++++++++++++++++++ PlanetSurface.tscn | 19 +--- ResourcePickupVFX.gd.uid | 1 + Ship.gd | 6 + SkillTreeView.gd | 15 +++ tests/test_chaser_rams.gd | 138 +++++++++++++++++++++++ tests/test_desktop_combat.gd | 174 +++++++++++++++++++++++++++++ tests/test_enemy_collision.gd | 20 ++-- tests/test_gas_burner_pause.gd.uid | 1 + tests/test_pickup_feedback.gd.uid | 1 + tests/test_research_double_tap.gd | 140 +++++++++++++++++++++++ 19 files changed, 677 insertions(+), 62 deletions(-) create mode 100644 MouseCombatController.gd create mode 100644 ResourcePickupVFX.gd.uid create mode 100644 tests/test_chaser_rams.gd create mode 100644 tests/test_desktop_combat.gd create mode 100644 tests/test_gas_burner_pause.gd.uid create mode 100644 tests/test_pickup_feedback.gd.uid create mode 100644 tests/test_research_double_tap.gd diff --git a/AimedFleetAbilityController.gd b/AimedFleetAbilityController.gd index 2a004ee..191c062 100644 --- a/AimedFleetAbilityController.gd +++ b/AimedFleetAbilityController.gd @@ -59,6 +59,25 @@ func can_begin_aim() -> bool: and not aiming and _next_ready_ship() != null +# Fires immediately at a world point, with no press-drag-release. The mouse +# path (#80) has a crosshair already - it does not need to be dragged into +# existence - so it aims and fires in one call rather than pretending to be +# a touch. +func fire_at(world_target: Vector2) -> bool: + if not button.visible or button._blocked_by_modal() or _next_ready_ship() == null: + return false + if not _can_afford_shot(): + _say(_unaffordable_message()) + return false + # Recorded before firing because _fire() ends by restoring it, and a stale + # value from an earlier aim would snap the camera somewhere unrelated. + saved_zoom = camera.zoom.x + restoring_zoom = false + aim_target = world_target + _fire() + return true + + func handle_touch(event: InputEventScreenTouch) -> bool: if event.pressed: if not can_begin_aim() \ diff --git a/BuildController.gd b/BuildController.gd index fe8281d..a3c13ef 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -161,7 +161,6 @@ var lab_building_id: int = 0 @onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel") @onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber") @onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel") -@onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton") @onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton") @onready var formation_menu: Control = get_node("../UI/FormationMenu") @@ -286,8 +285,8 @@ func _ready() -> void: formation_editor.formation_changed.connect(_on_formation_changed) skill_close_button.pressed.connect(func(): skill_menu.visible = false) - skill_unlock_button.pressed.connect(_on_unlock_pressed) skill_tree.node_picked.connect(_on_skill_node_picked) + skill_tree.node_confirmed.connect(_on_skill_node_confirmed) build_menu.visible = false info_popup.visible = false @@ -803,6 +802,13 @@ func _try_place(type: String, cost: int) -> void: # --- research (the mothership's skill tree) -------------------------------- +# A second tap on the node already selected buys it (#83). Routed into the +# same handler the buy button used, so the affordability re-check and the +# failure message it does are not duplicated here. +func _on_skill_node_confirmed(_id: String) -> void: + _on_unlock_pressed() + + func _on_skill_node_picked(id: String) -> void: _refresh_skill_menu(id) @@ -817,7 +823,6 @@ func _refresh_skill_menu(id: String = "") -> void: skill_cost.amount = 0 skill_status.text = "Fleet capacity %d ยท Research points %d" \ % [GameState.max_fleet_size(), GameState.research_points] - skill_unlock_button.visible = false return var node: Dictionary = GameState.skill_node(id) @@ -831,25 +836,23 @@ func _refresh_skill_menu(id: String = "") -> void: if GameState.is_unlocked(id): skill_status.text = "Researched" - skill_unlock_button.visible = false elif not GameState.prerequisite_met(id): skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name - skill_unlock_button.visible = false elif not GameState.can_afford(int(node.cost)): # available_ore(), not the bank alone: research is paid for the same # pooled way as everything else, so quoting the bank here would tell # the player they cannot afford something they can. skill_status.text = "Not enough rocks - you have %d" % GameState.available_ore() - skill_unlock_button.visible = false elif GameState.research_points < points_needed: # #63: a distinct reason from the ore one above - conflating them # would leave the player trying to fix the wrong shortage. skill_status.text = "Not enough research points - you have %d, need %d" \ % [GameState.research_points, points_needed] - skill_unlock_button.visible = false else: - skill_status.text = "" - skill_unlock_button.visible = true + # The button that used to appear here was the only thing saying "this + # is buyable". With it gone the line has to say so itself, or an + # affordable node and an unaffordable one look identical. + skill_status.text = "Tap again to research" func _on_unlock_pressed() -> void: diff --git a/ChaserEnemyDefinition.tres b/ChaserEnemyDefinition.tres index 90f4385..757a5de 100644 --- a/ChaserEnemyDefinition.tres +++ b/ChaserEnemyDefinition.tres @@ -13,4 +13,5 @@ acceleration = 260.0 rotation_speed = 6.0 hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0) glow_color = Color(1, 0.15, 0.2, 1) +dies_on_contact = true component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/Enemy.gd b/Enemy.gd index 16335b6..feef317 100644 --- a/Enemy.gd +++ b/Enemy.gd @@ -51,6 +51,16 @@ func _physics_process(delta: float) -> void: child.tick(delta) +# Called by a player vessel this has just collided with. Whether the impact +# destroys it is the definition's call - see EnemyDefinition.dies_on_contact. +# Routed through take_damage so it dies exactly as it dies to gunfire: same +# explosion, same `destroyed` signal, same bookkeeping for whatever is +# listening. +func collide_with_player_vessel() -> void: + if definition != null and bool(definition.dies_on_contact): + take_damage(hp) + + func take_damage(amount: int) -> void: hp -= amount if hp <= 0: diff --git a/EnemyDefinition.gd b/EnemyDefinition.gd index e214181..4d16006 100644 --- a/EnemyDefinition.gd +++ b/EnemyDefinition.gd @@ -26,5 +26,10 @@ extends Resource # Rings, as triples of (centre x, centre y, radius). Sensor domes, muzzles # and bay openings all read better as circles than as polygon chords. @export var detail_rings: PackedVector2Array = PackedVector2Array() +# #82: this enemy is destroyed by the impact when it reaches a player vessel, +# rather than shrugging it off and lining up another pass. Data, not a type +# check: the chaser rams because ramming is its whole behaviour, while a +# sniper that happens to drift into a ship has not spent itself doing it. +@export var dies_on_contact: bool = false @export var core_color: Color = Color(0.06, 0.02, 0.03) @export var component_scenes: Array[PackedScene] = [] diff --git a/FireStickController.gd b/FireStickController.gd index 6ed57db..a5b4548 100644 --- a/FireStickController.gd +++ b/FireStickController.gd @@ -27,10 +27,18 @@ const GUIDE_COLOR: Color = Color(1.0, 0.55, 0.38, 0.28) @onready var stick: Node = get_node_or_null("../UI/FireStick") var aim_direction: Vector2 = Vector2.ZERO +# Set by MouseCombatController (#80) when a desktop player holds the left +# button. Kept as a second input rather than a second aimer, so there is still +# exactly one thing in the scene that points a gun. +var external_direction: Vector2 = Vector2.ZERO func _process(_delta: float) -> void: var direction: Vector2 = stick.direction if stick != null else Vector2.ZERO + if direction == Vector2.ZERO: + # The stick wins when both are live: a finger on it is a deliberate + # act, a mouse resting somewhere is not. + direction = external_direction 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 diff --git a/GameController.gd b/GameController.gd index f8f5ea7..137ebdb 100644 --- a/GameController.gd +++ b/GameController.gd @@ -95,7 +95,6 @@ const KEY_PIVOT_CW: int = KEY_E const KEY_PIVOT_CCW: int = KEY_Q const WHEEL_ZOOM_STEP: float = 1.12 -@onready var keyboard_hint: Label = get_node_or_null("UI/KeyboardHintLabel") var keys_down: Dictionary = {} var keyboard_seen: bool = false @@ -105,8 +104,6 @@ var keyboard_steering: bool = false func _ready() -> void: if joystick != null: joystick.direction_changed.connect(_on_joystick_direction) - if keyboard_hint != null: - keyboard_hint.visible = false func _held_pivot_direction() -> float: @@ -150,8 +147,6 @@ func _note_keyboard() -> void: if keyboard_seen: return keyboard_seen = true - if keyboard_hint != null: - keyboard_hint.visible = true # The stick is only taken away where there is no touchscreen to work it # with. A touch device that also has a keyboard keeps both, and a device # wrongly reported as non-touch still shows the stick until the player diff --git a/Main.tscn b/Main.tscn index 43eba93..7e6a459 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=30 format=3] +[gd_scene load_steps=31 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://MouseCombatController.gd" id="mouse_ctrl"] [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"] @@ -62,6 +63,9 @@ script = ExtResource("29") [node name="FireStickController" type="Node2D" parent="."] script = ExtResource("fire_ctrl") +[node name="MouseCombatController" type="Node2D" parent="."] +script = ExtResource("mouse_ctrl") + [node name="LaserController" type="Node2D" parent="."] script = ExtResource("30") @@ -203,19 +207,6 @@ offset_bottom = 172 script = ExtResource("10") icon = 0 -[node name="KeyboardHintLabel" type="Label" parent="UI"] -visible = false -mouse_filter = 2 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = 28 -offset_right = 360 -offset_top = -300 -offset_bottom = -120 -theme_override_font_sizes/font_size = 36 -autowrap_mode = 2 -text = "WASD / arrows move Q / E pivot wheel zoom" - [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("13") @@ -2140,19 +2131,6 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "" -[node name="UnlockButton" type="Control" parent="UI/SkillMenu"] -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -74.0 -offset_top = -170.0 -offset_right = 74.0 -offset_bottom = -22.0 -script = ExtResource("10") -icon = 5 -glow_color = Color(0.45, 0.9, 0.55, 1) - [node name="CloseButton" type="Control" parent="UI/SkillMenu"] anchor_left = 1.0 anchor_right = 1.0 diff --git a/MouseCombatController.gd b/MouseCombatController.gd new file mode 100644 index 0000000..0d16845 --- /dev/null +++ b/MouseCombatController.gd @@ -0,0 +1,123 @@ +extends Node2D + +# Forgejo #80: on a computer the mouse aims and the left button fires, and the +# number keys choose which weapon the click uses - 1 the fleet's own guns, 2 +# and up the special abilities. +# +# The twin sticks are built for thumbs. On a desktop they mean dragging a +# virtual stick with a pointer that could simply BE the crosshair, so this +# offers the shape a mouse actually wants: point, click, hold to keep firing. +# +# Touch is unaffected, and deliberately so: Godot emulates a mouse button from +# every touch, so without a guard a tap anywhere on a phone would fire the +# guns. The guard is the presence of a touchscreen rather than a "has used a +# mouse" flag, because the wrong answer has to fail safe - a desktop wrongly +# reported as touch-capable loses a convenience, while a phone wrongly treated +# as a desktop fires its weapons at every tap. + +# A literal, not PackedStringArray(...): a constructor call is not a constant +# expression, and the script fails to PARSE rather than to run - which shows up +# as "nonexistent function" on a node that looks fine in the scene. +const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController"] + +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") +@onready var fire_stick_controller: Node = get_node_or_null("../FireStickController") + +# 0 is the fleet's own guns; 1.. index into `abilities` below. +var selected_weapon: int = 0 +var firing: bool = false +var aim_world: Vector2 = Vector2.ZERO +var abilities: Array = [] + + +func _ready() -> void: + # Resolved by NAME in a fixed order, so "2" is the gas bomb on every + # scene and in every session. Discovering them by scanning children would + # make the number keys depend on scene node order, which is not something + # a player can see or rely on. + for node_name in ABILITY_ORDER: + var controller: Node = get_node_or_null("../%s" % node_name) + if controller != null: + abilities.append(controller) + + +# AUTO decides from the display, which is what every scene ships with. The +# explicit settings exist because "is there a touchscreen" is not a question +# that can be answered honestly in every environment - headless reports one, +# so with AUTO alone the aiming below could never be exercised by a test at +# all, and the only covered behaviour would be the guard itself. +enum PointerMode { AUTO, ON, OFF } + +@export var pointer_mode: PointerMode = PointerMode.AUTO + + +func is_enabled() -> bool: + match pointer_mode: + PointerMode.ON: + return true + PointerMode.OFF: + return false + return not DisplayServer.is_touchscreen_available() + + +func weapon_count() -> int: + return abilities.size() + 1 + + +# Which weapon a given number key selects. Public so a test can ask rather +# than hardcode the mapping a second time. +func select_weapon(index: int) -> bool: + if index < 0 or index >= weapon_count(): + return false + selected_weapon = index + _refresh_highlight() + return true + + +func _refresh_highlight() -> void: + # The chosen ability's own button carries the selection, so the answer to + # "what will a click do" is on screen rather than remembered. + for i in range(abilities.size()): + var button: Node = abilities[i].get("button") + if button != null: + button.highlighted = selected_weapon == i + 1 + button.queue_redraw() + + +func _unhandled_input(event: InputEvent) -> void: + if not is_enabled(): + return + if event is InputEventMouseMotion: + aim_world = get_global_mouse_position() + return + if event is InputEventKey and event.pressed and not event.echo: + var index: int = event.keycode - KEY_1 + if index >= 0 and index < weapon_count() and select_weapon(index): + get_viewport().set_input_as_handled() + return + if not (event is InputEventMouseButton) or event.button_index != MOUSE_BUTTON_LEFT: + return + + aim_world = get_global_mouse_position() + firing = event.pressed + if not event.pressed: + return + get_viewport().set_input_as_handled() + if selected_weapon > 0: + # One shot per click. Abilities have their own cooldowns and costs; + # holding the button down must not queue them up. + abilities[selected_weapon - 1].fire_at(aim_world) + + +func _process(_delta: float) -> void: + if fire_stick_controller == null: + return + # Handed to the stick controller rather than driving the weapons here, so + # there is still exactly one thing in the scene that aims a gun. The stick + # wins when both are live - a finger on it is a deliberate act, while a + # mouse merely resting somewhere is not. + var heading: Vector2 = Vector2.ZERO + if is_enabled() and firing and selected_weapon == 0 and not fleet.ships.is_empty(): + heading = aim_world - fleet.get_centroid() + fire_stick_controller.external_direction = heading diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 89805e0..4d995e2 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=15 format=3] +[gd_scene load_steps=16 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://MouseCombatController.gd" id="mouse_ctrl"] [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"] @@ -29,6 +30,9 @@ script = ExtResource("14") [node name="FireStickController" type="Node2D" parent="."] script = ExtResource("fire_ctrl") +[node name="MouseCombatController" type="Node2D" parent="."] +script = ExtResource("mouse_ctrl") + [node name="LaserController" type="Node2D" parent="."] script = ExtResource("16") @@ -119,19 +123,6 @@ icon = 4 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) -[node name="KeyboardHintLabel" type="Label" parent="UI"] -visible = false -mouse_filter = 2 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = 28 -offset_right = 360 -offset_top = -300 -offset_bottom = -120 -theme_override_font_sizes/font_size = 36 -autowrap_mode = 2 -text = "WASD / arrows move Q / E pivot wheel zoom" - [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("7") diff --git a/ResourcePickupVFX.gd.uid b/ResourcePickupVFX.gd.uid new file mode 100644 index 0000000..e5ead3b --- /dev/null +++ b/ResourcePickupVFX.gd.uid @@ -0,0 +1 @@ +uid://byysi5a0ssymi diff --git a/Ship.gd b/Ship.gd index 08baa0c..0ca2ca7 100644 --- a/Ship.gd +++ b/Ship.gd @@ -179,6 +179,12 @@ func take_damage(_amount: int) -> bool: func _on_area_entered(area: Area2D) -> void: if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): return + # The enemy is told about the impact BEFORE this ship resolves its own + # damage, because resolving it can free this node - and a ship that dies + # to a ramming chaser must still take the chaser with it. Whether the + # impact actually destroys the enemy is the enemy's decision (#82). + if area.is_in_group("enemies") and area.has_method("collide_with_player_vessel"): + area.collide_with_player_vessel() var absorbed: bool = take_damage(1) if absorbed and area.is_in_group("asteroids") and is_instance_valid(area): area.queue_free() diff --git a/SkillTreeView.gd b/SkillTreeView.gd index b6ea6dc..b1992d6 100644 --- a/SkillTreeView.gd +++ b/SkillTreeView.gd @@ -30,6 +30,18 @@ const LOCKED_PULSE: float = 0.5 @export var node_radius: float = 46.0 @export var row_spacing: float = 250.0 +# #83: a second tap on the node already selected buys it. The separate buy +# button is gone - it sat at the bottom of the panel, a long way from the node +# being chosen, and meant every purchase was look-down-tap-look-up. Tapping +# the thing you want is the shorter sentence. +# +# Deliberately "tap the SAME node twice" rather than a timed double-tap: no +# window to miss on a slow phone, no accidental purchase from a fast +# double-tap while browsing, and the first tap always does something visible +# (it selects, and the panel fills in with the cost and what it unlocks) so +# the second one is never a blind commitment. +signal node_confirmed(id: String) + var selected_id: String = "" var scroll_y: float = 0.0 var _dragging: bool = false @@ -90,9 +102,12 @@ func handle_touch(event: InputEventScreenTouch) -> bool: var local: Vector2 = event.position - global_position for node in GameState.SKILL_TREE: if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25: + var confirming: bool = selected_id == node.id selected_id = node.id queue_redraw() node_picked.emit(node.id) + if confirming: + node_confirmed.emit(node.id) return true return false diff --git a/tests/test_chaser_rams.gd b/tests/test_chaser_rams.gd new file mode 100644 index 0000000..2b0c8ea --- /dev/null +++ b/tests/test_chaser_rams.gd @@ -0,0 +1,138 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_chaser_rams.gd +# Forgejo #82: a chaser is destroyed by the impact when it reaches a player +# vessel. It used to pass straight through the wreck untouched and line up +# another pass, which made a ramming enemy strictly better than any other. +# +# Covers: +# A. the chaser dies on contact, and dies the same way it dies to gunfire - +# through take_damage, so the explosion and the destroyed signal happen +# B. the vessel it hit still dies too. Trading one for one is the point; +# the chaser must not become harmless +# C. an enemy NOT marked dies_on_contact survives the same collision, so +# this is a per-enemy decision and not a blanket rule about touching +# D. a SHIELDED vessel survives, and still destroys the chaser + +var game_state: Node +var main: Node +var fleet: Node +var stage: int = 0 +var elapsed: float = 0.0 +var chaser: Node2D +var sniper: Node2D +var victim: Node2D +var destroyed_fired: bool = false + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.vessel_levels["unlock_shield"] = 1 + main = load("res://Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + + +func _spawn_enemy(enemy_id: String, at: Vector2) -> Node2D: + var enemy: Node2D = load("res://Enemy.tscn").instantiate() + enemy.enemy_id = enemy_id + enemy.position = at + main.add_child(enemy) + enemy.speed = 0.0 + return enemy + + +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() + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + + var definitions: Script = load("res://EnemyDefinitions.gd") + if not bool(definitions.get_definition("chaser").dies_on_contact): + return _fail("the chaser is not marked dies_on_contact") + if bool(definitions.get_definition("sniper").dies_on_contact): + return _fail("the sniper is marked dies_on_contact, so case C proves nothing") + + # Held as an instance, not read back off fleet.ships: the + # wiped-out-fleet safety net rebuilds a vessel within a frame, so + # "the fleet is empty" is never true for long enough to observe. + victim = fleet.ships[0] + chaser = _spawn_enemy("chaser", victim.global_position) + chaser.destroyed.connect(func(): destroyed_fired = true) + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.5: + return false + # --- A/B. one for one ------------------------------------------ + if is_instance_valid(chaser) and not chaser.is_queued_for_deletion(): + return _fail("the chaser survived ramming a vessel") + if not destroyed_fired: + return _fail("the chaser vanished without firing `destroyed` - it was not routed through take_damage") + if is_instance_valid(victim) and not victim.is_queued_for_deletion(): + return _fail("the rammed vessel survived, so the chaser cost nothing to stop") + print("chaser and vessel destroyed each other, through the normal death path") + + # --- C. not a blanket rule ------------------------------------- + fleet.spawn_ships(1, Vector2(3000.0, 0.0), PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + sniper = _spawn_enemy("sniper", fleet.ships[0].global_position) + stage = 2 + elapsed = 0.0 + return false + 2: + if elapsed < 0.5: + return false + if not is_instance_valid(sniper) or sniper.is_queued_for_deletion(): + return _fail("a sniper died on contact too - the rule is meant to be per-enemy") + print("a sniper survives the same collision") + sniper.queue_free() + + # --- D. a shield saves the vessel, not the chaser -------------- + fleet.spawn_ships(1, Vector2(-3000.0, 0.0), PackedStringArray([GameState.VESSEL_SHIELD])) + stage = 3 + elapsed = 0.0 + return false + 3: + if elapsed < 0.3: + return false + victim = fleet.ships[0] + var shielded: Node2D = victim + if not shielded.has_shield(): + return _fail("the shield vessel spawned with no shield, so this case proves nothing") + chaser = _spawn_enemy("chaser", shielded.global_position) + stage = 4 + elapsed = 0.0 + return false + 4: + if elapsed < 0.5: + return false + if is_instance_valid(chaser) and not chaser.is_queued_for_deletion(): + return _fail("the chaser survived ramming a shielded vessel") + if not is_instance_valid(victim) or victim.is_queued_for_deletion(): + return _fail("the shielded vessel died anyway") + print("a shielded vessel absorbs the ram and the chaser still dies") + + print("TEST_CHASER_RAMS: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_CHASER_RAMS: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_desktop_combat.gd b/tests/test_desktop_combat.gd new file mode 100644 index 0000000..e212275 --- /dev/null +++ b/tests/test_desktop_combat.gd @@ -0,0 +1,174 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_desktop_combat.gd +# Forgejo #80: on a computer the mouse aims and the left button fires, and the +# number keys pick the weapon - 1 the fleet's guns, 2 and up the abilities. +# +# Covers: +# A. the weapon numbering is FIXED and named, so "2" is the gas bomb in +# every scene rather than whatever node order happens to produce +# B. holding the button with weapon 1 selected points the fleet's guns at +# the cursor, through the same controller the stick uses - not a second +# thing that aims +# C. releasing stops it +# D. the stick WINS while it is held, so a mouse resting somewhere cannot +# override a finger +# E. selecting an ability highlights its button, so what a click will do is +# on screen rather than remembered +# F. it is inert on a touch device. Godot emulates a mouse button from every +# touch, so without that guard a tap on a phone would fire the guns + +var game_state: Node +var main: Node +var fleet: Node +var mouse: Node +var fire_stick: Node +var stick: Control +var stage: int = 0 +var elapsed: float = 0.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") + mouse = main.get_node("MouseCombatController") + # Checked here rather than at first use: a script that fails to PARSE + # leaves a bare Node2D in the scene, and every call against it becomes a + # per-frame "nonexistent function" error that runs until the harness + # times the test out three minutes later instead of failing in one line. + if not mouse.has_method("weapon_count"): + print("TEST_DESKTOP_COMBAT: FAIL (MouseCombatController.gd did not load - see the parse error above)") + quit(1) + return + fire_stick = main.get_node("FireStickController") + stick = main.get_node("UI/FireStick") + + +func _weapon_heading() -> Vector2: + return fire_stick.aim_direction + + +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() + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + + # --- A. fixed, named ordering --------------------------------- + if mouse.weapon_count() != mouse.abilities.size() + 1: + return _fail("weapon count %d does not match 1 + %d abilities" \ + % [mouse.weapon_count(), mouse.abilities.size()]) + if mouse.abilities.is_empty(): + return _fail("no ability controllers were resolved, so the number keys address nothing") + if mouse.abilities[0] != main.get_node("GasBombController"): + return _fail("weapon 2 is not the gas bomb - the ticket names it explicitly") + if not mouse.select_weapon(0): + return _fail("could not select the default weapon") + if mouse.select_weapon(mouse.weapon_count()): + return _fail("selecting a weapon past the end was accepted") + print("weapon 1 is the fleet's guns, weapon 2 is the gas bomb, %d in total" \ + % mouse.weapon_count()) + + # --- F. inert where a touchscreen exists ----------------------- + # Checked against the real capability rather than forced, so this + # reports what will actually happen on the machine it runs on. + print("touchscreen present: %s -> mouse combat enabled: %s" \ + % [DisplayServer.is_touchscreen_available(), mouse.is_enabled()]) + if mouse.pointer_mode != mouse.PointerMode.AUTO: + return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested") + if mouse.is_enabled() == DisplayServer.is_touchscreen_available(): + return _fail("mouse combat is not gated on the absence of a touchscreen") + print("gated correctly on the display's own answer") + + # Everything below is about the AIMING, not the guard. Headless + # reports a touchscreen, so the mode is forced on here - otherwise + # these cases would silently never run and this file would test + # nothing but the line above. + mouse.pointer_mode = mouse.PointerMode.ON + if not mouse.is_enabled(): + return _fail("forcing the pointer mode on did not enable it") + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.1: + return false + # --- B. holding the button aims the guns at the cursor --------- + mouse.select_weapon(0) + mouse.aim_world = fleet.get_centroid() + Vector2(0.0, -500.0) + mouse.firing = true + mouse._process(0.0) + fire_stick._process(0.0) + var heading: Vector2 = _weapon_heading().normalized() + if heading.distance_to(Vector2(0.0, -1.0)) > 0.05: + return _fail("holding the button aimed along %s, but the cursor was straight up" % heading) + print("holding the button aims the guns at the cursor") + + # --- C. releasing stops ----------------------------------------- + mouse.firing = false + mouse._process(0.0) + fire_stick._process(0.0) + if _weapon_heading() != Vector2.ZERO: + return _fail("releasing the button left the guns aimed at %s" % _weapon_heading()) + print("releasing stops the guns") + + # --- D. a held stick wins --------------------------------------- + var centre: Vector2 = stick.global_position + stick.size * 0.5 + var press := InputEventScreenTouch.new() + press.index = 9 + press.pressed = true + press.position = centre + stick.handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = 9 + drag.position = centre + Vector2(stick.max_radius, 0.0) + stick.handle_drag(drag) + mouse.aim_world = fleet.get_centroid() + Vector2(0.0, -500.0) + mouse.firing = true + mouse._process(0.0) + fire_stick._process(0.0) + var contested: Vector2 = _weapon_heading().normalized() + if contested.distance_to(Vector2(1.0, 0.0)) > 0.05: + return _fail("with the stick pushed right and the mouse aiming up, the guns went %s - the stick must win" % contested) + print("a held stick overrides the mouse") + var release := InputEventScreenTouch.new() + release.index = 9 + release.pressed = false + release.position = centre + stick.handle_touch(release) + mouse.firing = false + + # --- E. the chosen ability is shown ---------------------------- + mouse.select_weapon(1) + var bomb_button: Node = mouse.abilities[0].button + if not bomb_button.highlighted: + return _fail("selecting the gas bomb did not highlight its button") + mouse.select_weapon(0) + if bomb_button.highlighted: + return _fail("going back to the guns left the gas bomb still highlighted") + print("the selected ability's own button carries the selection") + + print("TEST_DESKTOP_COMBAT: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_DESKTOP_COMBAT: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_enemy_collision.gd b/tests/test_enemy_collision.gd index 8ea1d12..0055efa 100644 --- a/tests/test_enemy_collision.gd +++ b/tests/test_enemy_collision.gd @@ -2,9 +2,14 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_enemy_collision.gd -# Verifies that an enemy touching a ship destroys that ship, is removed -# from the fleet, while the enemy itself survives the touch (enemies only -# die from being shot, not from ramming). +# Verifies that an enemy touching a ship destroys that ship and takes it out +# of the fleet. +# +# This used to also assert the enemy SURVIVED the touch. #82 reversed that for +# the chaser - which is the enemy this spawns - so a ram now costs both. The +# rule itself, including which enemies it applies to and what a shield does +# about it, is tests/test_chaser_rams.gd; here it is only checked far enough +# to confirm this test is exercising the ramming enemy it thinks it is. const CHECK_DELAY: float = 0.05 @@ -58,13 +63,14 @@ func _finish() -> void: var ship_destroyed: bool = not is_instance_valid(target_ship) var ship_removed_from_fleet: bool = fleet.ships.find(target_ship) == -1 var fleet_count_ok: bool = fleet.ships.size() == 4 - var enemy_survived: bool = is_instance_valid(enemy) and enemy.hp > 0 + # The chaser is destroyed by the impact too (#82). + var enemy_died: bool = not is_instance_valid(enemy) or enemy.is_queued_for_deletion() - print("ship_destroyed=%s ship_removed_from_fleet=%s ships_remaining=%d enemy_survived=%s" % [ - ship_destroyed, ship_removed_from_fleet, fleet.ships.size(), enemy_survived + print("ship_destroyed=%s ship_removed_from_fleet=%s ships_remaining=%d enemy_died=%s" % [ + ship_destroyed, ship_removed_from_fleet, fleet.ships.size(), enemy_died ]) - if ship_destroyed and ship_removed_from_fleet and fleet_count_ok and enemy_survived: + if ship_destroyed and ship_removed_from_fleet and fleet_count_ok and enemy_died: print("TEST_ENEMY_COLLISION: PASS") quit(0) else: diff --git a/tests/test_gas_burner_pause.gd.uid b/tests/test_gas_burner_pause.gd.uid new file mode 100644 index 0000000..bef8f94 --- /dev/null +++ b/tests/test_gas_burner_pause.gd.uid @@ -0,0 +1 @@ +uid://bu5mnp245dht7 diff --git a/tests/test_pickup_feedback.gd.uid b/tests/test_pickup_feedback.gd.uid new file mode 100644 index 0000000..cc7fa70 --- /dev/null +++ b/tests/test_pickup_feedback.gd.uid @@ -0,0 +1 @@ +uid://du732a85tmlhl diff --git a/tests/test_research_double_tap.gd b/tests/test_research_double_tap.gd new file mode 100644 index 0000000..1d2563a --- /dev/null +++ b/tests/test_research_double_tap.gd @@ -0,0 +1,140 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_research_double_tap.gd +# Forgejo #83: the research tree's separate buy button is gone. Tapping a node +# selects it; tapping the SAME node again buys it. +# +# Covers: +# A. the buy button really is gone from the scene, not merely bypassed - a +# leftover would still be tappable and would still be the thing a player +# reaches for +# B. the first tap selects and does NOT buy, so browsing the tree is free +# C. a second tap on the same node buys it +# D. moving to a DIFFERENT node resets that - two taps on two nodes must +# never add up to a purchase +# E. tapping an unaffordable node twice buys nothing and says why + +var game_state: Node +var main: Node +var tree_view: Node +var controller: Node +var stage: int = 0 +var elapsed: float = 0.0 +const NODE_ID := "shipyards" + + +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 + controller = main.get_node("BuildController") + tree_view = main.get_node("UI/SkillMenu/Tree") + + +func _tap(id: String) -> void: + # Straight at the node's own position in the tree's local space, so this + # exercises the same hit test a finger does. + var node: Dictionary = game_state.skill_node(id) + var event := InputEventScreenTouch.new() + event.index = 1 + event.pressed = true + event.position = tree_view.global_position + tree_view.node_screen_pos(node) + tree_view.handle_touch(event) + var release := InputEventScreenTouch.new() + release.index = 1 + release.pressed = false + release.position = event.position + tree_view.handle_touch(release) + + +func _process(delta: float) -> bool: + elapsed += delta + if stage != 0: + return false + if elapsed < 0.3: + return false + stage = 1 + + # --- A. the button is gone --------------------------------------------- + if main.get_node_or_null("UI/SkillMenu/UnlockButton") != null: + return _fail("the research panel still has an UnlockButton node") + print("the separate buy button is gone from the panel") + + main.get_node("UI/SkillMenu").visible = true + tree_view.visible = true + tree_view.reset_scroll() + game_state.unlocked.clear() + game_state.research_points = 99 + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0) + + var cost: int = int(game_state.skill_node(NODE_ID).cost) + var before: int = game_state.available_ore() + + # --- B. the first tap only selects ------------------------------------- + _tap(NODE_ID) + if tree_view.selected_id != NODE_ID: + return _fail("the first tap did not select the node") + if game_state.is_unlocked(NODE_ID): + return _fail("the first tap bought the research - browsing the tree must be free") + if game_state.available_ore() != before: + return _fail("the first tap spent %d" % (before - game_state.available_ore())) + print("first tap selects and spends nothing") + + # --- C. the second tap buys -------------------------------------------- + _tap(NODE_ID) + if not game_state.is_unlocked(NODE_ID): + return _fail("a second tap on the same node did not buy it") + if game_state.available_ore() != before - cost: + return _fail("bought for %d, expected %d" % [before - game_state.available_ore(), cost]) + print("second tap on the same node buys it (%d ore)" % cost) + + # --- D. two taps on two nodes are not a purchase ----------------------- + var first: String = "" + var second: String = "" + for node in GameState.SKILL_TREE: + var id: String = String(node.id) + if game_state.is_unlocked(id) or not game_state.prerequisite_met(id): + continue + if first == "": + first = id + elif second == "": + second = id + break + if second == "": + return _fail("could not find two unlocked-but-available nodes for the cross-node case") + var ore_before: int = game_state.available_ore() + _tap(first) + _tap(second) + if game_state.is_unlocked(first) or game_state.is_unlocked(second): + return _fail("tapping two different nodes bought one of them") + if game_state.available_ore() != ore_before: + return _fail("tapping two different nodes spent %d" % (ore_before - game_state.available_ore())) + print("one tap each on two nodes buys neither") + + # --- E. unaffordable stays unaffordable -------------------------------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0) + _tap(second) + _tap(second) + if game_state.is_unlocked(second): + return _fail("a node was bought with no ore at all") + controller._refresh_skill_menu(second) + if controller.skill_status.text == "": + return _fail("an unaffordable node was refused silently") + print("unaffordable node refused, and says so: '%s'" % controller.skill_status.text) + + print("TEST_RESEARCH_DOUBLE_TAP: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_RESEARCH_DOUBLE_TAP: FAIL (%s)" % message) + quit(1) + return true