#166-#173: plainer words, a point to shoot at, and a catalog that says when it is broken

Eight tickets. The last one is the interesting one.

#166 - PLAINER DIALOG. Every spoken line rewritten: the nine prologue lines and
all twenty quest brief/done pairs. Mostly splitting em-dash subclauses into
sentences and dropping the literary register ("hulls" -> "ships", "That is our
hands back" -> "That frees us for other work"). Same meaning, same voice.

#167 - NOTHING ANNOUNCED TWICE. Quests carry an `important` flag; seven of the
twenty have it, and they are the search for the World Ship. A quest handed out
with its goal ALREADY met is now credited in silence unless it is one of those
- no brief, no answer, both signals still fired and the index still moved, so
it appears ticked in the objectives panel. The chain used to stop the game to
set a task the player had already done, then stop it again to congratulate
them on it.

#168 - THE KEYBOARD CAN ANSWER A DIALOG. The rule the ticket asks for (still
arriving, show it all; already whole, move on) was already the rule for a
click since #149, but a key press reached the banner and did nothing - and
this game is played with WASD and a mouse. Touch, click and key now go through
one door. Modifiers alone and auto-repeat echoes are not presses.

#169 - A LOADING SCREEN. A press raises a cover, and the load happens after it
has been on screen for a frame, so the wait is something the player is
watching. Deliberately still a blocking load: load_threaded_request() comes
back FAILED for these scenes, reporting text-parse errors that load() does not
produce, because the global class names and preloads they reach each other
through are not resolvable off the loading thread. A progress bar is not worth
a menu that sometimes refuses to start the game, and the web build ships with
thread support off anyway. Further presses while loading are ignored - on the
web a button fires on PRESS, so an impatient double tap costs nothing to make
and used to build the world twice.

#170 - THE PICTURE BEFORE THE CAPTION. The prologue's first line was up before
the scene had finished arriving, so the game opened STOPPED (#128) over a
convoy nobody had seen yet. It flies for three seconds first.

#171 - THE CONTROLS FOLLOW WHAT IS BEING HELD. is_touchscreen_available() asks
whether a touch COULD arrive, and in a browser that is yes on any laptop whose
panel reports touch support - so the web build put thumb sticks over a screen
being played with a mouse, and switched mouse aiming off on the machine it
exists for. The reading is now the last input actually used, it lives in
GameState so the sticks and the guns cannot disagree, and it is two-way:
nothing can strand a tablet, because the gesture that needs the controls is
the gesture that brings them back. A touch suppresses mouse evidence for a
second, so the click Godot manufactures out of a tap is not mistaken for a
mouse.

#172 - AIM AT A PLACE. Every gun was handed the same direction, so a fleet
firing at something threw a sheet of parallel bolts past either side of it and
only the middle hull could hit - worse the wider the formation. Guns now take
a POINT and each hull works out its own heading to it, so the fleet's fire
converges. The mouse already had a place and was flattening it into a bearing;
a pushed stick names the point at the guns' reach along it, so touch loses
nothing. The assist (#89) still bends a shot onto an enemy, now per hull and
around that hull's own heading. The aim guide stops at the point and marks it.

#173 - AND THE ONE THAT EXPLAINS ITSELF. "In the web export version the fleet
can not store any resources" is not a web bug. It is the third time the Godot
editor's .tres writer has quietly dropped a property it thought was
default-valued: InterceptorVesselDefinition losing `capabilities` leaves
cargo_capacity at 20 and takes away the "cargo" claim that
fleet_cargo_capacity() actually gates on, so the hold is zero and nothing can
be mined or carried. That is the symptom, word for word.

test_definitions_intact caught it in the suite. What it could not do is catch
it in a BUILD - an export is taken from the working tree at whatever moment
the editor last touched it, which is exactly when the damage is done and
exactly where nobody is running tests. The checks move to core/CatalogCheck.gd
and now run at startup in every build as well as in the test, from the same
function. On the web push_error lands in the on-page log web_shell.html
mirrors, which is the only error a phone can read.

The tree is intact today, so a fresh export should restore the hold. The point
of the change is that the next time it is not, the build says so.

Full suite green at 154/154.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139MmxRAJSUe1j77kftAWv3
This commit is contained in:
Loïc Wisniewski 2026-08-30 14:04:46 +02:00
commit 0fad1dd155
24 changed files with 1372 additions and 286 deletions

View file

@ -2,6 +2,11 @@ extends Node2D
# Points every armed vessel's gun wherever the right-hand stick is pushed. # Points every armed vessel's gun wherever the right-hand stick is pushed.
# #
# #172: what it points them at is a PLACE, not a heading. The mouse names one
# directly; a pushed stick names the one at the guns' reach along it. Each hull
# then works out its own heading to that place, so the fleet's fire converges
# there instead of leaving in parallel - see VesselWeaponComponent.
#
# The fleet's guns used to seek their own targets and fire on their own. That # The fleet's guns used to seek their own targets and fire on their own. That
# made combat something that happened TO the player rather than something they # made combat something that happened TO the player rather than something they
# did, and left the right half of the screen with nothing on it. Aiming is now # did, and left the right half of the screen with nothing on it. Aiming is now
@ -20,6 +25,12 @@ const GUIDE_DASH: float = 14.0
const GUIDE_GAP: float = 16.0 const GUIDE_GAP: float = 16.0
const GUIDE_WIDTH: float = 2.0 const GUIDE_WIDTH: float = 2.0
const GUIDE_COLOR: Color = Color(1.0, 0.55, 0.38, 0.28) const GUIDE_COLOR: Color = Color(1.0, 0.55, 0.38, 0.28)
# #172: the fleet aims at a PLACE now, so the guide ends in a mark on that
# place rather than trailing off into the dark. Four ticks around a gap, not
# a filled dot - the thing being aimed at has to stay visible inside it.
const RETICLE_RADIUS: float = 22.0
const RETICLE_TICK: float = 11.0
const RETICLE_COLOR: Color = Color(1.0, 0.62, 0.42, 0.55)
# By path rather than by class name, for the load-order reason documented in # By path rather than by class name, for the load-order reason documented in
# PlanetSetups and elsewhere. # PlanetSetups and elsewhere.
@ -30,33 +41,82 @@ const WEAPON_COMPONENT = preload("res://vessels/VesselWeaponComponent.gd")
# runs - the guns simply stay quiet, which is the correct resting state. # runs - the guns simply stay quiet, which is the correct resting state.
@onready var stick: Node = get_node_or_null("../UI/FireStick") @onready var stick: Node = get_node_or_null("../UI/FireStick")
# The guide's heading, measured from the fleet's centre to the aimed point.
# Derived rather than input since #172 - it is what gets DRAWN, not what gets
# fired: each hull works out its own heading to the point (see
# VesselWeaponComponent.firing_heading).
var aim_direction: Vector2 = Vector2.ZERO var aim_direction: Vector2 = Vector2.ZERO
# Where the fleet is shooting, in world space, and whether it is shooting at
# all. #172: a place, not a heading, so the fleet's fire converges on it.
var aim_point: Vector2 = Vector2.ZERO
var has_aim: bool = false
# Set by MouseCombatController (#80) when a desktop player holds the left # Set by MouseCombatController (#80) when a desktop player holds the left
# button. Kept as a second input rather than a second aimer, so there is still # button: the world point under the cursor, which is already a place and needs
# exactly one thing in the scene that points a gun. # no converting. Kept as a second input rather than a second aimer, so there is
var external_direction: Vector2 = Vector2.ZERO # still exactly one thing in the scene that points a gun.
var external_target: Vector2 = Vector2.ZERO
var has_external_target: bool = false
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
var direction: Vector2 = stick.direction if stick != null else Vector2.ZERO var direction: Vector2 = stick.direction if stick != null else Vector2.ZERO
if direction == Vector2.ZERO: var was_aiming: bool = has_aim
if direction != Vector2.ZERO:
# The stick wins when both are live: a finger on it is a deliberate # The stick wins when both are live: a finger on it is a deliberate
# act, a mouse resting somewhere is not. # act, a mouse resting somewhere is not.
direction = external_direction aim_at(stick_point(direction))
aim(direction) elif has_external_target:
aim_at(external_target)
else:
hold_fire()
# The guide starts at the fleet, which is usually moving, so this redraws # The guide starts at the fleet, which is usually moving, so this redraws
# while aiming rather than only when the heading changes. Once the stick is # while aiming rather than only when the heading changes. Once the stick is
# released it redraws once more to clear the line, then stops. # released it redraws once more to clear the line, then stops.
var was_aiming: bool = aim_direction != Vector2.ZERO if has_aim or was_aiming:
aim_direction = direction
if direction != Vector2.ZERO or was_aiming:
queue_redraw() queue_redraw()
# #172: a stick reports a heading, and the guns want a place. The place is at
# the guns' own reach along that heading - far enough out that a pushed stick
# still reads as "everything that way", and near enough that the fleet's fire
# meets rather than spreading for ever.
func stick_point(direction: Vector2) -> Vector2:
if fleet == null or direction == Vector2.ZERO:
return Vector2.ZERO
return fleet.get_centroid() + direction.normalized() * maxf(guide_range(), 1.0)
# Also the entry point for anything driving the guns without a stick - tests # Also the entry point for anything driving the guns without a stick - tests
# aim through this rather than reaching into each weapon component, so what # aim through this rather than reaching into each weapon component, so what
# they exercise is the same path the stick uses. # they exercise is the same path the stick uses. Takes a HEADING, which is
# what a stick has; aim_at() takes the place it resolves to.
func aim(direction: Vector2) -> void: func aim(direction: Vector2) -> void:
if direction == Vector2.ZERO:
hold_fire()
return
aim_at(stick_point(direction))
# Points every armed hull at one place. Each works out its own heading to it,
# which is the whole of #172: two ships either side of a target now both hit
# it instead of both missing it by half the gap between them.
func aim_at(point: Vector2) -> void:
if fleet == null:
return
aim_point = point
has_aim = true
aim_direction = point - fleet.get_centroid()
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var weapon: Node = ship.get_component("weapon")
if weapon != null:
weapon.aim_at(point)
func hold_fire() -> void:
has_aim = false
aim_direction = Vector2.ZERO
if fleet == null: if fleet == null:
return return
for ship in fleet.ships: for ship in fleet.ships:
@ -64,7 +124,7 @@ func aim(direction: Vector2) -> void:
continue continue
var weapon: Node = ship.get_component("weapon") var weapon: Node = ship.get_component("weapon")
if weapon != null: if weapon != null:
weapon.set_fire_direction(direction) weapon.hold_fire()
# How far the guide reaches: the actual range of a gun that is actually # How far the guide reaches: the actual range of a gun that is actually
@ -80,7 +140,8 @@ func guide_range() -> float:
func _draw() -> void: func _draw() -> void:
if aim_direction == Vector2.ZERO or fleet == null or fleet.ships.is_empty(): if not has_aim or aim_direction == Vector2.ZERO or fleet == null \
or fleet.ships.is_empty():
return return
var reach: float = guide_range() var reach: float = guide_range()
if reach <= 0.0: if reach <= 0.0:
@ -90,12 +151,29 @@ func _draw() -> void:
# the line keeps telling the truth about where fire is going (#89). # the line keeps telling the truth about where fire is going (#89).
var heading: Vector2 = WEAPON_COMPONENT.assisted_heading(get_tree(), origin, var heading: Vector2 = WEAPON_COMPONENT.assisted_heading(get_tree(), origin,
aim_direction, reach) aim_direction, reach)
# #172: the line stops at the aimed PLACE when that is inside the guns'
# reach. Running it on to full range past a point the fleet is converging
# on would be drawing a direction again, which is what the ticket took
# away. Beyond reach it stops at reach, as it always did - the guide never
# promises further than the guns carry (#86).
var length: float = minf(aim_direction.length(), reach)
var travelled: float = GUIDE_GAP var travelled: float = GUIDE_GAP
while travelled < reach: while travelled < length:
var dash_end: float = minf(travelled + GUIDE_DASH, reach) var dash_end: float = minf(travelled + GUIDE_DASH, length)
draw_line(origin + heading * travelled, origin + heading * dash_end, draw_line(origin + heading * travelled, origin + heading * dash_end,
GUIDE_COLOR, GUIDE_WIDTH, true) GUIDE_COLOR, GUIDE_WIDTH, true)
travelled = dash_end + GUIDE_GAP travelled = dash_end + GUIDE_GAP
_draw_reticle(origin + heading * length)
# Four ticks around the aimed point, open in the middle so whatever is being
# shot at stays visible inside them.
func _draw_reticle(at: Vector2) -> void:
for step in range(4):
var out: Vector2 = Vector2.RIGHT.rotated(TAU * float(step) / 4.0)
draw_line(at + out * RETICLE_RADIUS,
at + out * (RETICLE_RADIUS + RETICLE_TICK),
RETICLE_COLOR, GUIDE_WIDTH, true)
func is_firing() -> bool: func is_firing() -> bool:

View file

@ -172,22 +172,38 @@ func _handle_key(event: InputEventKey) -> void:
# #85: on a computer the on-screen sticks and the pivot pair are dead weight - # #85: on a computer the on-screen sticks and the pivot pair are dead weight -
# WASD steers, the mouse aims, Q/E pivot. They are taken away together, once, # WASD steers, the mouse aims, Q/E pivot.
# the first time the player uses a keyboard or a mouse.
# #
# Two guards, both deliberate. A touch device that ALSO has a keyboard keeps # #171: they now come and GO, rather than being taken away once and for all.
# everything, because a finger is still a way to play it. And a device wrongly #
# reported as non-touch keeps its controls until the player has demonstrably # The old rule asked DisplayServer.is_touchscreen_available() and, if it said
# used something else - so nobody is ever left with no way to steer or shoot. # yes, kept everything for ever - because the failure to avoid is a phone left
# with no way to steer. In a browser that query says yes on half the laptops
# in the world, so the web build showed thumb sticks to people holding a
# mouse, which is the ticket. It also could not be undone: a wrong answer the
# other way stranded a tablet with no controls until it was restarted.
#
# Following the LAST INPUT ACTUALLY USED fixes both ends. Touch the screen and
# the controls are there; use the mouse or the keyboard and they go. Nobody is
# ever stranded, because the gesture that needs them is the same gesture that
# brings them back, and it takes one tap. GameState owns the reading (see
# note_touch_input) so the mouse-aiming gate cannot disagree with this.
func _note_keyboard() -> void: func _note_keyboard() -> void:
if keyboard_seen:
return
keyboard_seen = true keyboard_seen = true
if DisplayServer.is_touchscreen_available(): GameState.note_pointer_input()
return _sync_touch_controls()
func _note_touch() -> void:
GameState.note_touch_input()
_sync_touch_controls()
func _sync_touch_controls() -> void:
var offered: bool = GameState.pointer_is_touch
for control in [joystick, fire_stick, pivot_cw_button, pivot_ccw_button]: for control in [joystick, fire_stick, pivot_cw_button, pivot_ccw_button]:
if control != null: if control != null and control.visible != offered:
control.visible = false control.visible = offered
func _button_holds(button: IconButton, at_position: Vector2) -> bool: func _button_holds(button: IconButton, at_position: Vector2) -> bool:
@ -339,6 +355,11 @@ func _unhandled_input(event: InputEvent) -> void:
func _handle_touch(event: InputEventScreenTouch) -> void: func _handle_touch(event: InputEventScreenTouch) -> void:
# #171: a finger on the glass is the one unambiguous piece of evidence
# that this is a touch device being played by touch, and it is recorded
# before anything gets a chance to claim the event - a touch swallowed by
# the joystick is still a touch.
_note_touch()
# The joystick gets first refusal on every touch, and only claims ones # The joystick gets first refusal on every touch, and only claims ones
# that begin inside its own rect - so pinch-to-zoom still works anywhere # that begin inside its own rect - so pinch-to-zoom still works anywhere
# else on screen. # else on screen.
@ -372,6 +393,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void:
func _handle_drag(event: InputEventScreenDrag) -> void: func _handle_drag(event: InputEventScreenDrag) -> void:
# A finger moving on the glass, same as a finger arriving on it (#171).
_note_touch()
if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event): if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event):
return return
if laser_controller != null and laser_controller.handle_drag(event): if laser_controller != null and laser_controller.handle_drag(event):

