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