Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
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>
This commit is contained in:
commit
85814ce88c
192 changed files with 9602 additions and 0 deletions
31
tests/_build_popup_preview.gd
Normal file
31
tests/_build_popup_preview.gd
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the
|
||||
# build menu and shows a building's info popup, so the panel layout can be
|
||||
# checked with real text in it. Uses the mothership-already-exists case on
|
||||
# purpose: that is the longest message any option produces, so if it fits,
|
||||
# the others do.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_build_popup_preview.tscn /tmp/out.png 40
|
||||
|
||||
|
||||
const SHOW_POPUP: bool = true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.rocks = 500
|
||||
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-900.0, 0.0))
|
||||
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("AsteroidField").set_process(false)
|
||||
main.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
var controller: Node = main.get_node("BuildController")
|
||||
controller._on_build_pressed()
|
||||
# Flip SHOW_POPUP to check the menu underneath
|
||||
# instead of the popup that covers it.
|
||||
if SHOW_POPUP:
|
||||
controller._on_place_mothership()
|
||||
1
tests/_build_popup_preview.gd.uid
Normal file
1
tests/_build_popup_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://wlijuo1j14d3
|
||||
6
tests/_build_popup_preview.tscn
Normal file
6
tests/_build_popup_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_build_popup_preview.gd" id="1"]
|
||||
|
||||
[node name="BuildPopupPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
41
tests/_crafter_preview.gd
Normal file
41
tests/_crafter_preview.gd
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview scene for eyeballing building art - NOT run by run_tests.sh
|
||||
# (underscore prefix). Lays out the mothership and command centre alongside
|
||||
# several phases of the ship crafter's assembly cycle, with the crafter's
|
||||
# _process disabled and anim_time pinned, so a single softpipe render shows
|
||||
# the whole animation at once (the renderer is far too slow here to time a
|
||||
# live frame).
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_crafter_preview.tscn /tmp/out.png 20
|
||||
|
||||
# Each cell is either a building type (drawn once) or a crafter phase.
|
||||
const CELLS: Array = [
|
||||
{"type": "mothership"},
|
||||
{"type": "command_center"},
|
||||
{"type": "mothership"},
|
||||
{"phase": 0.10},
|
||||
{"phase": 0.35},
|
||||
{"phase": 0.60},
|
||||
{"phase": 0.88},
|
||||
]
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
|
||||
var building_scene: PackedScene = load("res://Building.tscn")
|
||||
for i in range(CELLS.size()):
|
||||
var cell: Dictionary = CELLS[i]
|
||||
var b: Node2D = building_scene.instantiate()
|
||||
b.building_type = cell.get("type", GameState.BUILDING_SHIP_CRAFTER)
|
||||
b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0)
|
||||
add_child(b)
|
||||
if cell.has("phase"):
|
||||
b.set_process(false)
|
||||
b.anim_time = float(cell.phase) * b.CYCLE
|
||||
b.queue_redraw()
|
||||
|
||||
var cam: Camera2D = Camera2D.new()
|
||||
cam.zoom = Vector2(1.65, 1.65)
|
||||
add_child(cam)
|
||||
cam.make_current()
|
||||
1
tests/_crafter_preview.gd.uid
Normal file
1
tests/_crafter_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ha7uto42cl6w
|
||||
6
tests/_crafter_preview.tscn
Normal file
6
tests/_crafter_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_crafter_preview.gd" id="1"]
|
||||
|
||||
[node name="CrafterPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
39
tests/_font_probe.gd
Normal file
39
tests/_font_probe.gd
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
extends SceneTree
|
||||
|
||||
# Diagnostic - NOT run by run_tests.sh (underscore prefix). Prints what the
|
||||
# project's font settings actually resolve to at runtime, and measures real
|
||||
# string widths, so UI boxes can be sized from measurements instead of
|
||||
# guessed character widths.
|
||||
#
|
||||
# godot --headless -s res://tests/_font_probe.gd
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
print("gui/theme/default_font_size setting = %s" % ProjectSettings.get_setting("gui/theme/default_font_size", "<unset>"))
|
||||
print("gui/theme/custom = '%s'" % ProjectSettings.get_setting("gui/theme/custom", ""))
|
||||
print("gui/theme/custom_font = '%s'" % ProjectSettings.get_setting("gui/theme/custom_font", ""))
|
||||
|
||||
var scene: Node = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
|
||||
for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel",
|
||||
"UI/BuildMenu/InfoPopup/Panel/MessageLabel",
|
||||
"UI/BuildMenu/MothershipLabel",
|
||||
"UI/BuildMenu/TitleLabel",
|
||||
"UI/NavLabel"]:
|
||||
var label: Label = scene.get_node_or_null(path)
|
||||
if label == null:
|
||||
print("%s: MISSING" % path)
|
||||
continue
|
||||
# The theme item is "font_size"; "font" is the typeface item. Asking
|
||||
# for the wrong one silently returns the engine fallback (16) and
|
||||
# hides both the project setting and any per-node override.
|
||||
var size: int = label.get_theme_font_size("font_size")
|
||||
var font: Font = label.get_theme_font("font")
|
||||
var sample: String = "Fleet home port. A wiped-out fleet rebuilds here."
|
||||
var width: float = font.get_string_size(sample, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
|
||||
print("%-46s effective size=%d box=%s 48-char sample is %.0fpx wide (%.1f px/char), line height %.0f" % [
|
||||
path, size, label.size, width, width / float(sample.length()), font.get_height(size)
|
||||
])
|
||||
|
||||
quit(0)
|
||||
1
tests/_font_probe.gd.uid
Normal file
1
tests/_font_probe.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cn7j12klcr3ji
|
||||
35
tests/_icon_preview.gd
Normal file
35
tests/_icon_preview.gd
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
extends Control
|
||||
|
||||
# Preview scene - NOT run by run_tests.sh (underscore prefix). Draws every
|
||||
# IconButton glyph at the size the build menu uses, so the art can be
|
||||
# checked without rendering the whole game scene.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_icon_preview.tscn /tmp/out.png 20
|
||||
|
||||
const BUTTON_SIZE: float = 190.0
|
||||
const COLUMNS: int = 3
|
||||
|
||||
const SWATCHES: Array = [
|
||||
{"icon": 8, "color": Color(0.45, 0.9, 0.55)}, # mothership
|
||||
{"icon": 9, "color": Color(1.0, 0.72, 0.25)}, # ship crafter
|
||||
{"icon": 10, "color": Color(0.55, 0.6, 1.0)}, # command centre
|
||||
{"icon": 11, "color": Color(0.35, 0.9, 0.75)}, # extractor
|
||||
{"icon": 11, "color": Color(0.35, 0.9, 0.75), "enabled": false},
|
||||
{"icon": 5, "color": Color(0.25, 0.85, 1.0)}, # generic build, for contrast
|
||||
]
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
|
||||
for i in range(SWATCHES.size()):
|
||||
var swatch: Dictionary = SWATCHES[i]
|
||||
var b: IconButton = IconButton.new()
|
||||
b.icon = swatch.icon
|
||||
b.glow_color = swatch.color
|
||||
b.enabled = swatch.get("enabled", true)
|
||||
b.size = Vector2(BUTTON_SIZE, BUTTON_SIZE)
|
||||
b.position = Vector2(
|
||||
120.0 + float(i % COLUMNS) * (BUTTON_SIZE + 60.0),
|
||||
100.0 + float(i / COLUMNS) * (BUTTON_SIZE + 60.0)
|
||||
)
|
||||
add_child(b)
|
||||
1
tests/_icon_preview.gd.uid
Normal file
1
tests/_icon_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dfd33rtm5ick2
|
||||
8
tests/_icon_preview.tscn
Normal file
8
tests/_icon_preview.tscn
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_icon_preview.gd" id="1"]
|
||||
|
||||
[node name="IconPreview" type="Control"]
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1")
|
||||
27
tests/_ingame_buildings_preview.gd
Normal file
27
tests/_ingame_buildings_preview.gd
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders the
|
||||
# buildings through the REAL game path: records in GameState, spawned by
|
||||
# Main.tscn's BuildingField, drawn under the game's own camera. The sibling
|
||||
# _crafter_preview only proves Building.gd's art in isolation; this proves
|
||||
# what actually reaches the screen in a running game.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_ingame_buildings_preview.tscn /tmp/out.png 40
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.reset_formation()
|
||||
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0))
|
||||
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0))
|
||||
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("AsteroidField").set_process(false)
|
||||
main.get_node("EnemySpawner").set_process(false)
|
||||
# Park the fleet below the pair so the camera frames both, and zoom out
|
||||
# far enough to see them whole.
|
||||
main.get_node("Fleet").move_to(Vector2(0.0, 210.0))
|
||||
var cam: Camera2D = main.get_node("Camera2D")
|
||||
cam.zoom = Vector2(1.35, 1.35)
|
||||
1
tests/_ingame_buildings_preview.gd.uid
Normal file
1
tests/_ingame_buildings_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bi1tbjjmkbjyb
|
||||
6
tests/_ingame_buildings_preview.tscn
Normal file
6
tests/_ingame_buildings_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_ingame_buildings_preview.gd" id="1"]
|
||||
|
||||
[node name="InGameBuildingsPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
40
tests/_lunar_check.png.import
Normal file
40
tests/_lunar_check.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dvk0oafywbooj"
|
||||
path="res://.godot/imported/_lunar_check.png-f10cc44a07f4a4fea5b976037f96d6c2.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/_lunar_check.png"
|
||||
dest_files=["res://.godot/imported/_lunar_check.png-f10cc44a07f4a4fea5b976037f96d6c2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
53
tests/_screenshot_tool.gd
Normal file
53
tests/_screenshot_tool.gd
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
extends SceneTree
|
||||
|
||||
# Reusable screenshot tool - NOT run via run_tests.sh (underscore prefix).
|
||||
# Loads a given scene, waits for a given number of frames to render, then
|
||||
# saves a PNG and quits. Must be run WITHOUT --headless (that forces
|
||||
# Godot's dummy/null renderer) - see tests/screenshot.sh for the full
|
||||
# invocation (Xvfb + Mesa softpipe; llvmpipe's JIT crashes with SIGILL in
|
||||
# this environment, softpipe's non-JIT rasterizer does not).
|
||||
#
|
||||
# Args (after `--`): <scene_res_path> <output_png_path> [wait_frames]
|
||||
|
||||
var wait_frames: int = 60
|
||||
var frames: int = 0
|
||||
var scene_path: String
|
||||
var output_path: String
|
||||
var loaded_scene: Node
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args: PackedStringArray = OS.get_cmdline_user_args()
|
||||
if args.size() < 2:
|
||||
printerr("usage: -- <scene_res_path> <output_png_path> [wait_frames]")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
scene_path = args[0]
|
||||
output_path = args[1]
|
||||
if args.size() >= 3:
|
||||
wait_frames = int(args[2])
|
||||
|
||||
var packed: PackedScene = load(scene_path)
|
||||
if packed == null:
|
||||
printerr("could not load scene: %s" % scene_path)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
loaded_scene = packed.instantiate()
|
||||
root.add_child(loaded_scene)
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if frames < wait_frames:
|
||||
return false
|
||||
var img: Image = root.get_viewport().get_texture().get_image()
|
||||
var err: int = img.save_png(output_path)
|
||||
if err != OK:
|
||||
printerr("save_png failed with error %d" % err)
|
||||
quit(1)
|
||||
return true
|
||||
print("SCREENSHOT_SAVED: %s (%s)" % [output_path, str(img.get_size())])
|
||||
quit(0)
|
||||
return true
|
||||
1
tests/_screenshot_tool.gd.uid
Normal file
1
tests/_screenshot_tool.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dbf48h8dn1m84
|
||||
20
tests/run_tests.sh
Normal file
20
tests/run_tests.sh
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
# Runs the headless GDScript gameplay tests from Termux, via the Godot
|
||||
# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container.
|
||||
# Usage: bash tests/run_tests.sh
|
||||
|
||||
set -e
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||
|
||||
status=0
|
||||
for test_script in "$PROJECT_DIR"/tests/test_*.gd; do
|
||||
name="$(basename "$test_script")"
|
||||
echo "=== Running $name ==="
|
||||
if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then
|
||||
status=1
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
exit $status
|
||||
1
tests/scratch_scene_a.gd.uid
Normal file
1
tests/scratch_scene_a.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dxlt7qjmxbgi4
|
||||
1
tests/scratch_scene_b.gd.uid
Normal file
1
tests/scratch_scene_b.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dej0ijrlaa72o
|
||||
1
tests/scratch_swap_check.gd.uid
Normal file
1
tests/scratch_swap_check.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cd8yndydui6ml
|
||||
38
tests/screenshot.sh
Normal file
38
tests/screenshot.sh
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
# Renders a real screenshot of a Godot scene from Termux, via the Godot
|
||||
# 4.7.1 Linux arm64 build inside the proot-distro Ubuntu container, using
|
||||
# Xvfb (virtual display) + Mesa's softpipe software rasterizer.
|
||||
#
|
||||
# NOTE: Mesa's default software rasterizer, llvmpipe, crashes with
|
||||
# SIGILL in this environment (confirmed via feasibility spike - its LLVM
|
||||
# JIT appears to emit a CPU instruction this ARM64/proot/Android setup
|
||||
# doesn't actually support, reproduced even with an empty project and no
|
||||
# script). softpipe is Mesa's older non-JIT (pure C) rasterizer - slower,
|
||||
# but doesn't hit this crash. Good enough for one-shot screenshots.
|
||||
#
|
||||
# Usage: bash tests/screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]
|
||||
# Example: bash tests/screenshot.sh res://Main.tscn /tmp/out.png 60 720x1280
|
||||
#
|
||||
# The resolution matters for anything with UI in it: the game is authored
|
||||
# for a 720x1280 portrait viewport, so rendering at the default landscape
|
||||
# size stretches the layout and makes it useless for judging whether
|
||||
# controls fit. Pass 720x1280 when checking UI.
|
||||
|
||||
set -e
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||
|
||||
SCENE_PATH="${1:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]}"
|
||||
OUTPUT_PATH="${2:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]}"
|
||||
WAIT_FRAMES="${3:-60}"
|
||||
RESOLUTION="${4:-1280x720}"
|
||||
|
||||
proot-distro login ubuntu -- bash -c "
|
||||
Xvfb :99 -screen 0 ${RESOLUTION}x24 &
|
||||
XVFB_PID=\$!
|
||||
sleep 2
|
||||
mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt
|
||||
cd '$PROJECT_DIR'
|
||||
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_screenshot_tool.gd -- '$SCENE_PATH' '$OUTPUT_PATH' '$WAIT_FRAMES'
|
||||
kill \$XVFB_PID
|
||||
"
|
||||
120
tests/test_animation_continuity.gd
Normal file
120
tests/test_animation_continuity.gd
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_animation_continuity.gd
|
||||
# Regression test for the mothership's animation jumping.
|
||||
#
|
||||
# The reactor pulse and the orbiting satellites used to be driven from
|
||||
# anim_time, which wraps at CYCLE (3.6s) because the CRAFTER's assembly
|
||||
# sequence is expressed as a fraction of it. Neither of them completes a
|
||||
# whole number of periods in 3.6s, so every wrap made them leap - the
|
||||
# satellites snapped back 66 degrees, once every cycle, forever.
|
||||
#
|
||||
# Verifies, by stepping a mothership across two full wraps of the assembly
|
||||
# clock in small frames:
|
||||
# 1. the satellites keep advancing - no frame turns them backwards
|
||||
# 2. no frame moves them much more than a steady frame would
|
||||
# 3. the core pulse never jumps by more than a steady frame's worth
|
||||
# 4. the assembly clock really does wrap during the test, so the test is
|
||||
# actually exercising the boundary that used to break
|
||||
|
||||
const FRAME: float = 1.0 / 60.0
|
||||
const FRAMES: int = 500 # 8.3s, comfortably past two wraps of 3.6s
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
var field: Node
|
||||
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()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
field = scene.get_node("BuildingField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var ship: Node2D = null
|
||||
for b in field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
ship = b
|
||||
if ship == null:
|
||||
print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Drive it by hand at a fixed frame time, so the measurement is
|
||||
# not at the mercy of the headless tick rate.
|
||||
ship.set_process(false)
|
||||
var expected_orbit_step: float = FRAME * ship.ORBIT_SPEED
|
||||
var prev_orbit: float = ship.orbit_angle()
|
||||
var prev_pulse: float = ship.core_pulse()
|
||||
var wrapped: bool = false
|
||||
var prev_anim: float = ship.anim_time
|
||||
|
||||
var worst_orbit_step: float = 0.0
|
||||
var min_orbit_step: float = 1e9
|
||||
var worst_pulse_step: float = 0.0
|
||||
# A steady frame of the pulse: derivative of a unit sine is at
|
||||
# most 1, so 0.5 * speed * dt bounds one frame's change.
|
||||
var expected_pulse_step: float = 0.5 * ship.PULSE_SPEED * FRAME
|
||||
|
||||
for i in range(FRAMES):
|
||||
ship._process(FRAME)
|
||||
if ship.anim_time < prev_anim:
|
||||
wrapped = true
|
||||
prev_anim = ship.anim_time
|
||||
|
||||
var orbit_step: float = ship.orbit_angle() - prev_orbit
|
||||
worst_orbit_step = maxf(worst_orbit_step, orbit_step)
|
||||
min_orbit_step = minf(min_orbit_step, orbit_step)
|
||||
prev_orbit = ship.orbit_angle()
|
||||
|
||||
worst_pulse_step = maxf(worst_pulse_step, absf(ship.core_pulse() - prev_pulse))
|
||||
prev_pulse = ship.core_pulse()
|
||||
|
||||
print("assembly clock wrapped during the run: %s" % wrapped)
|
||||
print("orbit step per frame: min %.6f max %.6f rad (steady %.6f)" % [
|
||||
min_orbit_step, worst_orbit_step, expected_orbit_step
|
||||
])
|
||||
print("worst pulse step: %.6f (steady bound %.6f)" % [
|
||||
worst_pulse_step, expected_pulse_step
|
||||
])
|
||||
|
||||
if not wrapped:
|
||||
print("TEST_ANIMATION_CONTINUITY: FAIL (clock never wrapped - test proves nothing)")
|
||||
quit(1)
|
||||
return true
|
||||
if min_orbit_step <= 0.0:
|
||||
print("TEST_ANIMATION_CONTINUITY: FAIL (satellites went backwards)")
|
||||
quit(1)
|
||||
return true
|
||||
if worst_orbit_step > expected_orbit_step * 1.05:
|
||||
print("TEST_ANIMATION_CONTINUITY: FAIL (satellites jumped forwards)")
|
||||
quit(1)
|
||||
return true
|
||||
if worst_pulse_step > expected_pulse_step * 1.05:
|
||||
print("TEST_ANIMATION_CONTINUITY: FAIL (reactor pulse jumped)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_ANIMATION_CONTINUITY: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_animation_continuity.gd.uid
Normal file
1
tests/test_animation_continuity.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bht2sd2fyla4a
|
||||
72
tests/test_asteroid_ship_collision.gd
Normal file
72
tests/test_asteroid_ship_collision.gd
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_asteroid_ship_collision.gd
|
||||
# Verifies that a ship touching an asteroid destroys BOTH the ship (removed
|
||||
# from the fleet) and the asteroid itself (mutual destruction), each
|
||||
# spawning its own coherent explosion VFX: light-burst blue for the ship,
|
||||
# debris brown for the asteroid.
|
||||
|
||||
const CHECK_DELAY: float = 0.05
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var target_ship: Node
|
||||
var asteroid: Node2D
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# Wait one tick so Fleet._ready() has run and fleet.ships is
|
||||
# populated before we grab a ship reference.
|
||||
target_ship = fleet.ships[0]
|
||||
var asteroid_scene: PackedScene = load("res://Asteroid.tscn")
|
||||
asteroid = asteroid_scene.instantiate()
|
||||
asteroid.position = target_ship.position
|
||||
asteroid.velocity = Vector2.ZERO
|
||||
main_scene.add_child(asteroid)
|
||||
stage = 1
|
||||
elapsed = 0.0
|
||||
return false
|
||||
1:
|
||||
if elapsed < CHECK_DELAY:
|
||||
return false
|
||||
_check_results()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _check_results() -> void:
|
||||
var ship_destroyed: bool = not is_instance_valid(target_ship)
|
||||
var ship_removed_from_fleet: bool = fleet.ships.find(target_ship) == -1
|
||||
var asteroid_destroyed: bool = not is_instance_valid(asteroid)
|
||||
|
||||
var ship_vfx_ok: bool = false
|
||||
var asteroid_vfx_ok: bool = false
|
||||
for child in root.get_children():
|
||||
if child is ShipExplosionVFX:
|
||||
ship_vfx_ok = true
|
||||
elif child is AsteroidExplosionVFX:
|
||||
asteroid_vfx_ok = true
|
||||
|
||||
print("ship_destroyed=%s ship_removed_from_fleet=%s asteroid_destroyed=%s ship_vfx_ok=%s asteroid_vfx_ok=%s" % [
|
||||
ship_destroyed, ship_removed_from_fleet, asteroid_destroyed, ship_vfx_ok, asteroid_vfx_ok
|
||||
])
|
||||
|
||||
if ship_destroyed and ship_removed_from_fleet and asteroid_destroyed and ship_vfx_ok and asteroid_vfx_ok:
|
||||
print("TEST_ASTEROID_SHIP_COLLISION: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_ASTEROID_SHIP_COLLISION: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_asteroid_ship_collision.gd.uid
Normal file
1
tests/test_asteroid_ship_collision.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://sa7a3btjwp4l
|
||||
115
tests/test_asteroid_spawning.gd
Normal file
115
tests/test_asteroid_spawning.gd
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_asteroid_spawning.gd
|
||||
# Verifies AsteroidField spawns moving asteroids once the fleet enters an
|
||||
# ASTEROID_FIELD biome chunk, and despawns them again once the fleet moves
|
||||
# far enough away - guards against unbounded node accumulation over a long
|
||||
# play session while still populating newly-entered regions on the fly.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var asteroid_field: Node
|
||||
var asteroid_chunk: Vector2i
|
||||
var spawned_asteroids: Array = []
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
var ok: bool = true
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.biome_at_chunk(chunk) == WorldGen.Biome.ASTEROID_FIELD:
|
||||
asteroid_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any ASTEROID_FIELD chunk to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
asteroid_field = main_scene.get_node("AsteroidField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# Wait one tick so Fleet._ready() has run and fleet.ships is
|
||||
# populated before we try to teleport them - reposition
|
||||
# attempted synchronously in _initialize() would silently
|
||||
# no-op on an empty ships array.
|
||||
var target_pos: Vector2 = WorldGen.chunk_origin(asteroid_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = target_pos
|
||||
fleet.move_to(target_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < asteroid_field.check_interval + 0.1:
|
||||
return false
|
||||
_check_spawned()
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
var far_pos: Vector2 = WorldGen.chunk_origin(asteroid_chunk) + Vector2(50000, 50000)
|
||||
for ship in fleet.ships:
|
||||
ship.position = far_pos
|
||||
fleet.move_to(far_pos)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < asteroid_field.check_interval + 0.1:
|
||||
return false
|
||||
_check_despawned()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _check_spawned() -> void:
|
||||
var has_chunk: bool = asteroid_field.populated_chunks.has(asteroid_chunk)
|
||||
if not has_chunk:
|
||||
ok = false
|
||||
print("FAIL: asteroid_chunk %s not populated after check_interval" % asteroid_chunk)
|
||||
return
|
||||
|
||||
spawned_asteroids = asteroid_field.populated_chunks[asteroid_chunk]
|
||||
var count_ok: bool = spawned_asteroids.size() == asteroid_field.asteroids_per_chunk
|
||||
var moving_ok: bool = true
|
||||
for asteroid in spawned_asteroids:
|
||||
if asteroid.velocity.length() <= 0.1:
|
||||
moving_ok = false
|
||||
|
||||
ok = ok and count_ok and moving_ok
|
||||
print("spawned_count=%d expected=%d moving_ok=%s" % [spawned_asteroids.size(), asteroid_field.asteroids_per_chunk, moving_ok])
|
||||
|
||||
|
||||
func _check_despawned() -> void:
|
||||
var chunk_removed: bool = not asteroid_field.populated_chunks.has(asteroid_chunk)
|
||||
var all_freed: bool = true
|
||||
for asteroid in spawned_asteroids:
|
||||
if is_instance_valid(asteroid):
|
||||
all_freed = false
|
||||
|
||||
ok = ok and chunk_removed and all_freed
|
||||
print("chunk_removed=%s all_freed=%s" % [chunk_removed, all_freed])
|
||||
|
||||
if ok:
|
||||
print("TEST_ASTEROID_SPAWNING: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_ASTEROID_SPAWNING: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_asteroid_spawning.gd.uid
Normal file
1
tests/test_asteroid_spawning.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://xo183n5xkxi5
|
||||
38
tests/test_biome_generation.gd
Normal file
38
tests/test_biome_generation.gd
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_biome_generation.gd
|
||||
# Verifies WorldGen's biome-per-chunk function is deterministic (querying
|
||||
# the same chunk twice gives the same biome) and that the seeded noise
|
||||
# field actually produces both biome types within a reasonably large
|
||||
# sampled area - a regression guard against a threshold tuned so one biome
|
||||
# never appears at all.
|
||||
|
||||
func _initialize() -> void:
|
||||
var deterministic_ok: bool = true
|
||||
for i in range(20):
|
||||
var chunk: Vector2i = Vector2i(i * 3 - 10, i * 2 - 5)
|
||||
var a: int = WorldGen.biome_at_chunk(chunk)
|
||||
var b: int = WorldGen.biome_at_chunk(chunk)
|
||||
if a != b:
|
||||
deterministic_ok = false
|
||||
print("FAIL: chunk %s gave %d then %d" % [chunk, a, b])
|
||||
|
||||
var space_count: int = 0
|
||||
var asteroid_count: int = 0
|
||||
for x in range(-25, 25):
|
||||
for y in range(-25, 25):
|
||||
var biome: int = WorldGen.biome_at_chunk(Vector2i(x, y))
|
||||
if biome == WorldGen.Biome.SPACE:
|
||||
space_count += 1
|
||||
elif biome == WorldGen.Biome.ASTEROID_FIELD:
|
||||
asteroid_count += 1
|
||||
|
||||
print("deterministic_ok=%s space_count=%d asteroid_count=%d" % [deterministic_ok, space_count, asteroid_count])
|
||||
|
||||
if deterministic_ok and space_count > 0 and asteroid_count > 0:
|
||||
print("TEST_BIOME_GENERATION: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_BIOME_GENERATION: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_biome_generation.gd.uid
Normal file
1
tests/test_biome_generation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://mxq52bxdf0l1
|
||||
123
tests/test_build_info_popup.gd
Normal file
123
tests/test_build_info_popup.gd
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_build_info_popup.gd
|
||||
# Every build option explains itself before anything is spent. Picking a
|
||||
# structure costs ore and, for a mothership, can destroy the one you already
|
||||
# have, so no option may build straight off the menu press.
|
||||
#
|
||||
# Verifies, for EVERY option in the build menu:
|
||||
# 1. pressing it opens the popup with that building's own title and a
|
||||
# non-empty description, and shows its price
|
||||
# 2. nothing is built and no ore is spent until the confirm is pressed
|
||||
# 3. confirming builds exactly that structure and charges exactly its cost
|
||||
# 4. closing the popup instead drops the pending build entirely - a later
|
||||
# confirm press does nothing
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
var controller: Node
|
||||
var popup: Control
|
||||
var title: Label
|
||||
var message: Label
|
||||
var cost_number: NeonNumber
|
||||
var build_button: IconButton
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
# Extractor is excluded: it needs a planet underneath, which
|
||||
# test_extractor_building covers in full.
|
||||
const OPTIONS: Array = [
|
||||
{"press": "_on_place_mothership", "type": "mothership", "title": "Mothership"},
|
||||
{"press": "_on_place_crafter", "type": "ship_crafter", "title": "Ship Crafter"},
|
||||
{"press": "_on_place_command_center", "type": "command_center", "title": "Command Center"},
|
||||
]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 500
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
controller = scene.get_node("BuildController")
|
||||
popup = scene.get_node("UI/BuildMenu/InfoPopup")
|
||||
title = popup.get_node("Panel/TitleLabel")
|
||||
message = popup.get_node("Panel/MessageLabel")
|
||||
cost_number = popup.get_node("Panel/CostNumber")
|
||||
build_button = popup.get_node("Panel/BuildButton")
|
||||
|
||||
|
||||
func _count(type: String) -> int:
|
||||
var n: int = 0
|
||||
for b in game_state.buildings:
|
||||
if b.type == type:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
|
||||
for option in OPTIONS:
|
||||
var type: String = option.type
|
||||
var expected_cost: int = controller.cost_of(type)
|
||||
var rocks_before: int = game_state.rocks
|
||||
var built_before: int = _count(type)
|
||||
|
||||
controller._on_build_pressed()
|
||||
controller.call(option.press)
|
||||
|
||||
var informed: bool = popup.visible \
|
||||
and title.text == option.title \
|
||||
and message.text.length() > 40 \
|
||||
and cost_number.amount == expected_cost \
|
||||
and build_button.visible
|
||||
var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before
|
||||
print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [
|
||||
type, popup.visible, title.text, cost_number.amount,
|
||||
build_button.visible, untouched
|
||||
])
|
||||
if not (informed and untouched):
|
||||
print("TEST_BUILD_INFO_POPUP: FAIL (%s did not explain itself before building)" % type)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- closing drops it, it must not build later ------------
|
||||
controller._close_info()
|
||||
controller._confirm_build()
|
||||
if _count(type) != built_before or game_state.rocks != rocks_before:
|
||||
print("TEST_BUILD_INFO_POPUP: FAIL (%s built after the popup was dismissed)" % type)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- reopening and confirming builds it ------------------
|
||||
controller._on_build_pressed()
|
||||
controller.call(option.press)
|
||||
controller._confirm_build()
|
||||
var built: bool = _count(type) == built_before + 1
|
||||
var charged: bool = game_state.rocks == rocks_before - expected_cost
|
||||
print("%s: confirmed -> built=%s rocks %d->%d charged=%s" % [
|
||||
type, built, rocks_before, game_state.rocks, charged
|
||||
])
|
||||
if not (built and charged):
|
||||
print("TEST_BUILD_INFO_POPUP: FAIL (%s did not build on confirmation)" % type)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_BUILD_INFO_POPUP: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_build_info_popup.gd.uid
Normal file
1
tests/test_build_info_popup.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://mqhjheyn0hq4
|
||||
64
tests/test_build_menu_icons.gd
Normal file
64
tests/test_build_menu_icons.gd
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_build_menu_icons.gd
|
||||
# The build menu is where a structure is chosen, so each option has to look
|
||||
# like the thing it builds. It used to pick from the generic icon set: the
|
||||
# mothership and the command centre both drew a folded MAP glyph, the ship
|
||||
# crafter drew a bare triangle and the extractor drew the same "+" as the
|
||||
# button that opens the menu - so redrawing the placed buildings changed
|
||||
# nothing about how they looked at the point of choosing one.
|
||||
#
|
||||
# Verifies, for every option in the menu:
|
||||
# 1. it uses its own dedicated building glyph
|
||||
# 2. no two options share an icon
|
||||
# 3. `icon` is a real Icon value that _draw actually handles
|
||||
|
||||
var scene: Node
|
||||
|
||||
const EXPECTED: Dictionary = {
|
||||
"MothershipButton": IconButton.Icon.MOTHERSHIP,
|
||||
"CrafterButton": IconButton.Icon.CRAFTER,
|
||||
"CommandButton": IconButton.Icon.COMMAND_CENTER,
|
||||
"ExtractorButton": IconButton.Icon.EXTRACTOR,
|
||||
}
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
var menu: Node = scene.get_node("UI/BuildMenu")
|
||||
var seen: Array = []
|
||||
var ok: bool = true
|
||||
|
||||
for name in EXPECTED:
|
||||
var button: IconButton = menu.get_node_or_null(name)
|
||||
if button == null:
|
||||
print("%s: MISSING" % name)
|
||||
ok = false
|
||||
continue
|
||||
var expected: int = EXPECTED[name]
|
||||
var matches: bool = button.icon == expected
|
||||
# An out-of-range int would silently draw an empty frame, since
|
||||
# _draw's match simply falls through.
|
||||
var in_range: bool = button.icon >= 0 and button.icon < IconButton.Icon.size()
|
||||
print("%s: icon=%d expected=%d ok=%s in_range=%s" % [
|
||||
name, button.icon, expected, matches, in_range
|
||||
])
|
||||
if not (matches and in_range):
|
||||
ok = false
|
||||
if button.icon in seen:
|
||||
print("%s: icon %d is already used by another option" % [name, button.icon])
|
||||
ok = false
|
||||
seen.append(button.icon)
|
||||
|
||||
if ok:
|
||||
print("TEST_BUILD_MENU_ICONS: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_BUILD_MENU_ICONS: FAIL (build options are not using their own building glyphs)")
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_build_menu_icons.gd.uid
Normal file
1
tests/test_build_menu_icons.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c373ho7s2jyn2
|
||||
98
tests/test_camera_settles.gd
Normal file
98
tests/test_camera_settles.gd
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_camera_settles.gd
|
||||
# Regression test for a permanently-drifting camera. GameController followed
|
||||
# the fleet with an exponential lerp and no snap threshold, so it approached
|
||||
# the fleet centroid asymptotically and NEVER arrived - the camera kept
|
||||
# moving by a fraction of a pixel every single frame, forever, even with the
|
||||
# fleet completely stationary. That kept the background shaders' camera_pos
|
||||
# uniform changing every frame, which made screen tearing permanently visible
|
||||
# as horizontal bands of the background sliding sideways against each other.
|
||||
#
|
||||
# Verifies the camera reaches an EXACTLY stable position (bit-identical
|
||||
# frame to frame) once the fleet has stopped, not merely a nearly-stable one.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var camera: Camera2D
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
var last_pos: Vector2
|
||||
var identical_frames: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
camera = main_scene.get_node("Camera2D")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
# Send the fleet somewhere well away from the camera so the
|
||||
# follow actually has distance to close.
|
||||
fleet.move_to(Vector2(1500, 900))
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# Wait for the fleet to genuinely arrive AND the camera to close
|
||||
# the remaining distance - a fixed delay is unreliable here, since
|
||||
# the trip length depends on the ships' braking curve.
|
||||
if elapsed > 30.0:
|
||||
print("TEST_CAMERA_SETTLES: FAIL (fleet/camera never arrived)")
|
||||
quit(1)
|
||||
return true
|
||||
for ship in fleet.ships:
|
||||
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
|
||||
return false
|
||||
# Wait for the snap itself to fire, not merely for the camera to
|
||||
# get close: the final approach is still an active lerp, and
|
||||
# sampling during it would just measure normal convergence rather
|
||||
# than the perpetual drift this test is about.
|
||||
if camera.global_position.distance_to(fleet.get_centroid()) > 0.0001:
|
||||
return false
|
||||
last_pos = camera.global_position
|
||||
identical_frames = 0
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# With the fleet settled, the camera must be BIT-IDENTICAL frame
|
||||
# to frame. Any residual drift (however small) means the lerp is
|
||||
# still creeping and the tearing-visibility bug is back.
|
||||
if camera.global_position == last_pos:
|
||||
identical_frames += 1
|
||||
else:
|
||||
print("camera still drifting: %s -> %s (delta=%.9f)" % [
|
||||
last_pos, camera.global_position,
|
||||
last_pos.distance_to(camera.global_position)
|
||||
])
|
||||
print("TEST_CAMERA_SETTLES: FAIL (camera never stops moving)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
last_pos = camera.global_position
|
||||
if identical_frames < 30:
|
||||
return false
|
||||
|
||||
var centroid: Vector2 = fleet.get_centroid()
|
||||
var on_target: bool = camera.global_position.distance_to(centroid) <= 1.0
|
||||
print("camera stable for %d frames at %s (fleet centroid %s)" % [
|
||||
identical_frames, camera.global_position, centroid
|
||||
])
|
||||
if on_target:
|
||||
print("TEST_CAMERA_SETTLES: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_CAMERA_SETTLES: FAIL (settled, but not on the fleet)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_camera_settles.gd.uid
Normal file
1
tests/test_camera_settles.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c2it165t3pxvd
|
||||
118
tests/test_crafter_animation.gd
Normal file
118
tests/test_crafter_animation.gd
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_crafter_animation.gd
|
||||
# The ship crafter is drawn as a factory with a hull being assembled on the
|
||||
# line. Verifies:
|
||||
# 1. a crafter spawned the normal way (BuildingField, from GameState)
|
||||
# actually animates - the animation is what tells the player what the
|
||||
# building does. The mothership animates too (reactor core, orbiting
|
||||
# satellites)
|
||||
# 2. the command centre stays static, because per-frame redraws of art
|
||||
# that never changes were a measurable framerate cost earlier on
|
||||
# 3. the hull assembles progressively over the cycle rather than popping
|
||||
# in whole, and every piece is on before the assembly phase ends
|
||||
# 4. the cycle loops without anim_time growing without bound
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
var field: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var crafter: Node2D
|
||||
var time_at_start: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 200
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
field = scene.get_node("BuildingField")
|
||||
|
||||
|
||||
func _find(type: String) -> Node2D:
|
||||
for b in field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == type:
|
||||
return b
|
||||
return null
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(500.0, 0.0))
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-500.0, 0.0))
|
||||
field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 500.0))
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# Spawning is deferred, so give the tree a frame to attach them.
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
crafter = _find(GameState.BUILDING_SHIP_CRAFTER)
|
||||
if crafter == null:
|
||||
print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var animated: bool = crafter.is_processing()
|
||||
var mothership: Node2D = _find(GameState.BUILDING_MOTHERSHIP)
|
||||
var mothership_animates: bool = mothership != null and mothership.is_processing()
|
||||
var command: Node2D = _find(GameState.BUILDING_COMMAND_CENTER)
|
||||
var command_static: bool = command != null and not command.is_processing()
|
||||
print("crafter animates=%s mothership animates=%s command centre static=%s" % [
|
||||
animated, mothership_animates, command_static
|
||||
])
|
||||
if not (animated and mothership_animates and command_static):
|
||||
print("TEST_CRAFTER_ANIMATION: FAIL (wrong buildings are animating)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- the hull assembles piece by piece ------------------------
|
||||
var total: int = crafter.SHIP_PARTS.size()
|
||||
var counts: Array = []
|
||||
for p in [0.0, 0.1, 0.35, 0.55, 0.9, 1.0]:
|
||||
counts.append(crafter.attached_part_count(p))
|
||||
var progressive: bool = counts[0] == 0 and counts[-1] == total
|
||||
for i in range(1, counts.size()):
|
||||
if counts[i] < counts[i - 1]:
|
||||
progressive = false
|
||||
# Nothing may still be missing when the welding phase ends, or
|
||||
# the hull would launch half-built.
|
||||
var complete_before_launch: bool = crafter.attached_part_count(1.0) == total
|
||||
print("parts attached across the phase: %s (of %d) progressive=%s" % [
|
||||
counts, total, progressive
|
||||
])
|
||||
if not (progressive and complete_before_launch and counts[1] < total):
|
||||
print("TEST_CRAFTER_ANIMATION: FAIL (hull does not build up gradually)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
time_at_start = crafter.anim_time
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
var advanced: bool = crafter.anim_time != time_at_start
|
||||
var bounded: bool = crafter.anim_time >= 0.0 and crafter.anim_time < crafter.CYCLE
|
||||
print("anim_time %.3f -> %.3f (cycle %.2f) advanced=%s bounded=%s" % [
|
||||
time_at_start, crafter.anim_time, crafter.CYCLE, advanced, bounded
|
||||
])
|
||||
if advanced and bounded:
|
||||
print("TEST_CRAFTER_ANIMATION: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_CRAFTER_ANIMATION: FAIL (animation clock stalled or ran away)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_crafter_animation.gd.uid
Normal file
1
tests/test_crafter_animation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://qa1hoclxyhon
|
||||
65
tests/test_enemy_collision.gd
Normal file
65
tests/test_enemy_collision.gd
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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).
|
||||
|
||||
const CHECK_DELAY: float = 0.05
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var enemy: Node
|
||||
var target_ship: Node
|
||||
var elapsed: float = 0.0
|
||||
var started: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> bool:
|
||||
if not started:
|
||||
# Disabling EnemySpawner in _initialize() doesn't reliably stick -
|
||||
# a node's set_process(false) call made before it has fully
|
||||
# finished entering the tree can get silently reset once the
|
||||
# engine's own (deferred) tree-entry finalization runs, which
|
||||
# happens strictly after _ready(). Here, at the point _ready() is
|
||||
# already confirmed complete, it's safe.
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
target_ship = fleet.ships[0]
|
||||
var enemy_scene: PackedScene = load("res://Enemy.tscn")
|
||||
enemy = enemy_scene.instantiate()
|
||||
enemy.position = target_ship.position
|
||||
main_scene.add_child(enemy)
|
||||
started = true
|
||||
return false
|
||||
|
||||
elapsed += delta
|
||||
if elapsed >= CHECK_DELAY:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
])
|
||||
|
||||
if ship_destroyed and ship_removed_from_fleet and fleet_count_ok and enemy_survived:
|
||||
print("TEST_ENEMY_COLLISION: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_ENEMY_COLLISION: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_enemy_collision.gd.uid
Normal file
1
tests/test_enemy_collision.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bflxuyu0qjekl
|
||||
74
tests/test_enemy_combat.gd
Normal file
74
tests/test_enemy_combat.gd
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_enemy_combat.gd
|
||||
# Verifies the fleet auto-fires on an enemy within range and kills it after
|
||||
# a few hits, without the enemy ever having to touch a ship, and that a
|
||||
# coherent red light-burst explosion VFX spawns at the moment it dies.
|
||||
|
||||
const TEST_DURATION: float = 3.0
|
||||
const SPAWN_OFFSET: Vector2 = Vector2(220, 0) # inside fire_range, outside collision radius
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var enemy: Node
|
||||
var elapsed: float = 0.0
|
||||
var started: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> bool:
|
||||
if not started:
|
||||
# Disabling EnemySpawner in _initialize() doesn't reliably stick -
|
||||
# a node's set_process(false) call made before it has fully
|
||||
# finished entering the tree can get silently reset once the
|
||||
# engine's own (deferred) tree-entry finalization runs, which
|
||||
# happens strictly after _ready(). Here, at the point _ready() is
|
||||
# already confirmed complete, it's safe.
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
var enemy_scene: PackedScene = load("res://Enemy.tscn")
|
||||
enemy = enemy_scene.instantiate()
|
||||
enemy.position = fleet.get_centroid() + SPAWN_OFFSET
|
||||
main_scene.add_child(enemy)
|
||||
started = true
|
||||
return false
|
||||
|
||||
elapsed += delta
|
||||
|
||||
if not is_instance_valid(enemy):
|
||||
_finish(true, "enemy destroyed by fleet fire after %.2fs" % elapsed)
|
||||
return true
|
||||
|
||||
if elapsed >= TEST_DURATION:
|
||||
_finish(false, "enemy still alive after %.2fs (hp=%d)" % [elapsed, enemy.hp])
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
|
||||
func _finish(ok: bool, message: String) -> void:
|
||||
print("ships_remaining=%d" % fleet.ships.size())
|
||||
print(message)
|
||||
|
||||
var vfx_ok: bool = false
|
||||
for child in root.get_children():
|
||||
if child is EnemyExplosionVFX:
|
||||
vfx_ok = true
|
||||
print("enemy_vfx_ok=%s" % vfx_ok)
|
||||
|
||||
if ok and fleet.ships.size() == 5 and vfx_ok:
|
||||
print("TEST_ENEMY_COMBAT: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
if fleet.ships.size() != 5:
|
||||
print("FAIL: a ship was lost - enemy must have reached melee range instead of dying to fire")
|
||||
if not vfx_ok:
|
||||
print("FAIL: no matching red light-burst explosion VFX found for the destroyed enemy")
|
||||
print("TEST_ENEMY_COMBAT: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_enemy_combat.gd.uid
Normal file
1
tests/test_enemy_combat.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bvulrmqnxw84w
|
||||
59
tests/test_exploration_tracker.gd
Normal file
59
tests/test_exploration_tracker.gd
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_exploration_tracker.gd
|
||||
# Verifies ExplorationTracker marks the fleet's current chunk as explored
|
||||
# once the fleet is there, while a distant chunk the fleet never visited
|
||||
# stays unexplored - this is what lets the world map reveal only chunks
|
||||
# actually seen instead of the whole generated world at once.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var tracker: Node
|
||||
var elapsed: float = 0.0
|
||||
var teleported: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
tracker = main_scene.get_node("ExplorationTracker")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
if not teleported:
|
||||
# Wait one tick so Fleet._ready() has run and fleet.ships is
|
||||
# populated before we try to teleport them - reposition attempted
|
||||
# synchronously in _initialize() would silently no-op on an empty
|
||||
# ships array.
|
||||
var target_pos: Vector2 = Vector2(5000, -3000)
|
||||
for ship in fleet.ships:
|
||||
ship.position = target_pos
|
||||
fleet.move_to(target_pos)
|
||||
teleported = true
|
||||
return false
|
||||
|
||||
elapsed += delta
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
|
||||
var visited_chunk: Vector2i = WorldGen.chunk_coords(Vector2(5000, -3000))
|
||||
var far_chunk: Vector2i = Vector2i(9999, 9999)
|
||||
|
||||
var explored: Array = tracker.get_explored_chunks()
|
||||
var visited_ok: bool = explored.has(visited_chunk)
|
||||
var far_unexplored: bool = not explored.has(far_chunk)
|
||||
|
||||
print("visited_chunk=%s in_explored=%s far_chunk=%s in_explored=%s" % [
|
||||
visited_chunk, visited_ok, far_chunk, not far_unexplored
|
||||
])
|
||||
|
||||
if visited_ok and far_unexplored:
|
||||
print("TEST_EXPLORATION_TRACKER: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_EXPLORATION_TRACKER: FAIL")
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_exploration_tracker.gd.uid
Normal file
1
tests/test_exploration_tracker.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ctwsrcwjw75ve
|
||||
161
tests/test_extractor_building.gd
Normal file
161
tests/test_extractor_building.gd
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_extractor_building.gd
|
||||
# Extractors are built from SPACE, over a planet - not from the planet's
|
||||
# surface. Verifies:
|
||||
# 1. the option is greyed out when no planet is in reach, but still
|
||||
# pressable, and explains itself instead of silently failing
|
||||
# 2. it lights up once the fleet is over a planet
|
||||
# 3. its info popup names the planet and offers a confirm, and confirming
|
||||
# charges the cost and registers the extractor against that planet
|
||||
# 4. a second one on the same planet is refused, with no confirm offered
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var game_state: Node
|
||||
var controller: Node
|
||||
var button: IconButton
|
||||
var popup: Control
|
||||
var target_chunk: Vector2i
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
if not found:
|
||||
print("FAIL: no planet chunk to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 200
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
|
||||
main_scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
controller = main_scene.get_node("BuildController")
|
||||
button = main_scene.get_node("UI/BuildMenu/ExtractorButton")
|
||||
popup = main_scene.get_node("UI/BuildMenu/InfoPopup")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
# Far from any planet: greyed, and pressing it explains why.
|
||||
controller._on_build_pressed()
|
||||
if button.enabled:
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._on_place_extractor()
|
||||
var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower()
|
||||
var offered: bool = popup.get_node("Panel/BuildButton").visible
|
||||
print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [
|
||||
not button.enabled, explained, offered
|
||||
])
|
||||
if not explained or offered:
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (greyed press did not explain itself, or offered a build)")
|
||||
quit(1)
|
||||
return true
|
||||
if not game_state.extractors.is_empty():
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (built one without a planet)")
|
||||
quit(1)
|
||||
return true
|
||||
popup.visible = false
|
||||
|
||||
# Fly over the planet.
|
||||
var near: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = near
|
||||
fleet.move_to(near)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
var planet_field: Node = main_scene.get_node("PlanetField")
|
||||
if elapsed < planet_field.check_interval + 0.2:
|
||||
return false
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (planet never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
for ship in fleet.ships:
|
||||
ship.position = planet.position
|
||||
fleet.move_to(planet.position)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
controller._on_build_pressed()
|
||||
if not button.enabled:
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (still greyed while over a planet)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var rocks_before: int = game_state.rocks
|
||||
controller._on_place_extractor()
|
||||
var planet_field2: Node = main_scene.get_node("PlanetField")
|
||||
var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0]
|
||||
|
||||
# The popup must name the planet it is about to mine, and nothing
|
||||
# may be spent until it is confirmed.
|
||||
var names_planet: bool = planet2.planet_name in popup.get_node("Panel/MessageLabel").text
|
||||
var spent_early: bool = game_state.rocks != rocks_before
|
||||
print("popup: names_planet=%s confirm_offered=%s spent_before_confirm=%s" % [
|
||||
names_planet, popup.get_node("Panel/BuildButton").visible, spent_early
|
||||
])
|
||||
if not names_planet or spent_early or not popup.get_node("Panel/BuildButton").visible:
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (popup did not offer an informed build)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._confirm_build()
|
||||
|
||||
var built: bool = game_state.has_extractor_at(planet2.planet_name)
|
||||
var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST
|
||||
print("over planet '%s': enabled=%s built=%s rocks %d->%d charged=%s" % [
|
||||
planet2.planet_name, button.enabled, built, rocks_before, game_state.rocks, charged
|
||||
])
|
||||
if not (built and charged):
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (did not build/charge over a planet)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# A second one on the same planet must be refused, not charged.
|
||||
var rocks_now: int = game_state.rocks
|
||||
controller._on_build_pressed()
|
||||
controller._on_place_extractor()
|
||||
var re_offered: bool = popup.get_node("Panel/BuildButton").visible
|
||||
controller._confirm_build() # must do nothing even if pressed
|
||||
var still_one: bool = game_state.extractors.size() == 1 and not re_offered
|
||||
var not_charged: bool = game_state.rocks == rocks_now
|
||||
print("second attempt: extractors=%d rocks=%d confirm_offered=%s refused=%s" % [
|
||||
game_state.extractors.size(), game_state.rocks, re_offered, still_one and not_charged
|
||||
])
|
||||
if still_one and not_charged:
|
||||
print("TEST_EXTRACTOR_BUILDING: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_EXTRACTOR_BUILDING: FAIL (allowed a duplicate extractor)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_extractor_building.gd.uid
Normal file
1
tests/test_extractor_building.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://5b2mac7kn3gs
|
||||
106
tests/test_extractor_income.gd
Normal file
106
tests/test_extractor_income.gd
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_extractor_income.gd
|
||||
# Covers the extractor's automated hauler:
|
||||
# 1. income accrues with NOTHING about the route loaded (no scene showing
|
||||
# it), because the simulation lives in the GameState autoload
|
||||
# 2. ore is delivered exactly when the hauler reaches the mothership -
|
||||
# the payout and the drawn arrival are the same event, driven by the
|
||||
# same `progress` value, so they cannot drift apart
|
||||
# 3. no mothership means no route and therefore no income
|
||||
# 4. the hauler's drawn position tracks that progress along the route
|
||||
|
||||
var game_state: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var mothership_pos: Vector2 = Vector2(4000.0, 0.0)
|
||||
var planet_pos: Vector2 = Vector2.ZERO
|
||||
var rocks_before_arrival: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 0
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
|
||||
# An extractor with NO mothership: there is nowhere to deliver, so it
|
||||
# must not pay out.
|
||||
game_state.add_extractor("Test Planet", planet_pos)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 1.0:
|
||||
return false
|
||||
if game_state.rocks != 0:
|
||||
print("earned %d rocks with no mothership" % game_state.rocks)
|
||||
print("TEST_EXTRACTOR_INCOME: FAIL (income without a delivery point)")
|
||||
quit(1)
|
||||
return true
|
||||
print("no mothership -> no income (rocks=%d)" % game_state.rocks)
|
||||
|
||||
# Give it somewhere to deliver.
|
||||
game_state.add_building(GameState.BUILDING_MOTHERSHIP, mothership_pos)
|
||||
game_state.extractors[0].progress = 0.0
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
var e: Dictionary = game_state.extractors[0]
|
||||
var cycle: float = game_state.get_hauler_route_time(e)
|
||||
if cycle <= 0.0:
|
||||
print("TEST_EXTRACTOR_INCOME: FAIL (route never established)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Nothing in this scene tree draws the route at all - no Main
|
||||
# scene, no HaulerField, no hauler node. Income must still tick.
|
||||
if e.progress < GameState.HAULER_ARRIVAL:
|
||||
rocks_before_arrival = game_state.rocks
|
||||
# The drawn position must track progress along the route.
|
||||
var pos: Variant = game_state.get_hauler_position(e)
|
||||
var expected: Vector2 = planet_pos.lerp(mothership_pos, e.progress / GameState.HAULER_ARRIVAL)
|
||||
if pos == null or pos.distance_to(expected) > 1.0:
|
||||
print("hauler at %s, expected %s" % [pos, expected])
|
||||
print("TEST_EXTRACTOR_INCOME: FAIL (drawn position does not follow the simulation)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
|
||||
# Just crossed the arrival point: the payout must have landed on
|
||||
# the very same step, and the hauler must be AT the mothership.
|
||||
var paid: bool = game_state.rocks == rocks_before_arrival + GameState.HAULER_YIELD
|
||||
var at_mothership: Variant = game_state.get_hauler_position(e)
|
||||
var arrived: bool = at_mothership != null and at_mothership.distance_to(mothership_pos) < 40.0
|
||||
print("arrival: rocks %d -> %d (paid=%s) hauler at %s (arrived=%s)" % [
|
||||
rocks_before_arrival, game_state.rocks, paid, at_mothership, arrived
|
||||
])
|
||||
if not (paid and arrived):
|
||||
print("TEST_EXTRACTOR_INCOME: FAIL (payout and arrival did not coincide)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
rocks_before_arrival = game_state.rocks
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# It must not pay again until it has been back and returned.
|
||||
var e2: Dictionary = game_state.extractors[0]
|
||||
if e2.progress > GameState.HAULER_ARRIVAL:
|
||||
if game_state.rocks != rocks_before_arrival:
|
||||
print("paid %d extra mid-return" % (game_state.rocks - rocks_before_arrival))
|
||||
print("TEST_EXTRACTOR_INCOME: FAIL (paid while returning empty)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
# Wrapped past the planet and started a new outbound leg.
|
||||
print("returned empty without paying again (rocks=%d)" % game_state.rocks)
|
||||
print("TEST_EXTRACTOR_INCOME: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_extractor_income.gd.uid
Normal file
1
tests/test_extractor_income.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://doxpe0jsbc7dw
|
||||
77
tests/test_fleet_formation.gd
Normal file
77
tests/test_fleet_formation.gd
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_fleet_formation.gd
|
||||
# Verifies that issuing a fleet move order makes every ship converge on its
|
||||
# formation slot (offset preserved) around the ordered point AND actually
|
||||
# stops there - a regression guard for the arrival-overshoot bug where ships
|
||||
# oscillated back and forth around the target forever instead of settling.
|
||||
|
||||
const CHECKPOINT_TIME: float = 4.0
|
||||
const TEST_DURATION: float = 7.0
|
||||
const ARRIVE_TOLERANCE: float = 2.0
|
||||
const STABLE_TOLERANCE: float = 1.0
|
||||
const VELOCITY_TOLERANCE: float = 1.0
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var target: Vector2 = Vector2(600.0, 300.0)
|
||||
var elapsed: float = 0.0
|
||||
var started: bool = false
|
||||
var checkpoint_taken: bool = false
|
||||
var checkpoint_positions: Array = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> bool:
|
||||
if not started:
|
||||
# Wait one physics tick so Fleet/Ship _ready() has run and
|
||||
# formation_offsets are computed before we issue the order.
|
||||
fleet.move_to(target)
|
||||
started = true
|
||||
return false
|
||||
|
||||
elapsed += delta
|
||||
|
||||
if not checkpoint_taken and elapsed >= CHECKPOINT_TIME:
|
||||
checkpoint_taken = true
|
||||
for ship in fleet.ships:
|
||||
checkpoint_positions.append(ship.position)
|
||||
|
||||
if elapsed >= TEST_DURATION:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
var all_ok: bool = true
|
||||
for i in range(fleet.ships.size()):
|
||||
var ship: Node = fleet.ships[i]
|
||||
var expected: Vector2 = target + fleet.formation_offsets[i]
|
||||
var dist: float = ship.position.distance_to(expected)
|
||||
var drift_since_checkpoint: float = ship.position.distance_to(checkpoint_positions[i])
|
||||
var speed: float = ship.velocity.length()
|
||||
|
||||
var arrived: bool = dist <= ARRIVE_TOLERANCE
|
||||
var stable: bool = drift_since_checkpoint <= STABLE_TOLERANCE
|
||||
var stopped: bool = speed <= VELOCITY_TOLERANCE
|
||||
var ok: bool = arrived and stable and stopped
|
||||
all_ok = all_ok and ok
|
||||
|
||||
print("Ship %d expected=%s actual=%s dist=%.3f drift_since_t%.0fs=%.3f speed=%.3f %s" % [
|
||||
i, expected, ship.position, dist, CHECKPOINT_TIME, drift_since_checkpoint, speed, "OK" if ok else "FAIL"
|
||||
])
|
||||
|
||||
if all_ok:
|
||||
print("TEST_FLEET_FORMATION: PASS (ships arrived and settled, no oscillation)")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_FLEET_FORMATION: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_fleet_formation.gd.uid
Normal file
1
tests/test_fleet_formation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cdn05ghel0emh
|
||||
56
tests/test_icon_button.gd
Normal file
56
tests/test_icon_button.gd
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_icon_button.gd
|
||||
# Verifies IconButton correctly turns a touch-down + touch-up into its
|
||||
# `pressed` signal, which the Map button is wired to open the world map -
|
||||
# and that opening the map hides the Map button itself (avoiding it
|
||||
# overlapping the Close button, which occupies the same corner).
|
||||
#
|
||||
# Calls _gui_input directly rather than routing a synthetic touch through
|
||||
# the viewport's screen-position hit-testing, since headless mode's
|
||||
# placeholder window makes exact on-screen coordinates unreliable to
|
||||
# target (see test_world_map_ui.gd's pan test for the same issue) - this
|
||||
# is a more targeted unit test of the button's own press logic anyway.
|
||||
|
||||
var main_scene: Node
|
||||
var world_map: Control
|
||||
var map_button: IconButton
|
||||
var press_count: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
world_map = main_scene.get_node("UI/WorldMap")
|
||||
map_button = main_scene.get_node("UI/MapButton")
|
||||
map_button.pressed.connect(_on_pressed)
|
||||
|
||||
|
||||
func _on_pressed() -> void:
|
||||
press_count += 1
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
var down: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
down.pressed = true
|
||||
map_button._gui_input(down)
|
||||
|
||||
var up: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
up.pressed = false
|
||||
map_button._gui_input(up)
|
||||
|
||||
var press_fired: bool = press_count == 1
|
||||
var map_opened: bool = world_map.visible
|
||||
var map_button_hidden: bool = not map_button.visible
|
||||
|
||||
print("press_fired=%s map_opened=%s map_button_hidden=%s" % [press_fired, map_opened, map_button_hidden])
|
||||
|
||||
if press_fired and map_opened and map_button_hidden:
|
||||
print("TEST_ICON_BUTTON: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_ICON_BUTTON: FAIL")
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_icon_button.gd.uid
Normal file
1
tests/test_icon_button.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ceqoknn4vn1n2
|
||||
56
tests/test_icon_button_drag_cancel.gd
Normal file
56
tests/test_icon_button_drag_cancel.gd
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_icon_button_drag_cancel.gd
|
||||
# Verifies IconButton only counts as pressed if the finger/cursor is still
|
||||
# over the button when it lifts - dragging off the button before
|
||||
# releasing (a standard way to "cancel" a touch/click) must NOT still
|
||||
# trigger it, and releasing back inside the button's bounds must.
|
||||
|
||||
var main_scene: Node
|
||||
var map_button: IconButton
|
||||
var press_count: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
map_button = main_scene.get_node("UI/MapButton")
|
||||
map_button.pressed.connect(_on_pressed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
var inside: Vector2 = map_button.size / 2.0
|
||||
var far_outside: Vector2 = map_button.size * 10.0
|
||||
|
||||
# Press inside, release far outside the button's rect - must NOT fire.
|
||||
_touch(true, inside)
|
||||
_touch(false, far_outside)
|
||||
var cancel_ok: bool = press_count == 0
|
||||
print("after_drag_off press_count=%d cancel_ok=%s" % [press_count, cancel_ok])
|
||||
|
||||
# Press and release both inside - must fire normally.
|
||||
_touch(true, inside)
|
||||
_touch(false, inside)
|
||||
var normal_press_ok: bool = press_count == 1
|
||||
print("after_normal_press press_count=%d normal_press_ok=%s" % [press_count, normal_press_ok])
|
||||
|
||||
if cancel_ok and normal_press_ok:
|
||||
print("TEST_ICON_BUTTON_DRAG_CANCEL: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_ICON_BUTTON_DRAG_CANCEL: FAIL")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
|
||||
func _touch(is_pressed: bool, at_position: Vector2) -> void:
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.pressed = is_pressed
|
||||
ev.position = at_position
|
||||
map_button._gui_input(ev)
|
||||
|
||||
|
||||
func _on_pressed() -> void:
|
||||
press_count += 1
|
||||
1
tests/test_icon_button_drag_cancel.gd.uid
Normal file
1
tests/test_icon_button_drag_cancel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cl6hr8yuqdjvm
|
||||
134
tests/test_joystick_control.gd
Normal file
134
tests/test_joystick_control.gd
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_joystick_control.gd
|
||||
# Verifies the virtual joystick steers the fleet: holding the stick in a
|
||||
# direction flies the fleet that way, and releasing brings it to a stop
|
||||
# rather than freezing it mid-flight. Replaces the old tap-to-move test,
|
||||
# since tapping no longer issues move orders.
|
||||
#
|
||||
# Touch events are handed to the joystick directly rather than through
|
||||
# Input.parse_input_event: headless mode's placeholder window rescales
|
||||
# synthetic touch coordinates by an environment-specific factor, which
|
||||
# would land them outside the joystick's rect for reasons that have nothing
|
||||
# to do with the behaviour under test.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var joystick: TouchJoystick
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var start_centroid: Vector2
|
||||
var moving_centroid: Vector2
|
||||
var stop_check_frames: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
joystick = main_scene.get_node("UI/Joystick")
|
||||
|
||||
|
||||
func _press(local: Vector2) -> void:
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.index = 0
|
||||
ev.pressed = true
|
||||
ev.position = joystick.global_position + local
|
||||
joystick.handle_touch(ev)
|
||||
|
||||
|
||||
func _drag(local: Vector2) -> void:
|
||||
var ev: InputEventScreenDrag = InputEventScreenDrag.new()
|
||||
ev.index = 0
|
||||
ev.position = joystick.global_position + local
|
||||
joystick.handle_drag(ev)
|
||||
|
||||
|
||||
func _release(local: Vector2) -> void:
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.index = 0
|
||||
ev.pressed = false
|
||||
ev.position = joystick.global_position + local
|
||||
joystick.handle_touch(ev)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
start_centroid = fleet.get_centroid()
|
||||
|
||||
# The joystick receives touches via GameController's
|
||||
# _unhandled_input, which never fires for a Control that
|
||||
# consumes them through the GUI system first. With Godot's
|
||||
# default mouse_filter (STOP) the stick silently does nothing
|
||||
# on a real device - it must be IGNORE. Checked structurally
|
||||
# because the rest of this test drives handle_touch() directly
|
||||
# and so cannot catch it.
|
||||
if joystick.mouse_filter != Control.MOUSE_FILTER_IGNORE:
|
||||
print("joystick mouse_filter=%d (needs IGNORE=%d)" % [
|
||||
joystick.mouse_filter, Control.MOUSE_FILTER_IGNORE
|
||||
])
|
||||
print("TEST_JOYSTICK_CONTROL: FAIL (stick would swallow its own touches)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Push the stick hard to the right.
|
||||
var centre: Vector2 = joystick.size / 2.0
|
||||
_press(centre)
|
||||
_drag(centre + Vector2(joystick.max_radius, 0.0))
|
||||
if joystick.direction.x < 0.9:
|
||||
print("joystick direction wrong: %s" % joystick.direction)
|
||||
print("TEST_JOYSTICK_CONTROL: FAIL (stick did not register)")
|
||||
quit(1)
|
||||
return true
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 1.5:
|
||||
return false
|
||||
moving_centroid = fleet.get_centroid()
|
||||
var moved_right: bool = moving_centroid.x - start_centroid.x > 100.0
|
||||
var stayed_level: bool = abs(moving_centroid.y - start_centroid.y) < 80.0
|
||||
print("moved from %s to %s (right=%s level=%s)" % [
|
||||
start_centroid, moving_centroid, moved_right, stayed_level
|
||||
])
|
||||
if not (moved_right and stayed_level):
|
||||
print("TEST_JOYSTICK_CONTROL: FAIL (fleet did not follow the stick)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
_release(joystick.size / 2.0)
|
||||
if joystick.direction != Vector2.ZERO:
|
||||
print("TEST_JOYSTICK_CONTROL: FAIL (direction not cleared on release)")
|
||||
quit(1)
|
||||
return true
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# After release the fleet should coast to a halt, not freeze
|
||||
# instantly and not keep flying.
|
||||
if elapsed < 4.0:
|
||||
return false
|
||||
var speed: float = 0.0
|
||||
for ship in fleet.ships:
|
||||
speed = max(speed, ship.velocity.length())
|
||||
var stopped: bool = speed < 1.0
|
||||
var coasted: bool = fleet.get_centroid().x > moving_centroid.x
|
||||
print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [
|
||||
speed, stopped, coasted
|
||||
])
|
||||
if stopped and coasted:
|
||||
print("TEST_JOYSTICK_CONTROL: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_JOYSTICK_CONTROL: FAIL (did not coast to a stop)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_joystick_control.gd.uid
Normal file
1
tests/test_joystick_control.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dtgk6slv301ob
|
||||
145
tests/test_keyboard_input.gd
Normal file
145
tests/test_keyboard_input.gd
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_keyboard_input.gd
|
||||
# Desktop/web controls. The phone build is driven entirely by touch, but the
|
||||
# web build is played with a keyboard and mouse, so WASD (and the arrows)
|
||||
# must steer the fleet, Q/E must pivot the formation, and releasing the keys
|
||||
# must stop - the stick has a centred-again signal to hook, the keyboard has
|
||||
# nothing equivalent, so a missed release would leave the fleet flying off
|
||||
# forever.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. W/A/S/D and the arrow keys each produce the right heading, and
|
||||
# diagonals combine and normalise
|
||||
# 2. holding a key actually moves the fleet that way
|
||||
# 3. releasing every key stops it
|
||||
# 4. Q and E turn the formation, in opposite directions
|
||||
# 5. the on-screen stick is not taken away on a touch device
|
||||
|
||||
var scene: Node
|
||||
var controller: Node
|
||||
var fleet: Node
|
||||
var joystick: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var start_centroid: Vector2 = Vector2.ZERO
|
||||
var moving_centroid: Vector2 = Vector2.ZERO
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
root.get_node("GameState").reset_formation()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
controller = scene
|
||||
fleet = scene.get_node("Fleet")
|
||||
joystick = scene.get_node("UI/Joystick")
|
||||
|
||||
|
||||
func _key(code: int, pressed: bool) -> void:
|
||||
var event: InputEventKey = InputEventKey.new()
|
||||
event.keycode = code
|
||||
event.pressed = pressed
|
||||
controller._handle_key(event)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
# --- headings -------------------------------------------------
|
||||
var cases: Array = [
|
||||
{"keys": [KEY_W], "expect": Vector2.UP},
|
||||
{"keys": [KEY_S], "expect": Vector2.DOWN},
|
||||
{"keys": [KEY_A], "expect": Vector2.LEFT},
|
||||
{"keys": [KEY_D], "expect": Vector2.RIGHT},
|
||||
{"keys": [KEY_UP], "expect": Vector2.UP},
|
||||
{"keys": [KEY_RIGHT], "expect": Vector2.RIGHT},
|
||||
{"keys": [KEY_W, KEY_D], "expect": Vector2(1, -1).normalized()},
|
||||
{"keys": [KEY_W, KEY_S], "expect": Vector2.ZERO},
|
||||
]
|
||||
for case in cases:
|
||||
for k in case.keys:
|
||||
_key(k, true)
|
||||
var got: Vector2 = controller.keyboard_direction()
|
||||
var ok: bool = got.distance_to(case.expect) < 0.001
|
||||
if not ok:
|
||||
print("keys %s -> %s, expected %s" % [case.keys, got, case.expect])
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (wrong heading)")
|
||||
quit(1)
|
||||
return true
|
||||
for k in case.keys:
|
||||
_key(k, false)
|
||||
print("all %d key combinations produced the right heading" % cases.size())
|
||||
|
||||
# The stick must survive on a touch device.
|
||||
var stick_kept: bool = joystick.visible
|
||||
print("stick still visible after keyboard use: %s (touchscreen=%s)" % [
|
||||
stick_kept, DisplayServer.is_touchscreen_available()
|
||||
])
|
||||
if DisplayServer.is_touchscreen_available() and not stick_kept:
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (took the stick away on a touch device)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- holding a key flies the fleet ----------------------------
|
||||
start_centroid = fleet.get_centroid()
|
||||
_key(KEY_D, true)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.6:
|
||||
return false
|
||||
moving_centroid = fleet.get_centroid()
|
||||
var travelled: float = moving_centroid.x - start_centroid.x
|
||||
var sideways: float = absf(moving_centroid.y - start_centroid.y)
|
||||
print("held D for 0.6s: moved x=%.1f y-drift=%.1f" % [travelled, sideways])
|
||||
if travelled < 50.0 or sideways > 30.0:
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (holding a key did not fly the fleet that way)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- releasing stops ------------------------------------------
|
||||
_key(KEY_D, false)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.9:
|
||||
return false
|
||||
var coasted: float = fleet.get_centroid().x - moving_centroid.x
|
||||
var still_steering: bool = fleet.steer_direction != Vector2.ZERO
|
||||
print("0.9s after release: drifted a further %.1f, still steering=%s" % [
|
||||
coasted, still_steering
|
||||
])
|
||||
if still_steering:
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (fleet kept flying after the key was released)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- Q/E pivot ------------------------------------------------
|
||||
var game_state: Node = root.get_node("GameState")
|
||||
var before: float = game_state.formation[1].angle()
|
||||
_key(KEY_E, true)
|
||||
_key(KEY_E, false)
|
||||
var after_cw: float = game_state.formation[1].angle()
|
||||
_key(KEY_Q, true)
|
||||
_key(KEY_Q, false)
|
||||
var after_ccw: float = game_state.formation[1].angle()
|
||||
|
||||
var cw_turn: float = angle_difference(before, after_cw)
|
||||
var ccw_turn: float = angle_difference(after_cw, after_ccw)
|
||||
print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, ccw_turn])
|
||||
if absf(cw_turn) < 0.01 or absf(ccw_turn) < 0.01 or signf(cw_turn) == signf(ccw_turn):
|
||||
print("TEST_KEYBOARD_INPUT: FAIL (Q/E did not pivot in opposite directions)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_KEYBOARD_INPUT: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_keyboard_input.gd.uid
Normal file
1
tests/test_keyboard_input.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cdhlgvjrc41aq
|
||||
118
tests/test_lopsided_formation_movement.gd
Normal file
118
tests/test_lopsided_formation_movement.gd
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_lopsided_formation_movement.gd
|
||||
# Regression test for the fleet lurching the wrong way on the first frames
|
||||
# of a move after the formation was edited at a command centre.
|
||||
#
|
||||
# Ships sit at anchor + offset, so the fleet CENTROID is anchor + the
|
||||
# AVERAGE offset. Seeding the steering anchor from the centroid therefore
|
||||
# displaced every slot by that average the moment the stick was pushed. The
|
||||
# default wedge is balanced (average ~zero), so this was invisible; drag
|
||||
# every slot into one corner and the average becomes large, and the fleet
|
||||
# jerks towards that corner before the anchor catches up.
|
||||
#
|
||||
# Verifies, with every slot dragged to the top-right:
|
||||
# 1. starting a move does NOT displace the ships' targets sideways
|
||||
# 2. the fleet actually travels bottom-left from the outset - no frame in
|
||||
# which the centroid moves the wrong way
|
||||
# 3. releasing the stick leaves the formation origin where the ships are,
|
||||
# so re-applying the layout does not teleport them
|
||||
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var game_state: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var samples: Array = []
|
||||
var start_centroid: Vector2 = Vector2.ZERO
|
||||
var worst_backwards: float = 0.0
|
||||
|
||||
const HEADING: Vector2 = Vector2(-0.7071, 0.7071) # bottom-left
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.reset_formation()
|
||||
# Every slot dragged into the top-right corner, as the command centre
|
||||
# allows. The layout keeps its shape; only its centre is offset.
|
||||
for i in range(game_state.formation.size()):
|
||||
game_state.formation[i] += Vector2(90.0, -70.0)
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
fleet.apply_formation()
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# Let the ships settle into the lopsided layout first.
|
||||
if elapsed < 1.0:
|
||||
return false
|
||||
|
||||
start_centroid = fleet.get_centroid()
|
||||
var origin_before: Vector2 = fleet.get_formation_origin()
|
||||
fleet.move_in_direction(HEADING)
|
||||
|
||||
# Direct assertion of the bug: the anchor steering starts from
|
||||
# must be the point the ships are already arranged around. Taking
|
||||
# the centroid instead puts it a whole average-offset away, which
|
||||
# is what yanked the fleet sideways.
|
||||
var drift: float = fleet.anchor.distance_to(origin_before)
|
||||
print("anchor is %.2f units from the formation origin (centroid is %.2f away)" % [
|
||||
drift, fleet.get_centroid().distance_to(origin_before)
|
||||
])
|
||||
if drift > 1.0:
|
||||
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (steering anchored off the formation)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# Watch the centroid every frame: it must make progress along the
|
||||
# heading from the very first one, never against it.
|
||||
var travelled: float = (fleet.get_centroid() - start_centroid).dot(HEADING)
|
||||
worst_backwards = minf(worst_backwards, travelled)
|
||||
samples.append(travelled)
|
||||
if elapsed < 0.6:
|
||||
return false
|
||||
|
||||
print("progress along heading: first=%.2f last=%.2f worst=%.2f" % [
|
||||
samples[0], samples[-1], worst_backwards
|
||||
])
|
||||
if worst_backwards < -2.0:
|
||||
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (fleet moved backwards before setting off)")
|
||||
quit(1)
|
||||
return true
|
||||
if samples[-1] < 50.0:
|
||||
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (fleet did not travel along the heading)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- releasing the stick --------------------------------------
|
||||
fleet.stop()
|
||||
var origin: Vector2 = fleet.fleet_target
|
||||
var expected: Vector2 = fleet.get_formation_origin()
|
||||
var settled_ok: bool = origin.distance_to(expected) < 0.01
|
||||
print("after stop: fleet_target=%s formation origin=%s ok=%s" % [
|
||||
origin, expected, settled_ok
|
||||
])
|
||||
if settled_ok:
|
||||
print("TEST_LOPSIDED_FORMATION_MOVEMENT: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (stop left the anchor off the formation)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_lopsided_formation_movement.gd.uid
Normal file
1
tests/test_lopsided_formation_movement.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dq2mnag071oth
|
||||
150
tests/test_mothership_replacement.gd
Normal file
150
tests/test_mothership_replacement.gd
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_mothership_replacement.gd
|
||||
# Only one mothership may exist. Building another replaces it, which moves
|
||||
# where a wiped-out fleet respawns and re-routes every extractor hauler, so
|
||||
# it must be confirmed rather than done silently. Verifies:
|
||||
# 1. the first mothership's info popup carries no destruction warning, and
|
||||
# confirming it builds one
|
||||
# 2. with one already up, the popup DOES warn, and builds nothing until
|
||||
# confirmed
|
||||
# 3. dismissing the popup cancels - confirming afterwards does nothing
|
||||
# 4. confirming replaces it: still exactly one, at the new position
|
||||
# 5. an unaffordable replacement offers no confirm button at all, and
|
||||
# cannot destroy the existing one
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var game_state: Node
|
||||
var controller: Node
|
||||
var popup: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var first_pos: Vector2 = Vector2(600.0, 200.0)
|
||||
var second_pos: Vector2 = Vector2(-900.0, 400.0)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 200
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
|
||||
main_scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
controller = main_scene.get_node("BuildController")
|
||||
popup = main_scene.get_node("UI/BuildMenu/InfoPopup")
|
||||
|
||||
|
||||
func _place_fleet(at: Vector2) -> void:
|
||||
for ship in fleet.ships:
|
||||
ship.position = at
|
||||
fleet.move_to(at)
|
||||
|
||||
|
||||
func _count_motherships() -> int:
|
||||
var n: int = 0
|
||||
for b in game_state.buildings:
|
||||
if b.type == GameState.BUILDING_MOTHERSHIP:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
_place_fleet(first_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
# --- first one: informed, then built -------------------------
|
||||
controller._on_build_pressed()
|
||||
controller._on_place_mothership()
|
||||
var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower()
|
||||
var first_warned: bool = "destroy" in first_text
|
||||
print("first: popup=%s warned=%s built_before_confirm=%d" % [
|
||||
popup.visible, first_warned, _count_motherships()
|
||||
])
|
||||
if not popup.visible or first_warned or _count_motherships() != 0:
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (first build warned, or built without confirming)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._confirm_build()
|
||||
if _count_motherships() != 1:
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (confirming the first one did not build it)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- second: warns, builds nothing ---------------------------
|
||||
_place_fleet(second_pos)
|
||||
controller._on_build_pressed()
|
||||
controller._on_place_mothership()
|
||||
var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower()
|
||||
print("second press: warned=%s motherships=%d" % [warned, _count_motherships()])
|
||||
if not warned or _count_motherships() != 1:
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (no warning, or built without confirming)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- dismissing cancels --------------------------------------
|
||||
controller._close_info()
|
||||
controller._confirm_build()
|
||||
print("after cancel, confirm pressed anyway: motherships=%d at %s" % [
|
||||
_count_motherships(), game_state.get_mothership_position()
|
||||
])
|
||||
if _count_motherships() != 1 or game_state.get_mothership_position().distance_to(first_pos) > 60.0:
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (cancel did not drop the pending build)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- confirming replaces -------------------------------------
|
||||
var rocks_before: int = game_state.rocks
|
||||
controller._on_place_mothership()
|
||||
controller._confirm_build()
|
||||
var only_one: bool = _count_motherships() == 1
|
||||
var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0
|
||||
var charged: bool = game_state.rocks == rocks_before - GameState.MOTHERSHIP_COST
|
||||
print("confirmed: motherships=%d moved=%s rocks %d->%d charged=%s" % [
|
||||
_count_motherships(), moved, rocks_before, game_state.rocks, charged
|
||||
])
|
||||
if not (only_one and moved and charged):
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (replacement did not take effect cleanly)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- unaffordable replacement must not destroy the old one ---
|
||||
game_state.rocks = GameState.MOTHERSHIP_COST - 1
|
||||
_place_fleet(Vector2(2500.0, -800.0))
|
||||
controller._on_build_pressed()
|
||||
controller._on_place_mothership()
|
||||
# With nothing affordable there is no confirm button to press;
|
||||
# pressing it anyway must still do nothing.
|
||||
var offered: bool = popup.get_node("Panel/BuildButton").visible
|
||||
print("unaffordable: confirm offered=%s" % offered)
|
||||
if offered:
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (offered a build it cannot pay for)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._confirm_build()
|
||||
var survived: bool = _count_motherships() == 1
|
||||
var still_there: bool = game_state.get_mothership_position() != null \
|
||||
and game_state.get_mothership_position().distance_to(second_pos) < 60.0
|
||||
print("unaffordable: motherships=%d kept_old_position=%s" % [
|
||||
_count_motherships(), still_there
|
||||
])
|
||||
if survived and still_there:
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (destroyed the old one without affording a new one)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_mothership_replacement.gd.uid
Normal file
1
tests/test_mothership_replacement.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://co6bbt3fo0b13
|
||||
70
tests/test_no_popin_on_load.gd
Normal file
70
tests/test_no_popin_on_load.gd
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_no_popin_on_load.gd
|
||||
# Verifies AsteroidField/PlanetField populate the area around the fleet's
|
||||
# STARTING position immediately (synchronously, during Main.tscn's own
|
||||
# _ready() chain), not only after their ~1s check_interval timer first
|
||||
# elapses - otherwise the world visibly pops in a beat after the screen
|
||||
# is already visible (most noticeable right after returning from a
|
||||
# planet's surface, which is what this simulates via GameState).
|
||||
|
||||
var main_scene: Node
|
||||
var asteroid_field: Node
|
||||
var planet_field: Node
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var target_chunk: Vector2i = Vector2i.ZERO
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# GameState (an autoload) isn't accessible as a bare global identifier
|
||||
# from a script that extends SceneTree directly - that auto-injection
|
||||
# only applies to Node-derived scripts (e.g. everything inside
|
||||
# main_scene). Fetch it explicitly via the tree root instead.
|
||||
var game_state: Node = root.get_node("GameState")
|
||||
game_state.has_return_position = true
|
||||
game_state.surviving_ship_count = 5
|
||||
game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
game_state.return_camera_zoom = 1.0
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
asteroid_field = main_scene.get_node("AsteroidField")
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
# Check on literally the FIRST frame after the scene has entered the
|
||||
# tree and _ready() chains have run - no waiting for any spawner's
|
||||
# own check_interval timer.
|
||||
var asteroids_populated: bool = not asteroid_field.populated_chunks.is_empty()
|
||||
var planet_populated: bool = not planet_field.populated_chunks.is_empty()
|
||||
|
||||
print("asteroids_populated=%s (chunks=%d) planet_populated=%s (chunks=%d)" % [
|
||||
asteroids_populated, asteroid_field.populated_chunks.size(),
|
||||
planet_populated, planet_field.populated_chunks.size()
|
||||
])
|
||||
|
||||
if asteroids_populated and planet_populated:
|
||||
print("TEST_NO_POPIN_ON_LOAD: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_NO_POPIN_ON_LOAD: FAIL")
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_no_popin_on_load.gd.uid
Normal file
1
tests/test_no_popin_on_load.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cijpi8dij2nfo
|
||||
94
tests/test_pinch_zoom.gd
Normal file
94
tests/test_pinch_zoom.gd
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_pinch_zoom.gd
|
||||
# Injects synthetic two-finger touch/drag events (via Input.parse_input_event)
|
||||
# to exercise the real GameController input-handling code path: spreading two
|
||||
# fingers apart should zoom the camera IN (lower Camera2D.zoom value) and must
|
||||
# not be misread as a single-finger fleet move order.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var camera: Camera2D
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
var initial_zoom: float
|
||||
var fleet_target_before: Vector2
|
||||
var baseline_captured: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
camera = main_scene.get_node("Camera2D")
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> bool:
|
||||
if not baseline_captured:
|
||||
# Fleet._ready() runs on the first physics tick in `-s` script mode,
|
||||
# not synchronously inside _initialize(), so capture baselines here.
|
||||
initial_zoom = camera.zoom.x
|
||||
fleet_target_before = fleet.fleet_target
|
||||
baseline_captured = true
|
||||
return false
|
||||
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
_touch(0, true, Vector2(300, 600))
|
||||
_touch(1, true, Vector2(340, 600))
|
||||
stage = 1
|
||||
1:
|
||||
if elapsed > 0.1:
|
||||
# Spread fingers apart: should zoom IN (Camera2D.zoom is a
|
||||
# magnification, so zooming in means zoom.x increases), and
|
||||
# must not move the fleet.
|
||||
_drag(0, Vector2(100, 600))
|
||||
_drag(1, Vector2(540, 600))
|
||||
stage = 2
|
||||
2:
|
||||
if elapsed > 0.4:
|
||||
_touch(0, false, Vector2(100, 600))
|
||||
_touch(1, false, Vector2(540, 600))
|
||||
stage = 3
|
||||
3:
|
||||
if elapsed > 0.7:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _touch(index: int, pressed: bool, pos: Vector2) -> void:
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.index = index
|
||||
ev.pressed = pressed
|
||||
ev.position = pos
|
||||
Input.parse_input_event(ev)
|
||||
|
||||
|
||||
func _drag(index: int, pos: Vector2) -> void:
|
||||
var ev: InputEventScreenDrag = InputEventScreenDrag.new()
|
||||
ev.index = index
|
||||
ev.position = pos
|
||||
Input.parse_input_event(ev)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
var zoomed_in: bool = camera.zoom.x > initial_zoom + 0.01
|
||||
var fleet_untouched: bool = fleet.fleet_target == fleet_target_before
|
||||
|
||||
print("initial_zoom=%.3f final_zoom=%.3f" % [initial_zoom, camera.zoom.x])
|
||||
print("fleet_target_before=%s fleet_target_after=%s" % [fleet_target_before, fleet.fleet_target])
|
||||
|
||||
if zoomed_in and fleet_untouched:
|
||||
print("TEST_PINCH_ZOOM: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
if not zoomed_in:
|
||||
print("FAIL: camera did not zoom in on pinch-out gesture")
|
||||
if not fleet_untouched:
|
||||
print("FAIL: pinch gesture incorrectly triggered a fleet move order")
|
||||
print("TEST_PINCH_ZOOM: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_pinch_zoom.gd.uid
Normal file
1
tests/test_pinch_zoom.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c71vyg5655hdy
|
||||
153
tests/test_pivot_controls.gd
Normal file
153
tests/test_pivot_controls.gd
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_pivot_controls.gd
|
||||
# Covers the fleet-pivot buttons:
|
||||
# 1. HOLDING one turns the formation continuously, not once per press
|
||||
# 2. a brief tap still produces a clear minimum step
|
||||
# 3. pivoting works while the fleet is under way, i.e. the new layout
|
||||
# survives the joystick's per-frame steering updates instead of being
|
||||
# overwritten by the old offsets
|
||||
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var controller: Node
|
||||
var cw: IconButton
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var angle_at_hold_start: float = 0.0
|
||||
var angle_after_hold: float = 0.0
|
||||
# GameState (an autoload) is not available as a bare identifier from a
|
||||
# script that extends SceneTree - fetch it via the tree root instead.
|
||||
var game_state: Node
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.reset_formation()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
controller = scene
|
||||
cw = scene.get_node("UI/PivotCWButton")
|
||||
|
||||
|
||||
func _angle() -> float:
|
||||
return game_state.formation[1].angle()
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
# Hold the button down without releasing it.
|
||||
angle_at_hold_start = _angle()
|
||||
cw.pressed_down = true
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.6:
|
||||
return false
|
||||
angle_after_hold = _angle()
|
||||
var turned: float = angle_difference(angle_at_hold_start, angle_after_hold)
|
||||
# 0.6s at PIVOT_SPEED should be far beyond a single tap step.
|
||||
var held_ok: bool = absf(turned) > controller.PIVOT_MIN_STEP * 1.5
|
||||
print("held 0.6s -> turned %.3f rad (min step %.3f) held_ok=%s" % [
|
||||
absf(turned), controller.PIVOT_MIN_STEP, held_ok
|
||||
])
|
||||
if not held_ok:
|
||||
print("TEST_PIVOT_CONTROLS: FAIL (holding did not turn continuously)")
|
||||
quit(1)
|
||||
return true
|
||||
cw.pressed_down = false
|
||||
controller.pivot_hold_amount = 0.0
|
||||
|
||||
# --- brief tap ------------------------------------------------
|
||||
var before_tap: float = _angle()
|
||||
controller._finish_pivot_tap(1.0)
|
||||
var tapped: float = absf(angle_difference(before_tap, _angle()))
|
||||
var tap_ok: bool = tapped >= controller.PIVOT_MIN_STEP - 0.001
|
||||
print("tap -> turned %.3f rad tap_ok=%s" % [tapped, tap_ok])
|
||||
if not tap_ok:
|
||||
print("TEST_PIVOT_CONTROLS: FAIL (tap did not produce the minimum step)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- pivot WHILE the joystick is held -------------------------
|
||||
# Two fingers at once. This is routed through the same raw-touch
|
||||
# path the real device uses, because Godot's Control GUI treats
|
||||
# touch as one emulated pointer and would not deliver a second
|
||||
# simultaneous finger to a button - the exact reason pivoting
|
||||
# while steering used to do nothing.
|
||||
var joy: TouchJoystick = scene.get_node("UI/Joystick")
|
||||
var stick_down: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
stick_down.index = 0
|
||||
stick_down.pressed = true
|
||||
stick_down.position = joy.global_position + joy.size / 2.0
|
||||
controller._handle_touch(stick_down)
|
||||
var stick_drag: InputEventScreenDrag = InputEventScreenDrag.new()
|
||||
stick_drag.index = 0
|
||||
stick_drag.position = joy.global_position + joy.size / 2.0 + Vector2(joy.max_radius, 0.0)
|
||||
controller._handle_drag(stick_drag)
|
||||
|
||||
if joy.direction.x < 0.9:
|
||||
print("TEST_PIVOT_CONTROLS: FAIL (stick did not engage)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Second finger lands on the clockwise pivot button.
|
||||
var pivot_down: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
pivot_down.index = 1
|
||||
pivot_down.pressed = true
|
||||
pivot_down.position = cw.global_position + cw.size / 2.0
|
||||
controller._handle_touch(pivot_down)
|
||||
|
||||
var both_ok: bool = cw.pressed_down and joy.direction.x > 0.9
|
||||
print("simultaneous: stick=%s pivot_held=%s" % [joy.direction, cw.pressed_down])
|
||||
if not both_ok:
|
||||
print("TEST_PIVOT_CONTROLS: FAIL (cannot pivot while steering)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
angle_at_hold_start = _angle()
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
# Both fingers still down: the formation must have kept turning
|
||||
# while the stick kept re-issuing move orders every frame.
|
||||
var turned_while_steering: float = absf(angle_difference(angle_at_hold_start, _angle()))
|
||||
print("turned %.3f rad while steering" % turned_while_steering)
|
||||
if turned_while_steering < 0.05:
|
||||
print("TEST_PIVOT_CONTROLS: FAIL (no rotation while the stick was held)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Steering is re-issued every frame; pivot in the middle of it.
|
||||
fleet.move_in_direction(Vector2.RIGHT)
|
||||
var before_moving_pivot: Vector2 = game_state.formation[1]
|
||||
controller._pivot(PI / 2.0)
|
||||
var after: Vector2 = game_state.formation[1]
|
||||
fleet.move_in_direction(Vector2.RIGHT)
|
||||
|
||||
# The ship's target must reflect the NEW offset even though
|
||||
# steering re-issued move orders straight afterwards.
|
||||
var expected: Vector2 = fleet.fleet_target + after
|
||||
var followed: bool = fleet.ships[1].move_target.distance_to(expected) < 0.01
|
||||
var rotated: bool = after.distance_to(before_moving_pivot.rotated(PI / 2.0)) < 0.01
|
||||
print("moving pivot: %s -> %s rotated=%s ship_target_ok=%s" % [
|
||||
before_moving_pivot, after, rotated, followed
|
||||
])
|
||||
if rotated and followed:
|
||||
print("TEST_PIVOT_CONTROLS: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PIVOT_CONTROLS: FAIL (pivot lost while moving)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_pivot_controls.gd.uid
Normal file
1
tests/test_pivot_controls.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://blw63561fixnv
|
||||
260
tests/test_planet_atmosphere_transition.gd
Normal file
260
tests/test_planet_atmosphere_transition.gd
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_planet_atmosphere_transition.gd
|
||||
# Verifies the full enter/exit atmosphere flow now that it's a genuine
|
||||
# scene swap (Main.tscn <-> PlanetSurface.tscn) rather than an in-place
|
||||
# overlay: standing on a planet shows the prompt, pressing it fades out
|
||||
# and swaps to PlanetSurface.tscn with the SAME surviving ship count,
|
||||
# where no asteroid/enemy ever exists (not hidden - structurally absent,
|
||||
# since PlanetSurface.tscn has no such nodes at all) and the fleet is
|
||||
# still fully flyable. Exiting fades back to Main.tscn with the fleet
|
||||
# restored at the world position it left from.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var planet_field: Node
|
||||
var landing_controller: Node
|
||||
var asteroid_field_ref: Node
|
||||
var planet_name_label: Label
|
||||
var enter_button: IconButton
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
var target_chunk: Vector2i
|
||||
var position_before_leaving: Vector2
|
||||
var cached_fade_duration: float = 0.4
|
||||
var returned_ship_count: int = -1
|
||||
var game_state: Node
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
game_state = root.get_node("GameState")
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
# change_scene_to_file() operates on current_scene - the real game
|
||||
# gets this set automatically via project.godot's run/main_scene on
|
||||
# boot, but a hand-built headless test harness has to do it itself.
|
||||
current_scene = main_scene
|
||||
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
landing_controller = main_scene.get_node("LandingController")
|
||||
asteroid_field_ref = main_scene.get_node("AsteroidField")
|
||||
planet_name_label = main_scene.get_node("UI/PlanetNameLabel")
|
||||
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# target_chunk is guaranteed ASTEROID_FIELD biome (a
|
||||
# prerequisite for having a planet), so regular drifting
|
||||
# asteroids/enemies could also spawn nearby and interfere with
|
||||
# a ship during this focused scenario - isolate it, same as
|
||||
# test_enemy_combat.gd does for its own scenario.
|
||||
#
|
||||
# This has to happen here, not in _initialize(): a node's
|
||||
# set_process(false) call made before it has fully finished
|
||||
# entering the tree doesn't reliably stick - the engine's own
|
||||
# (deferred) tree-entry finalization, which runs strictly
|
||||
# after _ready(), can silently reset it back to enabled. Here,
|
||||
# at the point _ready() is already confirmed complete
|
||||
# (fleet.ships is reliably populated by now), it's safe.
|
||||
asteroid_field_ref.set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
# Disabling the spawners stops NEW asteroids, but any already
|
||||
# placed keep drifting under their own _process and can still
|
||||
# collide with a ship mid-test. Clear the existing ones too.
|
||||
for a in get_nodes_in_group("asteroids"):
|
||||
a.queue_free()
|
||||
|
||||
# Get the fleet into the target chunk's vicinity first so
|
||||
# PlanetField's spawn radius covers it - the planet's own exact
|
||||
# position within the chunk is randomized, so this is only
|
||||
# enough to trigger spawning, not yet close enough to land on.
|
||||
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = vicinity_pos
|
||||
fleet.move_to(vicinity_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < planet_field.check_interval + 0.1:
|
||||
return false
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (planet never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
for ship in fleet.ships:
|
||||
ship.position = planet.position
|
||||
fleet.move_to(planet.position)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.5:
|
||||
return false
|
||||
var prompt_ok: bool = enter_button.visible and planet_name_label.visible and planet_name_label.text != ""
|
||||
print("prompt_ok=%s ship_count=%d name_text=%s" % [prompt_ok, fleet.ships.size(), planet_name_label.text])
|
||||
if not prompt_ok:
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (prompt never appeared)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Let the fleet finish settling onto the planet before recording
|
||||
# where it left from. Capturing mid-settle made this test
|
||||
# intermittently fail later: the baseline was a still-moving
|
||||
# centroid, while the return always places the fleet exactly on
|
||||
# the planet, so the two could differ by more than the tolerance.
|
||||
for ship in fleet.ships:
|
||||
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
|
||||
return false
|
||||
|
||||
position_before_leaving = fleet.get_centroid()
|
||||
cached_fade_duration = landing_controller.fade_duration
|
||||
_press(enter_button)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
# Fade-out + change_scene_to_file + fade-in. landing_controller
|
||||
# gets freed once the scene swap completes, so the wait
|
||||
# duration must be cached beforehand, not read live each frame.
|
||||
if elapsed < cached_fade_duration + 0.3:
|
||||
return false
|
||||
var on_surface: bool = current_scene != null and current_scene.name == "PlanetSurface"
|
||||
print("on_surface=%s current_scene=%s" % [on_surface, current_scene])
|
||||
if not on_surface:
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not swap to PlanetSurface)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var surface_fleet: Node = current_scene.get_node("Fleet")
|
||||
var ship_count_ok: bool = surface_fleet.ships.size() == 5
|
||||
var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty()
|
||||
var no_enemies: bool = get_nodes_in_group("enemies").is_empty()
|
||||
print("ship_count_ok=%s (count=%d) no_asteroids=%s no_enemies=%s" % [
|
||||
ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_enemies
|
||||
])
|
||||
if not (ship_count_ok and no_asteroids and no_enemies):
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Confirm the fleet is genuinely flyable on the surface.
|
||||
var start_pos: Vector2 = surface_fleet.get_centroid()
|
||||
surface_fleet.move_to(start_pos + Vector2(400, 0))
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
if elapsed < 2.5:
|
||||
return false
|
||||
var surface_fleet: Node = current_scene.get_node("Fleet")
|
||||
var moved_ok: bool = surface_fleet.get_centroid().x > 300.0
|
||||
print("moved_ok=%s centroid=%s" % [moved_ok, surface_fleet.get_centroid()])
|
||||
if not moved_ok:
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (fleet could not move on the surface)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var exit_button: IconButton = current_scene.get_node("UI/ExitButton")
|
||||
var surface_controller: Node = current_scene.get_node("SurfaceController")
|
||||
cached_fade_duration = surface_controller.fade_duration
|
||||
_press(exit_button)
|
||||
elapsed = 0.0
|
||||
stage = 5
|
||||
return false
|
||||
5:
|
||||
# Poll for the swap rather than waiting a fixed duration, so the
|
||||
# ship count can be captured on the very FIRST frame back. The
|
||||
# fresh Main.tscn populates asteroids during its own _ready(),
|
||||
# and they can destroy a ship within a few frames - waiting out
|
||||
# the fade first meant sometimes measuring a fleet that had
|
||||
# already lost one to a collision, which says nothing about the
|
||||
# transition being tested here.
|
||||
var back_in_space: bool = current_scene != null and current_scene.name == "Main"
|
||||
if not back_in_space:
|
||||
if elapsed > cached_fade_duration + 3.0:
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not return to Main)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
if returned_ship_count == -1:
|
||||
print("back_in_space=%s current_scene=%s" % [back_in_space, current_scene])
|
||||
|
||||
# Returning builds a BRAND NEW Main.tscn, so the spawners
|
||||
# silenced back in stage 0 are live again in this fresh scene.
|
||||
# Silence them on the first frame back, and record the ship count
|
||||
# right then: asteroids populate immediately on scene load and
|
||||
# could destroy a ship during the settle wait below, which is a
|
||||
# gameplay collision rather than anything about the transition
|
||||
# this test is checking.
|
||||
var space_fleet: Node = current_scene.get_node("Fleet")
|
||||
if returned_ship_count == -1:
|
||||
current_scene.get_node("AsteroidField").set_process(false)
|
||||
current_scene.get_node("EnemySpawner").set_process(false)
|
||||
for a in get_nodes_in_group("asteroids"):
|
||||
a.queue_free()
|
||||
returned_ship_count = space_fleet.ships.size()
|
||||
|
||||
# Ships respawn in a wedge and then steer into formation, so the
|
||||
# centroid only means anything once they have actually stopped.
|
||||
# Sampling after a fixed delay made this test intermittently fail
|
||||
# by measuring a fleet still on its way in.
|
||||
for ship in space_fleet.ships:
|
||||
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
|
||||
return false
|
||||
|
||||
# Assert on the HANDOFF, not on how many ships survive the next
|
||||
# few frames. Asteroids populate during the fresh scene's own
|
||||
# _ready(), so one can spawn overlapping the returning fleet and
|
||||
# destroy a ship on the first physics frame - before any test
|
||||
# code can run. That is a gameplay collision, not a transition
|
||||
# fault, and made this test fail roughly 1 run in 12. What the
|
||||
# transition owes us is that the surviving count was carried
|
||||
# across correctly and a fleet came back.
|
||||
var handoff_ok: bool = game_state.surviving_ship_count == 5
|
||||
var ship_count_ok: bool = handoff_ok and returned_ship_count >= 1
|
||||
var position_ok: bool = space_fleet.get_centroid().distance_to(position_before_leaving) < 50.0
|
||||
print("handoff=%d returned_ships=%d ship_count_ok=%s position_ok=%s centroid=%s expected=%s" % [
|
||||
game_state.surviving_ship_count, returned_ship_count,
|
||||
ship_count_ok, position_ok, space_fleet.get_centroid(), position_before_leaving
|
||||
])
|
||||
if ship_count_ok and position_ok:
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (return state wrong)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _press(button: IconButton) -> void:
|
||||
var down: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
down.pressed = true
|
||||
button._gui_input(down)
|
||||
var up: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
up.pressed = false
|
||||
button._gui_input(up)
|
||||
1
tests/test_planet_atmosphere_transition.gd.uid
Normal file
1
tests/test_planet_atmosphere_transition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b8s3sfcv4u7qp
|
||||
122
tests/test_planet_identity_persists.gd
Normal file
122
tests/test_planet_identity_persists.gd
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_planet_identity_persists.gd
|
||||
# Regression test for: leaving a planet's surface and coming back
|
||||
# respawned Main.tscn's PlanetField from scratch, and since planet
|
||||
# name/position/shape were picked with the unseeded global RNG at spawn
|
||||
# time, the "same" planet (same chunk) would come back with a different
|
||||
# name, a different position within the chunk, and a different outline
|
||||
# shape every single time. Fixed by seeding planet identity purely from
|
||||
# the chunk's own coordinates (mirrors WorldGen's own deterministic noise
|
||||
# pattern), so this loads Main.tscn twice and checks the planet at the
|
||||
# same chunk is identical both times.
|
||||
|
||||
var target_chunk: Vector2i
|
||||
var first_name: String
|
||||
var first_pos: Vector2
|
||||
var first_shape: PackedVector2Array
|
||||
var stage: int = 0
|
||||
var main_scene: Node
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
_load_main()
|
||||
|
||||
|
||||
func _load_main() -> void:
|
||||
# GameState (an autoload) isn't accessible as a bare global identifier
|
||||
# from a script that extends SceneTree directly - fetch it via root.
|
||||
var game_state: Node = root.get_node("GameState")
|
||||
game_state.has_return_position = true
|
||||
game_state.surviving_ship_count = 5
|
||||
game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
game_state.return_camera_zoom = 1.0
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
match stage:
|
||||
0:
|
||||
# Wait one tick for _ready() (and the forced synchronous
|
||||
# update_chunks() call inside it) to have actually run.
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
var planet_field: Node = main_scene.get_node("PlanetField")
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("FAIL: planet never spawned in target chunk on first load")
|
||||
quit(1)
|
||||
return true
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
first_name = planet.planet_name
|
||||
first_pos = planet.position
|
||||
first_shape = planet.shape_points.duplicate()
|
||||
|
||||
main_scene.queue_free()
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# Give queue_free() a moment to actually clear the old tree
|
||||
# before loading a fresh Main.tscn on top of it.
|
||||
elapsed += 1.0
|
||||
if elapsed < 3.0:
|
||||
return false
|
||||
_load_main()
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
var planet_field: Node = main_scene.get_node("PlanetField")
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("FAIL: planet never spawned in target chunk on second load")
|
||||
quit(1)
|
||||
return true
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
|
||||
var name_ok: bool = planet.planet_name == first_name
|
||||
var pos_ok: bool = planet.position.distance_to(first_pos) < 0.01
|
||||
var shape_ok: bool = planet.shape_points.size() == first_shape.size()
|
||||
if shape_ok:
|
||||
for i in range(first_shape.size()):
|
||||
if planet.shape_points[i].distance_to(first_shape[i]) > 0.01:
|
||||
shape_ok = false
|
||||
break
|
||||
|
||||
print("name: %s vs %s (ok=%s)" % [first_name, planet.planet_name, name_ok])
|
||||
print("pos: %s vs %s (ok=%s)" % [first_pos, planet.position, pos_ok])
|
||||
print("shape_ok=%s" % shape_ok)
|
||||
|
||||
if name_ok and pos_ok and shape_ok:
|
||||
print("TEST_PLANET_IDENTITY_PERSISTS: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PLANET_IDENTITY_PERSISTS: FAIL")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_planet_identity_persists.gd.uid
Normal file
1
tests/test_planet_identity_persists.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dwe8pssmin7pp
|
||||
122
tests/test_planet_landing_proximity.gd
Normal file
122
tests/test_planet_landing_proximity.gd
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_planet_landing_proximity.gd
|
||||
# Verifies flying the fleet onto a planet's landing radius makes
|
||||
# PlanetField.get_planet_fleet_is_on() return it, and that flying back
|
||||
# away clears it - without the planet destroying the ships (planets are
|
||||
# landable, not hazards).
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var planet_field: Node
|
||||
var target_chunk: Vector2i
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# target_chunk is guaranteed ASTEROID_FIELD biome (a
|
||||
# prerequisite for having a planet), so regular drifting
|
||||
# asteroids/enemies could also spawn nearby and destroy a ship
|
||||
# mid-test - isolate it, same as other focused tests do.
|
||||
#
|
||||
# This has to happen on the first tick, not in _initialize():
|
||||
# a node's set_process(false) call made before it has fully
|
||||
# finished entering the tree doesn't reliably stick - the
|
||||
# engine's own (deferred) tree-entry finalization, which runs
|
||||
# strictly after _ready(), can silently reset it back to
|
||||
# enabled. Here, at the point _ready() is already confirmed
|
||||
# complete (fleet.ships is reliably populated by now), it's safe.
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
# PlanetField only scans chunks near the fleet's CURRENT
|
||||
# position, so it never even looks at target_chunk unless the
|
||||
# fleet is already nearby - move it there first.
|
||||
var near_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = near_pos
|
||||
fleet.move_to(near_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# Wait for PlanetField to spawn the target planet.
|
||||
if elapsed < planet_field.check_interval + 0.1:
|
||||
return false
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("TEST_PLANET_LANDING_PROXIMITY: FAIL (planet never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# Fly the fleet exactly onto the planet.
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
for ship in fleet.ships:
|
||||
ship.position = planet.position
|
||||
fleet.move_to(planet.position)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
var query_ok: bool = planet_field.get_planet_fleet_is_on() == planet
|
||||
var ships_alive: bool = fleet.ships.size() == 5
|
||||
print("query_ok=%s ships_alive=%s" % [query_ok, ships_alive])
|
||||
if not (query_ok and ships_alive):
|
||||
print("TEST_PLANET_LANDING_PROXIMITY: FAIL (landing detection)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var far_pos: Vector2 = planet.position + Vector2(5000, 5000)
|
||||
for ship in fleet.ships:
|
||||
ship.position = far_pos
|
||||
fleet.move_to(far_pos)
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
var query_cleared_ok: bool = planet_field.get_planet_fleet_is_on() == null
|
||||
print("query_cleared_ok=%s" % query_cleared_ok)
|
||||
if query_cleared_ok:
|
||||
print("TEST_PLANET_LANDING_PROXIMITY: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PLANET_LANDING_PROXIMITY: FAIL (leaving detection)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_planet_landing_proximity.gd.uid
Normal file
1
tests/test_planet_landing_proximity.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ba36mht630fb7
|
||||
69
tests/test_planet_map_marker_matches_spawn.gd
Normal file
69
tests/test_planet_map_marker_matches_spawn.gd
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_planet_map_marker_matches_spawn.gd
|
||||
# The world map draws a planet marker for any explored chunk using
|
||||
# WorldGen.planet_data_at_chunk(chunk) directly (so it can show a marker
|
||||
# even for a chunk whose Planet node isn't currently spawned/loaded).
|
||||
# This verifies that function returns exactly what PlanetField actually
|
||||
# spawns, so the map marker is never in a different spot / has a
|
||||
# different name than the real planet.
|
||||
|
||||
var main_scene: Node
|
||||
var target_chunk: Vector2i
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var game_state: Node = root.get_node("GameState")
|
||||
game_state.has_return_position = true
|
||||
game_state.surviving_ship_count = 5
|
||||
game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
game_state.return_camera_zoom = 1.0
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
var planet_field: Node = main_scene.get_node("PlanetField")
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("FAIL: planet never spawned in target chunk")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
var data: Dictionary = WorldGen.planet_data_at_chunk(target_chunk)
|
||||
var expected_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + data.local_pos
|
||||
|
||||
var pos_ok: bool = planet.position.distance_to(expected_pos) < 0.01
|
||||
var name_ok: bool = planet.planet_name == data.name
|
||||
|
||||
print("spawned_pos=%s expected_pos=%s pos_ok=%s" % [planet.position, expected_pos, pos_ok])
|
||||
print("spawned_name=%s expected_name=%s name_ok=%s" % [planet.planet_name, data.name, name_ok])
|
||||
|
||||
if pos_ok and name_ok:
|
||||
print("TEST_PLANET_MAP_MARKER_MATCHES_SPAWN: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PLANET_MAP_MARKER_MATCHES_SPAWN: FAIL")
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_planet_map_marker_matches_spawn.gd.uid
Normal file
1
tests/test_planet_map_marker_matches_spawn.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://0cbwho5kdsiv
|
||||
123
tests/test_planet_prompt_map_interaction.gd
Normal file
123
tests/test_planet_prompt_map_interaction.gd
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_planet_prompt_map_interaction.gd
|
||||
# Regression test for a real bug: EnterAtmosphereButton (shown while
|
||||
# standing on a planet, bottom-center) and the world map's own
|
||||
# RecenterButton (also bottom-center) occupy the exact same screen
|
||||
# position. Opening the map while standing on a planet must suppress the
|
||||
# enter-atmosphere prompt entirely - not just let it sit hidden behind
|
||||
# the map's backdrop, since closing the map would then abruptly reveal it
|
||||
# at the same instant RecenterButton disappears (which is what most
|
||||
# plausibly explains "buttons blinking/disappearing").
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var planet_field: Node
|
||||
var world_map: Control
|
||||
var map_button: IconButton
|
||||
var planet_name_label: Label
|
||||
var enter_button: IconButton
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
var target_chunk: Vector2i
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
world_map = main_scene.get_node("UI/WorldMap")
|
||||
map_button = main_scene.get_node("UI/MapButton")
|
||||
planet_name_label = main_scene.get_node("UI/PlanetNameLabel")
|
||||
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = vicinity_pos
|
||||
fleet.move_to(vicinity_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < planet_field.check_interval + 0.1:
|
||||
return false
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (planet never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
for ship in fleet.ships:
|
||||
ship.position = planet.position
|
||||
fleet.move_to(planet.position)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.5:
|
||||
return false
|
||||
var prompt_shown: bool = enter_button.visible and planet_name_label.visible
|
||||
print("prompt_shown_on_planet=%s" % prompt_shown)
|
||||
if not prompt_shown:
|
||||
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt never appeared)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
world_map.open_map()
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var prompt_suppressed: bool = not enter_button.visible and not planet_name_label.visible
|
||||
print("prompt_suppressed_while_map_open=%s enter_visible=%s name_visible=%s" % [
|
||||
prompt_suppressed, enter_button.visible, planet_name_label.visible
|
||||
])
|
||||
if not prompt_suppressed:
|
||||
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt visible behind map)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
world_map.close_map()
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var prompt_restored: bool = enter_button.visible and planet_name_label.visible
|
||||
print("prompt_restored_after_close=%s" % prompt_restored)
|
||||
if prompt_restored:
|
||||
print("TEST_PLANET_PROMPT_MAP_INTERACTION: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt did not come back)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_planet_prompt_map_interaction.gd.uid
Normal file
1
tests/test_planet_prompt_map_interaction.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://by7edhk7h8tjg
|
||||
105
tests/test_planet_spawning.gd
Normal file
105
tests/test_planet_spawning.gd
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_planet_spawning.gd
|
||||
# Verifies PlanetField spawns a planet once the fleet enters a chunk where
|
||||
# WorldGen says one belongs, and despawns it again once the fleet moves
|
||||
# far enough away - mirrors test_asteroid_spawning.gd's lifecycle check.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var planet_field: Node
|
||||
var target_chunk: Vector2i
|
||||
var spawned_planet: Node = null
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
var ok: bool = true
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
var target_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = target_pos
|
||||
fleet.move_to(target_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < planet_field.check_interval + 0.1:
|
||||
return false
|
||||
_check_spawned()
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
var far_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2(50000, 50000)
|
||||
for ship in fleet.ships:
|
||||
ship.position = far_pos
|
||||
fleet.move_to(far_pos)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < planet_field.check_interval + 0.1:
|
||||
return false
|
||||
_check_despawned()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _check_spawned() -> void:
|
||||
var has_chunk: bool = planet_field.populated_chunks.has(target_chunk)
|
||||
if not has_chunk:
|
||||
ok = false
|
||||
print("FAIL: target_chunk %s not populated after check_interval" % target_chunk)
|
||||
return
|
||||
|
||||
var planets: Array = planet_field.populated_chunks[target_chunk]
|
||||
spawned_planet = planets[0] if planets.size() > 0 else null
|
||||
var count_ok: bool = planets.size() == 1
|
||||
var name_ok: bool = spawned_planet != null and spawned_planet.planet_name != ""
|
||||
|
||||
ok = ok and count_ok and name_ok
|
||||
print("spawned_count=%d name_ok=%s planet_name=%s" % [planets.size(), name_ok, spawned_planet.planet_name if spawned_planet else "<none>"])
|
||||
|
||||
|
||||
func _check_despawned() -> void:
|
||||
var chunk_removed: bool = not planet_field.populated_chunks.has(target_chunk)
|
||||
var freed: bool = spawned_planet == null or not is_instance_valid(spawned_planet)
|
||||
|
||||
ok = ok and chunk_removed and freed
|
||||
print("chunk_removed=%s freed=%s" % [chunk_removed, freed])
|
||||
|
||||
if ok:
|
||||
print("TEST_PLANET_SPAWNING: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PLANET_SPAWNING: FAIL")
|
||||
quit(1)
|
||||
1
tests/test_planet_spawning.gd.uid
Normal file
1
tests/test_planet_spawning.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cvx8w0c8p7r1o
|
||||
107
tests/test_prompt_debounce.gd
Normal file
107
tests/test_prompt_debounce.gd
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_prompt_debounce.gd
|
||||
# Verifies the enter-atmosphere prompt is debounced: rapid flicker in the
|
||||
# underlying landing-detection (planet_field.get_planet_fleet_is_on())
|
||||
# must NOT cause the prompt to flicker on/off - only a raw signal that
|
||||
# stays consistent for prompt_debounce_time in a row should commit a
|
||||
# visible change. Simulates the worst-case flicker by teleporting the
|
||||
# whole fleet back and forth across the planet's landing_radius boundary
|
||||
# every single frame (bypassing actual ship steering, for a fully
|
||||
# deterministic repro).
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var planet_field: Node
|
||||
var enter_button: IconButton
|
||||
var target_chunk: Vector2i
|
||||
var planet_pos: Vector2
|
||||
var landing_radius: float
|
||||
var flicker_frames: int = 0
|
||||
var visible_toggle_count: int = 0
|
||||
var last_visible_state: bool = false
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = vicinity_pos
|
||||
fleet.move_to(vicinity_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < planet_field.check_interval + 0.1:
|
||||
return false
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("TEST_PROMPT_DEBOUNCE: FAIL (planet never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
planet_pos = planet.position
|
||||
landing_radius = planet.landing_radius
|
||||
last_visible_state = enter_button.visible
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# Teleport the fleet's centroid just inside, then just outside,
|
||||
# the landing radius every single frame - the raw detection
|
||||
# flips every frame, so the debounced prompt must never react
|
||||
# to this at all.
|
||||
var inside_pos: Vector2 = planet_pos + Vector2(landing_radius - 5.0, 0.0)
|
||||
var outside_pos: Vector2 = planet_pos + Vector2(landing_radius + 5.0, 0.0)
|
||||
var target_pos: Vector2 = inside_pos if (flicker_frames % 2 == 0) else outside_pos
|
||||
for ship in fleet.ships:
|
||||
ship.position = target_pos
|
||||
flicker_frames += 1
|
||||
|
||||
if enter_button.visible != last_visible_state:
|
||||
visible_toggle_count += 1
|
||||
last_visible_state = enter_button.visible
|
||||
|
||||
if flicker_frames < 20:
|
||||
return false
|
||||
|
||||
print("visible_toggle_count=%d (should be 0)" % visible_toggle_count)
|
||||
if visible_toggle_count == 0:
|
||||
print("TEST_PROMPT_DEBOUNCE: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_PROMPT_DEBOUNCE: FAIL (prompt flickered despite debounce)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_prompt_debounce.gd.uid
Normal file
1
tests/test_prompt_debounce.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d3riakbwpwmtd
|
||||
222
tests/test_resources_and_buildings.gd
Normal file
222
tests/test_resources_and_buildings.gd
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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
|
||||
1
tests/test_resources_and_buildings.gd.uid
Normal file
1
tests/test_resources_and_buildings.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cib2d4f5hu5mt
|
||||
174
tests/test_return_position_matches_planet.gd
Normal file
174
tests/test_return_position_matches_planet.gd
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_return_position_matches_planet.gd
|
||||
# Verifies that returning to space places the fleet exactly at the
|
||||
# planet's position, not wherever the fleet's centroid happened to be
|
||||
# when "enter atmosphere" was pressed - the fleet only needs to be
|
||||
# somewhere within the planet's landing radius (not centered on it) for
|
||||
# the prompt to appear, so those two positions can genuinely differ.
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var planet_field: Node
|
||||
var landing_controller: Node
|
||||
var enter_button: IconButton
|
||||
var target_chunk: Vector2i
|
||||
var planet_position: Vector2
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
var cached_fade_duration: float = 0.4
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("FAIL: could not find any chunk with a planet to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
current_scene = main_scene
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
landing_controller = main_scene.get_node("LandingController")
|
||||
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = vicinity_pos
|
||||
fleet.move_to(vicinity_pos)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < planet_field.check_interval + 0.1:
|
||||
return false
|
||||
if not planet_field.populated_chunks.has(target_chunk):
|
||||
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (planet never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||
planet_position = planet.position
|
||||
|
||||
# Deliberately land the fleet SOMEWHERE INSIDE the landing
|
||||
# radius but NOT centered on the planet, simulating a player
|
||||
# who pressed enter before the fleet fully settled onto it.
|
||||
var offset_pos: Vector2 = planet.position + Vector2(60, 60)
|
||||
for ship in fleet.ships:
|
||||
ship.position = offset_pos
|
||||
fleet.move_to(offset_pos)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if not enter_button.visible:
|
||||
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (prompt did not appear from offset position)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
cached_fade_duration = landing_controller.fade_duration
|
||||
_press(enter_button)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < cached_fade_duration + 0.3:
|
||||
return false
|
||||
var on_surface: bool = current_scene != null and current_scene.name == "PlanetSurface"
|
||||
if not on_surface:
|
||||
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (did not swap to PlanetSurface)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var exit_button: IconButton = current_scene.get_node("UI/ExitButton")
|
||||
var surface_controller: Node = current_scene.get_node("SurfaceController")
|
||||
cached_fade_duration = surface_controller.fade_duration
|
||||
_press(exit_button)
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
# Poll for the swap instead of waiting a fixed duration, so the
|
||||
# fresh scene can be isolated on its very FIRST frame. It
|
||||
# populates asteroids during its own _ready(), and one killing a
|
||||
# ship shifts the centroid being measured here - losing the nose
|
||||
# ship alone moves it 12 units, which is what made this test
|
||||
# fail intermittently.
|
||||
var back_in_space: bool = current_scene != null and current_scene.name == "Main"
|
||||
if not back_in_space:
|
||||
if elapsed > cached_fade_duration + 3.0:
|
||||
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (did not return to Main)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
|
||||
current_scene.get_node("AsteroidField").set_process(false)
|
||||
current_scene.get_node("EnemySpawner").set_process(false)
|
||||
for a in get_nodes_in_group("asteroids"):
|
||||
a.queue_free()
|
||||
|
||||
# A ship lost before isolation took hold would bias the centroid,
|
||||
# and says nothing about the return position under test.
|
||||
if current_scene.get_node("Fleet").ships.size() != 5:
|
||||
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (lost a ship to a collision on arrival)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var space_fleet: Node = current_scene.get_node("Fleet")
|
||||
|
||||
# Ships spawn in a wedge around the return point and then steer
|
||||
# into formation, so the centroid is only meaningful once they've
|
||||
# actually stopped moving. Waiting a fixed duration made this
|
||||
# test flaky (it intermittently sampled a still-settling fleet
|
||||
# and measured a centroid several units off); wait for genuine
|
||||
# arrival instead - each ship at its own move_target, not merely
|
||||
# reporting zero velocity, since a ship that hasn't been physics
|
||||
# -ticked yet also reads velocity == 0.
|
||||
for ship in space_fleet.ships:
|
||||
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
|
||||
return false
|
||||
|
||||
var dist: float = space_fleet.get_centroid().distance_to(planet_position)
|
||||
var position_matches_planet: bool = dist < 5.0
|
||||
print("returned_centroid=%s planet_position=%s dist=%.2f position_matches_planet=%s" % [
|
||||
space_fleet.get_centroid(), planet_position, dist, position_matches_planet
|
||||
])
|
||||
if position_matches_planet:
|
||||
print("TEST_RETURN_POSITION_MATCHES_PLANET: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (returned somewhere other than the planet)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _press(button: IconButton) -> void:
|
||||
var down: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
down.pressed = true
|
||||
button._gui_input(down)
|
||||
var up: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
up.pressed = false
|
||||
button._gui_input(up)
|
||||
1
tests/test_return_position_matches_planet.gd.uid
Normal file
1
tests/test_return_position_matches_planet.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cyemjbd4xsjkh
|
||||
104
tests/test_ship_facing.gd
Normal file
104
tests/test_ship_facing.gd
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_ship_facing.gd
|
||||
# Regression test for ships freezing at a fixed heading.
|
||||
#
|
||||
# Ships holding formation sit INSIDE arrive_radius of their slot (the fleet
|
||||
# anchor advances less than that per frame at cruise speed), so they snap
|
||||
# to it with zero velocity. Facing used to be derived purely from a ship's
|
||||
# own velocity behind a "> 8.0" gate, which meant a station-keeping ship
|
||||
# had nothing to face along and stayed pointing whichever way it last
|
||||
# happened to travel - it would not turn when the fleet changed direction.
|
||||
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var heading_east: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
root.get_node("GameState").reset_formation()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
|
||||
|
||||
func _angles_match(a: float, b: float, tolerance: float) -> bool:
|
||||
return absf(angle_difference(a, b)) <= tolerance
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
fleet.move_in_direction(Vector2.RIGHT)
|
||||
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 1.5:
|
||||
return false
|
||||
|
||||
# Structural check, because the behavioural ones below cannot
|
||||
# reproduce the actual failure: on a real device at 60fps the
|
||||
# anchor advances ~4.3 units per frame, LESS than arrive_radius,
|
||||
# so a station-keeping ship snaps to its slot with zero velocity
|
||||
# and has no heading of its own to face along. Headless runs
|
||||
# _process uncapped against 60Hz physics, so ships here always
|
||||
# pick up real velocity and look fine either way. Asserting the
|
||||
# imposed heading is wired is what actually guards the fix.
|
||||
var expected_fleet_velocity: Vector2 = Vector2.RIGHT * fleet.cruise_speed
|
||||
for ship in fleet.ships:
|
||||
if not ship.fleet_velocity.is_equal_approx(expected_fleet_velocity):
|
||||
print("fleet_velocity=%s, expected %s" % [ship.fleet_velocity, expected_fleet_velocity])
|
||||
print("TEST_SHIP_FACING: FAIL (fleet motion not supplied to ships)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Flying east: every ship should point east (sprites are drawn
|
||||
# nose-up, hence the +PI/2 convention in Ship._physics_process).
|
||||
heading_east = Vector2.RIGHT.angle() + PI / 2.0
|
||||
for ship in fleet.ships:
|
||||
if not _angles_match(ship.rotation, heading_east, 0.25):
|
||||
print("ship facing %.3f, expected ~%.3f while flying east" % [
|
||||
ship.rotation, heading_east
|
||||
])
|
||||
print("TEST_SHIP_FACING: FAIL (not facing travel direction)")
|
||||
quit(1)
|
||||
return true
|
||||
print("flying east: all ships facing %.3f" % fleet.ships[0].rotation)
|
||||
|
||||
# Turn by a SMALL angle. This is the discriminating case: a big
|
||||
# turn shoves each ship sideways hard enough that its own
|
||||
# velocity happens to point roughly the new way, so facing looks
|
||||
# right even when broken. A gentle turn moves each ship only a
|
||||
# few units, its velocity never clears the "> 8.0" gate, and a
|
||||
# ship given no fleet motion simply stays pointing the old way.
|
||||
fleet.move_in_direction(Vector2.RIGHT.rotated(-0.6))
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 1.5:
|
||||
return false
|
||||
var heading_north: float = Vector2.RIGHT.rotated(-0.6).angle() + PI / 2.0
|
||||
for ship in fleet.ships:
|
||||
if not _angles_match(ship.rotation, heading_north, 0.15):
|
||||
print("ship stuck at %.3f, expected ~%.3f after the gentle turn" % [
|
||||
ship.rotation, heading_north
|
||||
])
|
||||
print("TEST_SHIP_FACING: FAIL (did not turn with the fleet)")
|
||||
quit(1)
|
||||
return true
|
||||
print("gentle turn: all ships facing %.3f (was %.3f)" % [
|
||||
fleet.ships[0].rotation, heading_east
|
||||
])
|
||||
print("TEST_SHIP_FACING: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_ship_facing.gd.uid
Normal file
1
tests/test_ship_facing.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dqilfnd6k24uw
|
||||
66
tests/test_star_coverage.gd
Normal file
66
tests/test_star_coverage.gd
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_star_coverage.gd
|
||||
# Verifies the shader-based starfield's uniforms actually track the camera:
|
||||
# after moving/zooming the camera, camera_pos/camera_zoom fed to the
|
||||
# starfield shader should match, at every zoom level (this replaces the old
|
||||
# tile-symmetry check now that stars are a single procedural fragment
|
||||
# shader pass with no CPU-side tiling to go asymmetric in the first place).
|
||||
|
||||
const TOLERANCE: float = 0.5
|
||||
|
||||
var main_scene: Node
|
||||
var camera: Camera2D
|
||||
var starfield: ColorRect
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
camera = main_scene.get_node("Camera2D")
|
||||
starfield = main_scene.get_node("BackgroundLayer/Starfield")
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
var all_ok: bool = true
|
||||
|
||||
for zoom_level in [1.0, 3.0, 6.0]:
|
||||
camera.zoom = Vector2(zoom_level, zoom_level)
|
||||
camera.global_position = Vector2(zoom_level * 100.0, -zoom_level * 50.0)
|
||||
|
||||
# Let the Starfield's own _process (which reads the camera and pushes
|
||||
# shader uniforms) run for this frame before checking.
|
||||
starfield._process(0.016)
|
||||
|
||||
# The camera position is handed to the shader pre-split into a cell
|
||||
# index plus a fraction within that cell (so the shader never handles
|
||||
# a large absolute world coordinate - see Starfield.gd). Verify the
|
||||
# two parts reconstruct the real camera position exactly, and that
|
||||
# the fraction really is a fraction: if it ever left [0,1) the split
|
||||
# would be wrong and the shader would sample the wrong cell.
|
||||
var cell: Vector2 = starfield.material.get_shader_parameter("camera_cell")
|
||||
var frac: Vector2 = starfield.material.get_shader_parameter("camera_frac")
|
||||
var cell_size: float = starfield.material.get_shader_parameter("cell_size")
|
||||
var uniform_zoom: float = starfield.material.get_shader_parameter("camera_zoom")
|
||||
|
||||
var reconstructed: Vector2 = (cell + frac) * cell_size
|
||||
var pos_ok: bool = reconstructed.distance_to(camera.global_position) <= TOLERANCE
|
||||
var frac_ok: bool = frac.x >= 0.0 and frac.x < 1.0 and frac.y >= 0.0 and frac.y < 1.0
|
||||
var zoom_ok: bool = abs(uniform_zoom - (1.0 / camera.zoom.x)) <= 0.01
|
||||
var ok: bool = pos_ok and frac_ok and zoom_ok
|
||||
all_ok = all_ok and ok
|
||||
|
||||
print("zoom=%.1f camera_pos=%s reconstructed=%s frac=%s zoom=%.2f/%.2f %s" % [
|
||||
zoom_level, camera.global_position, reconstructed, frac,
|
||||
camera.zoom.x, uniform_zoom, "OK" if ok else "FAIL"
|
||||
])
|
||||
|
||||
if all_ok:
|
||||
print("TEST_STAR_COVERAGE: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_STAR_COVERAGE: FAIL")
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_star_coverage.gd.uid
Normal file
1
tests/test_star_coverage.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dnc1u8diptger
|
||||
136
tests/test_world_map_ui.gd
Normal file
136
tests/test_world_map_ui.gd
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_world_map_ui.gd
|
||||
# Verifies the map UI: hidden by default, opening it recenters on the
|
||||
# fleet, a single-finger drag while the map is open pans the map content
|
||||
# instead of leaking through to the gameplay fleet-move/zoom controls, and
|
||||
# closing hides it again.
|
||||
|
||||
const POS_TOLERANCE: float = 3.0
|
||||
|
||||
var main_scene: Node
|
||||
var fleet: Node
|
||||
var camera: Camera2D
|
||||
var world_map: Control
|
||||
var content: Node2D
|
||||
var fleet_target_before: Vector2
|
||||
var camera_zoom_before: float
|
||||
var content_pos_before_drag: Vector2
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
main_scene = packed.instantiate()
|
||||
root.add_child(main_scene)
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
camera = main_scene.get_node("Camera2D")
|
||||
world_map = main_scene.get_node("UI/WorldMap")
|
||||
content = main_scene.get_node("UI/WorldMap/MapContent")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
if world_map.visible:
|
||||
print("TEST_WORLD_MAP_UI: FAIL (map visible by default)")
|
||||
quit(1)
|
||||
return true
|
||||
world_map.open_map()
|
||||
stage = 1
|
||||
elapsed = 0.0
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL
|
||||
var viewport_center: Vector2 = main_scene.get_viewport_rect().size / 2.0
|
||||
var expected_content_pos: Vector2 = viewport_center - fleet_local * content.scale.x
|
||||
var recenter_ok: bool = content.position.distance_to(expected_content_pos) <= POS_TOLERANCE
|
||||
print("open_visible=%s content_pos=%s expected=%s recenter_ok=%s" % [
|
||||
world_map.visible, content.position, expected_content_pos, recenter_ok
|
||||
])
|
||||
if not (world_map.visible and recenter_ok):
|
||||
print("TEST_WORLD_MAP_UI: FAIL (open/recenter)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
fleet_target_before = fleet.fleet_target
|
||||
camera_zoom_before = camera.zoom.x
|
||||
content_pos_before_drag = content.position
|
||||
_touch(0, true, Vector2(200, 600))
|
||||
stage = 2
|
||||
elapsed = 0.0
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
_drag(0, Vector2(260, 640))
|
||||
stage = 3
|
||||
elapsed = 0.0
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
_touch(0, false, Vector2(260, 640))
|
||||
|
||||
# Headless mode's placeholder window doesn't match the configured
|
||||
# viewport size, so Godot's own stretch transform rescales raw
|
||||
# synthetic touch coordinates by some environment-specific factor
|
||||
# before delivery - checking an exact pixel count here would be
|
||||
# testing that artifact, not the pan logic. Instead verify the
|
||||
# drag moved the map in the right DIRECTION and roughly the right
|
||||
# PROPORTION (dragged twice as far on X as Y, so the pan should
|
||||
# reflect that same ratio), which the scaling factor cancels out of.
|
||||
var pan_delta: Vector2 = content.position - content_pos_before_drag
|
||||
var moved_ok: bool = pan_delta.length() > 1.0
|
||||
var direction_ok: bool = pan_delta.x > 0.0 and pan_delta.y > 0.0
|
||||
var ratio_ok: bool = pan_delta.y != 0.0 and abs((pan_delta.x / pan_delta.y) - 1.5) <= 0.1
|
||||
var pan_ok: bool = moved_ok and direction_ok and ratio_ok
|
||||
var fleet_untouched: bool = fleet.fleet_target == fleet_target_before
|
||||
var zoom_untouched: bool = camera.zoom.x == camera_zoom_before
|
||||
print("pan_delta=%s moved_ok=%s direction_ok=%s ratio_ok=%s fleet_untouched=%s zoom_untouched=%s" % [
|
||||
pan_delta, moved_ok, direction_ok, ratio_ok, fleet_untouched, zoom_untouched
|
||||
])
|
||||
if not (pan_ok and fleet_untouched and zoom_untouched):
|
||||
print("TEST_WORLD_MAP_UI: FAIL (pan/isolation)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
world_map.close_map()
|
||||
stage = 4
|
||||
elapsed = 0.0
|
||||
return false
|
||||
4:
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
var closed_ok: bool = not world_map.visible
|
||||
print("closed_ok=%s" % closed_ok)
|
||||
if closed_ok:
|
||||
print("TEST_WORLD_MAP_UI: PASS")
|
||||
quit(0)
|
||||
else:
|
||||
print("TEST_WORLD_MAP_UI: FAIL (did not close)")
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _touch(index: int, pressed: bool, pos: Vector2) -> void:
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.index = index
|
||||
ev.pressed = pressed
|
||||
ev.position = pos
|
||||
Input.parse_input_event(ev)
|
||||
|
||||
|
||||
func _drag(index: int, pos: Vector2) -> void:
|
||||
var ev: InputEventScreenDrag = InputEventScreenDrag.new()
|
||||
ev.index = index
|
||||
ev.position = pos
|
||||
Input.parse_input_event(ev)
|
||||
1
tests/test_world_map_ui.gd.uid
Normal file
1
tests/test_world_map_ui.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://coa1sepq31622
|
||||
Loading…
Reference in a new issue