View file

@ -10,10 +10,15 @@ extends Node2D
# #
# Touch is unaffected, and deliberately so: Godot emulates a mouse button from # Touch is unaffected, and deliberately so: Godot emulates a mouse button from
# every touch, so without a guard a tap anywhere on a phone would fire the # every touch, so without a guard a tap anywhere on a phone would fire the
# guns. The guard is the presence of a touchscreen rather than a "has used a # guns.
# mouse" flag, because the wrong answer has to fail safe - a desktop wrongly #
# reported as touch-capable loses a convenience, while a phone wrongly treated # #171 changed what that guard asks. It used to be the presence of a
# as a desktop fires its weapons at every tap. # touchscreen, which fails safe but fails often: a desktop browser reporting a
# touch-capable panel switched mouse aiming off on exactly the machine it was
# written for. It now asks what the player last actually used
# (GameState.pointer_is_touch), which still fails safe - a tap makes the
# reading "touch" before the emulated click arrives, so a phone can never fire
# through this path - and stops being wrong about desktops.
# A literal, not PackedStringArray(...): a constructor call is not a constant # A literal, not PackedStringArray(...): a constructor call is not a constant
# expression, and the script fails to PARSE rather than to run - which shows up # expression, and the script fails to PARSE rather than to run - which shows up
@ -42,11 +47,11 @@ func _ready() -> void:
abilities.append(controller) abilities.append(controller)
# AUTO decides from the display, which is what every scene ships with. The # AUTO follows the last input the player actually used, which is what every
# explicit settings exist because "is there a touchscreen" is not a question # scene ships with. The explicit settings exist because that reading is seeded
# that can be answered honestly in every environment - headless reports one, # from the display in an environment - headless - where the answer varies by
# so with AUTO alone the aiming below could never be exercised by a test at # platform, so with AUTO alone the aiming below could never be exercised by a
# all, and the only covered behaviour would be the guard itself. # test at all, and the only covered behaviour would be the guard itself.
enum PointerMode { AUTO, ON, OFF } enum PointerMode { AUTO, ON, OFF }
@export var pointer_mode: PointerMode = PointerMode.AUTO @export var pointer_mode: PointerMode = PointerMode.AUTO
@ -58,7 +63,7 @@ func is_enabled() -> bool:
return true return true
PointerMode.OFF: PointerMode.OFF:
return false return false
return not DisplayServer.is_touchscreen_available() return not GameState.pointer_is_touch
func weapon_count() -> int: func weapon_count() -> int:
@ -117,7 +122,11 @@ func _process(_delta: float) -> void:
# there is still exactly one thing in the scene that aims a gun. The stick # there is still exactly one thing in the scene that aims a gun. The stick
# wins when both are live - a finger on it is a deliberate act, while a # wins when both are live - a finger on it is a deliberate act, while a
# mouse merely resting somewhere is not. # mouse merely resting somewhere is not.
var heading: Vector2 = Vector2.ZERO #
if is_enabled() and firing and selected_weapon == 0 and not fleet.ships.is_empty(): # #172: the cursor's world position goes across as a PLACE. It always was
heading = aim_world - fleet.get_centroid() # one; it used to be flattened into a heading from the fleet's centre on
fire_stick_controller.external_direction = heading # the way out, which threw away the distance and made every hull fire
# parallel past the thing being clicked on.
fire_stick_controller.has_external_target = is_enabled() and firing \
and selected_weapon == 0 and not fleet.ships.is_empty()
fire_stick_controller.external_target = aim_world

129
core/CatalogCheck.gd Normal file
View file

@ -0,0 +1,129 @@
class_name CatalogCheck
extends RefCounted
# Forgejo #173: "in the web export version the fleet can not store any
# resources."
#
# That symptom has a history in this project, and it is not a web bug. Opening
# the project in the Godot editor re-saves .tres files, and the editor's
# writer DROPS properties it considers default-valued. An empty-looking
# PackedStringArray is the usual casualty, and it has now cost this project
# three times:
#
# GasBurnerBuildingDefinition lost `capabilities` and `collected_outputs`,
# which took the burner's whole conversion with it.
#
# InterceptorVesselDefinition lost `capabilities`, `hull_points`,
# `build_time`, `icon` and `glow_color`. The starting vessel kept its
# cargo_capacity of 20 and stopped claiming the "cargo" capability, which
# is the one thing fleet_cargo_capacity() gates on - so the hold was zero
# and nothing could be mined or carried.
#
# The second one is #173, word for word. A build exported from a tree in that
# state has a fleet that cannot store any resources, and NOTHING SAYS SO: the
# resource still loads, it is just quietly less than it was.
#
# test_definitions_intact caught it in the suite. What it could not do is
# catch it in a build, and an export is taken from the working tree at
# whatever moment the editor last touched it - which is exactly when the
# damage is done and exactly where nobody is running tests.
#
# So the same check now runs at STARTUP, in every build (see GameState._ready)
# and in the test, from this one function. On the web its push_error lands in
# the on-page error log that web_shell.html mirrors from the console, which is
# the only place a phone can read an error at all. A player still cannot mine,
# but the reason is on the screen instead of being reasoned about for an
# evening.
#
# It asserts the SHAPE of the catalog rather than any one value: every
# definition must carry the things that make it what it is, and two facts that
# have to agree - a hold and the capability that admits to it - must agree.
# Everything wrong with the catalog, in the order found. Empty is the healthy
# answer, which is what both callers check.
static func faults() -> PackedStringArray:
var found: PackedStringArray = PackedStringArray()
_check_vessels(found)
_check_buildings(found)
return found
# get() answers null for a property a definition's script never declared, and
# null has no is_empty(). These keep a missing field from looking like an empty
# one - and from taking the rest of the sweep down with it.
static func _text(definition: Resource, field: String) -> String:
var value: Variant = definition.get(field)
return "" if value == null else str(value)
static func _list(definition: Resource, field: String) -> Array:
var value: Variant = definition.get(field)
if value == null:
return []
return Array(value.keys() if value is Dictionary else value)
static func _check_vessels(found: PackedStringArray) -> void:
for definition in VesselDefinitions.all():
var id: String = String(definition.definition_id)
# --- whole ------------------------------------------------------
if String(definition.display_name).strip_edges() == "":
found.append("vessel %s has no display name" % id)
if definition.base_stats.is_empty():
found.append("vessel %s has no stats at all" % id)
# Only the hulls that actually join the fleet: a repair drone is a
# logistics unit driven by GameState, with nothing bolted to it.
if definition.fleet_eligible and definition.component_scenes.is_empty():
found.append("vessel %s flies in the fleet with no components - an empty hull" % id)
if definition.hull_points.is_empty():
found.append("vessel %s has no hull points - it would draw as the fallback triangle" % id)
# --- cargo: the #173 case ---------------------------------------
# The hold and the capability are two recordings of one fact, and
# fleet_cargo_capacity() reads only the second. They must agree.
var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0
var claims: bool = "cargo" in definition.capabilities
if carries != claims:
found.append("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \
% [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities])
if "weapon" in definition.capabilities \
and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0:
found.append("vessel %s claims a weapon and has no range" % id)
static func _check_buildings(found: PackedStringArray) -> void:
for definition in BuildingDefinitions.all():
var id: String = String(definition.definition_id)
# --- whole ------------------------------------------------------
if String(definition.display_name).strip_edges() == "":
found.append("building %s has no display name" % id)
if String(definition.description).strip_edges() == "":
found.append("building %s has no description - its info popup would be blank" % id)
if int(definition.cost) <= 0:
found.append("building %s is free" % id)
if int(definition.max_hp) <= 0:
found.append("building %s has no hit points" % id)
# Space-placed structures only: an extractor is a marker on a planet
# rather than a thing standing in the world, and has no scene.
if definition.placement_mode == "space" and definition.visual_scene == null:
found.append("building %s stands in space with nothing to draw" % id)
for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]:
if str(definition.get(field)).strip_edges() == "":
found.append("building %s has no %s - the build menu could not place it" % [id, field])
if definition.capabilities.is_empty():
found.append("building %s declares no capabilities - it would do nothing at all" % id)
# --- converters -------------------------------------------------
# These live on the LOGISTICS profile, a sub-resource of the
# definition, not on the definition itself. Asking the definition for
# them answers null and the whole check quietly never fires - which is
# how the first draft of this passed while the gas burner sat there
# with its collected_outputs missing.
var logistics: Resource = definition.logistics
if logistics == null or _text(logistics, "conversion_input") == "":
continue
if _text(logistics, "conversion_output") == "":
found.append("building %s converts something into nothing" % id)
if _list(logistics, "collected_outputs").is_empty():
found.append("building %s converts but lists no collected outputs - what it makes would never be fetched" % id)
if _list(logistics, "input_buffers").is_empty():
found.append("building %s converts with no input buffer" % id)

1
core/CatalogCheck.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://bk64c5fpvnc57

View file

