spaceship/tests/test_intro_scene.gd
2026-09-02 20:18:44 +02:00

542 lines
24 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_intro_scene.gd
# Forgejo #108 (#122, #123, #124): the prologue.
#
# The director drives the REAL scene rather than being a scene of its own, so
# everything below is checked against the real fleet, the real enemies and
# the real asteroid field.
#
# Covers:
# A. it runs on a NEW game and not on a resumed one
# B. "no action button available, only movements" - every pressable
# affordance is gone, and the joystick is not
# C. the convoy: bigger than the player's own ceiling, and mostly holds
# D. the guide speaks, and the banner never shows a line nobody set
# E. the ambush is easy: chasers only, and clearing them advances the beat
# F. the belt is dense, and it is across the lane rather than beside it
# G. the breach takes the wheel: player input stops moving the fleet
# H. the vortex leaves exactly one vessel, and it is the default interceptor
# I. the handover: the flag clears, the buttons come back, the spawner and
# the asteroid field are running again, and the fleet is a new game's
# J. skipping lands in the same state as playing it through
# K. #125 - no beat ends before its own floor, and a beat whose point is
# the guide talking does not end mid-sentence
# L. #125 - the convoy is spread out, with its holds surrounded by escorts
# M. #125 - the breach is a growing black hole with a lens over it, not a
# few lines turning around a point
# N. #127 - a dialog stays up until it is dismissed: no timer closes it,
# a click does, and the click is eaten rather than also steering
# O. #127 - every dialog names its speaker
# P. #128 - the prologue opens STOPPED behind its first line, and
# acknowledging it is what starts the world
# Q. #133 - the handover runs straight into the first quest
# R. #139 - no belt rock is laid down inside the visible screen
# S. #170 - the prologue opens on the PICTURE: the convoy is flying and the
# game is running for a few seconds before the guide says anything
# T. #183 - absorption whites out the full screen, teleports only once the
# cover is opaque, then reveals the fleet at the normal spawn
var game_state: Node
var scene: Node
var director: Node
var fleet: Node
var narration: Control
var stage: int = 0
var elapsed: float = 0.0
var convoy_size: int = 0
var skipped_props: Array = []
var whiteout_mid_checked: bool = false
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.rocks = 0
func _fail(message: String) -> bool:
print("TEST_INTRO_SCENE: FAIL (%s)" % message)
quit(1)
return true
func _load_scene() -> void:
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
director = scene.get_node_or_null("IntroDirector")
narration = scene.get_node("UI/NarrationBanner")
func _drop_scene() -> void:
scene.queue_free()
scene = null
func _hidden(path: String) -> bool:
var node: Control = scene.get_node_or_null(path)
return node == null or not node.visible
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. a new game gets one ------------------------------
if not game_state.intro_active:
return _fail("reset_new_game() did not arm the prologue")
_load_scene()
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
if director == null:
return _fail("no IntroDirector in the scene")
if not director.running:
return _fail("the director did not start on a new game")
# --- S. #170: it opens on the picture, not the caption ---
# The scene is a third of a second old here, well inside the
# silence, so the convoy should be flying and nothing should be
# on the banner.
if narration.is_speaking():
return _fail("the guide was already talking %.2fs in - #170 asks for a few seconds first" \
% director.beat_time)
if paused:
return _fail("the prologue opened stopped, with nothing on the banner to stop it")
# Hand-ticked from here so the silence is MEASURED rather than
# waited out, and so the tree's own clock cannot race it.
director.set_process(false)
var ticks: int = 0
while not narration.is_speaking() and ticks < 1200:
director._process(1.0 / 60.0)
ticks += 1
if narration.is_speaking():
break
if director.beat_time > director.OPENING_SILENCE + 0.05:
return _fail("the clock passed the %.1fs silence at %.2fs and the guide still had not spoken" \
% [director.OPENING_SILENCE, director.beat_time])
if not narration.is_speaking():
return _fail("the guide never spoke at all")
if director.beat_time < director.OPENING_SILENCE:
return _fail("the guide spoke at %.2fs, inside the %.1fs silence" \
% [director.beat_time, director.OPENING_SILENCE])
print("the prologue flies for %.1fs before the guide calls in" % director.beat_time)
director.set_process(true)
# --- N. a dialog waits for the reader --------------------
var first_line: String = narration.label.text
if first_line.strip_edges() == "":
return _fail("the guide spoke with an empty banner")
# The beat clock as the line went up. Nothing may move it on while
# the line is unread (#128) - the tree is stopped, and this test
# stops hand-ticking the director for exactly that reason.
var clock_at_line: float = director.beat_time
# Ten seconds of clock, with nothing pressed. A timer-driven
# banner would have moved on several times over.
for i in range(600):
narration._process(1.0 / 60.0)
if narration.label.text != first_line:
return _fail("the line changed on its own after 10s - #127 asks for a click")
if not narration.visible:
return _fail("the banner closed itself")
# --- P. and the game is STOPPED behind it (#128) ---------
if not paused:
return _fail("the prologue opened on a line with the game still running")
if absf(director.beat_time - clock_at_line) > 0.01:
return _fail("the director's clock ran on %.2fs behind an unread line" \
% absf(director.beat_time - clock_at_line))
print("the line stopped the game: beat clock held at %.2fs" % director.beat_time)
var click: InputEventMouseButton = InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
click.pressed = true
narration._unhandled_input(click)
if narration.label.text == first_line and narration.is_speaking():
return _fail("a click did not move the dialog on")
print("line held for 10s untouched, then a click moved it on")
# --- O. and it says who is talking -----------------------
if narration.speaker.strip_edges() == "":
return _fail("the dialog names no speaker")
if narration.speaker_label.text != narration.speaker:
return _fail("the speaker label does not show the speaker")
# It sits on the top border, at the left - above the text, and
# left of centre.
if narration.speaker_label.position.y >= narration.label.position.y:
return _fail("the speaker sits below the text, not on the top border")
if narration.speaker_label.position.x > narration.size.x * 0.5:
return _fail("the speaker is not at the left of the border")
print("speaker \"%s\" on the top-left border" % narration.speaker)
# --- K. nothing ends before its floor --------------------
# The floors are what #125 bought: every beat had a condition
# that could be satisfied while the guide was still talking.
for b in range(director.Beat.AFTERMATH + 1):
if director._floor_time() <= 0.0 and b != director.Beat.RUN:
return _fail("beat %d has no minimum length" % b)
if director.beat != director.Beat.BRIEF:
return _fail("the opening beat was over before the first line finished")
if not narration.is_speaking():
return _fail("the opening beat is silent, so waiting for it proves nothing")
# Every beat has room to run past its floor before its deadline,
# so a player who will not finish a beat's task is carried past
# it. Since #128 that deadline is game time and a dialog stops
# the clock, so it is a way past an unfinished BEAT, never past
# an unacknowledged line.
if director._deadline() <= director._floor_time():
return _fail("the opening beat has no deadline past its floor - a stalled player would be stuck")
print("opening beat still running at %.1fs, guide still talking" % director.beat_time)
# --- C. the convoy --------------------------------------
convoy_size = fleet.ships.size()
if convoy_size <= GameState.BASE_FLEET_SIZE:
return _fail("the convoy is %d vessels, no more than a new game's %d slots" \
% [convoy_size, GameState.BASE_FLEET_SIZE])
var holds: int = 0
var escorts: int = 0
for ship in fleet.ships:
if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR:
escorts += 1
else:
holds += 1
if holds <= escorts:
return _fail("the convoy is %d holds to %d escorts - it should be mostly cargo" \
% [holds, escorts])
if escorts <= 0:
return _fail("the convoy has no escorts at all")
print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts])
# --- L. spread out, and the holds are on the inside -------
var spread: float = 0.0
var closest: float = 1.0e9
for a in fleet.ships:
for b in fleet.ships:
if a == b:
continue
spread = maxf(spread, a.position.distance_to(b.position))
closest = minf(closest, a.position.distance_to(b.position))
if spread < 400.0:
return _fail("the whole convoy fits in %.0fpx - that is a wedge, not a convoy" % spread)
if closest < 80.0:
return _fail("two convoy vessels are %.0fpx apart" % closest)
# Every hold must be further in than the furthest-out escort is,
# which is what "surrounded by the default vessels" means.
var escort_reach: float = 0.0
var hold_reach: float = 0.0
for ship in fleet.ships:
var from_centre: float = ship.position.length()
if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR:
escort_reach = maxf(escort_reach, from_centre)
else:
hold_reach = maxf(hold_reach, from_centre)
if hold_reach >= escort_reach:
return _fail("a hold sits %.0fpx out, past the escorts at %.0f - they are not screened" \
% [hold_reach, escort_reach])
print("convoy %.0fpx across, holds within %.0f and escorts out at %.0f" \
% [spread, hold_reach, escort_reach])
# --- B. movement and nothing else -----------------------
for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton",
"UI/BuildingActionButton", "UI/EnterAtmosphereButton",
"UI/TransferButton", "UI/HangarButton", "UI/LaserButton",
"UI/GasBombButton"]:
if not _hidden(pressable):
return _fail("%s is offered during the prologue" % pressable)
var joystick: Control = scene.get_node("UI/Joystick")
if not joystick.visible:
return _fail("the joystick is hidden - the player cannot even fly")
print("every action button is away; the joystick is not")
# --- D. the guide speaks --------------------------------
if not narration.is_speaking():
return _fail("the prologue opened without a word from the guide")
if narration.label.text.strip_edges() == "":
return _fail("the banner is up with nothing on it")
print("guide: \"%s\"" % narration.label.text)
# --- E. the ambush --------------------------------------
director._begin(director.Beat.AMBUSH)
if director.ambush.is_empty():
return _fail("the ambush beat spawned nothing")
for enemy in director.ambush:
if String(enemy.enemy_id) != "chaser":
return _fail("the ambush includes a %s - it is meant to be easy" % enemy.enemy_id)
if director._beat_complete():
return _fail("the ambush counts as cleared while its raiders are alive")
for enemy in director.ambush:
enemy.queue_free()
# #128: the beat announced itself, and an unacknowledged line
# stops the tree - including the director, whose advance is what
# stage 2 is waiting for. Acknowledged here, the way a player
# would, so the clock below is running.
narration.clear()
if paused:
return _fail("clearing the last line left the game stopped")
elapsed = 0.0
stage = 2
return false
2:
# Past the ambush beat's own floor (#125): clearing it is what
# ENDS it, but no beat may end before its floor, so watching for
# the advance means waiting that long.
if elapsed < director._floor_time() + 0.5:
return false
# The director advances itself, so the evidence that clearing the
# ambush ended the beat is that it has MOVED ON - asking
# _beat_complete() again would be asking about a beat it left.
if director.beat <= director.Beat.AMBUSH:
return _fail("killing every raider did not end the ambush beat")
print("%d raiders, all chasers, and clearing them ends the beat" % director.AMBUSH_COUNT)
# --- F. the belt ----------------------------------------
director._begin(director.Beat.GAUNTLET)
# Only the rocks still standing: the convoy flies into them, and
# a rock it has already broken is not evidence about the belt.
var standing: Array = []
for rock in director.rocks:
if is_instance_valid(rock) and not rock.is_queued_for_deletion():
standing.append(rock)
if standing.size() < 40:
return _fail("the \"pretty dense\" belt is %d rocks" % standing.size())
var ahead: int = 0
for rock in standing:
if rock.global_position.y < fleet.get_centroid().y:
ahead += 1
if ahead < standing.size() / 2:
return _fail("only %d of %d rocks are on the lane ahead" \
% [ahead, standing.size()])
print("belt: %d rocks, %d of them across the lane ahead" % [standing.size(), ahead])
# --- R. #139: and none of it appears on screen ------------
# The belt used to be laid out with its own arithmetic, which put
# its near edge within a screen of the fleet - so rocks popped
# into existence in view. It goes through the asteroid field's
# own keep-out now, the same one #126 gave the rest of the game.
var field: Node2D = scene.get_node("AsteroidField")
var keep_out: Rect2 = field.keep_out_rect(fleet.get_centroid())
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(field)
if visible_rect.size.x > 1.0 and not keep_out.encloses(visible_rect):
return _fail("the belt's keep-out %s does not cover the screen %s" \
% [keep_out, visible_rect])
var nearest: float = 1.0e9
for rock in standing:
if keep_out.has_point(rock.global_position):
return _fail("a belt rock was laid down at %s, inside the view %s" \
% [rock.global_position, keep_out])
nearest = minf(nearest, absf(rock.global_position.y - fleet.get_centroid().y))
print("no belt rock inside the keep-out %.0fx%.0f; nearest is %.0f ahead" \
% [keep_out.size.x, keep_out.size.y, nearest])
# --- G. the breach takes the wheel ----------------------
director._begin(director.Beat.BREACH)
if not game_state.intro_locks_movement:
return _fail("the breach beat left the player in control")
# GameController.gd is the script on Main itself, not a child.
var before: Vector2 = fleet.fleet_target
scene._process(0.1)
if fleet.fleet_target != before:
return _fail("player steering still moved the fleet during the breach")
print("the breach has the wheel: steering input moves nothing")
# --- M. the breach is a black hole ----------------------
var hole: Node2D = director.breach
if hole == null or not is_instance_valid(hole):
return _fail("the breach beat opened no black hole")
if hole.horizon_radius() > 1.0:
return _fail("the hole was already open the moment the beat began")
director._drive_breach(0.5)
var half_open: float = hole.horizon_radius()
director._drive_breach(1.0)
if hole.horizon_radius() <= half_open:
return _fail("the hole does not grow - %.1f at half, %.1f at full" \
% [half_open, hole.horizon_radius()])
if hole.disc_radius() <= hole.horizon_radius():
return _fail("the accretion disc is inside the horizon")
var lens: ColorRect = scene.get_node("UI/BreachOverlay")
if not lens.visible:
return _fail("the screen-space lens never came up")
if lens.material == null or lens.material.shader == null:
return _fail("the lens has no shader on it")
print("breach: horizon %.0f -> %.0f, disc %.0f, lens up" \
% [half_open, hole.horizon_radius(), hole.disc_radius()])
# --- H. the vortex --------------------------------------
director._begin(director.Beat.VORTEX)
var fade: ColorRect = scene.get_node("FadeOverlay/FadeRect")
var viewport_size: Vector2 = scene.get_viewport().get_visible_rect().size
if fade.size.distance_to(viewport_size) > 1.0:
return _fail("whiteout is %s, viewport is %s" % [fade.size, viewport_size])
if fade.color.r < 0.99 or fade.color.g < 0.99 or fade.color.b < 0.99:
return _fail("the vortex transition is not white")
if fade.color.a > 0.01:
return _fail("the whiteout began fully opaque instead of fading in")
elapsed = 0.0
stage = 3
return false
3:
# Left to the director's own clock rather than ticked by hand:
# the vortex is a timed animation and driving it twice would not
# be the thing that ships.
if not whiteout_mid_checked and elapsed >= director.WHITEOUT_IN_SECONDS * 0.5:
whiteout_mid_checked = true
var mid_fade: ColorRect = scene.get_node("FadeOverlay/FadeRect")
if mid_fade.color.a <= 0.05 or mid_fade.color.a >= 0.95:
return _fail("whiteout did not gradually fade in (alpha %.2f)" % mid_fade.color.a)
if director.whiteout_teleported:
return _fail("fleet teleported before the screen was fully white")
if fleet.get_formation_origin().distance_to(director.DEFAULT_SPAWN) <= 1.0:
return _fail("fleet reached default spawn before full white")
if elapsed < 4.2:
return false
var alive: int = 0
for ship in fleet.ships:
if is_instance_valid(ship) and not ship.is_queued_for_deletion():
alive += 1
if alive != 1:
return _fail("the vortex left %d vessels, expected 1" % alive)
if String(fleet.ships[0].vessel_type) != GameState.VESSEL_INTERCEPTOR:
return _fail("the survivor is a %s, not the default interceptor" \
% fleet.ships[0].vessel_type)
print("the vortex took %d vessels and left one interceptor" % (convoy_size - 1))
if not director.whiteout_teleported:
return _fail("the screen reached white without teleporting the fleet")
if fleet.get_formation_origin().distance_to(director.DEFAULT_SPAWN) > 1.0:
return _fail("whiteout revealed the fleet at %s instead of default spawn %s" \
% [fleet.get_formation_origin(), director.DEFAULT_SPAWN])
var revealing: ColorRect = scene.get_node("FadeOverlay/FadeRect")
if revealing.color.a >= 0.99:
return _fail("screen stayed fully white after teleport")
print("whiteout hid the teleport to default spawn, then began revealing it")
# --- I. the handover ------------------------------------
director.finish()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.3:
return false
if game_state.intro_active or game_state.intro_locks_movement:
return _fail("the prologue finished without clearing its own flags")
if game_state.intro_fleet_size != 0:
return _fail("the convoy's fleet-size override outlived the prologue")
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
return _fail("the fleet still holds %d slots after the prologue" \
% game_state.max_fleet_size())
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("the game starts with %d vessels, expected %d" \
% [fleet.ships.size(), GameState.STARTING_SHIP_COUNT])
if not scene.get_node("EnemySpawner").is_processing():
return _fail("waves are still switched off after the prologue")
if not scene.get_node("AsteroidField").is_processing():
return _fail("the asteroid field is still switched off after the prologue")
scene.get_node("BuildController")._process(0.1)
if _hidden("UI/BuildButton"):
return _fail("the build button never came back")
# #133: the guide has nothing more to say - but the quest chain
# starts exactly here, so what is on the banner now is the first
# objective rather than any leftover of the prologue.
var quest: Dictionary = game_state.active_quest()
if quest.is_empty():
return _fail("the handover left no first quest")
if narration.is_speaking() and narration.current != String(quest.brief):
return _fail("the banner is still on \"%s\" after the handover" \
% narration.current)
print("the prologue hands over to the first quest: \"%s\"" % quest.title)
if scene.get_node("UI/BreachOverlay").visible:
return _fail("the breach lens is still over the screen after the handover")
if is_instance_valid(director) and director.breach != null:
return _fail("the black hole outlived the prologue")
var finished_fade: ColorRect = scene.get_node("FadeOverlay/FadeRect")
if finished_fade.color.a > 0.001 or finished_fade.color.r > 0.001:
return _fail("intro left the shared fade overlay tinted white")
print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back")
_drop_scene()
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.3:
return false
# --- A (second half). a resumed game gets no prologue ----
game_state.intro_active = false
_load_scene()
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.4:
return false
if director != null and is_instance_valid(director) and director.running:
return _fail("the prologue ran again on a game that is past it")
if fleet.ships.size() > GameState.BASE_FLEET_SIZE:
return _fail("a resumed game opened with a convoy")
print("a game already past the prologue does not replay it")
_drop_scene()
elapsed = 0.0
stage = 7
return false
7:
if elapsed < 0.3:
return false
# --- J. skipping lands where playing it through does -----
game_state.reset_new_game()
_load_scene()
elapsed = 0.0
stage = 8
return false
8:
if elapsed < 0.4:
return false
# Skipped mid-prologue, with props on the board, so the check
# below is about clearing them rather than about an empty stage.
director._begin(director.Beat.AMBUSH)
director._begin(director.Beat.GAUNTLET)
skipped_props = director.ambush + director.rocks
if skipped_props.size() < 40:
return _fail("nothing was on the board to skip past")
director.skip()
elapsed = 0.0
stage = 9
return false
9:
if elapsed < 0.3:
return false
if game_state.intro_active or game_state.intro_locks_movement:
return _fail("skipping left the prologue's flags set")
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
return _fail("skipping left the convoy's fleet size behind")
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("skipping left %d vessels flying" % fleet.ships.size())
for prop in skipped_props:
if is_instance_valid(prop) and not prop.is_queued_for_deletion():
return _fail("skipping left the prologue's own props on the board")
print("skipping clears %d props and lands where playing it through does" \
% skipped_props.size())
# #161: the prologue widens the formation into a freight convoy
# by writing over game_state.formation. Finishing has to put the
# fleet's own wedge back, or the player flies the convoy's
# 600-unit spread for the rest of the game - a shape nobody chose,
# which reads as the fleet having drifted apart.
for i in range(mini(game_state.formation.size(),
game_state.DEFAULT_FORMATION.size())):
if not game_state.formation[i].is_equal_approx(game_state.DEFAULT_FORMATION[i]):
print("TEST_INTRO_SCENE: FAIL (slot %d is %s after the prologue, expected the default %s)" \
% [i, game_state.formation[i], game_state.DEFAULT_FORMATION[i]])
quit(1)
return true
print("formation restored to the fleet's own wedge")
print("TEST_INTRO_SCENE: PASS")
quit(0)
return true
return false