A fleet of neon-outlined ships explores a procedurally generated, seeded infinite world: tap/joystick steering, pinch zoom, formation pivoting, planet landings, an ore economy with extractors and haulers, and player built structures (mothership, ship crafter, command centre). All art is hand-drawn with draw_* calls rather than textures, and the backgrounds are camera-relative shaders so they stay precise at large world coordinates. Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot tooling that renders real frames through Xvfb and Mesa softpipe, since --headless has no GPU. Excluded from version control: .godot/ (import cache, and it holds export credentials), builds/, android/build/ and packaged APKs - all regenerable, and ~325 MB combined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
8.3 KiB
GDScript
222 lines
8.3 KiB
GDScript
extends SceneTree
|
|
|
|
# Headless gameplay test: run with
|
|
# godot --headless -s res://tests/test_resources_and_buildings.gd
|
|
# Covers the resource/building loop end to end:
|
|
# 1. flying a ship over a surface rock banks it in GameState
|
|
# 2. buildings are paid for, persist in GameState, and respawn with the
|
|
# space scene (they must survive the trip to a planet and back)
|
|
# 3. a ship crafter adds a vessel to the fleet for rocks
|
|
# 4. wiping out the fleet rebuilds one vessel at the mothership
|
|
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
var scene: Node
|
|
var fleet: Node
|
|
var game_state: Node
|
|
var rock: Area2D
|
|
var mothership_pos: Vector2 = Vector2(900.0, -400.0)
|
|
|
|
|
|
func _initialize() -> void:
|
|
game_state = root.get_node("GameState")
|
|
game_state.rocks = 0
|
|
game_state.buildings.clear()
|
|
game_state.has_return_position = false
|
|
|
|
scene = load("res://PlanetSurface.tscn").instantiate()
|
|
root.add_child(scene)
|
|
current_scene = scene
|
|
fleet = scene.get_node("Fleet")
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
elapsed += delta
|
|
match stage:
|
|
0:
|
|
# --- gather a rock -------------------------------------------
|
|
scene.get_node("RockField").set_process(false)
|
|
rock = load("res://Rock.tscn").instantiate()
|
|
rock.build_shape(1)
|
|
# Placed OUTSIDE the pickup shape but inside attract_radius, so
|
|
# this also proves the magnetic pull actually reels it in rather
|
|
# than the ship simply happening to overlap it on spawn.
|
|
rock.position = fleet.ships[0].position + Vector2(150.0, 0.0)
|
|
scene.add_child(rock)
|
|
elapsed = 0.0
|
|
stage = 1
|
|
return false
|
|
1:
|
|
if elapsed < 1.5:
|
|
return false
|
|
if game_state.rocks != 1:
|
|
print("rocks=%d after flying a ship onto one" % game_state.rocks)
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)")
|
|
quit(1)
|
|
return true
|
|
print("gathered rock -> rocks=%d" % game_state.rocks)
|
|
|
|
# Bank enough to build with, then move to the space scene.
|
|
game_state.rocks = 60
|
|
change_scene_to_file("res://Main.tscn")
|
|
elapsed = 0.0
|
|
stage = 2
|
|
return false
|
|
2:
|
|
if elapsed < 0.4:
|
|
return false
|
|
if current_scene == null or current_scene.name != "Main":
|
|
return false
|
|
scene = current_scene
|
|
fleet = scene.get_node("Fleet")
|
|
scene.get_node("AsteroidField").set_process(false)
|
|
scene.get_node("EnemySpawner").set_process(false)
|
|
|
|
# --- place both buildings -------------------------------------
|
|
var field: Node = scene.get_node("BuildingField")
|
|
var before: int = game_state.rocks
|
|
for ship in fleet.ships:
|
|
ship.position = mothership_pos
|
|
fleet.move_to(mothership_pos)
|
|
field.place(GameState.BUILDING_MOTHERSHIP, mothership_pos)
|
|
game_state.spend(GameState.MOTHERSHIP_COST)
|
|
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(1400.0, 300.0))
|
|
game_state.spend(GameState.SHIP_CRAFTER_COST)
|
|
|
|
var paid: bool = game_state.rocks == before - GameState.MOTHERSHIP_COST - GameState.SHIP_CRAFTER_COST
|
|
var stored: bool = game_state.buildings.size() == 2
|
|
print("buildings=%d rocks %d->%d paid=%s" % [
|
|
game_state.buildings.size(), before, game_state.rocks, paid
|
|
])
|
|
if not (paid and stored):
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (placing/paying wrong)")
|
|
quit(1)
|
|
return true
|
|
elapsed = 0.0
|
|
stage = 3
|
|
return false
|
|
3:
|
|
if elapsed < 0.3:
|
|
return false
|
|
# --- crafter adds a ship --------------------------------------
|
|
fleet.spawn_ships(3, Vector2.ZERO)
|
|
var before_ships: int = fleet.ships.size()
|
|
var before_rocks: int = game_state.rocks
|
|
game_state.spend(GameState.SHIP_COST)
|
|
var added: bool = fleet.add_ship()
|
|
print("craft: ships %d->%d rocks %d->%d" % [
|
|
before_ships, fleet.ships.size(), before_rocks, game_state.rocks
|
|
])
|
|
if not (added and fleet.ships.size() == before_ships + 1 and game_state.rocks == before_rocks - GameState.SHIP_COST):
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (crafting a ship)")
|
|
quit(1)
|
|
return true
|
|
|
|
# --- command centre: drag a formation slot ---------------------
|
|
var editor: Control = scene.get_node("UI/FormationMenu/Editor")
|
|
var menu: Control = scene.get_node("UI/FormationMenu")
|
|
menu.visible = true
|
|
editor.size = Vector2(520, 520)
|
|
# The editor is driven from BuildController._unhandled_input,
|
|
# which never fires for Controls that consume touches through
|
|
# the GUI system first. The menu AND its full-screen backdrop
|
|
# must both be IGNORE, or dragging a slot silently does nothing
|
|
# on a real device - which is exactly what happened.
|
|
for node in [menu, menu.get_node("Backdrop"), editor]:
|
|
if node.mouse_filter != Control.MOUSE_FILTER_IGNORE:
|
|
print("%s has mouse_filter=%d, needs IGNORE" % [node.name, node.mouse_filter])
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation menu would swallow drags)")
|
|
quit(1)
|
|
return true
|
|
|
|
var slot_before: Vector2 = game_state.formation[1]
|
|
|
|
var down: InputEventScreenTouch = InputEventScreenTouch.new()
|
|
down.index = 0
|
|
down.pressed = true
|
|
down.position = editor.global_position + editor._slot_screen_pos(1)
|
|
var grabbed: bool = editor.handle_touch(down)
|
|
|
|
var move: InputEventScreenDrag = InputEventScreenDrag.new()
|
|
move.index = 0
|
|
move.position = editor.global_position + editor._slot_screen_pos(1) + Vector2(40.0, 25.0)
|
|
editor.handle_drag(move)
|
|
var slot_after: Vector2 = game_state.formation[1]
|
|
menu.visible = false
|
|
|
|
print("formation slot 1: %s -> %s (grabbed=%s)" % [slot_before, slot_after, grabbed])
|
|
if not (grabbed and slot_after != slot_before):
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation slot did not drag)")
|
|
quit(1)
|
|
return true
|
|
|
|
# Editing must re-seat the ships ALREADY in flight, not just
|
|
# ones built later - otherwise the edit reads as doing nothing.
|
|
var live_target: Vector2 = fleet.ships[1].move_target
|
|
var expected_target: Vector2 = fleet.fleet_target + slot_after
|
|
var live_ok: bool = live_target.distance_to(expected_target) < 0.01
|
|
print("live ship 1 target %s, expected %s (ok=%s)" % [
|
|
live_target, expected_target, live_ok
|
|
])
|
|
if not live_ok:
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (existing ships ignored the edit)")
|
|
quit(1)
|
|
return true
|
|
|
|
# --- pivot the formation --------------------------------------
|
|
var before_pivot: Vector2 = game_state.formation[1]
|
|
game_state.rotate_formation(PI / 2.0)
|
|
fleet.apply_formation()
|
|
var after_pivot: Vector2 = game_state.formation[1]
|
|
var rotated_ok: bool = after_pivot.distance_to(before_pivot.rotated(PI / 2.0)) < 0.01
|
|
var length_kept: bool = abs(after_pivot.length() - before_pivot.length()) < 0.01
|
|
var ship_followed: bool = fleet.ships[1].move_target.distance_to(fleet.fleet_target + after_pivot) < 0.01
|
|
print("pivot: %s -> %s (rotated=%s shape_kept=%s ship_followed=%s)" % [
|
|
before_pivot, after_pivot, rotated_ok, length_kept, ship_followed
|
|
])
|
|
if not (rotated_ok and length_kept and ship_followed):
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (pivot)")
|
|
quit(1)
|
|
return true
|
|
game_state.rotate_formation(-PI / 2.0)
|
|
slot_after = game_state.formation[1]
|
|
|
|
# A rebuilt fleet must adopt the edited layout, which is the
|
|
# whole point of storing the formation in GameState.
|
|
fleet.spawn_ships(2, Vector2.ZERO)
|
|
var uses_layout: bool = fleet.ships[1].position.distance_to(slot_after) < 0.01
|
|
print("respawned ship 1 at %s, formation says %s (matches=%s)" % [
|
|
fleet.ships[1].position, slot_after, uses_layout
|
|
])
|
|
if not uses_layout:
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet ignored the edited formation)")
|
|
quit(1)
|
|
return true
|
|
|
|
# --- wipe the fleet -------------------------------------------
|
|
for ship in fleet.ships.duplicate():
|
|
ship.queue_free()
|
|
fleet.ships.clear()
|
|
fleet.formation_offsets.clear()
|
|
elapsed = 0.0
|
|
stage = 4
|
|
return false
|
|
4:
|
|
if elapsed < 0.5:
|
|
return false
|
|
# BuildController._process should have rebuilt one vessel, at
|
|
# the mothership rather than the origin.
|
|
var respawned: bool = fleet.ships.size() == 1
|
|
var at_mothership: bool = respawned and fleet.ships[0].position.distance_to(mothership_pos) < 120.0
|
|
print("after wipe: ships=%d at_mothership=%s (pos=%s expected~%s)" % [
|
|
fleet.ships.size(), at_mothership,
|
|
fleet.ships[0].position if respawned else Vector2.ZERO, mothership_pos
|
|
])
|
|
if respawned and at_mothership:
|
|
print("TEST_RESOURCES_AND_BUILDINGS: PASS")
|
|
quit(0)
|
|
else:
|
|
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet not rebuilt at the mothership)")
|
|
quit(1)
|
|
return true
|
|
return false
|