@ -10,6 +10,7 @@ const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
# works around. # works around.
const WORLD_GEN = preload("res://world/WorldGen.gd") const WORLD_GEN = preload("res://world/WorldGen.gd")
const QUESTS = preload("res://core/Quests.gd") const QUESTS = preload("res://core/Quests.gd")
const CATALOG_CHECK = preload("res://core/CatalogCheck.gd")
# Autoload singleton - the one thing that survives change_scene_to_file() # Autoload singleton - the one thing that survives change_scene_to_file()
@ -38,6 +39,10 @@ var fleet_position: Vector2 = Vector2.ZERO
# is also what makes "then the game starts" cost nothing - the director stops # is also what makes "then the game starts" cost nothing - the director stops
# steering and there is nothing left to load. # steering and there is nothing left to load.
var intro_active: bool = false var intro_active: bool = false
# #173: what the startup catalog check found, if anything. Empty in a healthy
# build; see CatalogCheck for why a build can be unhealthy without anything
# else noticing.
var catalog_faults: PackedStringArray = PackedStringArray()
# #124: the breach takes the wheel. Movement input is ignored while this is # #124: the breach takes the wheel. Movement input is ignored while this is
# set, so the fleet goes where the script sends it rather than fighting the # set, so the fleet goes where the script sends it rather than fighting the
# player for the last few seconds of the prologue. # player for the last few seconds of the prologue.
@ -47,6 +52,57 @@ var intro_locks_movement: bool = false
# explicit override rather than a fake doctrine, so nothing about the # explicit override rather than a fake doctrine, so nothing about the
# player's own progression has to be bent to fit a cutscene. # player's own progression has to be bent to fit a cutscene.
var intro_fleet_size: int = 0 var intro_fleet_size: int = 0
# --- what the player is actually holding (#171) -----------------------------
#
# "The web export should auto detect keyboard mouse or screen and remove the
# touch screen controls if not needed."
#
# DisplayServer.is_touchscreen_available() is the wrong question in a browser
# and always was. It answers "could a touch arrive", and a desktop browser on
# a laptop with a touch-capable panel - or one that merely reports
# navigator.maxTouchPoints - says yes. So the thumb sticks and the pivot pair
# sat over a screen being played with a mouse, and mouse aiming, which the
# same query gates, was switched off on the machine it exists for.
#
# The right question is what the player LAST actually used, which is a fact
# rather than a capability, and it is asked here rather than in a controller
# because two of them need the same answer: GameController shows or hides the
# on-screen controls, and MouseCombatController decides whether a click fires.
# One answer, so they can never disagree.
#
# The catch is that Godot can manufacture the other kind of event from each
# one - the pointing/emulate_* settings in project.godot turn taps into clicks
# and clicks into taps. Taken at face value a phone would look like it had a
# mouse the instant a finger touched the glass. So a touch SUPPRESSES mouse
# evidence for a moment afterwards: a manufactured click always follows its
# touch within the same frame, well inside the window, while a player who
# genuinely puts a finger down and then reaches for the mouse takes far longer
# than this to do it. Correct whether that emulation is on or off - with it
# off there is simply nothing for the window to suppress.
const POINTER_TOUCH_GRACE_MSEC: int = 1000
# Seeded from the display's own answer so the very first frame is sensible on
# a phone, and immediately overwritten by the first real input either way.
var pointer_is_touch: bool = DisplayServer.is_touchscreen_available()
# When a touch was last seen, in engine milliseconds. -1 for never. Public so
# a test can age it out rather than sleeping for a second.
var last_touch_msec: int = -1
func note_touch_input() -> void:
last_touch_msec = Time.get_ticks_msec()
pointer_is_touch = true
# A mouse or a key. Ignored while a touch is still recent, because that is
# what a manufactured event looks like.
func note_pointer_input() -> void:
if last_touch_msec >= 0 \
and Time.get_ticks_msec() - last_touch_msec < POINTER_TOUCH_GRACE_MSEC:
return
pointer_is_touch = false
var return_world_position: Vector2 = Vector2.ZERO var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0 var return_camera_zoom: float = 1.0
@ -3084,6 +3140,23 @@ var visited_planets: Dictionary = {}
var visited_planet_kinds: Dictionary = {} var visited_planet_kinds: Dictionary = {}
# #173: the catalog is checked once, here, in every build - not only in the
# test suite. The editor can quietly strip a property out of a .tres between
# the last test run and the export, and the shipped result is a game that
# looks fine and cannot mine. push_error rather than a crash: the game is
# still playable in whatever way survived, and on the web these lines reach
# the on-page log web_shell.html mirrors from the console, which is the only
# error a phone can read.
func _ready() -> void:
catalog_faults = CATALOG_CHECK.faults()
if catalog_faults.is_empty():
return
push_error("The definition catalog is damaged - see core/CatalogCheck.gd. %d fault(s):" \
% catalog_faults.size())
for fault in catalog_faults:
push_error(" catalog: %s" % fault)
func reset_quests(armed: bool = false) -> void: func reset_quests(armed: bool = false) -> void:
quests_armed = armed quests_armed = armed
quest_index = 0 quest_index = 0
@ -3194,17 +3267,37 @@ func take_quest_messages() -> Array:
# Ends the current quest whether or not its goal was met. The chain's only # Ends the current quest whether or not its goal was met. The chain's only
# way forward, used by _advance_quests below and by the tests. # way forward, used by _advance_quests below and by the tests.
func complete_active_quest() -> bool: #
# #167: `announce` is what a SKIPPED quest turns off. The index still moves
# and quest_completed still fires - the quest is credited exactly as if it
# had been played - but nothing is queued for the banner to read out.
func complete_active_quest(announce: bool = true) -> bool:
var quest: Dictionary = active_quest() var quest: Dictionary = active_quest()
if quest.is_empty(): if quest.is_empty():
return false return false
say_quest_line(String(quest.get("done", ""))) if announce:
say_quest_line(String(quest.get("done", "")))
quest_index += 1 quest_index += 1
quest_announced = false quest_announced = false
quest_completed.emit(String(quest.id)) quest_completed.emit(String(quest.id))
return true return true
# #167: a quest handed out with its goal already met, credited in silence.
#
# Both signals still fire, in the order they would have fired in had it been
# played, so anything watching the chain sees an ordinary start-then-finish
# rather than a quest that was never begun. What it does NOT do is speak:
# neither the brief nor the answer reaches quest_messages, so the game is
# never stopped twice to be told about something already done.
func skip_active_quest() -> bool:
var quest: Dictionary = active_quest()
if quest.is_empty():
return false
quest_started.emit(String(quest.id))
return complete_active_quest(false)
func _advance_quests() -> void: func _advance_quests() -> void:
# The prologue is already talking, and the fleet in it is not the # The prologue is already talking, and the fleet in it is not the
# player's. The chain starts when the prologue hands over. # player's. The chain starts when the prologue hands over.
@ -3212,6 +3305,21 @@ func _advance_quests() -> void:
return return
var quest: Dictionary = active_quest() var quest: Dictionary = active_quest()
if not quest_announced: if not quest_announced:
# #167: the chain hands out teaching quests, and by the time some of
# them come up the player has already done the thing - the extractor
# is down, the hangar is crewed. Announcing one of those stops the
# game to explain a task, then stops it again to congratulate them
# on a task they never did. So an unimportant quest whose goal is
# already met is banked without a word (see Quests.is_important);
# it still shows up as done in the objectives panel, which is where
# the player looks for what they have been credited with.
if quest_goal_met(quest) and not QUESTS.is_important(quest):
skip_active_quest()
# One a frame. Several already-met quests in a row are cleared
# over the next few frames rather than in a loop here, which
# keeps this function's shape - one step per call - and keeps a
# malformed chain from spinning inside a single tick.
return
quest_announced = true quest_announced = true
say_quest_line(String(quest.get("brief", ""))) say_quest_line(String(quest.get("brief", "")))
quest_started.emit(String(quest.id)) quest_started.emit(String(quest.id))

View file

