#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>
138 lines
4.9 KiB
GDScript
138 lines
4.9 KiB
GDScript
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
|