260 lines
10 KiB
GDScript3
260 lines
10 KiB
GDScript3
|
|
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)
|