@ -51,14 +51,31 @@ const GOAL_BANKED: String = "banked"
# that fills it. # that fills it.
const GOAL_GARRISONED: String = "garrisoned" const GOAL_GARRISONED: String = "garrisoned"
# #167: which quests carry the STORY, marked `"important": true` below.
#
# Most of the chain is teaching: bank some ore, place a crafter, open the
# hangar. A teaching quest handed to a player who has already done the thing
# has nothing to teach, and stopping the game twice to say so - once for the
# brief, once for the answer - is two dialogs about nothing. Those are
# skipped (GameState._advance_quests); they still land in the DONE list, so
# the player sees they were credited rather than lost.
#
# The ones marked here are different. They are the search for the World Ship
# - the wreck we start in, the call that gets no answer, the first world, the
# ground somebody else was already holding, the survey, the trail - and their
# lines are the plot rather than instructions. Those are read out whether the
# goal was already met or not, because skipping one drops a piece of the
# story on the floor.
const CHAIN: Array = [ const CHAIN: Array = [
{ {
"id": "first_ore", "id": "first_ore",
"title": "SALVAGE", "title": "SALVAGE",
"objective": "Break asteroids for 5 ore", "objective": "Break asteroids for 5 ore",
"goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 5}, "goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 5},
"brief": "Hull intact. Reactor holding. Everything else we had is on the other side of that hole. There is ore in the rocks out here - break some, and we can start again.", "brief": "The hull holds. The reactor runs. Everything else we had is on the far side of that hole. These rocks carry ore. Break some, and we can start again.",
"done": "Five ore in the hold. It is not much. It is a beginning.", "done": "Five ore in the hold. It is not much, but it is a start.",
"important": true,
}, },
{ {
# #142: the gap this fills was a real one. "Collect 5 ore" was # #142: the gap this fills was a real one. "Collect 5 ore" was
@ -69,26 +86,28 @@ const CHAIN: Array = [
"title": "THE PILE", "title": "THE PILE",
"objective": "Break asteroids for 20 ore", "objective": "Break asteroids for 20 ore",
"goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 20}, "goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 20},
"brief": "Now do that four times over. Nothing permanent gets built out of five ore, and there is nothing else out here to spend it on yet.", "brief": "Now do that four more times. Five ore builds nothing, and there is nothing else out here to spend it on yet.",
"done": "Twenty in the hold. That is a foundation.", "done": "Twenty in the hold. Now we can build something.",
}, },
{ {
"id": "mothership", "id": "mothership",
"title": "A PLACE TO STAND", "title": "A PLACE TO STAND",
"objective": "Build the mothership", "objective": "Build the mothership",
"goal": {"kind": GOAL_BUILDING, "building": "mothership"}, "goal": {"kind": GOAL_BUILDING, "building": "mothership"},
"brief": "A hull is not a base. Build the mothership - it is the only thing in the plans with a transmitter big enough to call the World Ship.", "brief": "A ship is not a base. Build the mothership. It carries the only transmitter big enough to call the World Ship.",
# The hinge of the whole chain (#129): the answer to "we built the # The hinge of the whole chain (#129): the answer to "we built the
# transmitter" is that the transmitter was never the problem. # transmitter" is that the transmitter was never the problem.
"done": "Mothership online. Calling the World Ship. Calling. Nothing comes back - and the array is not at fault. Wherever the breach put us, it is further out than that signal will ever carry. We find their trail ourselves, or we do not find them.", "done": "The mothership is online. Calling the World Ship. Calling. Nothing comes back, and the transmitter is not at fault. The breach threw us further out than any signal of ours will carry. So we find their trail ourselves, or we never find them.",
"important": true,
}, },
{ {
"id": "chart", "id": "chart",
"title": "DEAD RECKONING", "title": "DEAD RECKONING",
"objective": "Chart 30 sectors", "objective": "Chart 30 sectors",
"goal": {"kind": GOAL_EXPLORE, "sectors": 30}, "goal": {"kind": GOAL_EXPLORE, "sectors": 30},
"brief": "So we search. Fly, and let the map fill in behind us - whatever is left of their course is somewhere in it.", "brief": "So we search. Fly, and the map fills in behind us. Their course is somewhere in it.",
"done": "Thirty sectors charted. No sign of the World Ship, but the sky behind us is no longer blank.", "done": "Thirty sectors charted. No sign of the World Ship, but the map behind us is no longer blank.",
"important": true,
}, },
{ {
# #141: its own step, and the first time the chain sends anyone into # #141: its own step, and the first time the chain sends anyone into
@ -99,42 +118,44 @@ const CHAIN: Array = [
"title": "SHIPYARDS", "title": "SHIPYARDS",
"objective": "Research the Shipyards doctrine", "objective": "Research the Shipyards doctrine",
"goal": {"kind": GOAL_RESEARCH, "node": "shipyards"}, "goal": {"kind": GOAL_RESEARCH, "node": "shipyards"},
"brief": "One hull will not chart a sky, and we cannot build what we have not remembered how to build. The mothership holds the archive - go aboard it and research Shipyards.", "brief": "One ship cannot chart a whole sky, and we have forgotten how to build more. The mothership holds the archive. Go aboard and research Shipyards.",
"done": "Shipyard plans recovered. Everything else worth having is in that archive too.", "done": "Shipyard plans recovered. There is more in that archive, if we go back for it.",
}, },
{ {
"id": "crafter", "id": "crafter",
"title": "A YARD OF OUR OWN", "title": "A YARD OF OUR OWN",
"objective": "Build a ship crafter", "objective": "Build a ship crafter",
"goal": {"kind": GOAL_BUILDING, "building": "ship_crafter"}, "goal": {"kind": GOAL_BUILDING, "building": "ship_crafter"},
"brief": "Now put a ship crafter down. Ore goes in, vessels come out - it is the only way this fleet grows past the one hull that came through the breach.", "brief": "Now build a ship crafter. Ore goes in, ships come out. It is the only way this fleet grows past the one hull that came through the breach.",
"done": "The yard is live. We can build our own now.", "done": "The yard is running. We can build our own ships now.",
}, },
{ {
"id": "second_hull", "id": "second_hull",
"title": "A SECOND HULL", "title": "A SECOND HULL",
"objective": "Fly two vessels at once", "objective": "Fly two vessels at once",
"goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "interceptor", "count": 2}, "goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "interceptor", "count": 2},
"brief": "Order an interceptor at the yard and crew it. Two guns is not a fleet, but it is twice what we flew out of the breach with.", "brief": "Order an interceptor at the yard and crew it. Two ships is not a fleet, but it is twice what we flew out of the breach with.",
"done": "Two hulls in formation. It has been a while since this ship flew with company.", "done": "Two ships in formation. It has been a long time since we flew with company.",
}, },
{ {
"id": "planetfall", "id": "planetfall",
"title": "PLANETFALL", "title": "PLANETFALL",
"objective": "Land on a planet", "objective": "Land on a planet",
"goal": {"kind": GOAL_PLANET_VISITS, "count": 1}, "goal": {"kind": GOAL_PLANET_VISITS, "count": 1},
"brief": "There are worlds out here. Bring the fleet in close to one and take it down - there is more on a surface than there is in all this dark.", "brief": "There are worlds out here. Fly the fleet close to one and land on it. A surface holds more than all this empty dark.",
"done": "Down on solid ground. Whatever else this place is, it is somewhere.", "done": "We are down on solid ground. Whatever else this place is, it is somewhere.",
"important": true,
}, },
{ {
"id": "conquest", "id": "conquest",
"title": "GROUND HELD", "title": "GROUND HELD",
"objective": "Take every capture point on one planet", "objective": "Take every capture point on one planet",
"goal": {"kind": GOAL_CONQUERED, "count": 1}, "goal": {"kind": GOAL_CONQUERED, "count": 1},
"brief": "Something got to this surface before us and it is still holding it. Take every point on the planet - what we pull out of their positions is worth more than the ore is.", "brief": "Something reached this surface before us, and it is still holding it. Take every capture point on the planet. What we pull out of their camps is worth more than the ore.",
# Says out loud where research points come from, because nothing # Says out loud where research points come from, because nothing
# else in the game does. # else in the game does.
"done": "Planet secured. Their equipment reads out as research data - the first we have had. Every doctrine worth having is going to cost some.", "done": "The planet is ours. Their machines gave us research data, the first we have had. Every doctrine worth having will cost some.",
"important": true,
}, },
{ {
# #156: the extractor is the first thing the chain asks for that # #156: the extractor is the first thing the chain asks for that
@ -145,47 +166,47 @@ const CHAIN: Array = [
"title": "THE HOLD IS SMALL", "title": "THE HOLD IS SMALL",
"objective": "Bank 60 ore at the mothership", "objective": "Bank 60 ore at the mothership",
"goal": {"kind": GOAL_BANKED, "resource": "ore", "amount": 60}, "goal": {"kind": GOAL_BANKED, "resource": "ore", "amount": 60},
"brief": "Every hull out here carries twenty and no more, and we are going to need more than twenty. Fly what we have back to the mothership and unload it - what is stored there is not carried by anyone.", "brief": "Each ship carries twenty units and no more, and we are going to need more than twenty. Fly what we have back to the mothership and unload it. What is stored there stays there.",
"done": "Sixty banked. The hold empties, the vault does not - that is the difference, and everything from here on depends on it.", "done": "Sixty in the vault. A hold empties on the next trip out. A vault does not. Everything from here on depends on that.",
}, },
{ {
"id": "extractor", "id": "extractor",
"title": "STANDING MINE", "title": "STANDING MINE",
"objective": "Build an extractor on a planet", "objective": "Build an extractor on a planet",
"goal": {"kind": GOAL_BUILDING, "building": "extractor"}, "goal": {"kind": GOAL_BUILDING, "building": "extractor"},
"brief": "Research Logistics and put an extractor down on a surface. It keeps digging whether we are standing over it or not.", "brief": "Research Logistics, then place an extractor on a planet. It keeps digging whether we are standing over it or not.",
"done": "The extractor is cutting. Ore will be there when we come back for it.", "done": "The extractor is digging. The ore will be waiting when we come back for it.",
}, },
{ {
"id": "gas", "id": "gas",
"title": "THE HEAVY AIR", "title": "THE HEAVY AIR",
"objective": "Bring back 25 gas", "objective": "Bring back 25 gas",
"goal": {"kind": GOAL_RESOURCE, "resource": "gas", "amount": 25}, "goal": {"kind": GOAL_RESOURCE, "resource": "gas", "amount": 25},
"brief": "The banded worlds are full of gas, and gas is the only thing out here that burns. Take the fleet into one and fill the tanks.", "brief": "The striped worlds are full of gas, and gas is the only thing out here that burns. Take the fleet into one and fill the tanks.",
"done": "Twenty-five units of gas aboard. Now we need something that can burn it.", "done": "Twenty-five gas aboard. Now we need something that can burn it.",
}, },
{ {
"id": "hub", "id": "hub",
"title": "FREIGHT", "title": "FREIGHT",
"objective": "Build a transport hub", "objective": "Build a transport hub",
"goal": {"kind": GOAL_BUILDING, "building": "transport_hub"}, "goal": {"kind": GOAL_BUILDING, "building": "transport_hub"},
"brief": "Carrying every load by hand does not scale. Research Freight Handling and build a hub - its transporters run the routes without us.", "brief": "Hauling every load ourselves is slow. Research Freight Handling and build a transport hub. Its transporters fly the routes without us.",
"done": "The hub is running its own traffic. That is our hands back.", "done": "The hub runs its own traffic now. That frees us for other work.",
}, },
{ {
"id": "burner", "id": "burner",
"title": "POWER", "title": "POWER",
"objective": "Build a gas burner", "objective": "Build a gas burner",
"goal": {"kind": GOAL_BUILDING, "building": "gas_burner"}, "goal": {"kind": GOAL_BUILDING, "building": "gas_burner"},
"brief": "Research Power Systems and build a gas burner. Gas in, energy out - and energy is what the better weapons run on.", "brief": "Research Power Systems and build a gas burner. Gas in, energy out. The better weapons run on energy.",
"done": "The burner is lit. There is power on this fleet for the first time since the breach.", "done": "The burner is lit. This fleet has power again for the first time since the breach.",
}, },
{ {
"id": "lab", "id": "lab",
"title": "SHIP SCIENCE", "title": "SHIP SCIENCE",
"objective": "Build a ship lab", "objective": "Build a ship lab",
"goal": {"kind": GOAL_BUILDING, "building": "ship_lab"}, "goal": {"kind": GOAL_BUILDING, "building": "ship_lab"},
"brief": "Research Vessel Science and build a ship lab. Every hull we fly can be made better there, and there are hulls in the archive we have never built.", "brief": "Research Vessel Science and build a ship lab. It makes the ships we fly better, and it opens designs we have never built.",
"done": "The lab is open.", "done": "The lab is open.",
}, },
{ {
@ -193,40 +214,42 @@ const CHAIN: Array = [
"title": "A NEW HULL", "title": "A NEW HULL",
"objective": "Unlock the shield vessel", "objective": "Unlock the shield vessel",
"goal": {"kind": GOAL_VESSEL_UNLOCKED, "vessel": "shield"}, "goal": {"kind": GOAL_VESSEL_UNLOCKED, "vessel": "shield"},
"brief": "The archive has a shield hull in it. Unlock it at the lab - the fleet has been taking every hit on the paint so far.", "brief": "The archive holds a shield ship. Unlock it at the lab. So far the fleet has taken every hit on bare hull.",
"done": "Shield hull unlocked. There is more than one design in that archive.", "done": "The shield ship is unlocked. There is more than one design in that archive.",
}, },
{ {
"id": "outpost", "id": "outpost",
"title": "FORWARD BASES", "title": "FORWARD BASES",
"objective": "Build an outpost", "objective": "Build an outpost",
"goal": {"kind": GOAL_BUILDING, "building": "outpost"}, "goal": {"kind": GOAL_BUILDING, "building": "outpost"},
"brief": "We cannot be everywhere. Research Forward Bases and build an outpost - vessels stationed there hold ground we are not standing on.", "brief": "We cannot be everywhere at once. Research Forward Bases and build an outpost. Ships stationed there hold ground we are not standing on.",
"done": "The outpost is crewed. That is one place we no longer have to fly back to.", "done": "The outpost is up. That is one place we no longer have to fly back to.",
}, },
{ {
"id": "garrison", "id": "garrison",
"title": "STANDING WATCH", "title": "STANDING WATCH",
"objective": "Station a vessel at the outpost", "objective": "Station a vessel at the outpost",
"goal": {"kind": GOAL_GARRISONED, "count": 1}, "goal": {"kind": GOAL_GARRISONED, "count": 1},
"brief": "An empty outpost is a building. Open its hangar and station a vessel there - it holds that ground whether we are there or not.", "brief": "An empty outpost is only a building. Open its hangar and station a ship there. It will hold that ground whether we are there or not.",
"done": "The outpost has a crew. It watches that approach now, and we do not have to.", "done": "The outpost has a crew. It watches that approach now, so we do not have to.",
}, },
{ {
"id": "worlds", "id": "worlds",
"title": "THE SURVEY", "title": "THE SURVEY",
"objective": "Land on three different kinds of world", "objective": "Land on three different kinds of world",
"goal": {"kind": GOAL_PLANET_KINDS, "count": 3}, "goal": {"kind": GOAL_PLANET_KINDS, "count": 3},
"brief": "Rock, gas, and whatever else is out here. Set down on three different kinds of world - if the World Ship passed this way, it passed something.", "brief": "Rock, gas, and whatever else is out here. Land on three different kinds of world. If the World Ship came this way, it passed something.",
"done": "Three kinds of world surveyed. Every one of them empty of anything that came through with us.", "done": "Three kinds of world surveyed. Not one of them holds any sign of the convoy.",
"important": true,
}, },
{ {
"id": "trail", "id": "trail",
"title": "THE TRAIL", "title": "THE TRAIL",
"objective": "Chart 150 sectors", "objective": "Chart 150 sectors",
"goal": {"kind": GOAL_EXPLORE, "sectors": 150}, "goal": {"kind": GOAL_EXPLORE, "sectors": 150},
"brief": "Then we widen it. A hundred and fifty sectors, and every one of them looked at properly. They are out there.", "brief": "Then we search wider. A hundred and fifty sectors, and every one of them looked at properly. They are out there somewhere.",
"done": "A hundred and fifty sectors charted, and no trail. The search does not stop because the list ran out.", "done": "A hundred and fifty sectors charted, and still no trail. The search does not stop just because the list ran out.",
"important": true,
}, },
] ]
@ -241,6 +264,12 @@ static func at(index: int) -> Dictionary:
return CHAIN[index] return CHAIN[index]
# #167: whether a quest's lines are the story rather than instructions. Only
# these are said when the goal was already met the moment it was handed out.
static func is_important(quest: Dictionary) -> bool:
return bool(quest.get("important", false))
static func by_id(id: String) -> Dictionary: static func by_id(id: String) -> Dictionary:
for quest in CHAIN: for quest in CHAIN:
if String(quest.id) == id: if String(quest.id) == id:

216
tests/test_aim_converges.gd Normal file
View file

@ -0,0 +1,216 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_aim_converges.gd
# Forgejo #172: "when aiming, whether with mouse or touch controls, we should
# aim for a given point on the space (still using mouse or the joystick). As
# it's now a point and not a direction, it lets the player try to get the
# fleet's shot converge and not go in parallel. The auto aim when trying to
# aim for an enemy vessel should still work."
#
# The failure this replaces is worth stating, because it is invisible in a
# fleet of one and gets worse with every hull added: every gun was handed the
# SAME direction, so a fleet firing at something threw a sheet of parallel
# bolts and only the ship that happened to be lined up could hit. The wider
# the formation, the further the fire missed by.
#
# Covers:
# A. two hulls either side of a target aim at IT, not along one shared
# heading - and each one's line actually arrives at the point
# B. the mouse hands over a place: the cursor's world position, distance
# included, rather than a bearing from the fleet's centre
# C. a pushed stick still works, resolving to the point at the guns' reach
# along it - the touch player loses nothing
# D. the assist (#89) still bends a shot onto an enemy, now per hull and
# around each hull's own heading to the point
# E. releasing stops every gun in the fleet
# F. range belongs to the gun, not to the aim: a point past reach is fired
# toward rather than refused or clamped into a different heading
const WEAPON = preload("res://vessels/VesselWeaponComponent.gd")
var game_state: Node
var main: Node
var fleet: Node
var controller: Node
var mouse: Node
var stage: int = 0
var elapsed: float = 0.0
var left_ship: Node2D
var right_ship: Node2D
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.intro_active = false
game_state.reset_quests()
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
controller = main.get_node("FireStickController")
mouse = main.get_node("MouseCombatController")
func _fail(message: String) -> bool:
print("TEST_AIM_CONVERGES: FAIL (%s)" % message)
quit(1)
return true
func _clear() -> void:
for node in get_nodes_in_group("enemies") + get_nodes_in_group("asteroids") \
+ get_nodes_in_group("player_projectiles"):
if is_instance_valid(node):
node.free()
func _weapon(ship: Node2D) -> Node:
return ship.get_component("weapon")
# Where a shot from this hull would arrive if it flew far enough: the hull,
# plus its own heading, times the distance to the aimed point. The whole of
# "converging" is that this comes out the same for every hull.
func _lands_at(ship: Node2D, point: Vector2) -> Vector2:
var weapon: Node = _weapon(ship)
return ship.global_position \
+ weapon.firing_heading() * ship.global_position.distance_to(point)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.3:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
_clear()
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray(
[GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR]))
if fleet.ships.size() != 2:
return _fail("wanted two hulls, got %d" % fleet.ships.size())
# Placed by hand, wide apart and level, so the geometry the test
# talks about is the geometry it set up rather than whatever the
# current default formation happens to be.
left_ship = fleet.ships[0]
right_ship = fleet.ships[1]
left_ship.global_position = Vector2(-400.0, 0.0)
right_ship.global_position = Vector2(400.0, 0.0)
if _weapon(left_ship) == null or _weapon(right_ship) == null:
return _fail("an interceptor has no weapon component")
stage = 1
elapsed = 0.0
return false
1:
if elapsed < 0.1:
return false
# --- A. two hulls, one point --------------------------------
var point := Vector2(0.0, -900.0)
controller.aim_at(point)
var left: Vector2 = _weapon(left_ship).firing_heading()
var right: Vector2 = _weapon(right_ship).firing_heading()
if left == Vector2.ZERO or right == Vector2.ZERO:
return _fail("aiming at a point left a gun with no heading")
if left.distance_to(right) < 0.1:
return _fail("both hulls fire along %s - that is a direction, not a point" % left)
for ship in [left_ship, right_ship]:
var lands: Vector2 = _lands_at(ship, point)
if lands.distance_to(point) > 1.0:
return _fail("a hull at %s fires at %s, %.0f from the point it was given" \
% [ship.global_position, lands, lands.distance_to(point)])
print("hulls %.0f apart fire on headings %.0f degrees apart, and both arrive at the point" \
% [left_ship.global_position.distance_to(right_ship.global_position),
rad_to_deg(left.angle_to(right))])
# --- B. the mouse hands over a place ------------------------
mouse.pointer_mode = mouse.PointerMode.ON
mouse.select_weapon(0)
# Deliberately NOT on the line from the fleet's centre through
# anything: a bearing-from-the-centre implementation would still
# pass a point straight ahead.
var cursor := Vector2(650.0, -300.0)
mouse.aim_world = cursor
mouse.firing = true
mouse._process(0.0)
controller._process(0.0)
for ship in [left_ship, right_ship]:
if _weapon(ship).fire_target.distance_to(cursor) > 0.5:
return _fail("the mouse aimed a hull at %s, the cursor was at %s" \
% [_weapon(ship).fire_target, cursor])
if _lands_at(left_ship, cursor).distance_to(_lands_at(right_ship, cursor)) > 1.0:
return _fail("the two hulls' fire does not meet at the cursor")
print("holding the button aims every hull at the cursor's own position")
# --- E. and releasing stops all of them ---------------------
mouse.firing = false
mouse._process(0.0)
controller._process(0.0)
for ship in [left_ship, right_ship]:
if _weapon(ship).is_firing():
return _fail("releasing the button left a gun firing")
print("releasing stops every gun")
# --- C. a stick still resolves to a point -------------------
var reach: float = controller.guide_range()
if reach <= 0.0:
return _fail("the fleet reports no gun range")
var pushed := Vector2(0.0, -1.0)
var resolved: Vector2 = controller.stick_point(pushed)
var wanted: Vector2 = fleet.get_centroid() + pushed * reach
if resolved.distance_to(wanted) > 1.0:
return _fail("a stick pushed up resolved to %s, expected %s" % [resolved, wanted])
controller.aim(pushed)
for ship in [left_ship, right_ship]:
if not _weapon(ship).is_firing():
return _fail("a pushed stick did not fire a gun")
if _weapon(ship).fire_target.distance_to(wanted) > 1.0:
return _fail("a pushed stick aimed a hull somewhere other than the point it names")
print("a pushed stick names the point at the guns' reach (%.0f) along it" % reach)
# --- F. reach is the gun's, not the aim's -------------------
var far_point: Vector2 = fleet.get_centroid() + Vector2(0.0, -reach * 3.0)
controller.aim_at(far_point)
var to_far: Vector2 = (far_point - left_ship.global_position).normalized()
if _weapon(left_ship).firing_heading().distance_to(to_far) > 0.01:
return _fail("a point past the guns' reach bent the heading to %s, expected %s" \
% [_weapon(left_ship).firing_heading(), to_far])
print("a point beyond reach is still fired toward - the bolt falls short, the aim does not bend")
# --- D. the assist still works, per hull --------------------
# Each hull asks its own question now - "is anything within MY
# reach, near MY heading to the point" - so the case worth
# checking is one where the two hulls answer differently. The
# right hull is moved out past its own gun range of the enemy, so
# it cannot be assisted onto it however the angles fall.
_clear()
right_ship.global_position = Vector2(reach * 1.5, 0.0)
var target_point := Vector2(0.0, -300.0)
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
enemy.global_position = left_ship.global_position \
+ (target_point - left_ship.global_position).normalized().rotated(
deg_to_rad(15.0)) * 150.0
main.add_child(enemy)
enemy.speed = 0.0
if right_ship.global_position.distance_to(enemy.global_position) <= reach:
return _fail("the test failed to put the far hull out of range of the enemy")
controller.aim_at(target_point)
var to_enemy: Vector2 = (enemy.global_position - left_ship.global_position).normalized()
if _weapon(left_ship).firing_heading().distance_to(to_enemy) > 0.02:
return _fail("the assist no longer bends a shot onto an enemy near the aim (heading %s, enemy %s)" \
% [_weapon(left_ship).firing_heading(), to_enemy])
var right_wanted: Vector2 = (target_point - right_ship.global_position).normalized()
if _weapon(right_ship).firing_heading().distance_to(right_wanted) > 0.02:
return _fail("a hull out of range of the enemy was still bent onto it - the assist is not per hull")
print("the assist still snaps onto an enemy near the aim, and only for the hull that can reach it")
enemy.free()
print("TEST_AIM_CONVERGES: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://crknehe6qim5k

View file

@ -3,128 +3,79 @@ extends SceneTree
# Headless test: run with # Headless test: run with
# godot --headless -s res://tests/test_definitions_intact.gd # godot --headless -s res://tests/test_definitions_intact.gd
# #
# A guard against a failure mode that has now cost this project twice, and # A guard against a failure mode that has now cost this project three times,
# both times silently. # and every time silently.
# #
# Opening the project in the Godot editor re-saves .tres files, and its # Opening the project in the Godot editor re-saves .tres files, and its writer
# writer DROPS properties it considers default-valued. An empty-looking # DROPS properties it considers default-valued. An empty-looking
# PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost # PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost
# `capabilities` and `collected_outputs` once (which took the gas burner's # `capabilities` and `collected_outputs` once (which took the gas burner's
# whole conversion with it), and InterceptorVesselDefinition later lost # whole conversion with it), and InterceptorVesselDefinition later lost
# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` - # `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` - which
# which cost the starting vessel its cargo hold, so nothing could be mined. # cost the starting vessel its cargo hold, so nothing could be mined.
# #
# Neither showed up as an error. The resource still loaded; it was just # Neither showed up as an error. The resource still loaded; it was just
# quietly less than it had been, and the failure surfaced somewhere else # quietly less than it had been, and the failure surfaced somewhere else
# entirely - a full suite reporting ten unrelated tests broken. # entirely - a full suite reporting ten unrelated tests broken.
# #
# So this asserts the SHAPE of the catalog rather than any one field: every # The checks themselves have moved to core/CatalogCheck.gd, because #173 was
# definition must carry the things that make it what it is, and a capability # the same damage a THIRD time and reached a shipped web build: a fleet that
# a definition claims must have a component behind it. Anything the editor # could not store any resources. A test only guards the tree at the moment
# eats fails here first, next to the thing that was eaten. # somebody runs it, and an export is taken from the tree at whatever moment
# the editor last touched it. The same function now runs at startup in every
# build (GameState._ready) and here, so the two can never drift apart and a
# damaged build says so on its own.
# #
# Covers: # Covers:
# A. every vessel is whole - name, cost, icon, hull, and stats # A. the catalogue is whole - every vessel and building carries the things
# B. a vessel with cargo declares it and has a capacity to go with it # that make it what it is, and the paired facts agree
# C. every building is whole, and its menu wiring is complete # B. the shipped build asks the same question, from the same code, so this
# D. a converter building keeps the fields its conversion needs # test is not a promise the game does not keep
# E. no definition has lost its component list
var failures: Array = [] var stage: int = 0
func _fail(message: String) -> String:
failures.append(message)
return message
# get() answers null for a property a definition's script never declared,
# and null has no is_empty(). These keep a missing field from looking like
# an empty one - and from taking the rest of the sweep down with it.
func _text(definition: Resource, field: String) -> String:
var value: Variant = definition.get(field)
return "" if value == null else str(value)
func _list(definition: Resource, field: String) -> Array:
var value: Variant = definition.get(field)
if value == null:
return []
return Array(value.keys() if value is Dictionary else value)
func _check_vessels() -> void:
for definition in VesselDefinitions.all():
var id: String = String(definition.definition_id)
# --- A. whole ---------------------------------------------------
if String(definition.display_name).strip_edges() == "":
_fail("vessel %s has no display name" % id)
if definition.base_stats.is_empty():
_fail("vessel %s has no stats at all" % id)
# Only the hulls that actually join the fleet: a repair drone is a
# logistics unit driven by GameState, with nothing bolted to it.
if definition.fleet_eligible and definition.component_scenes.is_empty():
_fail("vessel %s flies in the fleet with no components - an empty hull" % id)
if definition.hull_points.is_empty():
_fail("vessel %s has no hull points - it would draw as the fallback triangle" % id)
# --- B. cargo --------------------------------------------------
var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0
var claims: bool = "cargo" in definition.capabilities
if carries != claims:
_fail("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \
% [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities])
if "weapon" in definition.capabilities \
and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0:
_fail("vessel %s claims a weapon and has no range" % id)
func _check_buildings() -> void:
for definition in BuildingDefinitions.all():
var id: String = String(definition.definition_id)
# --- C. whole --------------------------------------------------
if String(definition.display_name).strip_edges() == "":
_fail("building %s has no display name" % id)
if String(definition.description).strip_edges() == "":
_fail("building %s has no description - its info popup would be blank" % id)
if int(definition.cost) <= 0:
_fail("building %s is free" % id)
if int(definition.max_hp) <= 0:
_fail("building %s has no hit points" % id)
# Space-placed structures only: an extractor is a marker on a planet
# rather than a thing standing in the world, and has no scene.
if definition.placement_mode == "space" and definition.visual_scene == null:
_fail("building %s stands in space with nothing to draw" % id)
for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]:
if str(definition.get(field)).strip_edges() == "":
_fail("building %s has no %s - the build menu could not place it" % [id, field])
if definition.capabilities.is_empty():
_fail("building %s declares no capabilities - it would do nothing at all" % id)
# --- D. converters ---------------------------------------------
# These live on the LOGISTICS profile, a sub-resource of the
# definition, not on the definition itself. Asking the definition
# for them answers null and the whole check quietly never fires -
# which is how the first draft of this test passed while the gas
# burner sat there with its collected_outputs missing.
var logistics: Resource = definition.logistics
if logistics != null and _text(logistics, "conversion_input") != "":
if _text(logistics, "conversion_output") == "":
_fail("building %s converts something into nothing" % id)
if _list(logistics, "collected_outputs").is_empty():
_fail("building %s converts but lists no collected outputs - what it makes would never be fetched" % id)
if _list(logistics, "input_buffers").is_empty():
_fail("building %s converts with no input buffer" % id)
func _initialize() -> void: func _initialize() -> void:
_check_vessels() # --- A. nothing has been eaten ---------------------------------------
_check_buildings() var faults: PackedStringArray = CatalogCheck.faults()
if not failures.is_empty(): if not faults.is_empty():
for message in failures: for message in faults:
print(" - %s" % message) print(" - %s" % message)
print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" % failures.size()) print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" \
% faults.size())
quit(1) quit(1)
return return
print("%d vessels and %d buildings, all intact" \ print("%d vessels and %d buildings, all intact" \
% [VesselDefinitions.all().size(), BuildingDefinitions.all().size()]) % [VesselDefinitions.all().size(), BuildingDefinitions.all().size()])
func _process(_delta: float) -> bool:
if stage != 0:
return false
stage = 1
# --- B. and the build itself checked, from the same code -------------
# The whole point of #173 is that the suite is not where this damage gets
# noticed. GameState runs the check on startup and keeps what it found, so
# a build with an eaten .tres reports it instead of quietly failing to
# mine - on the web, into the on-page error log.
var game_state: Node = root.get_node("GameState")
if not (game_state.catalog_faults is PackedStringArray):
return _fail("GameState does not record what the startup check found")
if not game_state.catalog_faults.is_empty():
return _fail("the running build reports %d catalog fault(s) this test did not" \
% game_state.catalog_faults.size())
var source: String = FileAccess.get_file_as_string("res://core/GameState.gd")
if "CATALOG_CHECK.faults()" not in source or "push_error" not in source:
return _fail("GameState no longer runs the catalog check at startup, so a damaged build would ship silently again")
print("the running build ran the same check on startup and found nothing")
print("TEST_DEFINITIONS_INTACT: PASS") print("TEST_DEFINITIONS_INTACT: PASS")
quit(0) quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_DEFINITIONS_INTACT: FAIL (%s)" % message)
quit(1)
return true

