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

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