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.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