View file

@ -83,16 +83,23 @@ func _process(delta: float) -> bool:
print("weapon 1 is the fleet's guns, weapon 2 is the gas bomb, %d in total" \ print("weapon 1 is the fleet's guns, weapon 2 is the gas bomb, %d in total" \
% mouse.weapon_count()) % mouse.weapon_count())
# --- F. inert where a touchscreen exists ----------------------- # --- F. inert while the player is using touch ------------------
# Checked against the real capability rather than forced, so this # #171: the gate used to be "does this machine have a
# reports what will actually happen on the machine it runs on. # touchscreen", which is a capability rather than a fact and is
print("touchscreen present: %s -> mouse combat enabled: %s" \ # answered wrongly by every browser on a touch-capable laptop. It
% [DisplayServer.is_touchscreen_available(), mouse.is_enabled()]) # is now the last input actually used - which, unlike the display
# query, can be driven here, so both branches are exercised
# instead of whichever one this machine happens to report.
if mouse.pointer_mode != mouse.PointerMode.AUTO: if mouse.pointer_mode != mouse.PointerMode.AUTO:
return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested") return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested")
if mouse.is_enabled() == DisplayServer.is_touchscreen_available(): game_state.note_touch_input()
return _fail("mouse combat is not gated on the absence of a touchscreen") if mouse.is_enabled():
print("gated correctly on the display's own answer") return _fail("mouse combat is armed while the player is using touch - a tap would fire the guns")
game_state.last_touch_msec = -1
game_state.note_pointer_input()
if not mouse.is_enabled():
return _fail("mouse combat is off for a player using a mouse")
print("gated on the last input used: off under touch, on under a mouse")
# Everything below is about the AIMING, not the guard. Headless # Everything below is about the AIMING, not the guard. Headless
# reports a touchscreen, so the mode is forced on here - otherwise # reports a touchscreen, so the mode is forced on here - otherwise

View file

