Five things, all of them "this happens too fast or looks too thin". PACING. Every beat had a completion test that could be satisfied while the guide was still mid-sentence, which is why everything landed seconds after the thing before it. Beats now have a FLOOR as well as a deadline - a minimum length - and a beat whose point is the guide talking will not end while a line is still on screen. One rule in one place rather than tuning each condition. The run is 3400px instead of 1500, which at cruise was about four seconds, and a held line is 6.5s instead of 4.5. THE CONVOY. Escorts at the four corners of a box, holds packed in the middle of it, roughly three times the spread of the fleet's own wedge - this is freight under guard, not a fighting formation. CONVOY and CONVOY_FORMATION pair index-for-index, which is all it takes for spawn_ships to seat the guns on the outside, and the test pins that every hold sits further in than the furthest-out escort. THE DIALOG. Cut corners, brackets holding the short edges open, a tick strip along the bottom and a caret in the corner - geometry rather than art, so it scales with the banner and costs nothing. THE BREACH. It was a few lines turning around a point. It is now two halves driven by one number: DimensionalBreach, a growing black hole out in the world built on BlackHoleVisual's idiom - empty core, counter-rotating arc segments hotter toward the centre - with infalling streamers it does not have, because a planet's disc is steady and this one has to say "now"; and breach.gdshader over the top, bending the ACTUAL SCENE around it. Ships, rocks and the starfield all get dragged in, which is most of what makes a hole in space read as a hole rather than as a decal drawn on one. Inside the horizon the shader samples nothing at all - the same decision BlackHoleVisual makes, for the same reason: a black hole with something visible in the middle stops being one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
371 lines
15 KiB
GDScript
371 lines
15 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
|
|
|
|
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")
|
|
|
|
# --- 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")
|
|
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()
|
|
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])
|
|
|
|
# --- 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")
|
|
if narration.is_speaking():
|
|
return _fail("the guide is still talking after the handover")
|
|
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
|