Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
4 KiB
GDScript
120 lines
4 KiB
GDScript
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://world/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.visual
|
|
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
|