@ -4,35 +4,51 @@ extends SceneTree
# godot --headless -s res://tests/test_desktop_hides_touch_controls.gd # godot --headless -s res://tests/test_desktop_hides_touch_controls.gd
# Forgejo #85: on a computer the on-screen sticks and the pivot pair are dead # Forgejo #85: on a computer the on-screen sticks and the pivot pair are dead
# weight - WASD steers, the mouse aims, Q/E pivot. # weight - WASD steers, the mouse aims, Q/E pivot.
# Forgejo #171: "the web export version should auto detect keyboard mouse or
# screen and remove the touch screen controls if not needed."
#
# #85 asked DisplayServer.is_touchscreen_available() and, if it said yes, kept
# the controls for ever. In a browser that query says yes on any machine whose
# panel or pointer reports touch support, so the web build put thumb sticks
# over a screen being played with a mouse - and the decision could not be
# revisited, because it was made once.
#
# The rule is now the last input the player ACTUALLY used, which is a fact
# rather than a capability, and it is two-way. That also removes the reason
# the old rule had to be so cautious: nothing can strand a tablet with no
# controls, because the gesture that needs them is the gesture that brings
# them back.
# #
# Covers: # Covers:
# A. they all start visible, so this is testing a change and not a default # A. they all start visible, so this is testing a change and not a default
# B. using a keyboard hides all four at once on a machine with no # B. a keyboard takes all four away, together
# touchscreen # C. a touch brings all four back, together - on any device, whatever the
# C. a touch device keeps every one of them, even with a keyboard attached - # display claims
# a finger is still a way to play it # D. a mouse takes them away again, so the detection is not one-shot
# # E. the manufactured event is not believed: Godot makes a mouse click out
# The touchscreen answer is read from DisplayServer, and headless does not # of every touch, and one arriving straight after a touch must not read
# answer the same way everywhere: Godot on Android says yes, Godot on Windows # as "this player has a mouse"
# says no. So B is exercised by driving the handler directly under a stubbed # F. the two consumers agree - the same reading gates mouse aiming, so the
# answer, and C is skipped - not failed - wherever there is no touchscreen to # sticks and the guns can never disagree about what is being held
# exercise it with. That is the whole behaviour: "no touchscreen + keyboard
# used".
const CONTROLS: Array = ["UI/Joystick", "UI/FireStick", "UI/PivotCWButton", "UI/PivotCCWButton"] const CONTROLS: Array = ["UI/Joystick", "UI/FireStick", "UI/PivotCWButton", "UI/PivotCCWButton"]
var game_state: Node
var main: Node var main: Node
var controller: Node var controller: Node
var mouse: Node
var stage: int = 0 var stage: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
func _initialize() -> void: func _initialize() -> void:
root.get_node("GameState").reset_progression() game_state = root.get_node("GameState")
game_state.reset_progression()
main = load("res://world/Main.tscn").instantiate() main = load("res://world/Main.tscn").instantiate()
root.add_child(main) root.add_child(main)
current_scene = main current_scene = main
controller = main.get_node("GameController") if main.has_node("GameController") else main controller = main.get_node("GameController") if main.has_node("GameController") else main
mouse = main.get_node_or_null("MouseCombatController")
func _all_visible() -> bool: func _all_visible() -> bool:
@ -49,6 +65,13 @@ func _any_visible() -> bool:
return false return false
# A touch far enough in the past that the grace window has expired. Ageing the
# stamp rather than sleeping for a second: the window is what is under test,
# not the wall clock.
func _forget_touch() -> void:
game_state.last_touch_msec = -1
func _process(delta: float) -> bool: func _process(delta: float) -> bool:
elapsed += delta elapsed += delta
if stage != 0: if stage != 0:
@ -62,43 +85,54 @@ func _process(delta: float) -> bool:
return _fail("a touch control was already hidden before any input") return _fail("a touch control was already hidden before any input")
print("all four touch controls start visible") print("all four touch controls start visible")
# --- C. a touchscreen keeps them ---------------------------------- # --- B. a keyboard takes them away --------------------------------
# Only where the machine actually reports one: there the real code path _forget_touch()
# is the touch-device one and can be driven as-is. Where it reports none controller._note_keyboard()
# there is nothing to drive, and the static check at the end of B still
# guards the touchscreen condition itself, so skip rather than fail.
if DisplayServer.is_touchscreen_available():
controller.keyboard_seen = false
controller._note_keyboard()
if not _all_visible():
return _fail("a touch device hid its controls when a keyboard was used")
print("touch device keeps all four even after keyboard input")
else:
print("SKIP case C: this environment reports no touchscreen")
# --- B. no touchscreen: they all go -------------------------------
# The guard is the FIRST thing _note_keyboard checks, so the branch is
# reproduced here rather than faked: hide exactly what the function
# hides, then assert the function agrees.
for path in CONTROLS:
main.get_node(path).visible = false
if _any_visible(): if _any_visible():
return _fail("could not stage the hidden state") return _fail("using a keyboard left a touch control on screen")
print("with no touchscreen all four are hidden together") if game_state.pointer_is_touch:
return _fail("a keyboard did not change the reading to \"not touch\"")
print("using a keyboard hides all four together")
# The four are hidden as one list, so a control added to that list later # --- C. and a touch brings them back ------------------------------
# is covered without editing this test. Guard the list itself instead. # The case the old one-shot rule could not do at all, and the reason the
var source: FileAccess = FileAccess.open("res://controllers/GameController.gd", FileAccess.READ) # new rule is safe to apply on every platform rather than only where the
if source == null: # display says there is no touchscreen.
return _fail("could not read GameController.gd") controller._note_touch()
var text: String = source.get_as_text() if not _all_visible():
source.close() return _fail("touching the screen did not bring the touch controls back")
for control_name in ["joystick", "fire_stick", "pivot_cw_button", "pivot_ccw_button"]: if not game_state.pointer_is_touch:
if control_name not in text.split("func _note_keyboard()")[1].split("func ")[0]: return _fail("a touch did not change the reading to \"touch\"")
return _fail("_note_keyboard no longer hides %s" % control_name) print("a touch brings all four back")
if "is_touchscreen_available" not in text.split("func _note_keyboard()")[1].split("func ")[0]:
return _fail("_note_keyboard no longer checks for a touchscreen before hiding anything") # --- E. the manufactured click is not believed --------------------
print("_note_keyboard hides all four, and only where there is no touchscreen") # Godot turns every touch into a click (emulate_mouse_from_touch), so the
# click below is what a phone produces one frame after the touch above.
# Believing it would take the controls away from a player holding the
# device in their hands.
controller._note_keyboard()
if not _all_visible():
return _fail("a click arriving right after a touch took the controls away - that click is the touch")
print("a click in the shadow of a touch is ignored")
# --- D. a real mouse, later, does take them away ------------------
_forget_touch()
controller._note_keyboard()
if _any_visible():
return _fail("a mouse used well after the last touch did not hide the controls")
print("a mouse used later hides them again - the detection is not one-shot")
# --- F. and the guns read the same answer -------------------------
if mouse == null:
return _fail("no MouseCombatController in the scene")
if mouse.pointer_mode != mouse.PointerMode.AUTO:
return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested")
if not mouse.is_enabled():
return _fail("the controls are hidden for a mouse but mouse aiming is off")
controller._note_touch()
if mouse.is_enabled():
return _fail("a touch put the thumb controls back but left mouse firing armed - a tap would fire the guns")
print("mouse aiming follows the same reading the controls do")
print("TEST_DESKTOP_HIDES_TOUCH_CONTROLS: PASS") print("TEST_DESKTOP_HIDES_TOUCH_CONTROLS: PASS")
quit(0) quit(0)

View file

@ -21,6 +21,10 @@ extends SceneTree
# E. it types while the game is stopped, being the one node still running # E. it types while the game is stopped, being the one node still running
# F. the sci-fi part: the panel is drawn differently while decoding, and # F. the sci-fi part: the panel is drawn differently while decoding, and
# settles when the line is complete # settles when the line is complete
# G. #168 - a KEY does exactly what a click does, both halves of it, since
# the game is played on a desktop with WASD and a mouse and pressing
# space at a dialog reached nothing at all. Modifiers alone and held-key
# echoes are not presses, or a line would be run out by a held key.
var game_state: Node var game_state: Node
var scene: Node var scene: Node
@ -53,6 +57,14 @@ func _click() -> void:
narration._unhandled_input(press) narration._unhandled_input(press)
func _key(keycode: int, echo: bool = false) -> void:
var press: InputEventKey = InputEventKey.new()
press.keycode = keycode
press.pressed = true
press.echo = echo
narration._unhandled_input(press)
func _process(delta: float) -> bool: func _process(delta: float) -> bool:
elapsed += delta elapsed += delta
match stage: match stage:
@ -139,6 +151,38 @@ func _process(delta: float) -> bool:
return _fail("dismissing landed on \"%s\"" % narration.current) return _fail("dismissing landed on \"%s\"" % narration.current)
print("the press after that moves on to \"%s\"" % narration.current) print("the press after that moves on to \"%s\"" % narration.current)
# --- G. #168: a key is a press too -----------------------
narration.clear()
narration.say(first_line, "TEST")
narration.say("Second.", "TEST")
if not narration.is_typing():
return _fail("the fresh line was not arriving")
# Not everything that reaches the banner is a press.
_key(KEY_SHIFT)
_key(KEY_SPACE, true)
if not narration.is_typing():
return _fail("a modifier or a held-key echo finished the line")
# Still arriving: the key shows the whole of it, and no more.
_key(KEY_SPACE)
if narration.is_typing():
return _fail("a key did not finish a line that was still arriving")
if narration.current != first_line:
return _fail("a key on an arriving line dismissed it - the next line would be skipped unread")
# Whole: the key moves on, the same as a click.
_key(KEY_SPACE)
if narration.current != "Second.":
return _fail("a key on a finished line landed on \"%s\"" % narration.current)
# Any key, not one chosen key - the player has no reason to guess.
_key(KEY_W)
if narration.is_typing():
return _fail("only some keys finish a line")
_key(KEY_ENTER)
if narration.is_speaking():
return _fail("a key on the last line did not close the dialog")
if paused:
return _fail("keying through every line left the game stopped")
print("space, W and enter all finish then advance a line; shift and echoes do not")
print("TEST_DIALOG_TYPING: PASS") print("TEST_DIALOG_TYPING: PASS")
quit(0) quit(0)
return true return true

View file

@ -33,6 +33,8 @@ extends SceneTree
# acknowledging it is what starts the world # acknowledging it is what starts the world
# Q. #133 - the handover runs straight into the first quest # Q. #133 - the handover runs straight into the first quest
# R. #139 - no belt rock is laid down inside the visible screen # 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
var game_state: Node var game_state: Node
var scene: Node var scene: Node
@ -97,10 +99,43 @@ func _process(delta: float) -> bool:
if not director.running: if not director.running:
return _fail("the director did not start on a new game") 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 -------------------- # --- N. a dialog waits for the reader --------------------
var first_line: String = narration.label.text var first_line: String = narration.label.text
if first_line.strip_edges() == "": if first_line.strip_edges() == "":
return _fail("the prologue opened with an empty banner") 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 # Ten seconds of clock, with nothing pressed. A timer-driven
# banner would have moved on several times over. # banner would have moved on several times over.
for i in range(600): for i in range(600):
@ -112,10 +147,10 @@ func _process(delta: float) -> bool:
# --- P. and the game is STOPPED behind it (#128) --------- # --- P. and the game is STOPPED behind it (#128) ---------
if not paused: if not paused:
return _fail("the prologue opened on a line with the game still running") return _fail("the prologue opened on a line with the game still running")
if director.beat_time > 0.01: if absf(director.beat_time - clock_at_line) > 0.01:
return _fail("the director's clock ran on %.2fs behind an unread line" \ return _fail("the director's clock ran on %.2fs behind an unread line" \
% director.beat_time) % absf(director.beat_time - clock_at_line))
print("the prologue opens stopped: beat clock at %.2fs" % director.beat_time) print("the line stopped the game: beat clock held at %.2fs" % director.beat_time)
var click: InputEventMouseButton = InputEventMouseButton.new() var click: InputEventMouseButton = InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT click.button_index = MOUSE_BUTTON_LEFT

View file

@ -75,15 +75,21 @@ func _process(delta: float) -> bool:
_key(k, false) _key(k, false)
print("all %d key combinations produced the right heading" % cases.size()) print("all %d key combinations produced the right heading" % cases.size())
# The stick must survive on a touch device. # #171: the stick follows the last input actually used, rather
var stick_kept: bool = joystick.visible # than the display's claim about what could arrive. Using the
print("stick still visible after keyboard use: %s (touchscreen=%s)" % [ # keyboard takes it away - on any device - and a touch brings it
stick_kept, DisplayServer.is_touchscreen_available() # straight back, which is what keeps that safe.
]) if joystick.visible:
if DisplayServer.is_touchscreen_available() and not stick_kept: print("TEST_KEYBOARD_INPUT: FAIL (the stick survived keyboard use)")
print("TEST_KEYBOARD_INPUT: FAIL (took the stick away on a touch device)")
quit(1) quit(1)
return true return true
controller._note_touch()
if not joystick.visible:
print("TEST_KEYBOARD_INPUT: FAIL (a touch did not bring the stick back)")
quit(1)
return true
print("keyboard use hides the stick; a touch brings it back")
root.get_node("GameState").last_touch_msec = -1
# --- holding a key flies the fleet ---------------------------- # --- holding a key flies the fleet ----------------------------
start_centroid = fleet.get_centroid() start_centroid = fleet.get_centroid()

View file

@ -8,6 +8,13 @@ extends SceneTree
# 2. there is no Quit entry in any build - a web build has nothing to quit # 2. there is no Quit entry in any build - a web build has nothing to quit
# to, and Android's back gesture already does it # to, and Android's back gesture already does it
# 3. Play resets GameState to a fresh game AND reaches Main.tscn # 3. Play resets GameState to a fresh game AND reaches Main.tscn
# 4. #169 - a press puts a loading screen up FIRST and swaps the scene
# afterwards. A button that does nothing for several seconds reads as a
# button that was not pressed, which is how the web build reads; the
# order is the whole fix, so it is the thing asserted.
# 5. #169 - a second press while a load is running is ignored, or an
# impatient double tap builds the world twice (on the web a button fires
# on PRESS, so that tap costs nothing to make)
var menu: Node var menu: Node
var game_state: Node var game_state: Node
@ -46,17 +53,37 @@ func _process(delta: float) -> bool:
return _fail("the menu still carries a Quit entry") return _fail("the menu still carries a Quit entry")
_press(menu.editor_button) _press(menu.editor_button)
# --- 4. the cover goes up before anything blocks --------------
if menu.loading_path != "res://surface/PlanetSetupEditor.tscn":
return _fail("the press did not start a load (loading_path %s)" % menu.loading_path)
if menu.loading_screen == null or not menu.loading_screen.is_inside_tree():
return _fail("the press started a load with no loading screen up")
if current_scene != menu:
return _fail("the scene swapped inside the press, so nothing was ever shown")
print("pressing an entry raises the loading screen and leaves the menu up")
# --- 5. and a second press changes nothing --------------------
var screen: Node = menu.loading_screen
_press(menu.editor_button)
_press(menu.play_button)
if menu.loading_screen != screen \
or menu.loading_path != "res://surface/PlanetSetupEditor.tscn":
return _fail("a second press while loading started another scene")
print("further presses while a load is running are ignored")
elapsed = 0.0 elapsed = 0.0
stage = 1 stage = 1
return false return false
1: 1:
# _start awaits a frame so its status text paints before the # Waited on rather than timed: the load is spread over frames now,
# blocking load, so the change takes a couple of frames. # and how many it takes is a property of the machine.
if elapsed < 1.0:
return false
var on_editor: bool = current_scene != null and current_scene.name == "PlanetSetupEditor" var on_editor: bool = current_scene != null and current_scene.name == "PlanetSetupEditor"
if not on_editor: if not on_editor:
return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn") if elapsed < 30.0:
return false
return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn in 30s")
print("the load finished and the scene swapped")
# Return to the menu the same way the player would, then test Play. # Return to the menu the same way the player would, then test Play.
menu = load("res://ui/MainMenu.tscn").instantiate() menu = load("res://ui/MainMenu.tscn").instantiate()
@ -80,9 +107,9 @@ func _process(delta: float) -> bool:
stage = 3 stage = 3
return false return false
3: 3:
if elapsed < 1.0:
return false
var on_main: bool = current_scene != null and current_scene.name == "Main" var on_main: bool = current_scene != null and current_scene.name == "Main"
if not on_main and elapsed < 30.0:
return false
# A normal game now starts with NOTHING banked - the opening # A normal game now starts with NOTHING banked - the opening
# mining run is the tutorial, and 500 free ore skipped it. # mining run is the tutorial, and 500 free ore skipped it.
var reset_ok: bool = game_state.rocks == 0 and game_state.buildings.is_empty() \ var reset_ok: bool = game_state.rocks == 0 and game_state.buildings.is_empty() \

View file

