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 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-21 18:49:47 +02:00
commit baac11ae8b
19 changed files with 677 additions and 62 deletions

View file

@ -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() \

View file

@ -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:

View file

@ -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")])

View file

@ -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:

View file

@ -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] = []

View file

@ -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

View file

@ -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

View file

@ -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

123
MouseCombatController.gd Normal file
View file

@ -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

View file

@ -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")

1
ResourcePickupVFX.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://byysi5a0ssymi

View file

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

View file

@ -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

138
tests/test_chaser_rams.gd Normal file
View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -0,0 +1 @@
uid://bu5mnp245dht7

View file

@ -0,0 +1 @@
uid://du732a85tmlhl

View file

@ -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