The belt was laid out with its own arithmetic - a centre BELT_AHEAD in front of the fleet, rocks spread BELT_DEPTH either side of it - so its near edge reached back to within a screen of the convoy. At the aspect it was eyeballed on that was just outside the view; at a wider one, or any zoom out, rocks appeared in plain sight. #126 already answered this question for the rest of the game, so the answer is borrowed rather than restated: AsteroidField's keep-out rect and its push-out-through-the-nearest-edge placement are public now, and the belt runs through them. That also means the two cannot drift apart later. The distance moves as well, 2200 to 2900. The clamp alone would work, but it would work by pressing every stray rock onto the boundary line; with the belt properly out in front, the clamp has almost nothing left to do and the belt still looks laid out ahead rather than pinned to the edge of the screen. Measured: nearest rock 1551 units out, against a keep-out that reaches 1100.
461 lines
19 KiB
GDScript
461 lines
19 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
|
|
|
|
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 = []
|
|
|
|
|
|
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")
|
|
|
|
# --- N. a dialog waits for the reader --------------------
|
|
var first_line: String = narration.label.text
|
|
if first_line.strip_edges() == "":
|
|
return _fail("the prologue opened with an empty banner")
|
|
# 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 director.beat_time > 0.01:
|
|
return _fail("the director's clock ran on %.2fs behind an unread line" \
|
|
% director.beat_time)
|
|
print("the prologue opens stopped: beat clock 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)
|
|
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 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))
|
|
|
|
# --- 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")
|
|
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())
|
|
|
|
print("TEST_INTRO_SCENE: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|