@ -39,6 +39,9 @@ extends SceneTree
# is announced down there, not on the way back up, and the objective is # is announced down there, not on the way back up, and the objective is
# reachable from a button there as well # reachable from a button there as well
# K. landing is recorded, both the planet and the kind of world it was # K. landing is recorded, both the planet and the kind of world it was
# L. #167 - a TEACHING quest handed out with its goal already met is
# credited without a word, while a STORY quest in the same position is
# still read out
const QUESTS = preload("res://core/Quests.gd") const QUESTS = preload("res://core/Quests.gd")
@ -316,6 +319,61 @@ func _catalogue_cases() -> bool:
return _fail("there is still an active quest after the chain ended") return _fail("there is still an active quest after the chain ended")
game_state._advance_quests() game_state._advance_quests()
print("the chain walks to the end and stops there") print("the chain walks to the end and stops there")
# --- L. #167: nothing is announced twice about a job already done ----
# The chain is handed out in a fixed order, so by the time some of its
# teaching quests come up the player has long since done the thing -
# banked the ore, put the extractor down. Announcing one of those stops
# the game to set a task, then stops it again to congratulate them on a
# task they never did.
#
# The story quests are the exception, and the flag has to actually
# separate the two: a chain where everything is important skips nothing,
# and one where nothing is skips the plot.
var important: int = 0
for i in range(QUESTS.count()):
if QUESTS.is_important(QUESTS.at(i)):
important += 1
if important == 0:
return _fail("no quest carries the story, so every already-met quest would be silent")
if important == QUESTS.count():
return _fail("every quest is important, so nothing is ever skipped")
for story in ["mothership", "planetfall"]:
if not QUESTS.is_important(QUESTS.by_id(story)):
return _fail("the %s quest is not marked important - #167 names it as story" % story)
print("%d of %d quests carry the story, the rest are teaching" \
% [important, QUESTS.count()])
game_state.reset_new_game()
game_state.intro_active = false
# Enough ore that the first two quests are both already satisfied the
# moment they are handed out. One is story, one is teaching.
game_state.bank_inventory.deposit(GameState.RESOURCE_ORE, 500)
if not QUESTS.is_important(QUESTS.at(0)) or QUESTS.is_important(QUESTS.at(1)):
return _fail("this case needs a story quest followed by a teaching one, and the chain opens %s then %s" \
% [QUESTS.at(0).id, QUESTS.at(1).id])
game_state._advance_quests()
if game_state.quest_index != 0 or game_state.quest_messages.is_empty():
return _fail("a story quest whose goal was already met was skipped - the plot went with it")
game_state.take_quest_messages()
game_state._advance_quests()
if game_state.quest_index != 1:
return _fail("the story quest did not close on the frame after it was announced")
game_state.take_quest_messages()
var skipped: String = String(QUESTS.at(1).id)
game_state._advance_quests()
if game_state.quest_index != 2:
return _fail("the already-met %s quest was announced instead of being banked" % skipped)
if not game_state.quest_messages.is_empty():
return _fail("the skipped %s quest still said \"%s\"" \
% [skipped, game_state.quest_messages[0].line])
# ...and it was CREDITED, not dropped. quest_index is what the objectives
# panel counts its DONE list off, so the player still sees it ticked.
if String(game_state.quest_at(game_state.quest_index - 1).id) != skipped:
return _fail("the skipped quest is not the last one in the done list")
print("\"%s\" was already done when it was handed out: credited, and not a word said" \
% QUESTS.by_id(skipped).title)
return false return false

126
ui/LoadingScreen.gd Normal file
View file

@ -0,0 +1,126 @@
class_name LoadingScreen
extends Control
# Forgejo #169: "create a loading screen after clicking on the main menu
# buttons."
#
# Pressing Play used to do nothing visible for as long as the scene took to
# build - several seconds in a browser - and a button that does nothing reads
# as a button that was not pressed, so the player presses it again. The whole
# job of this is to answer the press immediately: a cover over the menu, the
# name of what is being opened, and a bar that moves.
#
# Two things make the answer immediate rather than merely eventual:
#
# 1. It goes up BEFORE the load starts, and the load waits for it to have
# been on screen for a frame or two (see `frames_shown` and
# MainMenu._process). A screen raised in the same frame as a blocking
# load is a screen nobody ever sees.
# 2. The bar reports nothing it does not know. The load is one blocking call
# (see MainMenu._process for why it is not a threaded one), so there is
# no progress to report and the bar does not pretend there is: it is a
# sweep, which says "working" without claiming to say how far.
#
# A Control built in code rather than a scene: it is one panel of geometry
# with nothing to author, and building it here keeps MainMenu.tscn - which the
# player's own editor touches - out of the change.
const BACKDROP: Color = Color(0.01, 0.02, 0.06, 0.94)
const GLOW: Color = Color(0.42, 0.86, 0.95)
const TEXT: Color = Color(0.82, 0.94, 1.0)
const MUTED: Color = Color(0.52, 0.66, 0.76)
const TITLE_SIZE: int = 44
const SUBTITLE_SIZE: int = 24
const BAR_WIDTH: float = 520.0
const BAR_HEIGHT: float = 16.0
const BAR_GAP: float = 46.0
# The travelling band inside the bar, and how much of the bar it covers.
const SWEEP_SECONDS: float = 1.2
const SWEEP_WIDTH: float = 0.22
var title_label: Label
var subtitle_label: Label
# How many frames this has been up and running. MainMenu waits for this before
# it starts the blocking part - see the note above. Counted in _process rather
# than in _draw so the count still advances under the headless renderer, where
# nothing paints and a test would otherwise wait for ever.
var frames_shown: int = 0
var _elapsed: float = 0.0
func _ready() -> void:
# Covers whatever is behind it and swallows nothing: the menu's buttons
# check for a modal themselves (see IconButton._blocked_by_modal), and
# MainMenu refuses a second press while a load is running anyway.
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_FULL_RECT)
title_label = _make_label("LOADING", TITLE_SIZE, TEXT)
subtitle_label = _make_label("", SUBTITLE_SIZE, MUTED)
resized.connect(_layout)
_layout()
func _make_label(text_value: String, font_size: int, colour: Color) -> Label:
var label := Label.new()
label.text = text_value
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", colour)
label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95))
label.add_theme_constant_override("outline_size", 3)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
add_child(label)
return label
# What is being opened, in the player's words rather than a file name.
func begin(what: String) -> void:
if subtitle_label != null:
subtitle_label.text = what
func _bar_rect() -> Rect2:
var width: float = minf(BAR_WIDTH, maxf(size.x - 120.0, 40.0))
return Rect2(Vector2((size.x - width) * 0.5, size.y * 0.5 + BAR_GAP),
Vector2(width, BAR_HEIGHT))
func _layout() -> void:
if title_label == null:
return
title_label.position = Vector2(0.0, size.y * 0.5 - float(TITLE_SIZE) * 1.4)
title_label.size = Vector2(size.x, float(TITLE_SIZE) * 1.4)
subtitle_label.position = Vector2(0.0, size.y * 0.5)
subtitle_label.size = Vector2(size.x, float(SUBTITLE_SIZE) * 1.6)
queue_redraw()
func _process(delta: float) -> void:
_elapsed += delta
frames_shown += 1
queue_redraw()
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, size), BACKDROP)
var bar: Rect2 = _bar_rect()
draw_rect(bar, Color(GLOW, 0.14))
# A band travelling the length of the bar. It stops moving while the load
# blocks, which is honest - the whole engine has stopped - and it is the
# thing that says "working" on the frames either side of that.
var head: float = fposmod(_elapsed, SWEEP_SECONDS) / SWEEP_SECONDS
var band: float = bar.size.x * SWEEP_WIDTH
var x: float = bar.position.x + (bar.size.x + band) * head - band
var left: float = maxf(x, bar.position.x)
var right: float = minf(x + band, bar.end.x)
if right > left:
draw_rect(Rect2(Vector2(left, bar.position.y),
Vector2(right - left, bar.size.y)), Color(GLOW, 0.8))
NeonDraw.draw_glowing_path(self, PackedVector2Array([
bar.position, Vector2(bar.end.x, bar.position.y), bar.end,
Vector2(bar.position.x, bar.end.y), bar.position,
]), Color(GLOW, 0.8), 0.7, 0.5)

1
ui/LoadingScreen.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://dneh3kfa08bv8

View file

@ -32,6 +32,25 @@ const STATUS_HEIGHT: float = 70.0
@onready var dev_label: Label = $UI/DevModeLabel @onready var dev_label: Label = $UI/DevModeLabel
@onready var status_label: Label = $UI/StatusLabel @onready var status_label: Label = $UI/StatusLabel
# #169: what is being opened, and the cover that says so. Empty path means
# nothing is loading, which is also what stops a second press starting a
# second scene - on the web a button fires on PRESS (see IconButton), and an
# impatient double tap on Play would otherwise build the world twice.
const LOADING_SCREEN = preload("res://ui/LoadingScreen.gd")
# How the player is told what they are waiting for. Keyed by scene path so a
# new entry cannot go in without a name to put on the screen.
const SCENE_NAMES: Dictionary = {
"res://world/Main.tscn": "Entering the convoy lane",
"res://surface/PlanetSetupEditor.tscn": "Opening the map editor",
}
# The load only starts once the cover has been up for a frame or two. One
# would do; two is cheap and removes any argument about whether the first one
# reached the screen.
const COVER_FRAMES: int = 2
var loading_path: String = ""
var loading_screen: Control = null
func _ready() -> void: func _ready() -> void:
# #148: the theme starts here, at the first screen, and keeps playing # #148: the theme starts here, at the first screen, and keeps playing
@ -93,6 +112,11 @@ func _layout_stack() -> void:
func _on_play_pressed() -> void: func _on_play_pressed() -> void:
# Checked here as well as in _start: this one has a side effect, and a
# second press while the first is still loading must not wipe the game
# that first press already set up (#169).
if loading_path != "":
return
GameState.reset_new_game() GameState.reset_new_game()
_start("res://world/Main.tscn") _start("res://world/Main.tscn")
@ -102,21 +126,71 @@ func _on_play_pressed() -> void:
# there was no way to tell which part failed. Split out, each step can say so # there was no way to tell which part failed. Split out, each step can say so
# - and a scene change that fails otherwise leaves the menu up, which looks # - and a scene change that fails otherwise leaves the menu up, which looks
# exactly like an unpressed button. # exactly like an unpressed button.
#
# #169: and now it does not look like anything else either. The press raises a
# loading screen straight away and the load itself happens over the following
# frames, driven from _process below, so the wait is something the player is
# watching rather than a menu that has stopped responding.
func _start(scene_path: String) -> void: func _start(scene_path: String) -> void:
if loading_path != "":
return
loading_path = scene_path
status_label.text = ""
loading_screen = LOADING_SCREEN.new()
$UI.add_child(loading_screen)
loading_screen.begin(String(SCENE_NAMES.get(scene_path, scene_path.get_file())))
# The load itself is still the ordinary blocking one, deliberately.
#
# ResourceLoader.load_threaded_request() was the obvious alternative and it
# does not work here: asked for these scenes it comes back FAILED, with the
# text-scene parser reporting errors ("Parse Error ... Rock.tscn:9", "Could
# not resolve external class member PROFILES") that the same files do not
# produce through load(). The project's scripts and scenes reach each other
# through global class names and preloads, and that resolution is not
# available off the loading thread. A progress bar is not worth a menu that
# sometimes refuses to start the game - and the web build could not have used
# the threaded path anyway, since it ships with thread support off.
#
# So what the cover buys is honest and worth having on its own: the press is
# answered instantly, the player is told what is opening, and the frame that
# blocks happens with something on the screen rather than behind a menu that
# has stopped responding.
func _process(_delta: float) -> void:
if loading_path == "":
return
# Nothing blocking until the cover is actually up - see LoadingScreen's
# own note. This is the whole reason the load waits for a frame at all.
if loading_screen == null or loading_screen.frames_shown < COVER_FRAMES:
return
var scene_path: String = loading_path
loading_path = ""
var packed: PackedScene = load(scene_path) var packed: PackedScene = load(scene_path)
if packed == null: if packed == null:
status_label.text = "Could not load %s" % scene_path.get_file() _abandon_loading("Could not load %s" % scene_path.get_file())
return return
var scene: Node = packed.instantiate() var scene: Node = packed.instantiate()
if scene == null: if scene == null:
status_label.text = "Could not build %s" % scene_path.get_file() _abandon_loading("Could not build %s" % scene_path.get_file())
return return
get_tree().root.add_child(scene) get_tree().root.add_child(scene)
get_tree().current_scene = scene get_tree().current_scene = scene
queue_free() queue_free()
# A failed load must leave a working menu behind, not a cover over a screen
# with nothing coming.
func _abandon_loading(message: String) -> void:
if loading_screen != null:
loading_screen.queue_free()
loading_screen = null
status_label.text = message
func _on_dev_mode_pressed() -> void: func _on_dev_mode_pressed() -> void:
if loading_path != "":
return
# Guarded as well as hidden. Visibility is presentation; this is the rule. # Guarded as well as hidden. Visibility is presentation; this is the rule.
if not is_developer_build(): if not is_developer_build():
status_label.text = "Dev mode only runs from Godot" status_label.text = "Dev mode only runs from Godot"

View file

@ -35,6 +35,10 @@ extends Control
# is not a flourish: without it, a player who clicks quickly through two # is not a flourish: without it, a player who clicks quickly through two
# lines never sees the second one at all, and the whole point of #127 was # lines never sees the second one at all, and the whole point of #127 was
# that lines wait for the reader. # that lines wait for the reader.
#
# #168 says the same rule out loud - still arriving, show it all; already
# whole, move on - and extends it to the KEYBOARD, which had no way in at
# all. See _is_advance_press.
signal dismissed signal dismissed
@ -263,13 +267,35 @@ func _process(delta: float) -> void:
queue_redraw() queue_redraw()
# #168: what counts as "go on". A tap, a left click, or a key.
#
# The rule the ticket asks for was already the rule for a click (#149) - a
# press finishes a line still arriving, a press on a finished line moves to
# the next - but the game is played on a desktop with WASD and a mouse, and
# a keyboard press reached nothing at all. Sitting behind a dialog pressing
# space is the most obvious thing a player can do, so the keyboard now goes
# through the same one door every other press does (dismiss()).
#
# Modifiers alone are not a press: holding shift before typing must not eat
# a line. Neither is an auto-repeat echo - a held key would otherwise run
# the whole queue out in a second.
func _is_advance_press(event: InputEvent) -> bool:
if event is InputEventScreenTouch:
return event.pressed
if event is InputEventMouseButton:
return event.pressed and event.button_index == MOUSE_BUTTON_LEFT
if event is InputEventKey:
if not event.pressed or event.echo:
return false
return not (event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META,
KEY_CAPSLOCK, KEY_NUMLOCK, KEY_SCROLLLOCK])
return false
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if current == "": if current == "":
return return
var pressed: bool = (event is InputEventScreenTouch and event.pressed) \ if not _is_advance_press(event):
or (event is InputEventMouseButton and event.pressed \
and event.button_index == MOUSE_BUTTON_LEFT)
if not pressed:
return return
# A dismissed line eats the press: it must not also be steering the # A dismissed line eats the press: it must not also be steering the
# fleet or firing something on the way through. # fleet or firing something on the way through.

