Skill tree, node-based building animation, bigger UI, and a 19x faster test suite
Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
85814ce88c
commit
b641fa5891
48 changed files with 1985 additions and 303 deletions
|
|
@ -33,7 +33,7 @@ func _ready() -> void:
|
|||
if cell.has("phase"):
|
||||
b.set_process(false)
|
||||
b.anim_time = float(cell.phase) * b.CYCLE
|
||||
b.queue_redraw()
|
||||
b.apply_animation()
|
||||
|
||||
var cam: Camera2D = Camera2D.new()
|
||||
cam.zoom = Vector2(1.65, 1.65)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ func _initialize() -> void:
|
|||
for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel",
|
||||
"UI/BuildMenu/InfoPopup/Panel/MessageLabel",
|
||||
"UI/BuildMenu/MothershipLabel",
|
||||
"UI/BuildMenu/TitleLabel",
|
||||
"UI/NavLabel"]:
|
||||
"UI/BuildMenu/TitleLabel"]:
|
||||
var label: Label = scene.get_node_or_null(path)
|
||||
if label == null:
|
||||
print("%s: MISSING" % path)
|
||||
|
|
|
|||
40
tests/_profile_startup.gd
Normal file
40
tests/_profile_startup.gd
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
extends SceneTree
|
||||
|
||||
# Not a test - a stopwatch. Splits the fixed cost every scene-based test pays
|
||||
# before it can assert anything, so tuning aims at the part that is actually
|
||||
# expensive.
|
||||
# godot --headless -s res://tests/_profile_startup.gd
|
||||
|
||||
var scene: Node
|
||||
var t0: int = 0
|
||||
var frames: int = 0
|
||||
var frame_start: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var boot: int = Time.get_ticks_msec()
|
||||
print("engine boot to _initialize: %d ms" % boot)
|
||||
|
||||
t0 = Time.get_ticks_msec()
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
print("load(Main.tscn): %d ms" % (Time.get_ticks_msec() - t0))
|
||||
|
||||
t0 = Time.get_ticks_msec()
|
||||
scene = packed.instantiate()
|
||||
print("instantiate(): %d ms" % (Time.get_ticks_msec() - t0))
|
||||
|
||||
t0 = Time.get_ticks_msec()
|
||||
root.add_child(scene)
|
||||
print("add_child() (_ready): %d ms" % (Time.get_ticks_msec() - t0))
|
||||
|
||||
frame_start = Time.get_ticks_msec()
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if frames < 60:
|
||||
return false
|
||||
var span: int = Time.get_ticks_msec() - frame_start
|
||||
print("60 frames: %d ms (%.1f fps)" % [span, 60000.0 / maxf(span, 1)])
|
||||
quit(0)
|
||||
return true
|
||||
1
tests/_profile_startup.gd.uid
Normal file
1
tests/_profile_startup.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dvfdj3y0xg3sj
|
||||
93
tests/_run_inside.sh
Normal file
93
tests/_run_inside.sh
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/bin/bash
|
||||
# Runs INSIDE the proot-distro Ubuntu container - do not call this directly,
|
||||
# call tests/run_tests.sh from Termux instead.
|
||||
#
|
||||
# It exists as its own file so the whole suite runs in ONE `proot-distro
|
||||
# login`: entering the container costs ~0.5s, which used to be paid 38 times
|
||||
# over. Everything below is the container-side half of the runner.
|
||||
#
|
||||
# Args: <project_dir> <jobs> <verbose> <test_file_name>...
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$1"; shift
|
||||
JOBS="$1"; shift
|
||||
VERBOSE="$1"; shift
|
||||
|
||||
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||
cd "$PROJECT_DIR" || exit 2
|
||||
|
||||
tests=("$@")
|
||||
total=${#tests[@]}
|
||||
if (( total == 0 )); then
|
||||
echo "no tests matched"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
outdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$outdir"' EXIT
|
||||
|
||||
# Import once, serially, before anything runs in parallel. Godot writes to
|
||||
# .godot/ while importing; several instances discovering a cold cache at the
|
||||
# same moment would race on it. After this the workers only read it.
|
||||
"$GODOT_BIN" --headless --import --quit >/dev/null 2>&1
|
||||
|
||||
run_one() {
|
||||
local name="$1"
|
||||
local log="$outdir/$name.log"
|
||||
local start end
|
||||
start=$(date +%s%N)
|
||||
# --fixed-fps pins delta to exactly 1/60 instead of measuring the wall
|
||||
# clock. Without it a test's `elapsed` counts REAL seconds, so running
|
||||
# several at once stretches every frame and the gameplay under test sees
|
||||
# deltas it never sees in the game: ships overshoot their braking curve,
|
||||
# settle late, and tests time out for no reason but load. That made
|
||||
# test_camera_settles and test_planet_prompt_map_interaction fail on 2 of
|
||||
# 3 parallel runs. Pinned, a test simulates the same 60fps whatever else
|
||||
# the phone is doing, and its result no longer depends on how busy the
|
||||
# machine was.
|
||||
"$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1
|
||||
local rc=$?
|
||||
end=$(date +%s%N)
|
||||
local ms=$(( (end - start) / 1000000 ))
|
||||
|
||||
# Two independent criteria, because either one alone can lie: a test that
|
||||
# crashes before reaching its verdict exits non-zero without ever printing
|
||||
# FAIL, and a hypothetical `quit(0)` on a failing path would exit clean
|
||||
# while the log says otherwise.
|
||||
if (( rc == 0 )) && grep -q ": PASS" "$log"; then
|
||||
printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status"
|
||||
else
|
||||
printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status"
|
||||
fi
|
||||
cat "$outdir/$name.status"
|
||||
}
|
||||
|
||||
running=0
|
||||
for name in "${tests[@]}"; do
|
||||
run_one "$name" &
|
||||
running=$(( running + 1 ))
|
||||
if (( running >= JOBS )); then
|
||||
wait -n
|
||||
running=$(( running - 1 ))
|
||||
fi
|
||||
done
|
||||
wait
|
||||
|
||||
failed=0
|
||||
for name in "${tests[@]}"; do
|
||||
if ! grep -q '^PASS' "$outdir/$name.status" 2>/dev/null; then
|
||||
failed=$(( failed + 1 ))
|
||||
echo
|
||||
echo "=== $name FAILED, full output ==="
|
||||
cat "$outdir/$name.log" 2>/dev/null
|
||||
elif [[ "$VERBOSE" == "1" ]]; then
|
||||
echo
|
||||
echo "=== $name ==="
|
||||
cat "$outdir/$name.log" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "$(( total - failed ))/$total passed"
|
||||
(( failed == 0 ))
|
||||
29
tests/_skill_menu_preview.gd
Normal file
29
tests/_skill_menu_preview.gd
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the
|
||||
# mothership's research tree with one branch already bought and a node
|
||||
# selected, so the laid-out tree, its state colours and the detail panel can
|
||||
# all be checked in one render.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn out.png 30 720x1280
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.reset_progression()
|
||||
GameState.rocks = 60
|
||||
# One branch researched, so the render shows all three node states at
|
||||
# once: done, reachable, and still locked further out.
|
||||
GameState.unlock("shipyards")
|
||||
GameState.unlock("coordination")
|
||||
|
||||
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.skill_menu.visible = true
|
||||
controller.skill_tree.selected_id = "wing"
|
||||
controller._refresh_skill_menu("wing")
|
||||
1
tests/_skill_menu_preview.gd.uid
Normal file
1
tests/_skill_menu_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d3n86yp86b755
|
||||
6
tests/_skill_menu_preview.tscn
Normal file
6
tests/_skill_menu_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_skill_menu_preview.gd" id="1"]
|
||||
|
||||
[node name="SkillMenuPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
47
tests/_vfx_preview.gd
Normal file
47
tests/_vfx_preview.gd
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders
|
||||
# projectiles and every explosion type so the node-animation conversion
|
||||
# (issue #10) can be checked by eye. Bolts once went INVISIBLE after a
|
||||
# similar optimisation and headless tests did not catch it, because
|
||||
# --headless has no GPU - so this exists to make that failure visible.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_vfx_preview.tscn out.png 20
|
||||
|
||||
const VFX_SCRIPTS: Array = [
|
||||
"res://ShipExplosionVFX.gd", "res://EnemyExplosionVFX.gd",
|
||||
"res://AsteroidExplosionVFX.gd", "res://LightBurstVFX.gd",
|
||||
]
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
|
||||
|
||||
# Bolts at several headings, held still so they can be inspected.
|
||||
var bolt_scene: PackedScene = load("res://Projectile.tscn")
|
||||
for i in range(6):
|
||||
var bolt: Area2D = bolt_scene.instantiate()
|
||||
bolt.position = Vector2(-460.0 + float(i) * 90.0, -220.0)
|
||||
bolt.rotation = float(i) * 0.35
|
||||
add_child(bolt)
|
||||
bolt.set_physics_process(false)
|
||||
|
||||
# One of each explosion, stepped forward by hand then frozen. Their real
|
||||
# lifetime is under a second, and a softpipe frame takes far longer than
|
||||
# that in wall-clock, so they would expire before the capture.
|
||||
for i in range(VFX_SCRIPTS.size()):
|
||||
var script: Script = load(VFX_SCRIPTS[i])
|
||||
if script == null:
|
||||
continue
|
||||
var vfx: Node2D = Node2D.new()
|
||||
vfx.set_script(script)
|
||||
vfx.position = Vector2(-390.0 + float(i) * 260.0, 90.0)
|
||||
vfx.lifetime = 999.0
|
||||
add_child(vfx)
|
||||
for step in range(8):
|
||||
vfx._process(0.05)
|
||||
vfx.set_process(false)
|
||||
|
||||
var cam: Camera2D = Camera2D.new()
|
||||
add_child(cam)
|
||||
cam.make_current()
|
||||
1
tests/_vfx_preview.gd.uid
Normal file
1
tests/_vfx_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dv32ajs2bxrwd
|
||||
6
tests/_vfx_preview.tscn
Normal file
6
tests/_vfx_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_vfx_preview.gd" id="1"]
|
||||
|
||||
[node name="VFXPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -1,20 +1,78 @@
|
|||
#!/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
|
||||
#
|
||||
# bash tests/run_tests.sh # everything
|
||||
# bash tests/run_tests.sh formation # only tests matching a substring
|
||||
# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang
|
||||
# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too
|
||||
# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself
|
||||
#
|
||||
# Three things make this fast, in descending order of how much they matter
|
||||
# (measured, not guessed - see tests/_profile_startup.gd):
|
||||
#
|
||||
# 1. It runs from a COPY on Termux's own filesystem, not from the project
|
||||
# directory. The project lives under ~/storage/shared, which is Android's
|
||||
# FUSE bridge; read through proot on top of that, `load("res://Main.tscn")`
|
||||
# alone took 8.5s per test, against ~2s from a native path. Nearly every
|
||||
# test loads Main.tscn, and none of that time is gameplay. The copy is
|
||||
# ~1.4s for the whole suite and its .godot import cache is kept between
|
||||
# runs.
|
||||
# 2. Tests run JOBS at a time. Each is its own Godot process - they call
|
||||
# quit() and stamp on the GameState autoload, so they cannot share one -
|
||||
# which makes the suite a pile of independent, single-core-bound jobs on
|
||||
# an 8-core phone.
|
||||
# 3. The whole run happens in ONE `proot-distro login`. Entering the
|
||||
# container costs ~0.5s, which used to be paid once per test.
|
||||
#
|
||||
# Output is buffered per test and printed only for failures; six interleaved
|
||||
# logs are unreadable.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
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
|
||||
# Leave headroom: Godot is memory-hungry and this is a phone, not a build
|
||||
# server. Override with JOBS= if you know better.
|
||||
JOBS="${JOBS:-6}"
|
||||
VERBOSE="${VERBOSE:-0}"
|
||||
IN_PLACE="${IN_PLACE:-0}"
|
||||
WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}"
|
||||
|
||||
filter="${1:-}"
|
||||
tests=()
|
||||
for path in "$PROJECT_DIR"/tests/test_*.gd; do
|
||||
name="$(basename "$path")"
|
||||
if [[ -z "$filter" || "$name" == *"$filter"* ]]; then
|
||||
tests+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#tests[@]} == 0 )); then
|
||||
echo "no tests match '$filter'"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
start=$(date +%s)
|
||||
|
||||
run_dir="$PROJECT_DIR"
|
||||
if [[ "$IN_PLACE" != "1" ]]; then
|
||||
run_dir="$WORK_DIR"
|
||||
mkdir -p "$run_dir"
|
||||
# Clear last run's sources but keep .godot, so imports stay warm. Without
|
||||
# the clear, a test deleted from the project would live on in the copy and
|
||||
# keep being run.
|
||||
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
|
||||
# android/ and builds/ are export artefacts no test touches, and the .apk
|
||||
# is 30MB of nothing useful here.
|
||||
tar -C "$PROJECT_DIR" \
|
||||
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
|
||||
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
|
||||
fi
|
||||
|
||||
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
|
||||
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
|
||||
status=$?
|
||||
|
||||
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"
|
||||
exit $status
|
||||
|
|
|
|||
114
tests/test_asteroid_safe_spawn.gd
Normal file
114
tests/test_asteroid_safe_spawn.gd
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_asteroid_safe_spawn.gd
|
||||
# Asteroid chunks are populated around wherever the fleet is - including the
|
||||
# chunk it is standing in - and positions used to be uniform within a chunk.
|
||||
# So an asteroid could be created directly on top of a ship and destroy it
|
||||
# with no chance to react. Worst on the frame a scene loads: coming back from
|
||||
# a planet's surface could cost a vessel instantly, which is also what made
|
||||
# test_return_position_matches_planet fail intermittently.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. _safe_position never returns a point inside the bubble, for candidates
|
||||
# all around and including the exact centre. This is checked directly
|
||||
# rather than by looking at where asteroids happened to land: with ~54
|
||||
# asteroids spread over 25 chunks, any single run only rarely produces a
|
||||
# near miss, so an observational check passes just as happily with the
|
||||
# guard removed and proves nothing.
|
||||
# 2. candidates already outside the bubble are left exactly where they are,
|
||||
# so the field is not distorted away from the fleet
|
||||
# 3. the field still spawns a full complement, and a stationary fleet loses
|
||||
# no ships
|
||||
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var ships_at_start: int = 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")
|
||||
field = scene.get_node("AsteroidField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
ships_at_start = fleet.ships.size()
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# Let the field populate around the fleet a few times over.
|
||||
if elapsed < 1.5:
|
||||
return false
|
||||
|
||||
var centre: Vector2 = fleet.get_centroid()
|
||||
var safe: float = field.safe_spawn_radius
|
||||
|
||||
# --- the guard itself, deterministically ----------------------
|
||||
var worst_inside: float = 1e9
|
||||
var moved_an_outside_one: bool = false
|
||||
for i in range(72):
|
||||
var a: float = (float(i) / 72.0) * TAU
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
# Candidates well inside, just inside, and exactly on centre.
|
||||
for d in [0.0, 1.0, safe * 0.25, safe * 0.99]:
|
||||
var got: Vector2 = field._safe_position(centre + dir * d, centre)
|
||||
worst_inside = minf(worst_inside, got.distance_to(centre))
|
||||
# Candidates already outside must be returned untouched.
|
||||
var outside: Vector2 = centre + dir * (safe * 1.5)
|
||||
if field._safe_position(outside, centre).distance_to(outside) > 0.001:
|
||||
moved_an_outside_one = true
|
||||
print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [
|
||||
worst_inside, safe
|
||||
])
|
||||
if worst_inside < safe - 0.001:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)")
|
||||
quit(1)
|
||||
return true
|
||||
if moved_an_outside_one:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var asteroids: Array = get_nodes_in_group("asteroids")
|
||||
var closest: float = 1e9
|
||||
for a in asteroids:
|
||||
if is_instance_valid(a):
|
||||
closest = minf(closest, a.position.distance_to(centre))
|
||||
|
||||
print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [
|
||||
asteroids.size(), closest, safe
|
||||
])
|
||||
if asteroids.is_empty():
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)")
|
||||
quit(1)
|
||||
return true
|
||||
# Asteroids drift, so one may have travelled inward since it was
|
||||
# created. Allow for that, but nothing should be anywhere near
|
||||
# on top of the fleet.
|
||||
if closest < safe * 0.5:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var kept_ships: bool = fleet.ships.size() == ships_at_start
|
||||
print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships])
|
||||
if not kept_ships:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_asteroid_safe_spawn.gd.uid
Normal file
1
tests/test_asteroid_safe_spawn.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ci8vodtuvwod7
|
||||
|
|
@ -39,6 +39,12 @@ func _initialize() -> void:
|
|||
game_state.rocks = 500
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
# Structures are gated behind the mothership's research tree. That gate
|
||||
# is test_skill_tree's subject, not this one's - here every option needs
|
||||
# to be available so the popup itself can be exercised.
|
||||
game_state.reset_progression()
|
||||
for node in GameState.SKILL_TREE:
|
||||
game_state.unlocked.append(node.id)
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
|
|
|
|||
110
tests/test_building_redraw_cost.gd
Normal file
110
tests/test_building_redraw_cost.gd
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_building_redraw_cost.gd
|
||||
# Animated buildings must not re-run _draw() to animate (Forgejo issues #6
|
||||
# and #10).
|
||||
#
|
||||
# Every NeonDraw call expands into four antialiased polylines, so redrawing
|
||||
# a whole shipyard each frame to advance a crane re-tessellates all of it.
|
||||
# The moving elements are now separate NeonPiece children, rasterised once
|
||||
# and thereafter only moved, scaled, faded or hidden - operations that reuse
|
||||
# the cached geometry. Ship.gd and Asteroid.gd already worked this way.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. an animated building's geometry is rasterised exactly ONCE, however
|
||||
# long it animates
|
||||
# 2. it is genuinely still animating (so this cannot be "passed" by
|
||||
# freezing the animation)
|
||||
# 3. the moving parts really do move - the crane travels and the
|
||||
# mothership's satellites orbit - proving the animation reaches the
|
||||
# nodes rather than being silently dropped
|
||||
# 4. the static command centre needs no per-frame work either
|
||||
|
||||
var scene: Node
|
||||
var field: Node
|
||||
var crafter: Node2D
|
||||
var mothership: Node2D
|
||||
var command: Node2D
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var crane_x_start: float = 0.0
|
||||
var satellite_rotation_start: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var game_state: Node = 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_SHIP_CRAFTER, Vector2(600.0, 0.0))
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-600.0, 0.0))
|
||||
field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 600.0))
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
for b in field.spawned:
|
||||
if not is_instance_valid(b):
|
||||
continue
|
||||
match b.building_type:
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
crafter = b
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
mothership = b
|
||||
GameState.BUILDING_COMMAND_CENTER:
|
||||
command = b
|
||||
if crafter == null or mothership == null or command == null:
|
||||
print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
crane_x_start = crafter._crane.position.x
|
||||
satellite_rotation_start = mothership._satellites.rotation
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# Long enough to cross several assembly phases and a good arc of
|
||||
# the satellites' orbit.
|
||||
if elapsed < 2.5:
|
||||
return false
|
||||
|
||||
print("rasterisations after %.1fs of animation - crafter:%d mothership:%d command:%d" % [
|
||||
elapsed, crafter.draw_count, mothership.draw_count, command.draw_count
|
||||
])
|
||||
for b in [crafter, mothership, command]:
|
||||
if b.draw_count != 1:
|
||||
print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [
|
||||
b.building_type, b.draw_count
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var animating: bool = crafter.anim_time > 0.0 and mothership.phase_time > 0.0
|
||||
var crane_moved: bool = absf(crafter._crane.position.x - crane_x_start) > 1.0
|
||||
var orbit_moved: bool = absf(mothership._satellites.rotation - satellite_rotation_start) > 0.05
|
||||
print("still animating=%s crane travelled=%s satellites orbited=%s" % [
|
||||
animating, crane_moved, orbit_moved
|
||||
])
|
||||
if not (animating and crane_moved and orbit_moved):
|
||||
print("TEST_BUILDING_REDRAW_COST: FAIL (nothing actually moved - not a fix)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_BUILDING_REDRAW_COST: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_building_redraw_cost.gd.uid
Normal file
1
tests/test_building_redraw_cost.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ihnpl5ogoxi1
|
||||
|
|
@ -42,6 +42,11 @@ func _initialize() -> void:
|
|||
game_state.rocks = 200
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
# Extractors are gated behind research (see test_skill_tree). This test
|
||||
# is about the planet-proximity rule, so grant the research up front.
|
||||
game_state.reset_progression()
|
||||
for node in GameState.SKILL_TREE:
|
||||
game_state.unlocked.append(node.id)
|
||||
|
||||
main_scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(main_scene)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ extends SceneTree
|
|||
var game_state: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var mothership_pos: Vector2 = Vector2(4000.0, 0.0)
|
||||
# The test has to sit through a whole there-and-back hauler cycle, and the
|
||||
# cycle length is distance/HAULER_SPEED - so this fixture distance IS the
|
||||
# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s
|
||||
# for the 4000 it used to be. Nothing here asserts on the distance itself,
|
||||
# only on payout and drawn position tracking the same `progress`, so a short
|
||||
# route exercises exactly the same code.
|
||||
var mothership_pos: Vector2 = Vector2(600.0, 0.0)
|
||||
var planet_pos: Vector2 = Vector2.ZERO
|
||||
var rocks_before_arrival: int = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -120,9 +120,17 @@ func _process(delta: float) -> bool:
|
|||
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
|
||||
# Not merely "moved forward at all": the fleet cruises at
|
||||
# cruise_speed and brakes at Ship.acceleration, so the coast is
|
||||
# cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a
|
||||
# real distance is what makes this a test of coasting - the old
|
||||
# `> moving_centroid.x` was satisfied by any float wobble, and
|
||||
# passed happily while the fleet stopped dead.
|
||||
var coast: float = fleet.get_centroid().x - moving_centroid.x
|
||||
var coasted: bool = coast > 40.0
|
||||
print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [
|
||||
speed, stopped, coast,
|
||||
(fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration)
|
||||
])
|
||||
if stopped and coasted:
|
||||
print("TEST_JOYSTICK_CONTROL: PASS")
|
||||
|
|
|
|||
169
tests/test_skill_tree.gd
Normal file
169
tests/test_skill_tree.gd
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_skill_tree.gd
|
||||
# The mothership's research tree (Forgejo issue #4). It gates which
|
||||
# structures may be built at all, and carries permanent upgrades that have
|
||||
# no building of their own.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. from a fresh start only the mothership is buildable - the others are
|
||||
# greyed, and pressing one explains which research opens it rather than
|
||||
# silently failing
|
||||
# 2. a locked option cannot be built even if the confirm is forced
|
||||
# 3. research costs rocks, needs its prerequisite, and unlocks its building
|
||||
# 4. the mothership's action button opens the tree (it used to have no
|
||||
# action at all)
|
||||
# 5. fleet capacity upgrades raise the cap, and the extra vessel can
|
||||
# actually be crafted afterwards
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
var controller: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var popup: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 500
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.reset_progression()
|
||||
game_state.reset_formation()
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
controller = scene.get_node("BuildController")
|
||||
fleet = scene.get_node("Fleet")
|
||||
field = scene.get_node("BuildingField")
|
||||
popup = scene.get_node("UI/BuildMenu/InfoPopup")
|
||||
|
||||
|
||||
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
|
||||
|
||||
# --- everything but the mothership starts locked --------------
|
||||
var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP)
|
||||
var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
|
||||
print("fresh start: mothership buildable=%s crafter locked=%s" % [
|
||||
mothership_free, crafter_locked
|
||||
])
|
||||
if not (mothership_free and crafter_locked):
|
||||
print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
controller._on_build_pressed()
|
||||
var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled
|
||||
controller._on_place_crafter()
|
||||
var explains: bool = popup.visible \
|
||||
and "research" in popup.get_node("Panel/MessageLabel").text.to_lower()
|
||||
var offered: bool = popup.get_node("Panel/BuildButton").visible
|
||||
print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [
|
||||
greyed, explains, offered
|
||||
])
|
||||
if not (greyed and explains) or offered:
|
||||
print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Forcing the confirm must still build nothing.
|
||||
var before: int = game_state.buildings.size()
|
||||
controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER
|
||||
controller._confirm_build()
|
||||
if game_state.buildings.size() != before:
|
||||
print("TEST_SKILL_TREE: FAIL (built a locked structure)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._close_info()
|
||||
|
||||
# --- the mothership opens the tree ----------------------------
|
||||
var mothership: Node2D = null
|
||||
for b in field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
mothership = b
|
||||
controller.current_building = mothership
|
||||
controller._on_action_pressed()
|
||||
var tree_open: bool = scene.get_node("UI/SkillMenu").visible
|
||||
print("mothership action opens research: %s" % tree_open)
|
||||
if not tree_open:
|
||||
print("TEST_SKILL_TREE: FAIL (mothership has no research action)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- prerequisites --------------------------------------------
|
||||
if game_state.can_unlock("wing"):
|
||||
print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- research costs rocks and unlocks its building ------------
|
||||
var rocks_before: int = game_state.rocks
|
||||
var cost: int = int(game_state.skill_node("shipyards").cost)
|
||||
var ok: bool = game_state.unlock("shipyards")
|
||||
var charged: bool = game_state.rocks == rocks_before - cost
|
||||
var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
|
||||
print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [
|
||||
ok, rocks_before, game_state.rocks, charged, now_buildable
|
||||
])
|
||||
if not (ok and charged and now_buildable):
|
||||
print("TEST_SKILL_TREE: FAIL (research did not unlock its building)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# The menu must show it as buildable now, without reopening.
|
||||
controller._on_build_pressed()
|
||||
if not scene.get_node("UI/BuildMenu/CrafterButton").enabled:
|
||||
print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._close_info()
|
||||
scene.get_node("UI/BuildMenu").visible = false
|
||||
|
||||
# --- fleet capacity -------------------------------------------
|
||||
var base_cap: int = game_state.max_fleet_size()
|
||||
game_state.unlock("coordination")
|
||||
var wing_ok: bool = game_state.unlock("wing")
|
||||
var raised_cap: int = game_state.max_fleet_size()
|
||||
print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [
|
||||
base_cap, raised_cap, wing_ok
|
||||
])
|
||||
if not (wing_ok and raised_cap == base_cap + 1):
|
||||
print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# And the extra vessel must actually be craftable.
|
||||
fleet.spawn_ships(base_cap, Vector2.ZERO)
|
||||
var added: bool = fleet.add_ship()
|
||||
print("fleet %d ships, add one more -> %s (now %d)" % [
|
||||
base_cap, added, fleet.ships.size()
|
||||
])
|
||||
if not (added and fleet.ships.size() == base_cap + 1):
|
||||
print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)")
|
||||
quit(1)
|
||||
return true
|
||||
if fleet.add_ship():
|
||||
print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_SKILL_TREE: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_skill_tree.gd.uid
Normal file
1
tests/test_skill_tree.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://db2nf67mtlg27
|
||||
Loading…
Reference in a new issue