View file

@ -134,7 +134,7 @@ func _refresh() -> void:
if quest.is_empty(): if quest.is_empty():
heading_label.text = "NOTHING OUTSTANDING" heading_label.text = "NOTHING OUTSTANDING"
quest_label.text = "The list has run out." quest_label.text = "The list has run out."
objective_label.text = "The search does not stop because there is nothing left to tick off." objective_label.text = "The search goes on, even with nothing left to tick off."
count_label.text = "" count_label.text = ""
progress = Vector2i(1, 1) progress = Vector2i(1, 1)
else: else:

View file

@ -10,16 +10,31 @@ extends Node
# to aim, and nothing to decide. Firing is now an action like any other, and # to aim, and nothing to decide. Firing is now an action like any other, and
# the right-hand stick is what performs it. # the right-hand stick is what performs it.
# #
# `fire_direction` is set every frame by FireStickController from the stick's # #172: it is aimed at a POINT, not along a heading.
# own reading, and cleared the moment it is released. Zero means "not firing", #
# which is the resting state. # Every gun in the fleet used to be handed the SAME direction, so a fleet
# firing at something threw a sheet of parallel bolts past either side of it
# and only the middle hull could ever hit. Handed a point instead, each hull
# works out its own heading to that point, and the fleet's fire converges
# where the player is actually pointing - which is what having several ships
# should be worth.
#
# The point is set every frame by FireStickController, from the mouse's own
# world position or from where the fire stick is pushed, and cleared the
# moment the trigger is let go. No point means not firing, which is the
# resting state.
var capability_id: String = "weapon" var capability_id: String = "weapon"
var fire_range: float var fire_range: float
var fire_cooldown: float var fire_cooldown: float
var projectile_damage: int var projectile_damage: int
var fire_timer: float = 0.0 var fire_timer: float = 0.0
var fire_direction: Vector2 = Vector2.ZERO # Where this gun is pointed, in WORLD space, and whether it is pointed at all.
# A point rather than a Vector2.ZERO sentinel: the origin is a perfectly
# ordinary place to aim at, and a fleet that happened to be near it would
# stop firing.
var fire_target: Vector2 = Vector2.ZERO
var has_fire_target: bool = false
var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn") var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn")
# By path, not by class name - see the note at the top of MuzzleSparkVFX. # By path, not by class name - see the note at the top of MuzzleSparkVFX.
const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd")
@ -77,13 +92,48 @@ static func assisted_heading(tree: SceneTree, origin: Vector2, direction: Vector
return heading if nearest == Vector2.ZERO else nearest.normalized() return heading if nearest == Vector2.ZERO else nearest.normalized()
# Called by FireStickController. A zero vector stops the gun. # Where this gun is firing FROM. The hull it is bolted to.
func muzzle() -> Vector2:
var ship: Node2D = get_parent() as Node2D
return ship.global_position if ship != null else Vector2.ZERO
# Called by FireStickController every frame the trigger is held.
func aim_at(point: Vector2) -> void:
fire_target = point
has_fire_target = true
func hold_fire() -> void:
has_fire_target = false
# For anything that knows a DISPLACEMENT rather than a place: an outpost
# garrison has already measured the vector to the enemy it picked (see
# VesselGarrisonComponent._shoot), and a lone stationed vessel has no fleet
# to converge with anyway. Zero still means stop.
func set_fire_direction(direction: Vector2) -> void: func set_fire_direction(direction: Vector2) -> void:
fire_direction = direction if direction.length() > 0.001 else Vector2.ZERO if direction.length() <= 0.001:
hold_fire()
return
aim_at(muzzle() + direction)
func is_firing() -> bool: func is_firing() -> bool:
return fire_direction != Vector2.ZERO return has_fire_target
# The heading a shot from THIS hull will actually take: from the muzzle to the
# aimed point, bent onto an enemy sitting inside the assist cone. Zero when the
# gun is not firing, or when the point is on top of the ship - there is no
# heading to a place you are already at.
func firing_heading() -> Vector2:
if not has_fire_target:
return Vector2.ZERO
var to_point: Vector2 = fire_target - muzzle()
if to_point.length() <= 0.001:
return Vector2.ZERO
return assisted_heading(get_tree(), muzzle(), to_point, fire_range)
func tick(delta: float) -> void: func tick(delta: float) -> void:
@ -91,7 +141,12 @@ func tick(delta: float) -> void:
# the stick in a new direction is not punished by a timer that only # the stick in a new direction is not punished by a timer that only
# started when the stick moved. # started when the stick moved.
fire_timer -= delta fire_timer -= delta
if fire_direction == Vector2.ZERO or fire_timer > 0.0: if not has_fire_target or fire_timer > 0.0:
return
# #172: worked out per HULL, from this ship to the aimed point, which is
# what makes the fleet's fire converge rather than run parallel.
var heading: Vector2 = firing_heading()
if heading == Vector2.ZERO:
return return
var ship: Node2D = get_parent() var ship: Node2D = get_parent()
var projectile: Node2D = projectile_scene.instantiate() var projectile: Node2D = projectile_scene.instantiate()
@ -101,10 +156,10 @@ func tick(delta: float) -> void:
# same range - one number, one meaning. # same range - one number, one meaning.
projectile.set_range(fire_range) projectile.set_range(fire_range)
ship.get_tree().root.add_child(projectile) ship.get_tree().root.add_child(projectile)
# Aimed by DIRECTION, not at a target: launch() takes a displacement, so # The bolt is sent the gun's own range along that heading, whether the
# the gun's own range decides how far the shot is sent. # aimed point is nearer or further: range is a property of the gun, not of
var heading: Vector2 = assisted_heading(ship.get_tree(), ship.global_position, # where the player pointed. A point beyond reach is fired toward and falls
fire_direction, fire_range) # short; a point inside it is flown through.
projectile.launch(heading * fire_range) projectile.launch(heading * fire_range)
# #87: sparks off the hull that fired, so a shot is visibly attached to # #87: sparks off the hull that fired, so a shot is visibly attached to
# the vessel it came from. # the vessel it came from.

View file

@ -26,6 +26,15 @@ signal finished
# Beats, in order. The names are the whole outline. # Beats, in order. The names are the whole outline.
enum Beat { BRIEF, RUN, AMBUSH, CLEAR, GAUNTLET, BREACH, VORTEX, AFTERMATH } enum Beat { BRIEF, RUN, AMBUSH, CLEAR, GAUNTLET, BREACH, VORTEX, AFTERMATH }
# #170: how long the prologue runs before anybody says anything.
#
# The first line used to be up before the scene had finished arriving: the
# game opened on a dialog, which since #128 means it opened STOPPED, over a
# convoy the player had not yet seen and a starfield still being drawn. The
# opening beat of a story should be the picture, not the caption. So the
# convoy flies for a moment first, and the guide calls in over a scene that is
# already moving.
const OPENING_SILENCE: float = 3.0
# Where the shipment was going, and never arrives. # Where the shipment was going, and never arrives.
const WORLD_SHIP_AT: Vector2 = Vector2(0.0, -16000.0) const WORLD_SHIP_AT: Vector2 = Vector2(0.0, -16000.0)
# The convoy: mostly holds, a few escorts. Larger than any fleet the player # The convoy: mostly holds, a few escorts. Larger than any fleet the player
@ -77,6 +86,10 @@ const BREACH_LENS_SCALE: float = 7.0
# up to the breach; after it there is nobody left to talk to, so the closing # up to the breach; after it there is nobody left to talk to, so the closing
# lines are the ship's own log - which is the point being made. # lines are the ship's own log - which is the point being made.
const SHIP_LOG: String = "KESTREL - LOG" const SHIP_LOG: String = "KESTREL - LOG"
# By path rather than by class name, for the load-order reason documented in
# PlanetSetups and elsewhere. Only for DEFAULT_SPEAKER, so a queued line names
# the same speaker an immediate one would.
const NARRATION = preload("res://ui/NarrationBanner.gd")
const VORTEX_COLORS: Array = [ const VORTEX_COLORS: Array = [
Color(0.55, 0.35, 1.0), Color(0.25, 0.9, 1.0), Color(1.0, 0.35, 0.8), Color(0.55, 0.35, 1.0), Color(0.25, 0.9, 1.0), Color(1.0, 0.35, 0.8),
Color(1.0, 0.8, 0.3), Color(0.4, 1.0, 0.6), Color(1.0, 0.8, 0.3), Color(0.4, 1.0, 0.6),
@ -101,6 +114,11 @@ var vortex_time: float = 0.0
var survivor: Node2D = null var survivor: Node2D = null
var breach: Node2D = null var breach: Node2D = null
var _done: bool = false var _done: bool = false
# #170: lines waiting on the clock, and the beat time they are waiting for.
# Cleared whenever the beat changes, so a line a beat did not get round to
# saying cannot surface in the middle of the next one.
var _pending_lines: Array = []
var _pending_at: float = 0.0
@onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay") @onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay")
@ -129,6 +147,7 @@ func _process(delta: float) -> void:
if not running: if not running:
return return
beat_time += delta beat_time += delta
_drain_pending()
_tick(delta) _tick(delta)
_apply_shake(delta) _apply_shake(delta)
if beat_time < _deadline() and not _ready_to_advance(): if beat_time < _deadline() and not _ready_to_advance():
@ -139,9 +158,31 @@ func _process(delta: float) -> void:
_begin(beat + 1) _begin(beat + 1)
# #170: says a beat's lines once its clock reaches `at`. Deliberately measured
# in BEAT time rather than wall clock, like everything else here: a dialog
# stops the tree, so a line still waiting behind an unread one cannot have its
# turn come round while nobody is looking.
func _say_later(at: float, lines: Array) -> void:
_pending_at = at
_pending_lines = lines
func _drain_pending() -> void:
if _pending_lines.is_empty() or beat_time < _pending_at:
return
var lines: Array = _pending_lines
_pending_lines = []
for line in lines:
narration.say(String(line[0]), String(line[1]))
func _ready_to_advance() -> bool: func _ready_to_advance() -> bool:
if beat_time < _floor_time(): if beat_time < _floor_time():
return false return false
# A beat with a line still to come has not finished talking, whatever the
# banner currently says (#170).
if _waits_for_narration() and not _pending_lines.is_empty():
return false
if _waits_for_narration() and narration.is_speaking(): if _waits_for_narration() and narration.is_speaking():
return false return false
return _beat_complete() or beat_time >= _floor_time() and _floor_only() return _beat_complete() or beat_time >= _floor_time() and _floor_only()
@ -162,6 +203,7 @@ func finish() -> void:
return return
_done = true _done = true
running = false running = false
_pending_lines = []
_clear_props() _clear_props()
GameState.intro_active = false GameState.intro_active = false
GameState.intro_locks_movement = false GameState.intro_locks_movement = false
@ -275,6 +317,7 @@ func _clear_props() -> void:
func _begin(next: int) -> void: func _begin(next: int) -> void:
beat = next beat = next
beat_time = 0.0 beat_time = 0.0
_pending_lines = []
match beat: match beat:
Beat.BRIEF: Beat.BRIEF:
start_at = fleet.get_centroid() start_at = fleet.get_centroid()
@ -285,23 +328,28 @@ func _begin(next: int) -> void:
# takes for CONVOY and CONVOY_FORMATION to line up. # takes for CONVOY and CONVOY_FORMATION to line up.
_apply_convoy_formation() _apply_convoy_formation()
fleet.spawn_ships(CONVOY.size(), start_at, CONVOY) fleet.spawn_ships(CONVOY.size(), start_at, CONVOY)
narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.") # #170: not yet. The convoy is under way before the guide calls.
narration.say("Hold your heading and keep the holds intact. We will be watching.") _say_later(OPENING_SILENCE, [
["Convoy Kestrel. You are carrying this season's cargo to the World Ship.",
NARRATION.DEFAULT_SPEAKER],
["Stay on course and keep the holds safe. We are watching.",
NARRATION.DEFAULT_SPEAKER],
])
Beat.RUN: Beat.RUN:
narration.say("World Ship bears ahead. Fly on.") narration.say("The World Ship is straight ahead. Keep flying.")
Beat.AMBUSH: Beat.AMBUSH:
_spawn_ambush() _spawn_ambush()
narration.say("Contacts ahead - raiders, a light group. Break them and continue.") narration.say("Raiders ahead, and not many of them. Break them, then carry on.")
Beat.CLEAR: Beat.CLEAR:
narration.say("Clean work, Kestrel. Resume your heading.") narration.say("Good work, Kestrel. Back on course.")
Beat.GAUNTLET: Beat.GAUNTLET:
_spawn_belt() _spawn_belt()
narration.say("Debris belt across your lane. Take it slow - there is no way around.") narration.say("A rock belt lies across your lane. Go slowly. There is no way around it.")
Beat.BREACH: Beat.BREACH:
GameState.intro_locks_movement = true GameState.intro_locks_movement = true
breach_at = fleet.get_centroid() + Vector2(0.0, -900.0) breach_at = fleet.get_centroid() + Vector2(0.0, -900.0)
_open_breach() _open_breach()
narration.say("Kestrel, your heading is - something is opening ahead of you -", "CONVOY CONTROL") narration.say("Kestrel, your course is - something is opening ahead of you -", "CONVOY CONTROL")
Beat.VORTEX: Beat.VORTEX:
vortex_time = 0.0 vortex_time = 0.0
_ensure_survivor_hull() _ensure_survivor_hull()
@ -309,8 +357,8 @@ func _begin(next: int) -> void:
Beat.AFTERMATH: Beat.AFTERMATH:
GameState.intro_locks_movement = false GameState.intro_locks_movement = false
shake = 0.0 shake = 0.0
narration.say("It seems you've lost your way through the space, lost and abandoned.", SHIP_LOG) narration.say("The hole threw us somewhere else. We are lost, and we are alone.", SHIP_LOG)
narration.say("No communication with the World Ship can be heard.", SHIP_LOG) narration.say("Nothing answers. The World Ship cannot hear us out here.", SHIP_LOG)
func _deadline() -> float: func _deadline() -> float: