Compare commits

...
Author SHA1 Message Date
Loïc Wisniewski
da8f6a81ea Block input behind open panels; keep craft-menu messages on screen
Three play-testing reports.

1. Buttons behind an open panel were still clickable. There are two
   input paths and both leaked. GUI clicks: the research and formation
   panels cannot block with mouse_filter = STOP, because their contents
   are driven from raw _unhandled_input and a Control consuming touches
   through the GUI system swallows them first - so the panels stay
   transparent and IconButton now checks whether it is behind one, via a
   "modal_menus" group. Raw input: a touch landing on the research panel
   but missing a node was left unhandled, so it fell through to
   GameController - which sits on the ROOT node and sees whatever its
   children ignore - and moved the fleet or pinch-zoomed the world.

2. "Store ore here" looked dead. It was working: the confirmation was
   overwritten one frame later by the per-frame status refresh added in
   #16, so it flashed for a single frame. Craft-panel messages now
   outlive a refresh (3s), and the button stays ENABLED with an empty
   hold so pressing it can say why - a greyed button that cannot explain
   itself is the same dead-button problem.

3. The mothership's description could overlap the button below it. The
   description block was positioned from the TOP while the buttons were
   anchored to the BOTTOM, so the gap between them was whatever the
   screen height left over - overlapping at 1280 and separating at 960.
   It is one bottom-anchored stack now, checked at both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:58 +02:00
Loïc Wisniewski
f19807618a Transporter capacity per hub, and research to raise it
Forgejo #20, part 7 of #12 and the last of it. A hub stations
BASE_HUB_CAPACITY (2) transporters. Total berths are hubs x per-hub
capacity, so building another hub or researching "Freight Yards" both
raise it - which is the choice #12 asks the player to make.

Capacity follows the same shape as max_fleet_size() (sum a bonus over
`unlocked`) rather than being a second mechanism doing the same job, so
research applies to hubs already built, not only to ones placed
afterwards.

A queued-but-unbuilt transporter holds its berth. Without that a player
could order ten against two berths and only discover the problem when
they refused to appear. Ordering past the cap is refused outright, and
the two refusals - no hub at all, versus every berth taken - say which,
because "nothing happened" is exactly the bug reported against the
craft button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +02:00
Loïc Wisniewski
d41128bc43 Transporter vessels, an explicit store button, and bottom-anchored menus
Forgejo #19, part 6 of #12. Transporters are built at the ship crafter,
fly themselves to the nearest hub and wait there. When a building is
short of what its queue needs, the nearest idle transporter runs
hub -> mothership -> building -> hub. Ore leaves the bank when it is
loaded and lands in the building's stock on arrival, so what is drawn
and what happens are the same event - the extractor hauler's rule.

Production orders are now TYPED (interceptor / transporter) rather than
a bare count, since the crafter builds two different things. One at a
time is dispatched per building: without that, every idle transporter
chases the same shortfall and they all arrive carrying ore nobody needs.

Three fixes from play-testing feedback:

- Storing ore into a building is now a deliberate button press, not a
  side effect of opening the menu.
- The mothership's load-ore controls were positioned from the TOP of the
  screen, so on any viewport shorter than ~1300px the label fell off the
  bottom. They are anchored to the bottom edge now, and the label had no
  explicit font size so it inherited the theme's 78px and wrapped off
  screen. Checked at 720x960 and 720x1280.
- The craft button looked live and did nothing. It was not the transport
  work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships,
  so the fleet is at its cap from the first frame and every interceptor
  order was refused. The button is now visibly disabled and says why.
  The underlying balance (crafter useless until Wing Doctrine) is left
  alone - that is a design call, not a bug to quietly change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
Loïc Wisniewski
823828f3b3 Transport Hub building
Forgejo #18, part 5 of #12. The building only - the vessels that do the
hauling are #19. Placeable, costed at 30, gated behind a new "Freight
Handling" research node hanging off Logistics, with its own info popup,
build-menu glyph and map marker.

Drawn as an octagonal dock ring with berth arms and a squared-off freight
core: deliberately radial-but-flat so it does not read as a small
mothership, which is the only structure with swept arms. Its stationed
transports are NeonPiece children of one rotating holder, per #10 - the
geometry is rasterised once and only the holder's rotation changes, so
the traffic costs no redraws.

Building._draw()'s match has a catch-all that renders anything
unrecognised as a ship crafter, so a missing case is silent and looks
almost right. The test checks for the hub's own _hub_traffic holder
rather than trusting that something plausible appeared - which caught
exactly that: a _ready() patch had silently not applied and the hub was
rendering through the crafter branch.

The build menu grew a third row. Every button, label and price moved:
the old row occupied 332px against a 372px pitch, and three of those do
not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px
price digits) at a 290px pitch, ending at y=1176. Rendered and looked at
- the first attempt tightened the pitch alone and left every price
overlapping the row beneath it, which no headless test would have seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:07 +02:00
Loïc Wisniewski
ff162faef5 Supply a building by hand: withdraw at the mothership, unload on arrival
Forgejo #17, part 4 of #12. #16 lets an order queue waiting for ore, so
something has to be able to fulfil one. Until transporters exist (#19)
this is the only route, which is why it lands before them rather than
after: otherwise a player with a queued order and no Transport Hub is
stuck forever.

A "Load ore" button at the mothership moves a batch from the bank into
the fleet's hold, and opening a building hands it whatever the fleet is
carrying.

Delivery at the mothership is now EDGE triggered on arrival rather than
running every frame the fleet is parked there. Continuous delivery made
withdrawing impossible - ore loaded into the hold was banked again on the
very next frame, so the button would have looked broken. A batch rather
than the whole bank, because the hold is the part that can be lost and
emptying the bank in one tap would also strand research, which still
spends from it.

Both new logistics tests now clear existing asteroids: disabling
AsteroidField only stops new chunks spawning, and a drifting rock
destroying a parked ship changed the fleet size the tests assert on.
That was a real intermittent failure, seen once in a full run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:36:11 +02:00
Loïc Wisniewski
3573ccf44f Buildings produce from their own stock, and queue orders until supplied
Forgejo #16, part 3 of #12. BuildController deducted the cost from the
bank at the moment of purchase and refused outright with "Not enough
rocks", so nothing ever had to be delivered anywhere. Each building now
carries its own stock, and an order it cannot pay for yet is QUEUED and
completes by itself once ore arrives.

Records get an `id` because a building has to be referable across the
space <-> planet-surface scene swap, when every Building NODE has been
destroyed and rebuilt. Position would nearly work and then fail on two
buildings placed on the same spot.

Production advances in the GameState autoload, the same way extractor
haulers already do, so an order can complete while the player is down a
gravity well. A finished vessel becomes `ships_owed` rather than being
handed straight to a Fleet that may not exist, and the fleet claims what
it is owed when it next has room.

The craft menu remembers which crafter it was opened for: _process()
clears current_building while any menu is open, so by the next frame the
menu would no longer know. It shows the ore delivered HERE rather than
the bank, since showing the bank would promise something the crafter
cannot actually build.

Ship crafting cannot be supplied until #17 lands - nothing yet moves ore
into a building.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:18:44 +02:00
Loïc Wisniewski
d1b82f4f5a Show what the fleet is carrying next to what is banked
Forgejo #15, part 2 of #12. Since #14 ore is cargo until the fleet
delivers it to the mothership, so the banked readout does not move on
pickup - mining looked broken, because gathering ore changed no number
on screen until the fleet happened to fly home.

Three things separate the carried readout from the banked one, since
colour alone is a weak signal on a phone in a game that is mostly teal:
amber rather than ore-teal (the same amber already used for ore in
transit - the extractor derrick, the hauler route), smaller and sitting
under the banked figure so it reads as subordinate, and present only
while actually carrying, so an empty hold is not a permanent 0 parked
beside the real total.

Rendered via tests/screenshot.sh and looked at, not only asserted on:
headless draws no pixels and cannot say whether two numbers read as two
different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:04:40 +02:00
Loïc Wisniewski
fc32e26d1b Fleet carries mined ore until it delivers it to the mothership
Forgejo #14, part 1 of #12. Rock.gd credited GameState.rocks on pickup,
so ore was banked instantly from anywhere on the map and a mining run
had nothing at stake. Ore now goes into GameState.fleet_cargo and only
becomes spendable when the fleet reaches the mothership.

Delivery lives in BuildingField because that node already knows where
the buildings are and already answers "is the fleet standing on one?",
using the fleet CENTROID - the same stable single-number test Planet
and Building use, rather than per-ship collision that flickers as ships
jostle in formation.

Cargo lives in the GameState autoload so it survives Main.tscn being
torn down on every trip to a planet's surface - cargo mined ON a
surface would otherwise be dropped the moment the player took off.

test_resources_and_buildings asserted the old behaviour (gathering a
rock raises `rocks`) and now asserts the new one. That is a spec
change, not a weakened test - the gathering assertion itself is intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:55:45 +02:00
Loïc Wisniewski
2eb779e5de Show the extractor derrick the moment it is built, not on respawn
Follow-up to #9: the marker was resolved once in Planet._ready() on the
assumption that extractors are built on PlanetSurface.tscn, so a Planet
node could never outlive a change to its own answer. That assumption was
wrong. Extractors are built from SPACE, via
BuildController._planet_under_fleet() with the fleet parked on the
planet, so the Planet node is already in the tree with _ready() long
since run. The derrick therefore did not appear until the planet
happened to despawn and respawn - building one read as doing nothing.

GameState now emits extractor_added, and Planet keeps its marker in step
with it, redrawing only when its own answer actually changes.

The test missed this because it spawned a fresh planet after registering
the extractor, which is not the order the game does it in. It now keeps
a planet alive across the build and asserts the derrick appears on that
node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:43:43 +02:00
Loïc Wisniewski
8dd5d7b78f Show buildings on the world map, and extractors on their planets
Forgejo #11: buildings the player has placed now appear on the world
map. They are placed freely in space rather than tied to a chunk, so
they get their own pass in WorldMapContent._draw() rather than riding
along with the chunk markers. Each type has its own shape and colour,
mirroring Building.gd's constants so a marker reads as the same
building you built.

Forgejo #9: a planet with an extractor built on it now shows it, in
both places you look at a planet - an amber derrick on its outline in
space, and a matching badge on its world-map marker. The extractor
lives on PlanetSurface.tscn, a scene you are not looking at from
space, so an already-mined planet was otherwise indistinguishable
from an untouched one until you landed on it.

The positioning steps are factored out (building_markers(),
chunk_has_extractor_badge(), extractor_derrick_tip()) so headless
tests can assert on them - --headless has no GPU and draws no pixels,
so there is nothing to check in _draw() itself. Both features were
also rendered for real via tests/screenshot.sh and looked at.

extractor_derrick_tip() exists because the first version of the
"derrick is not buried inside the planet" check measured the furthest
of all four derrick points, which passes a derrick pointing straight
into the ground: the base corners sit a half-width off the rim vertex
and clear the outline on their own. Mutation-testing caught it.

screenshot.sh now clears a stale /tmp/.X99-lock. A run killed
mid-screenshot leaves it behind and Xvfb then refuses :99, which
resurfaces later as Godot's misleading "X11 Display is not available".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:29:56 +02:00
Loïc Wisniewski
b641fa5891 Skill tree, node-based building animation, bigger UI, and a 19x faster test suite
Gameplay and UI
- Research tree at the mothership gates which structures can be built and
  grants permanent fleet-capacity upgrades (GameState.SKILL_TREE,
  SkillTreeView). Node depth is derived from prerequisites, so the layout
  cannot drift out of step with the dependencies.
- Building info popup before committing to a build, with per-building cost
  and description.
- Ship crafter redrawn as a space factory with an assembly animation;
  mothership redrawn as a radial flagship; command centre as a plotting hub.
- Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build.
- UI and font scaled up for phone use, via a custom Theme resource -
  gui/theme/default_font_size does not reach the default theme.
- Asteroids no longer spawn on top of the fleet's start position.
- Fleet formation origin derived from the layout's own offsets rather than
  the centroid, fixing the lurch when a lopsided formation started moving.
- Fleet.stop() brakes from the ship's TRUE motion (velocity +
  fleet_velocity). Ship zeroes its velocity when seated in its slot even
  while the anchor drags that slot forward, so braking from velocity alone
  stopped the fleet dead instead of coasting - and whether it did depended
  on the frame rate.

Animation architecture (issue #10)
- Buildings, projectiles and explosion VFX no longer animate by redrawing.
  Static geometry is rasterised once in _draw(); moving parts are NeonPiece
  children animated through node properties, which reuse the cached
  geometry. Every NeonDraw call expands into four antialiased polylines, so
  redrawing to move something re-tessellated all of it.

Test suite (issue #8): 1075s -> ~55s
- Runs from a copy on Termux's own filesystem. The project lives on
  Android's FUSE bridge, read through proot, where load("res://Main.tscn")
  alone cost 8.5s per test against ~2s natively - and nearly every test
  loads it.
- Runs 6 tests at a time, in a single proot-distro login, with output
  buffered per test and printed only on failure.
- --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's
  result no longer depends on how busy the machine is. Without it,
  parallelism made two tests fail on 2 of 3 runs.
- A test passes only if it both exits 0 and prints ": PASS".
- 38 tests, green on three consecutive runs.

Bundles Carrois Gothic SC (Apache 2.0) with its licence text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
99 changed files with 5055 additions and 342 deletions

View file

@ -17,13 +17,21 @@ func _random_particle_size() -> float:
return randf_range(3.0, 7.0)
func _draw_particle(p: Dictionary, alpha: float) -> void:
# Debris tumbles independently of its flight path, unlike the light-burst
# streaks - each chunk needs its own evolving rotation, so the base class
# must keep writing visual.rotation every frame instead of leaving it fixed.
func _particle_spins() -> bool:
return true
func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void:
var s: float = p["size"]
# Unrotated local points - the child node's own `rotation` (driven by
# the base class from p["rot"]) supplies the tumble that used to be
# baked into a fresh pt.rotated(p["rot"]) call every frame.
var local_pts: PackedVector2Array = PackedVector2Array([
Vector2(-s, -s * 0.6), Vector2(s * 0.7, -s * 0.5),
Vector2(s, s * 0.5), Vector2(-s * 0.6, s),
])
var world_pts: PackedVector2Array = PackedVector2Array()
for pt in local_pts:
world_pts.append(p["pos"] + pt.rotated(p["rot"]))
draw_colored_polygon(world_pts, Color(COLOR.r, COLOR.g, COLOR.b, alpha))
visual.draw_callable = func(item: CanvasItem) -> void:
item.draw_colored_polygon(local_pts, COLOR)

View file

@ -7,6 +7,10 @@ extends Node2D
@export var check_interval: float = 1.0
@export var spawn_radius_chunks: int = 2
# No asteroid may be created within this distance of the fleet. At cruise
# speed this is well over a second of warning, so anything that reaches the
# fleet was visible on its way in.
@export var safe_spawn_radius: float = 420.0
@export var despawn_radius_chunks: int = 4
@export var asteroids_per_chunk: int = 6
@export var min_speed: float = 12.0
@ -63,14 +67,31 @@ func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int:
return max(abs(a.x - b.x), abs(a.y - b.y))
func _safe_position(candidate: Vector2, centre: Vector2) -> Vector2:
# Nothing may materialise on top of the fleet. Chunks are populated around
# wherever the fleet happens to be - including the chunk it is standing in
# - so without this an asteroid could appear directly on a ship and
# destroy it with no chance to react. That is worst on the frame a scene
# loads: arriving back from a planet could cost a ship instantly.
if candidate.distance_to(centre) >= safe_spawn_radius:
return candidate
# Push it out to the edge of the bubble rather than dropping it, so the
# field keeps its density near the fleet instead of thinning out.
var away: Vector2 = candidate - centre
if away.length() < 0.001:
away = Vector2.RIGHT.rotated(randf() * TAU)
return centre + away.normalized() * safe_spawn_radius
func _spawn_chunk_asteroids(chunk: Vector2i) -> void:
var origin: Vector2 = WorldGen.chunk_origin(chunk)
var spawned: Array = []
var fleet_centre: Vector2 = fleet.get_centroid()
for i in range(asteroids_per_chunk):
var local_pos: Vector2 = Vector2(randf() * WorldGen.CHUNK_SIZE, randf() * WorldGen.CHUNK_SIZE)
var asteroid: Node2D = asteroid_scene.instantiate()
asteroid.position = origin + local_pos
asteroid.position = _safe_position(origin + local_pos, fleet_centre)
var angle: float = randf() * TAU
var speed: float = randf_range(min_speed, max_speed)
asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed

View file

@ -19,7 +19,6 @@ extends Node2D
@onready var world_map: Control = get_node("../UI/WorldMap")
@onready var build_button: IconButton = get_node("../UI/BuildButton")
@onready var action_button: IconButton = get_node("../UI/BuildActionButton")
@onready var action_label: Label = get_node("../UI/BuildActionLabel")
@onready var build_menu: Control = get_node("../UI/BuildMenu")
@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton")
@ -28,6 +27,7 @@ extends Node2D
@onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton")
@onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel")
@onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton")
@onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton")
@onready var planet_field: Node2D = get_node("../PlanetField")
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup")
@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
@ -38,8 +38,21 @@ extends Node2D
@onready var craft_menu: Control = get_node("../UI/CraftMenu")
@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton")
@onready var craft_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton")
@onready var craft_store_button: IconButton = get_node("../UI/CraftMenu/StoreOreButton")
@onready var craft_store_label: Label = get_node("../UI/CraftMenu/StoreOreLabel")
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
@onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton")
@onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel")
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber")
@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel")
@onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton")
@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton")
@onready var formation_menu: Control = get_node("../UI/FormationMenu")
@onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor")
@ -67,6 +80,10 @@ const BUILDING_INFO: Dictionary = {
"title": "Command Center",
"body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.",
},
GameState.BUILDING_TRANSPORT_HUB: {
"title": "Transport Hub",
"body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.",
},
GameState.BUILDING_EXTRACTOR: {
"title": "Extractor",
"body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.",
@ -76,6 +93,14 @@ const BUILDING_INFO: Dictionary = {
# Which structure the popup is currently offering to build, or "" when it is
# only explaining something that cannot be built right now.
var pending_build_type: String = ""
# Which crafter the craft menu is showing (see _on_action_pressed).
var craft_building_id: int = 0
# The craft panel refreshes every frame while it is open (so a delivery
# arriving visibly works the queue down). That overwrote any message the
# moment after it was set, which made a working button look dead - the store
# button in particular. Messages therefore have to outlive a refresh.
var craft_message: String = ""
var craft_message_until: int = 0
var current_building: Node2D = null
var pending_building: Node2D = null
@ -89,41 +114,66 @@ func _ready() -> void:
build_crafter_button.pressed.connect(_on_place_crafter)
build_command_button.pressed.connect(_on_place_command_center)
build_extractor_button.pressed.connect(_on_place_extractor)
build_hub_button.pressed.connect(_on_place_transport_hub)
info_close_button.pressed.connect(_close_info)
info_build_button.pressed.connect(_confirm_build)
action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor)
load_ore_button.pressed.connect(_on_load_ore)
craft_transporter_button.pressed.connect(_on_craft_transporter)
craft_store_button.pressed.connect(_on_store_ore)
craft_close_button.pressed.connect(func(): craft_menu.visible = false)
formation_close_button.pressed.connect(func(): formation_menu.visible = false)
formation_reset_button.pressed.connect(_on_reset_formation)
formation_editor.formation_changed.connect(_on_formation_changed)
skill_close_button.pressed.connect(func(): skill_menu.visible = false)
skill_unlock_button.pressed.connect(_on_unlock_pressed)
skill_tree.node_picked.connect(_on_skill_node_picked)
build_menu.visible = false
info_popup.visible = false
craft_menu.visible = false
formation_menu.visible = false
skill_menu.visible = false
action_button.visible = false
action_label.visible = false
# True while a building prompt or panel owns the shared action slot, so the
# enter-atmosphere prompt knows to stay out of the way.
func is_showing_building_prompt() -> bool:
return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible
return current_building != null or build_menu.visible or craft_menu.visible \
or formation_menu.visible or skill_menu.visible
func _unhandled_input(event: InputEvent) -> void:
# The formation editor is driven from here rather than via _gui_input,
# for the same reason as the joystick: its Control must not swallow
# touches, or dragging a slot would never reach it.
if not formation_menu.visible:
return
if event is InputEventScreenTouch and formation_editor.handle_touch(event):
# The formation editor and the skill tree are driven from here rather than
# via _gui_input, for the same reason as the joystick: a Control that
# consumes touches through the GUI system swallows them before they get
# here, which is what made dragging a formation slot silently do nothing.
if skill_menu.visible:
if event is InputEventScreenTouch:
skill_tree.handle_touch(event)
# Handled either way. A touch that lands on the panel but misses a
# node is still a touch ON THE PANEL - letting it through moved the
# fleet and pinch-zoomed the world behind the menu, because
# GameController sits on the root node and sees whatever this leaves
# unhandled.
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag and formation_editor.handle_drag(event):
return
if formation_menu.visible:
if event is InputEventScreenTouch:
formation_editor.handle_touch(event)
elif event is InputEventScreenDrag:
formation_editor.handle_drag(event)
get_viewport().set_input_as_handled()
return
if build_menu.visible or craft_menu.visible or world_map.visible:
# These panels have no raw-input handler of their own, but the world
# behind them must still not react.
get_viewport().set_input_as_handled()
@ -133,20 +183,24 @@ func _process(delta: float) -> void:
if build_menu.visible:
# Keep the greyed state live while the menu is open, so flying onto
# a planet lights the option up without reopening it.
build_extractor_button.enabled = _planet_under_fleet() != null
build_extractor_button.enabled = _planet_under_fleet() != null \
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible:
if craft_menu.visible:
# Live, so a delivery arriving while the menu is open visibly works
# the queue down instead of looking stuck.
_refresh_craft_status()
if world_map.visible or build_menu.visible or craft_menu.visible \
or formation_menu.visible or skill_menu.visible:
current_building = null
pending_building = null
action_button.visible = false
action_label.visible = false
return
# Debounced exactly like the enter-atmosphere prompt, so the action
# button can't flicker while the fleet drifts on the interact boundary.
var detected: Node2D = building_field.get_building_fleet_is_on()
if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP:
detected = null # nothing to do at a mothership beyond respawning
if detected != pending_building:
pending_building = detected
@ -159,13 +213,23 @@ func _process(delta: float) -> void:
current_building = pending_building
action_button.visible = current_building != null
action_label.visible = current_building != null
if current_building != null:
action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP
action_label.text = current_building.get_display_name()
action_button.icon = _action_icon_for(current_building.building_type)
action_button.queue_redraw()
func _action_icon_for(type: String) -> int:
match type:
GameState.BUILDING_SHIP_CRAFTER:
return IconButton.Icon.SHIP
GameState.BUILDING_TRANSPORT_HUB:
return IconButton.Icon.TRANSPORT_HUB
GameState.BUILDING_MOTHERSHIP:
return IconButton.Icon.MOTHERSHIP
_:
return IconButton.Icon.COMMAND_CENTER
func _check_fleet_wipe() -> void:
if not fleet.ships.is_empty():
return
@ -178,7 +242,11 @@ func _check_fleet_wipe() -> void:
func _on_action_pressed() -> void:
if current_building == null:
return
if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
skill_menu.visible = true
_refresh_load_ore()
_refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
craft_menu.visible = true
_refresh_craft_status()
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
@ -199,10 +267,15 @@ func _planet_under_fleet() -> Node2D:
func _refresh_build_status() -> void:
# The extractor option is always listed, but greyed unless the fleet is
# actually over a planet - showing it only sometimes would leave the
# player wondering whether the game has it at all.
build_extractor_button.enabled = _planet_under_fleet() != null
# Every option is always listed, greyed when it cannot be used - showing
# them only sometimes would leave the player wondering whether the game
# has them at all. Greyed buttons still emit `pressed`, so each one can
# explain itself in the info popup.
build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER)
build_hub_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB)
build_extractor_button.enabled = _planet_under_fleet() != null \
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
# Prices are shown under each option now, so this only needs to say
# whether the fleet is somewhere an extractor could go.
@ -224,6 +297,10 @@ func _on_place_command_center() -> void:
_show_building_info(GameState.BUILDING_COMMAND_CENTER)
func _on_place_transport_hub() -> void:
_show_building_info(GameState.BUILDING_TRANSPORT_HUB)
func _on_place_extractor() -> void:
# The option stays pressable while greyed, so this is also how a
# not-over-a-planet extractor explains itself.
@ -232,6 +309,8 @@ func _on_place_extractor() -> void:
func cost_of(type: String) -> int:
match type:
GameState.BUILDING_TRANSPORT_HUB:
return GameState.TRANSPORT_HUB_COST
GameState.BUILDING_MOTHERSHIP:
return GameState.MOTHERSHIP_COST
GameState.BUILDING_SHIP_CRAFTER:
@ -248,9 +327,15 @@ func _show_building_info(type: String) -> void:
var lines: Array = [info.body]
var can_build: bool = true
# Locked structures still list themselves, greyed, so the player can see
# what exists and what research would open it up.
if not GameState.is_building_unlocked(type):
lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type))
can_build = false
# Anything that would stop this particular placement is spelled out in
# the same popup, rather than silently failing after the press.
if type == GameState.BUILDING_EXTRACTOR:
if can_build and type == GameState.BUILDING_EXTRACTOR:
var planet: Node2D = _planet_under_fleet()
if planet == null:
lines.append("No planet in reach - fly over one.")
@ -260,7 +345,7 @@ func _show_building_info(type: String) -> void:
can_build = false
else:
lines.append("Will mine %s." % planet.planet_name)
elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
lines.append("WARNING: only one may exist - this destroys your current one.")
if can_build and not GameState.can_afford(cost):
@ -290,6 +375,9 @@ func _confirm_build() -> void:
# Everything is re-checked here rather than trusted from when the popup
# opened: the fleet keeps flying while it is up, so it can drift off the
# planet an extractor was going to mine.
if not GameState.is_building_unlocked(type):
return
if type == GameState.BUILDING_EXTRACTOR:
var planet: Node2D = _planet_under_fleet()
if planet == null or GameState.has_extractor_at(planet.planet_name):
@ -324,20 +412,162 @@ func _try_place(type: String, cost: int) -> void:
build_menu.visible = false
# --- research (the mothership's skill tree) --------------------------------
func _on_skill_node_picked(id: String) -> void:
_refresh_skill_menu(id)
func _refresh_skill_menu(id: String = "") -> void:
if id == "":
id = skill_tree.selected_id
if id == "":
# Nothing chosen yet: point the player at the tree rather than
# leaving the panel blank, which reads as broken.
skill_detail.text = "Select a node to see what it unlocks."
skill_cost.amount = 0
skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size()
skill_unlock_button.visible = false
return
var node: Dictionary = GameState.skill_node(id)
if node.is_empty():
return
skill_detail.text = "%s\n%s" % [node.name, node.desc]
skill_cost.amount = int(node.cost)
if GameState.is_unlocked(id):
skill_status.text = "Researched"
skill_unlock_button.visible = false
elif not GameState.prerequisite_met(id):
skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name
skill_unlock_button.visible = false
elif not GameState.can_afford(int(node.cost)):
skill_status.text = "Not enough rocks - you have %d" % GameState.rocks
skill_unlock_button.visible = false
else:
skill_status.text = ""
skill_unlock_button.visible = true
func _on_unlock_pressed() -> void:
var id: String = skill_tree.selected_id
if id == "":
return
if not GameState.unlock(id):
# Re-check rather than trust the button: rocks can be spent elsewhere
# while this menu is open, since haulers keep delivering.
_refresh_skill_menu(id)
return
# A capacity upgrade has to reach the fleet that is already flying, not
# just ones rebuilt later.
if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0:
fleet.apply_formation()
skill_tree.queue_redraw()
_refresh_skill_menu(id)
func _say(message: String) -> void:
craft_message = message
craft_message_until = Time.get_ticks_msec() + 3000
_refresh_craft_status()
func _refresh_craft_status() -> void:
craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [
GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size()
# "Here" is the ore delivered to THIS crafter, not the bank: since #16 a
# building builds out of its own stock, and showing the bank would say
# you can afford something the crafter cannot actually make.
var queued: int = GameState.pending_orders(craft_building_id)
var stock: int = GameState.building_stock(craft_building_id)
var queue_text: String = "" if queued == 0 else " Queued %d" % queued
craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [
GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text,
GameState.transporters_claimed(), GameState.transport_capacity()
]
# Nowhere to station one means the order would be refused, so say so
# rather than letting the button look available and do nothing.
craft_transporter_button.enabled = GameState.has_transport_hub() \
and GameState.can_station_transporter()
craft_transporter_button.queue_redraw()
craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo
# Deliberately left ENABLED with an empty hold: a greyed-out button that
# cannot say why is exactly the dead-button problem this panel already
# had. Pressing it explains instead.
craft_store_button.enabled = true
craft_store_button.queue_redraw()
# A full fleet cannot take another vessel, so the order would be refused.
# Showing that on the button is the difference between "not available yet"
# and a button that looks live and silently does nothing when pressed.
var room: bool = fleet.ships.size() + GameState.ships_owed \
+ GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \
< GameState.max_fleet_size()
craft_interceptor_button.enabled = room
craft_interceptor_button.queue_redraw()
# One statement of the fleet count, either way round: the capacity line
# replaces the plain readout rather than being bolted in front of it.
if craft_message != "":
if Time.get_ticks_msec() < craft_message_until:
craft_status.text = "%s\n%s" % [craft_message, craft_status.text]
else:
craft_message = ""
if room:
craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()]
else:
craft_status.text = "Fleet full %d/%d - research capacity at the mothership\n%s" % [
fleet.ships.size(), GameState.max_fleet_size(), craft_status.text
]
func _on_craft_interceptor() -> void:
if fleet.ships.size() >= GameState.formation.size():
craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size()
if craft_building_id == 0:
return
if not GameState.spend(GameState.SHIP_COST):
craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks]
# Still refused when there is nowhere to put the vessel - queueing an
# order that can never be delivered would just bank ships_owed forever.
if fleet.ships.size() + GameState.ships_owed \
+ GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \
>= GameState.max_fleet_size():
_say("Fleet is full (%d slots)" % GameState.max_fleet_size())
return
# No affordability check: an order the crafter cannot pay for yet is
# QUEUED, and completes by itself once ore is delivered here (#16).
GameState.queue_order(craft_building_id)
_refresh_craft_status()
func _refresh_load_ore() -> void:
load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo]
load_ore_button.enabled = GameState.rocks > 0
load_ore_button.queue_redraw()
func _on_load_ore() -> void:
GameState.withdraw_to_fleet()
_refresh_load_ore()
func _on_store_ore() -> void:
if craft_building_id == 0:
return
if GameState.fleet_cargo <= 0:
_say("The fleet is carrying nothing - load ore at the mothership first")
return
var moved: int = GameState.unload_into_building(craft_building_id)
_say("Stored %d ore here" % moved)
func _on_craft_transporter() -> void:
if craft_building_id == 0:
return
if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER):
# Two different refusals, and the player needs to know WHICH.
if not GameState.has_transport_hub():
_say("Build a Transport Hub first - a transporter needs somewhere to be stationed")
else:
_say("All %d berths are taken - build another hub, or research Freight Yards" %
GameState.transport_capacity())
return
fleet.add_ship()
_refresh_craft_status()

View file

@ -19,11 +19,28 @@ extends Node2D
# never changes were a real framerate cost earlier on.
@export var building_type: String = "mothership"
# Which GameState.buildings record this node is showing. Set by
# BuildingField when it spawns, so a node can be traced back to the stock
# and production queue that survive the scene swap (#16).
@export var building_id: int = 0
@export var interact_radius: float = 150.0
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0)
const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
# Transport hub: an octagonal dock ring with berths around it and a freight
# core in the middle. Deliberately radial-but-flat, so it does not read as a
# small mothership - the mothership is the only thing with swept arms.
const HUB_RING_RADIUS: float = 74.0
const HUB_DECK_RADIUS: float = 54.0
const HUB_CORE_RADIUS: float = 22.0
const HUB_BERTH_COUNT: int = 8
const HUB_BERTH_LENGTH: float = 20.0
const HUB_DOCKED_COUNT: int = 3
const HUB_DOCKED_ORBIT: float = 96.0
const HUB_SPIN_SPEED: float = 0.22
# The vessel on the assembly line is drawn in the fleet's own colour, so it
# reads as "one of yours being built" rather than as shipyard machinery.
const HULL_COLOR: Color = Color(0.25, 0.85, 1.0)
@ -39,6 +56,7 @@ const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0)
const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0)
const RIB_RADIUS: float = 36.0
const RAIL_Y: float = -32.0
const FLASH_RING_RADIUS: float = 20.0
# The hull in four pieces, attached in this order. Each carries the point
# the crane welds it on at, which is where the sparks fly from.
@ -66,26 +84,62 @@ var phase_time: float = 0.0
var _hull_parts: Array[PackedVector2Array] = []
# Every moving element is its own NeonPiece, rasterised once when the
# building is created and thereafter only moved, scaled, faded or hidden.
# Nothing here ever calls queue_redraw() again: the building's own _draw()
# lays down the fixed structure a single time, and the animation is pure
# node-property work from then on.
var _core_cell: NeonPiece = null
var _satellites: Node2D = null
var _beacons: Array = []
var _clamps: NeonPiece = null
var _crane: Node2D = null
var _hoist: NeonPiece = null
var _sparks: Node2D = null
var _hull_holder: Node2D = null
var _hull_pieces: Array = []
# Rotating holder for the transports stationed at a transport hub (#18).
var _hub_traffic: Node2D = null
var _flash_ring: NeonPiece = null
var _wash: NeonPiece = null
func _ready() -> void:
add_to_group("buildings")
z_index = -1
# The crafter runs an assembly line and the mothership holds a live
# reactor with satellites in orbit, so both redraw. The command centre is
# static art and is drawn exactly once - per-frame redraws of art that
# never changes were a real framerate cost earlier on.
# reactor with satellites in orbit, so both animate. The command centre is
# entirely static and needs no per-frame work at all.
var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER
set_process(animated)
if building_type == GameState.BUILDING_SHIP_CRAFTER:
for part in SHIP_PARTS:
_hull_parts.append(PackedVector2Array(part.points))
_build_crafter_pieces()
elif building_type == GameState.BUILDING_MOTHERSHIP:
_build_mothership_pieces()
elif building_type == GameState.BUILDING_TRANSPORT_HUB:
_build_hub_pieces()
queue_redraw()
func _process(delta: float) -> void:
anim_time = fmod(anim_time + delta, CYCLE)
phase_time += delta
queue_redraw()
apply_animation()
# Pushes the current clock onto the movable pieces. Normally driven from
# _process; public so a preview or a test can pin the clock and see exactly
# that frame without having to run the animation up to it in real time.
func apply_animation() -> void:
match building_type:
GameState.BUILDING_MOTHERSHIP:
_animate_mothership()
GameState.BUILDING_SHIP_CRAFTER:
_animate_crafter()
GameState.BUILDING_TRANSPORT_HUB:
_animate_transport_hub()
# Where the satellites are, and how open the reactor core is. Both are read
@ -109,14 +163,27 @@ func get_display_name() -> String:
return "Command Center"
# How many times this building's geometry has actually been rasterised. The
# whole point of the NeonPiece decomposition is that this stays at 1 for the
# building's lifetime however long it animates, so it is worth being able to
# assert rather than assume.
var draw_count: int = 0
func _draw() -> void:
draw_count += 1
# Runs exactly once per building: this is the fixed structure. Everything
# that moves is a NeonPiece child, animated through node properties, so
# nothing here ever asks for a redraw again.
match building_type:
GameState.BUILDING_COMMAND_CENTER:
_draw_command_center()
GameState.BUILDING_MOTHERSHIP:
_draw_mothership()
_draw_mothership_static(self)
GameState.BUILDING_TRANSPORT_HUB:
_draw_transport_hub_static(self)
_:
_draw_crafter()
_draw_crafter_static(self)
# --- shared helpers -------------------------------------------------------
@ -139,17 +206,101 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int
func _draw_command_center() -> void:
# Diamond with three formation pips inside - reads as "arrangement".
var body: PackedVector2Array = PackedVector2Array([
Vector2(0, -62), Vector2(58, 0), Vector2(0, 62), Vector2(-58, 0),
# --- command centre: a tactical plotting hub ------------------------------
# The mothership is radial-and-swept, the crafter is an asymmetric assembly
# line, so the command centre reads best as the odd one out: rigid, hexagonal,
# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond
# readout, and every spoke ends in a formation port - half of them holding a
# token, half left open - so the structure itself shows fleet slots waiting
# to be filled, which is exactly what the drag-and-drop editor inside does.
const CMD_HEX_RADIUS: float = 88.0
const CMD_DECK_RADIUS: float = 66.0
const CMD_HUB_RADIUS: float = 30.0
const CMD_SCREEN_RADIUS: float = 14.0
const CMD_PORT_COUNT: int = 6
const CMD_PORT_LENGTH: float = 16.0
const CMD_TOKEN_RADIUS: float = 9.0
const CMD_STUD_RADIUS: float = 7.0
# --- transport hub --------------------------------------------------------
func _draw_transport_hub_static(target: CanvasItem) -> void:
var c: Color = TRANSPORT_HUB_COLOR
# Dock ring and the deck nested inside it.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0)
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7)
# Freight core: a squared-off hold rather than a reactor, so it reads as
# storage and not as another power plant.
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95)
for i in range(HUB_BERTH_COUNT):
var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32)
# Berth arm standing off the ring, where a transporter parks.
NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS,
dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38)
func _build_hub_pieces() -> void:
# Stationed transports, as NeonPiece children of one holder that turns.
# Per #10 the geometry is rasterised once and only the holder's rotation
# changes, so the traffic costs no redraws at all.
_hub_traffic = Node2D.new()
add_child(_hub_traffic)
for i in range(HUB_DOCKED_COUNT):
var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT
var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR)
# A stubby freighter: blunt, boxy, nothing like the fleet's darts.
piece.add_polygon(PackedVector2Array([
Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5),
]))
piece.rotation = a + PI / 2.0
piece.queue_redraw()
func _animate_transport_hub() -> void:
_hub_traffic.rotation = phase_time * HUB_SPIN_SPEED
func _diamond(centre: Vector2, r: float) -> PackedVector2Array:
return PackedVector2Array([
centre + Vector2(0, -r), centre + Vector2(r, 0),
centre + Vector2(0, r), centre + Vector2(-r, 0),
])
NeonDraw.draw_glowing_polygon(self, body, COMMAND_COLOR, 1.0)
for p in [Vector2(0, -18), Vector2(-20, 16), Vector2(20, 16)]:
var pip: PackedVector2Array = PackedVector2Array([
p + Vector2(0, -9), p + Vector2(-8, 8), p + Vector2(8, 8),
])
NeonDraw.draw_glowing_polygon(self, pip, COMMAND_COLOR, 1.0)
func _draw_command_center() -> void:
var c: Color = COMMAND_COLOR
var readout: Color = Color(0.82, 0.86, 1.0)
# The rim: a hex table holding the whole layout together, with a fainter
# deck ring nested just inside it - the same "plating has layers" read the
# mothership gets from its nested reactor rings.
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7)
for i in range(CMD_PORT_COUNT):
var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0
var dir: Vector2 = Vector2(cos(a), sin(a))
# Spoke bracing the hub to the rim.
NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36)
# Socket where the mast plugs into the rim.
NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85)
# Port mast standing proud of the rim; every other one is manned.
var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH)
NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4)
if i % 2 == 0:
NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0)
# Command core: a diamond hub with a lit readout set inside it.
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
# --- mothership: a radial flagship ----------------------------------------
@ -187,49 +338,65 @@ func _talon(base_angle: float) -> PackedVector2Array:
return pts
func _draw_mothership() -> void:
func _draw_mothership_static(canvas: CanvasItem) -> void:
var c: Color = MOTHERSHIP_COLOR
var core_color: Color = Color(0.75, 1.0, 0.85)
for i in range(ARM_COUNT):
var base: float = (float(i) / float(ARM_COUNT)) * TAU
NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95)
NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95)
# Crystal panel set into the gap behind each arm.
var pa: float = base - 0.30
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([
Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0,
Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0,
Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0,
Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0,
]), c, 0.7)
# Reactor core: nested rings around a cell that breathes.
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
var breath: float = core_pulse()
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0),
core_color, lerpf(0.55, 1.0, breath))
# Reactor housing: the nested rings around the cell that breathes.
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
# Satellites holding station around it, drifting slowly round.
var orbit: float = orbit_angle()
const CORE_CELL_RADIUS: float = 12.0
func _build_mothership_pieces() -> void:
var core_color: Color = Color(0.75, 1.0, 0.85)
# The breathing cell: drawn at a reference size and then scaled, rather
# than rebuilt at a new radius every frame.
_core_cell = NeonPiece.create(self, Vector2.ZERO, core_color)
_core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0))
# Satellites sit at fixed offsets inside a holder; the orbit is the
# holder's rotation, so four hexagon pairs become one transform update.
_satellites = Node2D.new()
add_child(_satellites)
for i in range(SATELLITE_COUNT):
var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU
var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT
NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85)
NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8)
var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR)
shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85)
var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color)
cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8)
func _animate_mothership() -> void:
var breath: float = core_pulse()
var radius: float = lerpf(9.0, 15.0, breath)
_core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS)
_core_cell.modulate.a = lerpf(0.55, 1.0, breath)
_satellites.rotation = orbit_angle()
# --- ship crafter: an orbital yard with a hull in the cradle --------------
func _draw_crafter() -> void:
func _draw_crafter_static(canvas: CanvasItem) -> void:
var c: Color = CRAFTER_COLOR
var t: float = anim_time / CYCLE
# Fabricator core: a reactor housing whose inner cell pulses as it feeds
# the yard. This replaces the chimney - nothing vents smoke in vacuum.
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow)
# Fabricator core housing. This replaces the chimney - nothing vents
# smoke in vacuum.
NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
# Radiator fins above and below the core, slatted like real ones.
for dir_y in [-1.0, 1.0]:
@ -237,44 +404,114 @@ func _draw_crafter() -> void:
CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y),
CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y),
])
NeonDraw.draw_glowing_polygon(self, fin, c, 0.75)
NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75)
for f in [0.35, 0.68]:
NeonDraw.draw_glowing_line(self,
NeonDraw.draw_glowing_line(canvas,
fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28)
# Truss spine linking the core to the cradle: two rails with X-bracing,
# the standard "this was assembled in orbit" read.
var spine_top: float = 4.0
var spine_bottom: float = 22.0
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
for i in range(3):
var x0: float = -32.0 + float(i) * 15.0
var x1: float = x0 + 15.0
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
# Construction cradle: two open ribs holding the hull, with the crane
# rail overhead. Open at the top so the finished hull can clear it.
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
for side in [-1.0, 1.0]:
NeonDraw.draw_glowing_line(self,
NeonDraw.draw_glowing_line(canvas,
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
func _build_crafter_pieces() -> void:
var c: Color = CRAFTER_COLOR
# The core's inner cell: fades with the reactor rather than being redrawn
# at a new brightness each frame.
_core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6))
_core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0))
# Docking beacons, blinking out of step with each other.
for i in range(2):
var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0)
var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink)
var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75))
beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4))
_beacons.append(beacon)
# Clamp arms reaching in from each rib. They let go as the hull launches,
# which is a fade rather than a redraw.
_clamps = NeonPiece.create(self, Vector2.ZERO, c)
for side in [-1.0, 1.0]:
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
_clamps.add_line(outer, inner, 0.35)
_clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3)
# Crane: the head rides the rail, so only its x moves. The hoist is drawn
# one unit long and stretched with scale.y to reach whatever seam is
# being welded - stretching a line needs no redraw, redrawing it would.
_crane = Node2D.new()
add_child(_crane)
var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c)
head.add_polygon(PackedVector2Array([
Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6),
]))
head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8)
_hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5)
_hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3)
# Weld sparks: four spokes on a holder that spins and stretches. The
# jitter is genuinely procedural, but rotating and scaling a holder gives
# the same flicker without re-tessellating the spokes every frame.
_sparks = Node2D.new()
add_child(_sparks)
for i in range(4):
var a: float = float(i) * TAU / 4.0
var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7))
spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3)
# The hull under construction. Each part is its own piece so attaching one
# is a `visible` flip, and the launch is the holder's position and alpha.
_hull_holder = Node2D.new()
add_child(_hull_holder)
for i in range(_hull_parts.size()):
var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR)
part.add_polygon(_hull_parts[i])
part.visible = false
_hull_pieces.append(part)
# Engine wash under the hull, and the completion ring. The ring is drawn
# at a reference radius and scaled outwards.
_wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0)
for dx in [-4.0, 4.0]:
_wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45)
_flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0)
_flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0))
func _animate_crafter() -> void:
var t: float = anim_time / CYCLE
_core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
for i in range(_beacons.size()):
_beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
if t < ASSEMBLE_END:
_draw_assembly(t / ASSEMBLE_END, c)
_animate_assembly(t / ASSEMBLE_END)
elif t < FLASH_END:
_draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c)
_animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END))
else:
_draw_launch((t - FLASH_END) / (1.0 - FLASH_END))
_animate_launch((t - FLASH_END) / (1.0 - FLASH_END))
# How many pieces are on the hull at this point through the assembly phase.
@ -287,78 +524,73 @@ func attached_part_count(p: float) -> int:
return n
func _draw_clamps(c: Color, pulse: float) -> void:
# Arms reaching in from each rib to hold the hull steady. They let go
# once it launches, so this is not drawn during the launch phase.
for side in [-1.0, 1.0]:
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35)
NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3)
func _place_hull(centre: Vector2, alpha: float) -> void:
# The parts are defined around their own origin, so the holder goes
# straight to the point they should be centred on.
_hull_holder.position = centre
_hull_holder.modulate.a = alpha
func _draw_assembly(p: float, c: Color) -> void:
_draw_clamps(c, 0.8)
func _animate_assembly(p: float) -> void:
_clamps.visible = true
_clamps.modulate.a = 0.8
_flash_ring.modulate.a = 0.0
_wash.modulate.a = 0.0
_place_hull(BAY_CENTRE, 1.0)
# Everything welded on so far, newest part still glowing from the seam.
var next_part: int = attached_part_count(p)
for i in range(next_part):
# Freshly attached parts flare, then settle to full brightness.
var age: float = p - float(SHIP_PARTS[i].at)
var pulse: float = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
_draw_hull_part(i, BAY_CENTRE, pulse)
for i in range(_hull_pieces.size()):
var attached: bool = i < next_part
_hull_pieces[i].visible = attached
if attached:
# Freshly attached parts flare, then settle to full brightness.
var age: float = p - float(SHIP_PARTS[i].at)
_hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
# The crane rides the rail over whichever seam is being worked, and
# retracts once there is nothing left to attach.
var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2)
if next_part >= _hull_parts.size():
if next_part >= _hull_pieces.size():
weld.y = RAIL_Y + 16.0
var head: Vector2 = Vector2(weld.x, RAIL_Y + 13.0)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
head + Vector2(-10, -7), head + Vector2(10, -7),
head + Vector2(6, 6), head + Vector2(-6, 6),
]), c, 1.0)
NeonDraw.draw_glowing_line(self, Vector2(head.x, RAIL_Y), head + Vector2(0, -7), c, 0.8, 0.35)
NeonDraw.draw_glowing_line(self, head + Vector2(0, 6), weld, c, 0.5, 0.3)
_crane.position = Vector2(weld.x, RAIL_Y + 13.0)
_hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0)
if next_part < _hull_parts.size():
# Weld sparks: short spokes whose angles jitter frame to frame.
var welding: bool = next_part < _hull_pieces.size()
_sparks.visible = welding
if welding:
var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0))
for i in range(4):
var a: float = phase_time * 7.0 + float(i) * TAU / 4.0
var length: float = 6.0 + 5.0 * flicker
NeonDraw.draw_glowing_line(self, weld, weld + Vector2(cos(a), sin(a)) * length,
Color(1.0, 0.95, 0.7), flicker, 0.3)
_sparks.position = weld
_sparks.rotation = phase_time * 7.0
_sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker)
_sparks.modulate.a = flicker
func _draw_completion_flash(p: float, c: Color) -> void:
func _animate_completion_flash(p: float) -> void:
# Whole hull, clamps springing open, and a ring pushing outwards to mark
# it as finished.
_draw_clamps(c, 1.0 - p)
for i in range(_hull_parts.size()):
_draw_hull_part(i, BAY_CENTRE, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(BAY_CENTRE, lerpf(18.0, 56.0, p), 6, PI / 6.0), HULL_COLOR, 1.0 - p)
_clamps.modulate.a = 1.0 - p
_sparks.visible = false
_place_hull(BAY_CENTRE, 1.0)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Retracted, so it does not hang over the finished hull.
_crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0)
_hoist.scale.y = 1.0
_flash_ring.modulate.a = 1.0 - p
_flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS)
func _draw_launch(p: float) -> void:
func _animate_launch(p: float) -> void:
# Clears the cradle and fades out; the next cycle starts from bare ribs.
_clamps.modulate.a = 0.0
_sparks.visible = false
_flash_ring.modulate.a = 0.0
var eased: float = p * p
var centre: Vector2 = BAY_CENTRE + Vector2(0.0, -124.0 * eased)
var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0)
for i in range(_hull_parts.size()):
_draw_hull_part(i, centre, fade)
_place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Engine wash, strongest just after it breaks free of the clamps.
var thrust: float = fade * (0.4 + 0.6 * sin(minf(p * 3.2, PI)))
for dx in [-4.0, 4.0]:
NeonDraw.draw_glowing_line(self, centre + Vector2(dx, 14),
centre + Vector2(dx * 1.6, 26.0 + 16.0 * thrust),
Color(1.0, 0.85, 0.45), thrust, 0.45)
func _draw_hull_part(index: int, centre: Vector2, pulse: float) -> void:
if pulse <= 0.01:
return
var moved: PackedVector2Array = PackedVector2Array()
for point in _hull_parts[index]:
moved.append(centre + point)
NeonDraw.draw_glowing_polygon(self, moved, HULL_COLOR, pulse)
_wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI))

View file

@ -11,12 +11,33 @@ extends Node2D
var building_scene: PackedScene = preload("res://Building.tscn")
var spawned: Array = []
# Whether the fleet was already standing on the mothership last frame - see
# the edge-triggered delivery in _process().
var _was_on_mothership: bool = false
func _ready() -> void:
rebuild_all()
func _process(_delta: float) -> void:
# Unloading the fleet's hold (#14). Done here because this is the node
# that already knows where the buildings are and already answers "is the
# fleet standing on one?" - and it uses the fleet CENTROID, the same
# stable single-number test Planet and Building already use, rather than
# per-ship collision that flickers as ships jostle in formation.
# EDGE-triggered on arrival, not continuous. Delivering every frame the
# fleet is parked on the mothership would make withdrawing impossible:
# ore loaded into the hold at the mothership (#17) would be banked again
# the very next frame, and the button would look broken.
var under: Node2D = get_building_fleet_is_on()
var on_mothership: bool = under != null \
and under.building_type == GameState.BUILDING_MOTHERSHIP
if on_mothership and not _was_on_mothership and GameState.fleet_cargo > 0:
GameState.deliver_cargo()
_was_on_mothership = on_mothership
func rebuild_all() -> void:
for b in spawned:
if is_instance_valid(b):
@ -43,6 +64,7 @@ func _spawn(record: Dictionary) -> void:
var b: Node2D = building_scene.instantiate()
b.position = record.position
b.building_type = record.type
b.building_id = int(record.get("id", 0))
# Deferred for the same reason as the other field spawners: this can run
# from inside a parent's _ready() chain, where a direct add_child() is
# rejected while the tree is still setting up children.

33
CargoCounter.gd Normal file
View file

@ -0,0 +1,33 @@
extends NeonNumber
# What the fleet is CARRYING, shown under the banked total (#15, part 2 of
# #12). Since #14 ore is cargo until the fleet delivers it to the mothership,
# so without this, mining visibly does nothing - the banked figure above does
# not move until the fleet flies home, which reads as gathering being broken.
#
# Three things separate it from the banked readout, because colour alone is a
# weak signal on a phone screen in a game that is mostly teal:
# - amber rather than ore-teal, the same amber used elsewhere for ore in
# transit (the extractor derrick, the hauler route)
# - smaller, sitting UNDER the banked figure: subordinate to it, because
# this is the number that is not yours yet
# - present only while actually carrying something, so an empty hold is not
# a permanent zero taking up space next to the real total
#
# Reads GameState directly for the same reason ResourceCounter does: it stays
# correct across the space <-> planet-surface scene swap with nobody having
# to push updates into it.
const CARGO_GLOW: Color = Color(1.0, 0.78, 0.25)
const CARGO_CORE: Color = Color(0.22, 0.12, 0.0)
func _ready() -> void:
glow_color = CARGO_GLOW
core_color = CARGO_CORE
func _process(_delta: float) -> void:
visible = GameState.fleet_cargo > 0
if visible:
set_amount(GameState.fleet_cargo)

1
CargoCounter.gd.uid Normal file
View file

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

22
ExplosionParticle.gd Normal file
View file

@ -0,0 +1,22 @@
class_name ExplosionParticle
extends Node2D
# One debris chunk / energy streak inside an explosion. Draws its fixed
# local-space shape exactly once (like Ship.gd/Asteroid.gd draw once and
# animate via node properties), then ExplosionVFXBase drives it every frame
# purely through `position` and `rotation` - no further redraw needed.
#
# The shape itself is supplied by the owning VFX class via a Callable rather
# than being known here, since different explosion styles (glowing streak
# vs. rock-chunk polygon) draw completely differently; this class only owns
# the "draw once, then just move" mechanics.
var draw_callable: Callable
func _ready() -> void:
queue_redraw()
func _draw() -> void:
draw_callable.call(self)

1
ExplosionParticle.gd.uid Normal file
View file

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

View file

@ -22,14 +22,25 @@ func _ready() -> void:
for i in range(particle_count):
var angle: float = randf() * TAU
var speed: float = randf_range(min_speed, max_speed)
particles.append({
var p: Dictionary = {
"pos": Vector2.ZERO,
"vel": Vector2(cos(angle), sin(angle)) * speed,
"rot": randf() * TAU,
"rot_speed": randf_range(-6.0, 6.0),
"size": _random_particle_size(),
})
queue_redraw()
}
# Each particle is its own child CanvasItem, drawn once at its own
# fixed local shape (subclass decides the shape via
# _configure_particle). From here on the "animation" is just this
# child's position/rotation, exactly like Ship.gd/Asteroid.gd - no
# redraw, no re-tessellating four polylines per particle per frame.
var visual: ExplosionParticle = ExplosionParticle.new()
_configure_particle(visual, p)
if _particle_spins():
visual.rotation = p["rot"]
add_child(visual)
p["visual"] = visual
particles.append(p)
func _process(delta: float) -> void:
@ -37,22 +48,35 @@ func _process(delta: float) -> void:
if age >= lifetime:
queue_free()
return
# CanvasItem modulate is inherited down the tree, so setting it once
# here fades every particle's already-drawn shape for free - no need
# to recompute or re-draw a single one of them.
modulate.a = clamp(1.0 - (age / lifetime), 0.0, 1.0)
for p in particles:
p["pos"] += p["vel"] * delta
p["vel"] *= 0.9
p["rot"] += p["rot_speed"] * delta
queue_redraw()
func _draw() -> void:
var alpha: float = clamp(1.0 - (age / lifetime), 0.0, 1.0)
for p in particles:
_draw_particle(p, alpha)
var visual: ExplosionParticle = p["visual"]
visual.position = p["pos"]
if _particle_spins():
visual.rotation = p["rot"]
func _random_particle_size() -> float:
return randf_range(10.0, 20.0)
func _draw_particle(_p: Dictionary, _alpha: float) -> void:
# Called once per particle at spawn time. Subclasses set visual.draw_callable
# to a closure that draws their own fixed local shape (a streak, a rock
# chunk, ...) - it must not depend on `p` beyond values fixed at spawn (size,
# initial velocity direction), since it never runs again.
func _configure_particle(_visual: ExplosionParticle, _p: Dictionary) -> void:
pass
# True for particle styles whose shape itself spins independently of its
# direction of travel (tumbling debris). Energy streaks are drawn already
# aligned to their (fixed) velocity direction, so giving them this unrelated
# per-particle spin as well would visibly rotate them off their travel line.
func _particle_spins() -> bool:
return false

View file

@ -39,7 +39,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
ships.clear()
formation_offsets.clear()
var n: int = clamp(count, 1, GameState.formation.size())
var n: int = clamp(count, 1, GameState.max_fleet_size())
for i in range(n):
var offset: Vector2 = GameState.formation[i]
var ship: Area2D = ship_scene.instantiate()
@ -58,7 +58,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
func add_ship() -> bool:
# Adds one vessel in the next free formation slot (bought from a ship
# crafter). Returns false when the wedge is already full.
if ships.size() >= GameState.formation.size():
if ships.size() >= GameState.max_fleet_size():
return false
var offset: Vector2 = GameState.formation[ships.size()]
var ship: Area2D = ship_scene.instantiate()
@ -82,7 +82,7 @@ func apply_formation() -> void:
# Without this, editing the layout at a command centre (or pivoting it)
# only affected ships built later, which read as the edit doing nothing.
for i in range(ships.size()):
if i >= GameState.formation.size():
if i >= GameState.max_fleet_size():
break
formation_offsets[i] = GameState.formation[i]
_assign_slot_targets()
@ -113,6 +113,9 @@ func move_in_direction(direction: Vector2) -> void:
func _process(delta: float) -> void:
# Before the steering early-out: a ship the crafter has already built and
# paid for is owed whether or not the fleet happens to be flying (#16).
_claim_owed_ships()
if steer_direction == Vector2.ZERO:
return
# The anchor - the fleet's formation centre - travels at cruise_speed.
@ -122,6 +125,17 @@ func _process(delta: float) -> void:
_assign_slot_targets()
func _claim_owed_ships() -> void:
# Production completes in the GameState autoload, which may well happen
# while the player is on a planet's surface with no Fleet node in
# existence to hand a vessel to. Those ships wait as `ships_owed` until a
# fleet with room can take them.
while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size():
if not add_ship():
return
GameState.ships_owed -= 1
func _assign_slot_targets() -> void:
for i in range(ships.size()):
ships[i].set_move_target(fleet_target + formation_offsets[i])
@ -138,12 +152,32 @@ func stop() -> void:
# since that lands inside arrive_radius immediately).
fleet_target = get_formation_origin()
for ship in ships:
var speed: float = ship.velocity.length()
# A ship's own `velocity` is NOT how fast it is really travelling
# while the fleet cruises. Ship snaps to its slot and reports zero
# the moment it is within arrive_radius of it, even though the
# anchor is dragging that slot forward at cruise_speed underneath
# it. Its true motion is its catch-up velocity PLUS the fleet's,
# which it already carries as `fleet_velocity` (zero for a
# point-to-point move_to, where `velocity` alone is the whole
# story).
#
# Braking from `velocity` alone therefore stopped the fleet dead
# on any frame where the ships happened to be seated - and how
# often that happens depends on the frame rate, because the anchor
# advances cruise_speed * delta per frame. At ~25fps it steps far
# enough that ships always lag and always report motion, which is
# the only reason releasing the stick looked right.
var motion: Vector2 = ship.velocity + ship.fleet_velocity
# Context that is no longer true: the fleet is not cruising any
# more, so leaving it set would have Ship face a heading it is not
# travelling on.
ship.fleet_velocity = Vector2.ZERO
var speed: float = motion.length()
if speed <= 0.001:
ship.set_move_target(ship.position)
continue
var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration)
ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance)
ship.set_move_target(ship.position + motion.normalized() * braking_distance)
func get_move_targets() -> Array:

View file

@ -67,7 +67,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool:
return true
if dragging_index != -1:
return false
for i in range(GameState.formation.size()):
for i in range(GameState.max_fleet_size()):
if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4:
dragging_index = i
drag_touch_index = event.index
@ -126,7 +126,7 @@ func _draw() -> void:
NeonDraw.draw_glowing_line(self, centre + Vector2(-tick, 0), centre + Vector2(tick, 0), Color(0.4, 0.45, 0.5), 0.5, 0.3)
NeonDraw.draw_glowing_line(self, centre + Vector2(0, -tick), centre + Vector2(0, tick), Color(0.4, 0.45, 0.5), 0.5, 0.3)
for i in range(GameState.formation.size()):
for i in range(GameState.max_fleet_size()):
var p: Vector2 = _slot_screen_pos(i)
var held: bool = i == dragging_index
# Ship glyph, so the layout reads the way it will look in flight.

View file

@ -22,11 +22,70 @@ var explored_chunks: Dictionary = {}
# --- Resources -------------------------------------------------------------
# Rocks mined from asteroid-planet surfaces, spent on buildings and ships.
# Ore that has been BANKED at the mothership. This is the only pool anything
# can be bought with.
# Starts stocked for play-testing so buildings can be tried without mining
# first - drop this back to 0 for a real playthrough.
var rocks: int = 500
# Ore the fleet is physically carrying and has not delivered yet (#14). Mining
# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a
# long mining run is a real trip with something to lose rather than instant
# income from anywhere on the map.
#
# Lives here, like everything else that has to survive Main.tscn being torn
# down and rebuilt on every trip to a planet's surface - and cargo mined ON a
# surface would otherwise be dropped the moment the player took off.
var fleet_cargo: int = 0
signal cargo_delivered(amount: int)
func collect_cargo(amount: int = 1) -> void:
fleet_cargo += amount
# One press of "load ore" at the mothership. A batch rather than the whole
# bank: the hold is the part that can be lost, and emptying the bank in one
# tap would also strand research, which still spends from it.
const CARGO_LOAD_BATCH: int = 25
func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int:
# Banked ore back into the hold, so the fleet can carry it to a building
# that is waiting on it (#17).
var taken: int = mini(amount, rocks)
if taken <= 0:
return 0
rocks -= taken
fleet_cargo += taken
return taken
func unload_into_building(id: int) -> int:
# Everything the fleet is carrying, into this building's stock. Returns
# how much moved so a caller can tell the player something happened.
if fleet_cargo <= 0:
return 0
var moved: int = fleet_cargo
if deposit_to_building(id, moved) <= 0:
return 0
fleet_cargo = 0
return moved
func deliver_cargo() -> int:
# Everything the fleet is carrying, banked at once. Returns how much moved
# so a caller can react to a delivery actually having happened rather than
# to merely being in range.
if fleet_cargo <= 0:
return 0
var delivered: int = fleet_cargo
rocks += delivered
fleet_cargo = 0
cargo_delivered.emit(delivered)
return delivered
const MOTHERSHIP_COST: int = 20
const SHIP_CRAFTER_COST: int = 12
const SHIP_COST: int = 6
@ -42,15 +101,304 @@ const BUILDING_COMMAND_CENTER: String = "command_center"
# Extractors are not placed in space like the others - they are built ON a
# planet's surface and tracked separately (see `extractors`).
const BUILDING_EXTRACTOR: String = "extractor"
# Hosts the transport vessels that supply other buildings (#18). Built in
# space like the rest; it is the transporters (#19) that do the moving.
const BUILDING_TRANSPORT_HUB: String = "transport_hub"
const COMMAND_CENTER_COST: int = 15
const EXTRACTOR_COST: int = 25
const TRANSPORT_HUB_COST: int = 30
var buildings: Array = []
# --- Skill tree ------------------------------------------------------------
func add_building(type: String, at_position: Vector2) -> void:
buildings.append({"type": type, "position": at_position})
# Researched at the mothership. Two things hang off it: which structures the
# player is allowed to build at all, and permanent upgrades that have no
# building of their own. The mothership itself is deliberately NOT in the
# tree - it is the thing you research AT, so gating it behind research would
# be a deadlock.
#
# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT
# stored, it is derived from how many prerequisites deep the node is, so the
# tree always reads root-at-the-bottom and cannot drift out of step with the
# dependencies when nodes are added. `requires` is a single prerequisite id
# ("" for a root).
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
"x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER,
"desc": "Build ship crafters, and buy extra vessels at them.",
},
{
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
"x": 0.18, "unlocks": BUILDING_EXTRACTOR,
"desc": "Build extractors on planets. They mine ore whether or not you are nearby.",
},
{
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
"x": 0.82, "unlocks": BUILDING_COMMAND_CENTER,
"desc": "Build command centers, where the fleet formation is arranged.",
},
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"desc": "Build transport hubs, which supply your other buildings with ore.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.05, "hub_bonus": 1,
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
},
{
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.65, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.3, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
]
const BASE_FLEET_SIZE: int = 5
var unlocked: Array = []
func skill_node(id: String) -> Dictionary:
for node in SKILL_TREE:
if node.id == id:
return node
return {}
func skill_depth(id: String) -> int:
# How many prerequisites deep this node sits. Drives its height in the
# tree view: roots at the bottom, everything that depends on something
# above it.
var depth: int = 0
var node: Dictionary = skill_node(id)
while not node.is_empty() and node.requires != "":
depth += 1
node = skill_node(node.requires)
if depth > SKILL_TREE.size():
break # a cycle in the data would otherwise hang here
return depth
func max_skill_depth() -> int:
var deepest: int = 0
for node in SKILL_TREE:
deepest = maxi(deepest, skill_depth(node.id))
return deepest
func is_unlocked(id: String) -> bool:
return id in unlocked
func prerequisite_met(id: String) -> bool:
var node: Dictionary = skill_node(id)
if node.is_empty():
return false
return node.requires == "" or is_unlocked(node.requires)
func can_unlock(id: String) -> bool:
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
func unlock(id: String) -> bool:
if not can_unlock(id):
return false
if not spend(int(skill_node(id).cost)):
return false
unlocked.append(id)
return true
func is_building_unlocked(type: String) -> bool:
# The mothership is always buildable: it is where research happens, so
# locking it would leave a player with no mothership unable to ever get
# one.
if type == BUILDING_MOTHERSHIP:
return true
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return is_unlocked(node.id)
return true
func research_for_building(type: String) -> String:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return node.name
return ""
func max_fleet_size() -> int:
var n: int = BASE_FLEET_SIZE
for id in unlocked:
n += int(skill_node(id).get("fleet_bonus", 0))
return mini(n, DEFAULT_FORMATION.size())
func reset_progression() -> void:
unlocked.clear()
# --- building stock and production queue (#16) -----------------------------
# Since #12, a building cannot just deduct from the bank at the moment of
# purchase: what it produces has to be paid for out of resources physically
# delivered to it. So each building carries its own `stock`, and an order it
# cannot afford yet is QUEUED rather than refused, and completes by itself
# once the stock turns up.
#
# `id` exists because a building has to be referable across the space <->
# planet-surface scene swap, when every Building NODE has been destroyed and
# rebuilt. Position would nearly work and then fail on two buildings placed
# on the same spot.
var next_building_id: int = 1
# Ships that have been paid for and built but not yet handed to the fleet.
# Production is simulated here, in the autoload, the same way extractor
# haulers are - so an order can complete while the player is down on a
# planet's surface, where there is no Fleet node to hand a ship to. The fleet
# claims them when it next can.
var ships_owed: int = 0
signal order_completed(building_id: int, type: String)
func add_building(type: String, at_position: Vector2) -> int:
var id: int = next_building_id
next_building_id += 1
buildings.append({
"id": id, "type": type, "position": at_position, "stock": 0, "orders": [],
})
return id
func find_building(id: int) -> Dictionary:
for b in buildings:
if b.get("id", 0) == id:
return b
return {}
const PRODUCT_SHIP: String = "interceptor"
const PRODUCT_TRANSPORTER: String = "transporter"
const TRANSPORTER_COST: int = 10
func product_cost(product: String) -> int:
if product == PRODUCT_TRANSPORTER:
return TRANSPORTER_COST
return SHIP_COST
func can_produce(type: String) -> bool:
# Only the ship crafter builds anything. The hub STATIONS transporters,
# it does not make them (#12).
return type == BUILDING_SHIP_CRAFTER
func building_stock(id: int) -> int:
var b: Dictionary = find_building(id)
return 0 if b.is_empty() else int(b.get("stock", 0))
func pending_orders(id: int) -> int:
var b: Dictionary = find_building(id)
return 0 if b.is_empty() else (b.get("orders", []) as Array).size()
func pending_orders_of(id: int, product: String) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var n: int = 0
for o in b.get("orders", []):
if o == product:
n += 1
return n
func outstanding_cost(id: int) -> int:
# What this building still needs delivered to clear everything queued.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var need: int = 0
for o in b.get("orders", []):
need += product_cost(o)
return maxi(need - int(b.get("stock", 0)), 0)
func deposit_to_building(id: int, amount: int) -> int:
if amount <= 0:
return 0
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
b.stock = int(b.get("stock", 0)) + amount
return amount
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or not can_produce(b.type):
return false
if product == PRODUCT_TRANSPORTER and not has_transport_hub():
# Nowhere to station it. Refused rather than queued, so the player is
# told now instead of watching an order sit forever.
return false
if product == PRODUCT_TRANSPORTER and not can_station_transporter():
# Every berth is taken or spoken for (#20).
return false
(b.orders as Array).append(product)
return true
func _advance_production() -> void:
for b in buildings:
if not can_produce(b.type):
continue
# `while`, not `if`: a delivery big enough to cover several queued
# orders should clear them all rather than dribbling one out per
# frame.
var orders: Array = b.get("orders", [])
while not orders.is_empty():
var product: String = String(orders[0])
var cost: int = product_cost(product)
if int(b.get("stock", 0)) < cost:
break
if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity():
# A hub was demolished after this was ordered, so the berth it
# was going to use is gone. Hold the order rather than
# overfilling what is left.
break
if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
# Every hub was demolished while this sat in the queue. Leave
# it queued and the stock untouched rather than producing a
# vessel with nowhere to live.
break
b.stock = int(b.stock) - cost
orders.pop_front()
if product == PRODUCT_TRANSPORTER:
_station_transporter(b.position)
else:
ships_owed += 1
order_completed.emit(int(b.id), product)
func has_mothership() -> bool:
@ -84,8 +432,13 @@ func get_mothership_position() -> Variant:
# centre. Lives here (not as a const on Fleet) so a layout the player has
# arranged survives the trip to a planet's surface and back, and so a
# rebuilt fleet keeps their arrangement.
# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet
# capacity upgrade is researched, but they are defined up front so the wedge
# keeps a deliberate shape as it grows rather than having positions invented
# at unlock time.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
]
var formation: Array = DEFAULT_FORMATION.duplicate()
@ -133,12 +486,20 @@ func has_extractor_at(planet_name: String) -> bool:
return false
signal extractor_added(planet_name: String)
func add_extractor(planet_name: String, at_position: Vector2) -> void:
extractors.append({
"position": at_position,
"planet_name": planet_name,
"progress": 0.0,
})
# An extractor is built from SPACE, with the fleet parked on the planet -
# so the Planet node is already in the tree with its _ready() long since
# run. Without this it kept showing no derrick until it happened to
# despawn and respawn, which reads as the build having done nothing.
extractor_added.emit(planet_name)
func get_hauler_route_time(extractor: Dictionary) -> float:
@ -165,8 +526,11 @@ func get_hauler_position(extractor: Dictionary) -> Variant:
func _process(delta: float) -> void:
# Runs in the autoload, so extraction keeps earning while the player is
# on a planet's surface or nowhere near the route.
# Runs in the autoload, so extraction keeps earning - and queued orders
# keep completing - while the player is on a planet's surface or nowhere
# near either.
_advance_production()
_advance_transporters(delta)
if extractors.is_empty():
return
for e in extractors:
@ -183,6 +547,201 @@ func _process(delta: float) -> void:
e.progress -= 1.0
# --- transporters (#19) ---------------------------------------------------
# Vessels stationed at a transport hub that fetch ore from the mothership and
# deliver it to whichever building is waiting on it.
#
# Simulated HERE rather than by any node, exactly like the extractor haulers
# above and for the same reason: a route the player is nowhere near still has
# to run. `progress` drives both the drawn position and the moment the ore
# actually moves, so what you see and what happens cannot drift apart.
#
# Entries are {"hub_id", "state", "target_id", "carrying", "progress",
# "from", "to"}.
const TRANSPORTER_SPEED: float = 340.0
# How many transporters one hub can station. Raised for EVERY hub - built
# already or not - by research, which is the same shape as max_fleet_size()
# rather than a second mechanism doing the same job.
const BASE_HUB_CAPACITY: int = 2
const TRANSPORTER_IDLE: String = "idle"
const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership"
const TRANSPORTER_TO_BUILDING: String = "to_building"
const TRANSPORTER_RETURNING: String = "returning"
var transporters: Array = []
func hub_capacity() -> int:
# Per hub.
var n: int = BASE_HUB_CAPACITY
for id in unlocked:
n += int(skill_node(id).get("hub_bonus", 0))
return n
func hub_count() -> int:
var n: int = 0
for b in buildings:
if b.type == BUILDING_TRANSPORT_HUB:
n += 1
return n
func transport_capacity() -> int:
# Total berths: more hubs, or more capacity per hub. Either raises it,
# which is exactly the choice #12 asks the player to make.
return hub_count() * hub_capacity()
func pending_transporter_orders() -> int:
var n: int = 0
for b in buildings:
for o in b.get("orders", []):
if o == PRODUCT_TRANSPORTER:
n += 1
return n
func transporters_claimed() -> int:
# Built plus already ordered: an order in the queue has a berth reserved,
# or a player could queue ten of them against two berths and only find out
# when they refused to appear.
return transporters.size() + pending_transporter_orders()
func can_station_transporter() -> bool:
return transporters_claimed() < transport_capacity()
func has_transport_hub() -> bool:
return not nearest_hub_to(Vector2.ZERO).is_empty()
func nearest_hub_to(at: Vector2) -> Dictionary:
var best: Dictionary = {}
var best_d: float = INF
for b in buildings:
if b.type != BUILDING_TRANSPORT_HUB:
continue
var d: float = at.distance_squared_to(b.position)
if d < best_d:
best_d = d
best = b
return best
func _station_transporter(built_at: Vector2) -> void:
# Built at the crafter, then it flies itself to the nearest hub and waits
# there for orders (#12).
var hub: Dictionary = nearest_hub_to(built_at)
if hub.is_empty():
return
transporters.append({
"hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0,
"carrying": 0, "progress": 0.0, "from": built_at, "to": hub.position,
})
func transporter_position(t: Dictionary) -> Vector2:
return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0))
func _hub_position(hub_id: int) -> Variant:
var hub: Dictionary = find_building(hub_id)
return null if hub.is_empty() else hub.position
func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> void:
t.from = from
t.to = to
t.state = state
t.progress = 0.0
func _transporter_inbound_to(building_id: int) -> bool:
for t in transporters:
if t.state != TRANSPORTER_IDLE and int(t.target_id) == building_id:
return true
return false
func _dispatch_transporters() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return # nowhere to load from
for b in buildings:
if outstanding_cost(int(b.id)) <= 0:
continue
# One at a time per building: without this every idle transporter
# would be sent after the same shortfall and they would all arrive
# carrying ore nobody needs any more.
if _transporter_inbound_to(int(b.id)):
continue
var chosen: Dictionary = {}
var best_d: float = INF
for t in transporters:
if t.state != TRANSPORTER_IDLE:
continue
var here: Vector2 = transporter_position(t)
var d: float = here.distance_squared_to(b.position)
if d < best_d:
best_d = d
chosen = t
if chosen.is_empty():
return # every transporter is busy
chosen.target_id = int(b.id)
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
func _advance_transporters(delta: float) -> void:
if transporters.is_empty():
return
_dispatch_transporters()
for t in transporters:
if t.state == TRANSPORTER_IDLE:
continue
var distance: float = (t.from as Vector2).distance_to(t.to as Vector2)
var leg: float = maxf(distance / TRANSPORTER_SPEED, 0.0001)
t.progress += delta / leg
if t.progress < 1.0:
continue
t.progress = 1.0
_arrive(t)
func _arrive(t: Dictionary) -> void:
var here: Vector2 = t.to
var hub_pos: Variant = _hub_position(int(t.hub_id))
match t.state:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
var want: int = outstanding_cost(int(t.target_id))
var take: int = mini(want, rocks)
if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty.
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
rocks -= take
t.carrying = take
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
TRANSPORTER_TO_BUILDING:
# The ore moves at exactly the moment the vessel arrives.
if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0:
# Building demolished in transit - put the ore back rather
# than quietly destroying it.
rocks += int(t.carrying)
t.carrying = 0
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_RETURNING:
t.state = TRANSPORTER_IDLE
t.progress = 1.0
func can_afford(cost: int) -> bool:
return rocks >= cost

View file

@ -15,7 +15,7 @@ enum Icon {
# build menu used to pick from the generic set, which left the mothership
# and the command centre sharing a folded-map glyph and neither of them
# looking anything like the structure they build.
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR,
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB,
}
# Size the glyphs were originally drawn against. Every glyph dimension is
@ -50,7 +50,25 @@ func _ready() -> void:
queue_redraw()
func _blocked_by_modal() -> bool:
# A panel is open and this button is not part of it, so it is sitting
# BEHIND the panel and must not react.
#
# The panels cannot simply swallow GUI input with a mouse_filter of STOP:
# the skill tree and the formation editor are driven from raw
# _unhandled_input (see SkillTreeView), and a Control that consumes
# touches through the GUI system swallows them before they ever get
# there. So the panels stay transparent to input and the buttons check
# instead.
for menu in get_tree().get_nodes_in_group("modal_menus"):
if menu.visible and not menu.is_ancestor_of(self):
return true
return false
func _gui_input(event: InputEvent) -> void:
if _blocked_by_modal():
return
if event is InputEventScreenTouch:
_handle_press(event.pressed, event.position)
accept_event()
@ -119,6 +137,8 @@ func _draw() -> void:
_draw_command_icon(center, pulse, s)
Icon.EXTRACTOR:
_draw_extractor_icon(center, pulse, s)
Icon.TRANSPORT_HUB:
_draw_transport_hub_icon(center, pulse, s)
func _draw_close(center: Vector2, pulse: float, s: float) -> void:
@ -204,6 +224,32 @@ func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void:
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void:
# A ring of docking cradles with a freight box in the middle: reads as
# "things are stationed here and go out from here", which is what the hub
# does, and shares no silhouette with the crafter's open cradle.
var ring_r: float = 11.0 * s
var ring: PackedVector2Array = PackedVector2Array()
for i in range(8):
var a: float = (float(i) / 8.0) * TAU + PI / 8.0
ring.append(center + Vector2(cos(a), sin(a)) * ring_r)
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
var box: float = 4.2 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(-box, -box), center + Vector2(box, -box),
center + Vector2(box, box), center + Vector2(-box, box),
]), _active_color(), pulse, core_color)
# Three stubby berths, so the ring reads as occupied rather than as a
# plain octagon.
for i in range(3):
var a: float = (float(i) / 3.0) * TAU - PI / 2.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, center + dir * (box * 1.5),
center + dir * (ring_r - 1.5 * s), _active_color(), pulse, 0.35 * s, core_color)
func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
# Reactor core, truss spine, and an open cradle holding a hull.
var core_centre: Vector2 = center + Vector2(-11, 0) * s
@ -227,18 +273,31 @@ func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void:
# Diamond with three formation pips - "arrangement".
var d: float = 14.0 * s
# Hex plotting rim with spokes to a diamond hub, echoing the placed
# structure's silhouette: half the ports carry a formation token.
var hex_r: float = 11.0 * s
var hub_r: float = 4.0 * s
var hex: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
hex.append(center + Vector2(cos(a), sin(a)) * hex_r)
NeonDraw.draw_glowing_polygon(self, hex, _active_color(), pulse, core_color)
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, center + dir * hub_r, center + dir * hex_r, _active_color(), pulse, 0.32 * s, core_color)
if i % 2 == 0:
var tip: Vector2 = center + dir * hex_r
var t: float = 2.6 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
tip + Vector2(0, -t), tip + Vector2(t, 0), tip + Vector2(0, t), tip + Vector2(-t, 0),
]), _active_color(), pulse, core_color)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(0, -d), center + Vector2(d, 0),
center + Vector2(0, d), center + Vector2(-d, 0),
center + Vector2(0, -hub_r), center + Vector2(hub_r, 0),
center + Vector2(0, hub_r), center + Vector2(-hub_r, 0),
]), _active_color(), pulse, core_color)
for p in [Vector2(0, -5), Vector2(-5, 4), Vector2(5, 4)]:
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + (p + Vector2(0, -3)) * s,
center + (p + Vector2(-2.6, 2.4)) * s,
center + (p + Vector2(2.6, 2.4)) * s,
]), _active_color(), pulse, core_color)
func _draw_extractor_icon(center: Vector2, pulse: float, s: float) -> void:

View file

@ -7,9 +7,13 @@ extends ExplosionVFXBase
var color: Color = Color(1, 1, 1)
func _draw_particle(p: Dictionary, alpha: float) -> void:
func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void:
# Direction only ever scales (vel *= 0.9 each frame in the base class),
# it never turns - so the streak's local orientation is safe to fix
# once here rather than recomputed from vel every frame.
var vel: Vector2 = p["vel"]
var dir: Vector2 = vel.normalized() if vel.length() > 0.01 else Vector2.RIGHT
var half: Vector2 = dir * p["size"] * 0.5
var pos: Vector2 = p["pos"]
NeonDraw.draw_glowing_line(self, pos - half, pos + half, color, alpha, 0.6)
var line_color: Color = color
visual.draw_callable = func(item: CanvasItem) -> void:
NeonDraw.draw_glowing_line(item, -half, half, line_color, 1.0, 0.6)

386
Main.tscn
View file

@ -20,6 +20,9 @@
[ext_resource type="Script" path="res://HaulerField.gd" id="18"]
[ext_resource type="Script" path="res://NeonNumber.gd" id="19"]
[ext_resource type="Script" path="res://NeonPanel.gd" id="20"]
[ext_resource type="Script" path="res://SkillTreeView.gd" id="21"]
[ext_resource type="Script" path="res://CargoCounter.gd" id="22"]
[ext_resource type="Script" path="res://TransporterField.gd" id="23"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5")
@ -70,6 +73,9 @@ script = ExtResource("14")
[node name="HaulerField" type="Node2D" parent="."]
script = ExtResource("18")
[node name="TransporterField" type="Node2D" parent="."]
script = ExtResource("23")
[node name="BuildController" type="Node2D" parent="."]
script = ExtResource("15")
@ -83,10 +89,19 @@ layer = 1
mouse_filter = 2
offset_left = 28
offset_top = 24
offset_right = 520
offset_bottom = 140
offset_right = 320
offset_bottom = 92
script = ExtResource("16")
digit_height = 100.0
digit_height = 60.0
[node name="CargoCounter" type="Control" parent="UI"]
mouse_filter = 2
offset_left = 28
offset_top = 96
offset_right = 320
offset_bottom = 146
script = ExtResource("22")
digit_height = 42.0
[node name="PlanetNameLabel" type="Label" parent="UI"]
visible = false
@ -129,19 +144,6 @@ offset_bottom = -572
script = ExtResource("10")
icon = 2
[node name="BuildActionLabel" type="Label" parent="UI"]
visible = false
mouse_filter = 2
anchor_top = 1.0
anchor_bottom = 1.0
anchor_right = 1.0
offset_left = 24
offset_right = -24
offset_top = -566
offset_bottom = -440
text = ""
horizontal_alignment = 2
[node name="MapButton" type="Control" parent="UI"]
anchor_left = 1.0
anchor_right = 1.0
@ -201,20 +203,7 @@ offset_bottom = -24
script = ExtResource("10")
icon = 6
[node name="NavLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -322
offset_bottom = -196
text = "Pivot"
horizontal_alignment = 1
[node name="WorldMap" type="Control" parent="UI"]
[node name="WorldMap" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
@ -248,7 +237,7 @@ offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="BuildMenu" type="Control" parent="UI"]
[node name="BuildMenu" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
@ -285,10 +274,10 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -214
offset_right = -66
offset_top = -224
offset_bottom = -76
offset_left = -204
offset_right = -76
offset_top = -300
offset_bottom = -172
script = ExtResource("10")
icon = 8
glow_color = Color(0.45, 0.9, 0.55, 1)
@ -301,9 +290,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -290
offset_right = 10
offset_top = -70
offset_bottom = 60
theme_override_font_sizes/font_size = 44
offset_top = -166
offset_bottom = -100
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Mothership"
horizontal_alignment = 1
@ -314,23 +303,23 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -220
offset_right = -60
offset_top = 70
offset_bottom = 150
offset_left = -200
offset_right = -80
offset_top = -96
offset_bottom = -44
script = ExtResource("19")
amount = 20
digit_height = 66.0
digit_height = 40.0
[node name="CrafterButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 66
offset_right = 214
offset_top = -224
offset_bottom = -76
offset_left = 76
offset_right = 204
offset_top = -300
offset_bottom = -172
script = ExtResource("10")
icon = 9
glow_color = Color(1, 0.72, 0.25, 1)
@ -343,9 +332,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -10
offset_right = 290
offset_top = -70
offset_bottom = 60
theme_override_font_sizes/font_size = 44
offset_top = -166
offset_bottom = -100
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Ship Crafter"
horizontal_alignment = 1
@ -356,23 +345,23 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 60
offset_right = 220
offset_top = 70
offset_bottom = 150
offset_left = 80
offset_right = 200
offset_top = -96
offset_bottom = -44
script = ExtResource("19")
amount = 12
digit_height = 66.0
digit_height = 40.0
[node name="CommandButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -214
offset_right = -66
offset_top = 175
offset_bottom = 323
offset_left = -204
offset_right = -76
offset_top = -10
offset_bottom = 118
script = ExtResource("10")
icon = 10
glow_color = Color(0.55, 0.6, 1, 1)
@ -385,9 +374,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -290
offset_right = 10
offset_top = 333
offset_bottom = 463
theme_override_font_sizes/font_size = 44
offset_top = 124
offset_bottom = 190
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Command Center"
horizontal_alignment = 1
@ -398,23 +387,23 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -220
offset_right = -60
offset_top = 473
offset_bottom = 553
offset_left = -200
offset_right = -80
offset_top = 194
offset_bottom = 246
script = ExtResource("19")
amount = 15
digit_height = 66.0
digit_height = 40.0
[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 66
offset_right = 214
offset_top = 175
offset_bottom = 323
offset_left = 76
offset_right = 204
offset_top = -10
offset_bottom = 118
script = ExtResource("10")
icon = 11
glow_color = Color(0, 0.62, 0.7, 1)
@ -427,9 +416,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -10
offset_right = 290
offset_top = 333
offset_bottom = 463
theme_override_font_sizes/font_size = 44
offset_top = 124
offset_bottom = 190
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Extractor"
horizontal_alignment = 1
@ -440,13 +429,55 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 60
offset_right = 220
offset_top = 473
offset_bottom = 553
offset_left = 80
offset_right = 200
offset_top = 194
offset_bottom = 246
script = ExtResource("19")
amount = 25
digit_height = 66.0
digit_height = 40.0
[node name="TransportHubButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = 280
offset_bottom = 408
script = ExtResource("10")
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
[node name="TransportHubLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -150
offset_right = 150
offset_top = 414
offset_bottom = 480
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Transport Hub"
horizontal_alignment = 1
[node name="TransportHubCost" type="Control" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -60
offset_right = 60
offset_top = 484
offset_bottom = 536
script = ExtResource("19")
amount = 30
digit_height = 40.0
[node name="CloseButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 1.0
@ -458,7 +489,7 @@ offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="InfoPopup" type="Control" parent="UI/BuildMenu"]
[node name="InfoPopup" type="Control" parent="UI/BuildMenu" groups=["modal_menus"]]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
@ -545,7 +576,7 @@ script = ExtResource("10")
icon = 1
glow_color = Color(0, 0.62, 0.7, 1)
[node name="CraftMenu" type="Control" parent="UI"]
[node name="CraftMenu" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
@ -582,8 +613,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -74
offset_right = 74
offset_left = -234
offset_right = -86
offset_top = -74
offset_bottom = 74
script = ExtResource("10")
@ -595,14 +626,70 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -260
offset_right = 260
offset_left = -300
offset_right = -20
offset_top = 100
offset_bottom = 200
theme_override_font_sizes/font_size = 60
theme_override_font_sizes/font_size = 32
text = "Interceptor"
horizontal_alignment = 1
[node name="TransporterButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 86
offset_right = 234
offset_top = -74
offset_bottom = 74
script = ExtResource("10")
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
[node name="TransporterLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 20
offset_right = 300
offset_top = 96
offset_bottom = 176
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Transporter"
horizontal_alignment = 1
[node name="StoreOreButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -74
offset_top = -304
offset_right = 74
offset_bottom = -156
script = ExtResource("10")
icon = 3
glow_color = Color(1, 0.78, 0.25, 1)
[node name="StoreOreLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -260
offset_top = -148
offset_right = 260
offset_bottom = -24
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Store ore here"
horizontal_alignment = 1
[node name="CloseButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 1.0
anchor_right = 1.0
@ -613,7 +700,7 @@ offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="FormationMenu" type="Control" parent="UI"]
[node name="FormationMenu" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
mouse_filter = 2
anchor_right = 1.0
@ -696,3 +783,128 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0)
[node name="SkillMenu" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.94)
[node name="TitleLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 70.0
offset_right = -24.0
offset_bottom = 196.0
text = "Research"
horizontal_alignment = 1
[node name="Tree" type="Control" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 24.0
offset_top = 210.0
offset_right = -24.0
offset_bottom = -620.0
script = ExtResource("21")
[node name="DetailLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = -610.0
offset_right = -32.0
offset_bottom = -462.0
theme_override_font_sizes/font_size = 34
autowrap_mode = 2
horizontal_alignment = 1
text = ""
[node name="CostNumber" type="Control" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -64.0
offset_top = -456.0
offset_right = 76.0
offset_bottom = -392.0
script = ExtResource("19")
digit_height = 52.0
[node name="StatusLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = -386.0
offset_right = -32.0
offset_bottom = -318.0
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
horizontal_alignment = 1
text = ""
[node name="UnlockButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -74.0
offset_top = -304.0
offset_right = 74.0
offset_bottom = -156.0
script = ExtResource("10")
icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="LoadOreButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -256.0
offset_top = -304.0
offset_right = -108.0
offset_bottom = -156.0
script = ExtResource("10")
icon = 4
glow_color = Color(1, 0.78, 0.25, 1)
[node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -330.0
offset_top = -150.0
offset_right = -30.0
offset_bottom = -14.0
theme_override_font_sizes/font_size = 28
horizontal_alignment = 1
autowrap_mode = 2
text = "Load ore"
[node name="CloseButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172.0
offset_top = 24.0
offset_right = -24.0
offset_bottom = 172.0
script = ExtResource("10")
icon = 1
glow_color = Color(0.55, 0.6, 1, 1)

View file

@ -16,10 +16,10 @@ extends RefCounted
const DEFAULT_CORE: Color = Color(1.0, 1.0, 1.0)
static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE) -> void:
static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE, width_scale: float = 1.0) -> void:
var closed: PackedVector2Array = points.duplicate()
closed.append(points[0])
_draw_layers(item, closed, glow_color, pulse, 1.0, core_color)
_draw_layers(item, closed, glow_color, pulse, width_scale, core_color)
static func draw_glowing_line(item: CanvasItem, from: Vector2, to: Vector2, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void:

View file

@ -16,6 +16,13 @@ extends Control
@export var digit_height: float = 30.0
@export var digit_gap: float = 9.0
# Segment weight as a fraction of digit height, so the strokes keep the same
# visual heft at any size. This used to be a fixed width regardless of the
# digit height, which left large readouts looking hairline next to the solid
# ore hexagon beside them.
const STROKE_RATIO: float = 0.014
const HEX_STROKE_RATIO: float = 0.030
# Which of the seven segments each digit lights, in the usual order:
# top, top-right, bottom-right, bottom, bottom-left, top-left, middle.
const DIGIT_SEGMENTS: Array = [
@ -60,8 +67,8 @@ func _draw_hexagon(centre: Vector2, radius: float) -> void:
for i in range(6):
var a: float = (float(i) / 6.0) * TAU - PI / 2.0
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color)
NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, 0.26, core_color)
NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color, radius * HEX_STROKE_RATIO)
NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, radius * HEX_STROKE_RATIO * 0.8, core_color)
func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void:
@ -78,7 +85,8 @@ func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void:
[Vector2(0.0, 0.0), Vector2(0.0, mid)], # top-left
[Vector2(0.0, mid), Vector2(w, mid)], # middle
]
var stroke: float = h * STROKE_RATIO
for i in range(seg.size()):
if not on[i]:
continue
NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, 0.3, core_color)
NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, stroke, core_color)

47
NeonPiece.gd Normal file
View file

@ -0,0 +1,47 @@
class_name NeonPiece
extends Node2D
# One movable piece of neon art, rasterised ONCE and thereafter animated
# only through node properties.
#
# Godot re-runs _draw() solely when a redraw is requested; changing
# position/rotation/scale/modulate/visible reuses the cached geometry
# instead. Since every NeonDraw call expands into four antialiased
# polylines, redrawing a shape each frame just to move or fade it is the
# expensive way to do the cheapest kind of animation. Ship.gd and
# Asteroid.gd already work this way - this is the same idea packaged so a
# building can be assembled out of independently movable parts.
#
# Geometry is defined in local space around the piece's own origin, so the
# owner can move, spin, scale and fade it without touching the shape.
var polygons: Array = []
var lines: Array = []
var glow_color: Color = Color.WHITE
var core_color: Color = NeonDraw.DEFAULT_CORE
var pulse: float = 1.0
static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0) -> NeonPiece:
var piece: NeonPiece = NeonPiece.new()
piece.position = at
piece.glow_color = colour
piece.modulate.a = alpha
parent.add_child(piece)
return piece
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void:
polygons.append({"points": points, "pulse": piece_pulse})
func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0) -> void:
lines.append({"from": from, "to": to, "width": width, "pulse": piece_pulse})
func _draw() -> void:
for poly in polygons:
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color)
for line in lines:
NeonDraw.draw_glowing_line(self, line.from, line.to, glow_color,
line.pulse * pulse, line.width, core_color)

1
NeonPiece.gd.uid Normal file
View file

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

View file

@ -20,6 +20,23 @@ extends Node2D
var shape_points: PackedVector2Array = PackedVector2Array()
# An extractor is built down on this planet's SURFACE, in a different scene
# the player is no longer looking at, so from space nothing distinguished a
# planet already being mined from an untouched one - you had to land to find
# out. This is a derrick planted on the rim: the same amber the world map
# uses for its extractor badge, so the two read as the same fact.
const EXTRACTOR_MARKER_COLOR: Color = Color(1.0, 0.85, 0.3)
const DERRICK_HEIGHT: float = 44.0
const DERRICK_HALF_WIDTH: float = 16.0
# Kept in step with GameState.extractor_added rather than polled per frame.
# An extractor is built from SPACE (BuildController._planet_under_fleet(),
# with the fleet parked on the planet), so this node is already in the tree
# with its _ready() long since run when the answer changes - resolving it
# only at _ready() left the planet looking untouched until it happened to
# despawn and respawn.
var shows_extractor: bool = false
func _ready() -> void:
add_to_group("planets")
@ -32,8 +49,60 @@ func _ready() -> void:
var angle: float = (float(i) / point_count) * TAU
var radius: float = rng.randf_range(70.0, 100.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
shows_extractor = GameState.has_extractor_at(planet_name)
GameState.extractor_added.connect(_on_extractor_added)
queue_redraw()
func _on_extractor_added(built_on: String) -> void:
# Only the planet it was actually built on, and only a redraw when the
# answer really changed - every other planet in range gets this too.
if built_on != planet_name or shows_extractor:
return
shows_extractor = true
queue_redraw()
func extractor_derrick_anchor() -> Vector2:
# The rim vertex the derrick stands on: the shape's own topmost point,
# rather than a fixed radius, so it sits ON the outline whatever lumpy
# shape the seed drew instead of floating off it or sinking inside it.
var base: Vector2 = Vector2(0.0, -100.0)
for p in shape_points:
if p.y < base.y:
base = p
return base
func extractor_derrick_tip() -> Vector2:
# Exposed separately from the polygon because "does the derrick clear the
# planet's outline" is a question about the TIP alone. The base corners
# sit a half-width off to either side, so they are sqrt(|base|^2 + w^2)
# from the centre and can clear the rim on their own even when the tower
# points straight into the ground.
var base: Vector2 = extractor_derrick_anchor()
var out: Vector2 = base.normalized()
if out == Vector2.ZERO:
out = Vector2.UP
return base + out * DERRICK_HEIGHT
func extractor_derrick_points() -> PackedVector2Array:
var base: Vector2 = extractor_derrick_anchor()
var out: Vector2 = base.normalized()
if out == Vector2.ZERO:
out = Vector2.UP
var side: Vector2 = Vector2(-out.y, out.x)
var tip: Vector2 = extractor_derrick_tip()
return PackedVector2Array([
base - side * DERRICK_HALF_WIDTH,
tip - side * (DERRICK_HALF_WIDTH * 0.35),
tip + side * (DERRICK_HALF_WIDTH * 0.35),
base + side * DERRICK_HALF_WIDTH,
])
func _draw() -> void:
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)
if shows_extractor:
NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0)

View file

@ -58,10 +58,10 @@ layer = 1
mouse_filter = 2
offset_left = 28
offset_top = 24
offset_right = 520
offset_bottom = 140
offset_right = 320
offset_bottom = 92
script = ExtResource("9")
digit_height = 100.0
digit_height = 60.0
glow_color = Color(0, 0.42, 0.48, 1)
core_color = Color(0, 0.08, 0.1, 1)
@ -143,22 +143,6 @@ icon = 6
glow_color = Color(0.06, 0.32, 0.5, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="NavLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -322
offset_bottom = -196
text = "Pivot"
theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
theme_override_constants/outline_size = 5
horizontal_alignment = 1
[node name="FadeOverlay" type="CanvasLayer" parent="."]
layer = 10

View file

@ -12,6 +12,13 @@ var age: float = 0.0
func _ready() -> void:
add_to_group("player_projectiles")
area_entered.connect(_on_area_entered)
# Draw once. The earlier invisible-bolt bug came from _draw() deriving
# the bolt's look from `age` - drawing only at age==0 froze it at
# whatever the age==0 case rendered as. _draw() below is now a fixed
# shape with no age dependency, so one draw is safe and matches the
# Ship.gd/Asteroid.gd pattern: position/rotation are transform
# properties Godot applies at render time without re-tessellating.
queue_redraw()
func launch(direction: Vector2) -> void:
@ -22,14 +29,6 @@ func launch(direction: Vector2) -> void:
func _physics_process(delta: float) -> void:
position += velocity * delta
age += delta
# Redrawn every frame. In principle a fixed shape only needs drawing
# once (see Asteroid.gd, where dropping this was a large win), but
# doing that here left bolts invisible on screen - verified by
# rendering a frame mid-combat and finding zero pixels of the bolt
# colour. Rather than ship invisible shots a second time, this keeps
# the redraw: projectiles are few and live under two seconds, so the
# cost is negligible.
queue_redraw()
if age >= lifetime:
queue_free()

View file

@ -80,7 +80,9 @@ func _nearest_ship() -> Node2D:
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("player_ships"):
GameState.rocks += 1
# Into the fleet's hold, not the bank (#14). It only counts as
# spendable once the fleet has flown it back to the mothership.
GameState.collect_cargo(1)
collected.emit()
queue_free()

121
SkillTreeView.gd Normal file
View file

@ -0,0 +1,121 @@
extends Control
# The mothership's research tree, drawn in the game's own neon style rather
# than with Godot UI widgets, for the same reason everything else here is
# hand-drawn: a themed Button would look nothing like the rest of the game.
#
# Nodes are laid out from the normalised `pos` in GameState.SKILL_TREE, so
# the tree's shape is data next to the upgrades themselves instead of being
# duplicated here.
#
# Input arrives through BuildController._unhandled_input, NOT _gui_input -
# a Control that consumes touches through the GUI system swallows them
# before the menu can act on them, which is the bug that made the formation
# editor's drag silently do nothing. Everything in this menu must stay
# mouse_filter = IGNORE.
signal node_picked(id: String)
const LOCKED_COLOR: Color = Color(0.36, 0.39, 0.5)
const READY_COLOR: Color = Color(0.55, 0.6, 1.0)
const DONE_COLOR: Color = Color(0.45, 0.9, 0.55)
const SELECTED_RING: Color = Color(1.0, 0.85, 0.4)
# A locked node needs a dark core as well as a dim glow. NeonDraw tops every
# shape with a near-white core stroke, which at this size dominates the glow
# entirely - so a "dim" colour alone still came out the brightest thing on
# screen, making unreachable nodes look like the highlighted ones.
const LOCKED_CORE: Color = Color(0.17, 0.19, 0.26)
const LOCKED_PULSE: float = 0.5
@export var node_radius: float = 46.0
var selected_id: String = ""
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func node_screen_pos(node: Dictionary) -> Vector2:
# Height comes from dependency depth, not from the data: the root sits at
# the bottom and each further prerequisite climbs a row, so the tree grows
# upwards as it is researched and stays correct when nodes are added.
var deepest: int = maxi(GameState.max_skill_depth(), 1)
var climb: float = float(GameState.skill_depth(node.id)) / float(deepest)
# Inset so a node on row 0 or column 0 is not clipped in half.
var margin: float = node_radius * 1.4
return Vector2(
lerpf(margin, size.x - margin, float(node.x)),
lerpf(size.y - margin, margin, climb)
)
func _state_color(node: Dictionary) -> Color:
if GameState.is_unlocked(node.id):
return DONE_COLOR
if GameState.prerequisite_met(node.id):
return READY_COLOR
return LOCKED_COLOR
# Returns true when the press landed on a node, so the caller knows the
# touch was consumed.
func handle_touch(event: InputEventScreenTouch) -> bool:
if not event.pressed or not visible:
return false
var local: Vector2 = event.position - global_position
for node in GameState.SKILL_TREE:
if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25:
selected_id = node.id
queue_redraw()
node_picked.emit(node.id)
return true
return false
func _draw() -> void:
# Links first, so nodes sit on top of them.
for node in GameState.SKILL_TREE:
if node.requires == "":
continue
var parent: Dictionary = GameState.skill_node(node.requires)
if parent.is_empty():
continue
var from: Vector2 = node_screen_pos(parent)
var to: Vector2 = node_screen_pos(node)
# A link reads as "live" only once the step it leads to is actually
# reachable, so the eye follows the researched path outwards.
var lit: bool = GameState.is_unlocked(node.id)
var reachable: bool = GameState.prerequisite_met(node.id)
var link_color: Color = DONE_COLOR if lit else (READY_COLOR if reachable else LOCKED_COLOR)
var trim: Vector2 = (to - from).normalized() * node_radius
NeonDraw.draw_glowing_line(self, from + trim, to - trim, link_color,
1.0 if reachable else LOCKED_PULSE, 0.4,
NeonDraw.DEFAULT_CORE if reachable else LOCKED_CORE)
for node in GameState.SKILL_TREE:
var centre: Vector2 = node_screen_pos(node)
var colour: Color = _state_color(node)
var reach: bool = GameState.prerequisite_met(node.id)
var pulse: float = 1.0 if reach else LOCKED_PULSE
var core_stroke: Color = NeonDraw.DEFAULT_CORE if reach else LOCKED_CORE
var hexagon: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
hexagon.append(centre + Vector2(cos(a), sin(a)) * node_radius)
NeonDraw.draw_glowing_polygon(self, hexagon, colour, pulse, core_stroke)
# Researched nodes get a filled-in core; the rest stay hollow.
if GameState.is_unlocked(node.id):
var core: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
core.append(centre + Vector2(cos(a), sin(a)) * node_radius * 0.42)
NeonDraw.draw_glowing_polygon(self, core, colour, 1.0)
if node.id == selected_id:
var ring: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU
ring.append(centre + Vector2(cos(a), sin(a)) * node_radius * 1.32)
NeonDraw.draw_glowing_polygon(self, ring, SELECTED_RING, 0.9)

1
SkillTreeView.gd.uid Normal file
View file

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

38
Transporter.gd Normal file
View file

@ -0,0 +1,38 @@
extends Node2D
# Purely visual: a transport vessel running a hub -> mothership -> building
# supply route. Carries no state of its own; GameState owns the simulation
# and TransporterField creates, positions and frees this to match.
const HULL_COLOR: Color = Color(0.95, 0.55, 0.85)
var laden: bool = false
func _ready() -> void:
add_to_group("transporters")
z_index = -1
queue_redraw()
func set_laden(value: bool) -> void:
if laden == value:
return
laden = value
queue_redraw()
func _draw() -> void:
# Squat and boxy, in the transport hub's own magenta, so it reads as
# "belongs to the hub" and not as an extractor hauler (green) or a fleet
# vessel (sharp cyan triangle).
var hull: PackedVector2Array = PackedVector2Array([
Vector2(0, -16), Vector2(10, -4), Vector2(10, 14), Vector2(-10, 14), Vector2(-10, -4),
])
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0)
if laden:
# A visible payload, so a loaded run is distinguishable from a
# deadhead back to the hub at a glance.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
Vector2(-5, 0), Vector2(5, 0), Vector2(5, 9), Vector2(-5, 9),
]), HULL_COLOR, 0.85)

1
Transporter.gd.uid Normal file
View file

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

6
Transporter.tscn Normal file
View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Transporter.gd" id="1"]
[node name="Transporter" type="Node2D"]
script = ExtResource("1")

51
TransporterField.gd Normal file
View file

@ -0,0 +1,51 @@
extends Node2D
# Draws the transport vessels GameState is simulating, and only while they
# are somewhere the player could see them. Same arrangement as HaulerField,
# and for the same reason: the simulation is authoritative and the visuals
# are optional, so a run happening far away costs nothing but still moves
# ore. Position comes from the very same `progress` that decides when the
# ore actually changes hands.
@export var visible_radius: float = 2600.0
@onready var fleet: Node2D = get_node("../Fleet")
var transporter_scene: PackedScene = preload("res://Transporter.tscn")
# index into GameState.transporters -> live node
var active: Dictionary = {}
func _process(_delta: float) -> void:
var centre: Vector2 = fleet.get_centroid()
for i in range(GameState.transporters.size()):
var t: Dictionary = GameState.transporters[i]
var pos: Vector2 = GameState.transporter_position(t)
if pos.distance_to(centre) > visible_radius:
_despawn(i)
continue
var node: Node2D = active.get(i)
if node == null or not is_instance_valid(node):
node = transporter_scene.instantiate()
get_parent().add_child(node)
active[i] = node
var previous: Vector2 = node.position
node.position = pos
node.set_laden(int(t.carrying) > 0)
var travel: Vector2 = pos - previous
if travel.length() > 0.5:
node.rotation = travel.angle() + PI / 2.0
for key in active.keys():
if key >= GameState.transporters.size():
_despawn(key)
func _despawn(index: int) -> void:
var node: Node2D = active.get(index)
if node != null and is_instance_valid(node):
node.queue_free()
active.erase(index)

1
TransporterField.gd.uid Normal file
View file

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

View file

@ -2,6 +2,24 @@ extends Node2D
const WORLD_TO_LOCAL: float = 0.04
# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a
# marker on the map reads as "the same building" at a glance. Duplicated
# rather than cross-referenced - the same colour is already independently
# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this
# follows the codebase's existing convention rather than inventing a new one.
const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0)
const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85)
# A structure built on a planet's surface (see GameState.extractors) is not
# itself a map marker - it is a small badge on the existing planet marker,
# in a colour distinct from both the planets and the space buildings so it
# reads as "something is mined here" rather than another building type.
const EXTRACTOR_BADGE_COLOR: Color = Color(1.0, 0.85, 0.3)
const EXTRACTOR_BADGE_OFFSET: Vector2 = Vector2(8.0, -8.0)
const EXTRACTOR_BADGE_RADIUS: float = 5.0
@onready var tracker: Node2D = get_node("../../../ExplorationTracker")
@onready var fleet: Node2D = get_node("../../../Fleet")
@ -11,6 +29,61 @@ func _process(_delta: float) -> void:
queue_redraw()
# Local-space marker data for every player-placed building (GameState.
# buildings), one entry per building with its colour already resolved. Kept
# as a separate, callable step (rather than inlined in _draw()) so a
# headless test - which has no GPU and cannot check drawn pixels - can
# verify a built building actually produces a marker.
func building_markers() -> Array:
var markers: Array = []
for b in GameState.buildings:
markers.append({
"position": b.position * WORLD_TO_LOCAL,
"type": b.type,
"color": _building_marker_color(b.type),
})
return markers
func _building_marker_color(type: String) -> Color:
match type:
GameState.BUILDING_MOTHERSHIP:
return MOTHERSHIP_MARKER_COLOR
GameState.BUILDING_SHIP_CRAFTER:
return CRAFTER_MARKER_COLOR
GameState.BUILDING_TRANSPORT_HUB:
return TRANSPORT_HUB_MARKER_COLOR
_:
return COMMAND_MARKER_COLOR
# Whether the planet in this (explored) chunk has an extractor on it, per
# GameState.has_extractor_at(). A separate predicate for the same
# GPU-less-testability reason as building_markers().
func chunk_has_extractor_badge(chunk: Vector2i) -> bool:
if not WorldGen.has_planet_at_chunk(chunk):
return false
return GameState.has_extractor_at(WorldGen.planet_data_at_chunk(chunk).name)
func _regular_polygon(center: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(sides):
var a: float = (float(i) / float(sides)) * TAU + phase
pts.append(center + Vector2(cos(a), sin(a)) * radius)
return pts
# Filled shape plus a thin white outline - the same construction the fleet
# marker below already uses, just factored out since buildings now need it
# three times over (once per type) and the extractor badge needs it too.
func _draw_marker_polygon(pts: PackedVector2Array, color: Color) -> void:
draw_colored_polygon(pts, color)
var closed: PackedVector2Array = pts.duplicate()
closed.append(pts[0])
draw_polyline(closed, Color(1, 1, 1), 1.5, true)
func _draw() -> void:
var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL
@ -32,6 +105,24 @@ func _draw() -> void:
draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55))
draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true)
if chunk_has_extractor_badge(chunk):
var badge_pts: PackedVector2Array = _regular_polygon(
planet_local + EXTRACTOR_BADGE_OFFSET, EXTRACTOR_BADGE_RADIUS, 4, PI / 4.0)
_draw_marker_polygon(badge_pts, EXTRACTOR_BADGE_COLOR)
# Buildings are placed freely in space rather than tied to a chunk, so
# they get their own pass rather than living inside the chunk loop above.
for marker in building_markers():
match marker.type:
GameState.BUILDING_MOTHERSHIP:
_draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color)
GameState.BUILDING_SHIP_CRAFTER:
_draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color)
GameState.BUILDING_TRANSPORT_HUB:
_draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color)
_:
_draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color)
var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL
var marker_points: PackedVector2Array = PackedVector2Array([
fleet_local + Vector2(0, -10),

Binary file not shown.

View file

@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://co1qw50ehq2tl"
path="res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"
[deps]
source_file="res://fonts/CarroisGothicSC-Regular.ttf"
dest_files=["res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

202
fonts/LICENSE.txt Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

15
fonts/README.md Normal file
View file

@ -0,0 +1,15 @@
# Bundled fonts
## Carrois Gothic SC
`CarroisGothicSC-Regular.ttf` — designed by Ralph du Carrois, distributed via
Google Fonts.
Licensed under the Apache License, Version 2.0. The full licence text is in
`LICENSE.txt` alongside this file; it ships with the game because Apache 2.0
requires a copy of the licence to accompany any distribution of the work.
It is the project-wide UI typeface, wired up through `ui_theme.tres`
(`default_font`), which `project.godot` loads as `gui/theme/custom`. Note that
the `gui/theme/default_font_size` project setting does **not** reach the
default theme — the size lives on the theme resource too.

View file

@ -0,0 +1,20 @@
extends Node2D
# Preview, not a test. Opens the build menu itself, so the three-row layout
# added for the Transport Hub (#18) can be checked for fit on the real phone
# viewport - every button, label and price moved, and a bad offset is
# invisible to a headless test.
#
# bash tests/screenshot.sh res://tests/_build_menu_preview.tscn /tmp/menu.png 20 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.rocks = 500
# Everything researched, so nothing is greyed out and the whole menu is
# legible at full strength.
GameState.unlocked = ["shipyards", "logistics", "freight", "coordination"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("UI/BuildMenu").visible = true

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_build_menu_preview.gd" id="1"]
[node name="BuildMenuPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,15 @@
extends Node2D
# Preview, not a test. The resource readouts with the fleet carrying ore, so
# the #15 banked-vs-carried split can actually be looked at - headless draws
# no pixels and cannot say whether two numbers read as two different things.
#
# bash tests/screenshot.sh res://tests/_cargo_readout_preview.tscn /tmp/hud.png 20 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.rocks = 128
GameState.fleet_cargo = 34
add_child(load("res://Main.tscn").instantiate())

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_cargo_readout_preview.gd" id="1"]
[node name="CargoReadoutPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,27 @@
extends Node2D
# Preview, not a test. Opens the CraftMenu so its layout can be checked on a
# real viewport - the mothership's "Load ore" label was falling off the bottom
# of shorter screens, which no headless test can see.
#
# bash tests/screenshot.sh res://tests/_craft_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.rocks = 180
GameState.fleet_cargo = 24
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
var bc: Node = main.get_node("BuildController")
bc.craft_building_id = 1
main.get_node("UI/CraftMenu").visible = true
if "Craft" == "Craft":
bc._refresh_craft_status()
else:
bc._refresh_load_ore()

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_craft_menu_preview.gd" id="1"]
[node name="CraftMenuPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -33,7 +33,7 @@ func _ready() -> void:
if cell.has("phase"):
b.set_process(false)
b.anim_time = float(cell.phase) * b.CYCLE
b.queue_redraw()
b.apply_animation()
var cam: Camera2D = Camera2D.new()
cam.zoom = Vector2(1.65, 1.65)

View file

@ -19,8 +19,7 @@ func _initialize() -> void:
for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel",
"UI/BuildMenu/InfoPopup/Panel/MessageLabel",
"UI/BuildMenu/MothershipLabel",
"UI/BuildMenu/TitleLabel",
"UI/NavLabel"]:
"UI/BuildMenu/TitleLabel"]:
var label: Label = scene.get_node_or_null(path)
if label == null:
print("%s: MISSING" % path)

View file

@ -13,8 +13,9 @@ func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.reset_formation()
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0))
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0))
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0))
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0))
GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0))
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
@ -24,4 +25,4 @@ func _ready() -> void:
# far enough to see them whole.
main.get_node("Fleet").move_to(Vector2(0.0, 210.0))
var cam: Camera2D = main.get_node("Camera2D")
cam.zoom = Vector2(1.35, 1.35)
cam.zoom = Vector2(1.0, 1.0)

45
tests/_marker_preview.gd Normal file
View file

@ -0,0 +1,45 @@
extends Node2D
# Preview, not a test (underscore prefix keeps it out of run_tests.sh).
# Renders the world map with buildings placed and an extractor built, so the
# issue #11 building markers and the issue #9 extractor badge can actually be
# LOOKED at. --headless draws no pixels, so no headless test can tell whether
# a marker is visible - see tests/screenshot.sh.
#
# bash tests/screenshot.sh res://tests/_marker_preview.tscn /tmp/map.png 20 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.explored_chunks.clear()
# Near the origin, where the fleet starts, so everything lands on screen.
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(900.0, -700.0))
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(-1100.0, 300.0))
GameState.add_building(GameState.BUILDING_COMMAND_CENTER, Vector2(400.0, 1200.0))
# Reveal a patch of map, and put an extractor on whichever planet is
# nearest the origin so its badge is in frame too.
var best: Vector2i = Vector2i.ZERO
var best_distance: int = 1 << 30
for x in range(-6, 7):
for y in range(-6, 7):
var chunk: Vector2i = Vector2i(x, y)
GameState.explored_chunks[chunk] = true
if WorldGen.has_planet_at_chunk(chunk):
var d: int = absi(x) + absi(y)
if d < best_distance:
best_distance = d
best = chunk
if best_distance < (1 << 30):
var data: Dictionary = WorldGen.planet_data_at_chunk(best)
GameState.add_extractor(data.name, WorldGen.chunk_origin(best) + data.local_pos)
print("extractor on '%s' at chunk %s" % [data.name, best])
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
# open_map(), not `visible = true` - opening also recentres the view on
# the fleet, and without that the map is drawn from its own origin in the
# corner of the screen with everything near the fleet cropped off.
main.get_node("UI/WorldMap").open_map()

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_marker_preview.gd" id="1"]
[node name="MarkerPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,25 @@
extends Node2D
# Preview, not a test. Two planets side by side in SPACE - the left one has
# an extractor built on it, the right one does not - so the issue #9 derrick
# can be compared against a plain planet and judged as actually visible.
#
# bash tests/screenshot.sh res://tests/_planet_derrick_preview.tscn /tmp/derrick.png 15 1280x720
func _ready() -> void:
GameState.extractors.clear()
GameState.buildings.clear()
GameState.add_extractor("Mined World", Vector2.ZERO)
_spawn("Mined World", Vector2(380.0, 380.0), 12345)
_spawn("Untouched World", Vector2(900.0, 380.0), 4242)
func _spawn(planet_name: String, at: Vector2, shape_seed: int) -> void:
var planet: Node2D = load("res://Planet.tscn").instantiate()
planet.planet_name = planet_name
planet.shape_seed = shape_seed
planet.position = at
add_child(planet)
print("%s: derrick=%s" % [planet_name, planet.shows_extractor])

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_planet_derrick_preview.gd" id="1"]
[node name="PlanetDerrickPreview" type="Node2D"]
script = ExtResource("1")

40
tests/_profile_startup.gd Normal file
View file

@ -0,0 +1,40 @@
extends SceneTree
# Not a test - a stopwatch. Splits the fixed cost every scene-based test pays
# before it can assert anything, so tuning aims at the part that is actually
# expensive.
# godot --headless -s res://tests/_profile_startup.gd
var scene: Node
var t0: int = 0
var frames: int = 0
var frame_start: int = 0
func _initialize() -> void:
var boot: int = Time.get_ticks_msec()
print("engine boot to _initialize: %d ms" % boot)
t0 = Time.get_ticks_msec()
var packed: PackedScene = load("res://Main.tscn")
print("load(Main.tscn): %d ms" % (Time.get_ticks_msec() - t0))
t0 = Time.get_ticks_msec()
scene = packed.instantiate()
print("instantiate(): %d ms" % (Time.get_ticks_msec() - t0))
t0 = Time.get_ticks_msec()
root.add_child(scene)
print("add_child() (_ready): %d ms" % (Time.get_ticks_msec() - t0))
frame_start = Time.get_ticks_msec()
func _process(_delta: float) -> bool:
frames += 1
if frames < 60:
return false
var span: int = Time.get_ticks_msec() - frame_start
print("60 frames: %d ms (%.1f fps)" % [span, 60000.0 / maxf(span, 1)])
quit(0)
return true

View file

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

93
tests/_run_inside.sh Normal file
View file

@ -0,0 +1,93 @@
#!/bin/bash
# Runs INSIDE the proot-distro Ubuntu container - do not call this directly,
# call tests/run_tests.sh from Termux instead.
#
# It exists as its own file so the whole suite runs in ONE `proot-distro
# login`: entering the container costs ~0.5s, which used to be paid 38 times
# over. Everything below is the container-side half of the runner.
#
# Args: <project_dir> <jobs> <verbose> <test_file_name>...
set -uo pipefail
PROJECT_DIR="$1"; shift
JOBS="$1"; shift
VERBOSE="$1"; shift
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
cd "$PROJECT_DIR" || exit 2
tests=("$@")
total=${#tests[@]}
if (( total == 0 )); then
echo "no tests matched"
exit 2
fi
outdir="$(mktemp -d)"
trap 'rm -rf "$outdir"' EXIT
# Import once, serially, before anything runs in parallel. Godot writes to
# .godot/ while importing; several instances discovering a cold cache at the
# same moment would race on it. After this the workers only read it.
"$GODOT_BIN" --headless --import --quit >/dev/null 2>&1
run_one() {
local name="$1"
local log="$outdir/$name.log"
local start end
start=$(date +%s%N)
# --fixed-fps pins delta to exactly 1/60 instead of measuring the wall
# clock. Without it a test's `elapsed` counts REAL seconds, so running
# several at once stretches every frame and the gameplay under test sees
# deltas it never sees in the game: ships overshoot their braking curve,
# settle late, and tests time out for no reason but load. That made
# test_camera_settles and test_planet_prompt_map_interaction fail on 2 of
# 3 parallel runs. Pinned, a test simulates the same 60fps whatever else
# the phone is doing, and its result no longer depends on how busy the
# machine was.
"$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1
local rc=$?
end=$(date +%s%N)
local ms=$(( (end - start) / 1000000 ))
# Two independent criteria, because either one alone can lie: a test that
# crashes before reaching its verdict exits non-zero without ever printing
# FAIL, and a hypothetical `quit(0)` on a failing path would exit clean
# while the log says otherwise.
if (( rc == 0 )) && grep -q ": PASS" "$log"; then
printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status"
else
printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status"
fi
cat "$outdir/$name.status"
}
running=0
for name in "${tests[@]}"; do
run_one "$name" &
running=$(( running + 1 ))
if (( running >= JOBS )); then
wait -n
running=$(( running - 1 ))
fi
done
wait
failed=0
for name in "${tests[@]}"; do
if ! grep -q '^PASS' "$outdir/$name.status" 2>/dev/null; then
failed=$(( failed + 1 ))
echo
echo "=== $name FAILED, full output ==="
cat "$outdir/$name.log" 2>/dev/null
elif [[ "$VERBOSE" == "1" ]]; then
echo
echo "=== $name ==="
cat "$outdir/$name.log" 2>/dev/null
fi
done
echo
echo "$(( total - failed ))/$total passed"
(( failed == 0 ))

View file

@ -0,0 +1,31 @@
extends Node2D
# Preview, not a test. Opens the SkillMenu so its layout can be checked on a
# real viewport - the mothership's "Load ore" label was falling off the bottom
# of shorter screens, which no headless test can see.
#
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.rocks = 180
GameState.fleet_cargo = 24
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
var bc: Node = main.get_node("BuildController")
bc.craft_building_id = 1
main.get_node("UI/SkillMenu").visible = true
if "Skill" == "Craft":
bc._refresh_craft_status()
else:
bc._refresh_load_ore()
# Select a node so the description, price and status all have text -
# an empty description cannot collide with anything.
bc.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("freight")

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_skill_menu_preview.gd" id="1"]
[node name="SkillMenuPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,38 @@
extends Node2D
# Preview, not a test. A transporter mid-run between the mothership and a
# building it is supplying, so the #19 vessel can actually be looked at
# alongside the hub that stations it. Headless draws no pixels.
#
# bash tests/screenshot.sh res://tests/_transporter_preview.tscn /tmp/t.png 25 1280x720
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.rocks = 200
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-420.0, 0.0))
GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(420.0, 0.0))
# One laden, mid-leg, and one running home empty, so both states show.
GameState.transporters.append({
"hub_id": 2, "state": GameState.TRANSPORTER_TO_BUILDING, "target_id": 2,
"carrying": 8, "progress": 0.45,
"from": Vector2(-420.0, 0.0), "to": Vector2(420.0, 0.0),
})
GameState.transporters.append({
"hub_id": 2, "state": GameState.TRANSPORTER_RETURNING, "target_id": 0,
"carrying": 0, "progress": 0.5,
"from": Vector2(-380.0, 150.0), "to": Vector2(420.0, 150.0),
})
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
# Freeze the simulation so the vessels stay where they were placed.
GameState.set_process(false)
main.get_node("Fleet").move_to(Vector2(0.0, 260.0))
var cam: Camera2D = main.get_node("Camera2D")
cam.zoom = Vector2(0.85, 0.85)

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_transporter_preview.gd" id="1"]
[node name="TransporterPreview" type="Node2D"]
script = ExtResource("1")

47
tests/_vfx_preview.gd Normal file
View file

@ -0,0 +1,47 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders
# projectiles and every explosion type so the node-animation conversion
# (issue #10) can be checked by eye. Bolts once went INVISIBLE after a
# similar optimisation and headless tests did not catch it, because
# --headless has no GPU - so this exists to make that failure visible.
#
# bash tests/screenshot.sh res://tests/_vfx_preview.tscn out.png 20
const VFX_SCRIPTS: Array = [
"res://ShipExplosionVFX.gd", "res://EnemyExplosionVFX.gd",
"res://AsteroidExplosionVFX.gd", "res://LightBurstVFX.gd",
]
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
# Bolts at several headings, held still so they can be inspected.
var bolt_scene: PackedScene = load("res://Projectile.tscn")
for i in range(6):
var bolt: Area2D = bolt_scene.instantiate()
bolt.position = Vector2(-460.0 + float(i) * 90.0, -220.0)
bolt.rotation = float(i) * 0.35
add_child(bolt)
bolt.set_physics_process(false)
# One of each explosion, stepped forward by hand then frozen. Their real
# lifetime is under a second, and a softpipe frame takes far longer than
# that in wall-clock, so they would expire before the capture.
for i in range(VFX_SCRIPTS.size()):
var script: Script = load(VFX_SCRIPTS[i])
if script == null:
continue
var vfx: Node2D = Node2D.new()
vfx.set_script(script)
vfx.position = Vector2(-390.0 + float(i) * 260.0, 90.0)
vfx.lifetime = 999.0
add_child(vfx)
for step in range(8):
vfx._process(0.05)
vfx.set_process(false)
var cam: Camera2D = Camera2D.new()
add_child(cam)
cam.make_current()

View file

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

6
tests/_vfx_preview.tscn Normal file
View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_vfx_preview.gd" id="1"]
[node name="VFXPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -1,20 +1,78 @@
#!/data/data/com.termux/files/usr/bin/bash
# Runs the headless GDScript gameplay tests from Termux, via the Godot
# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container.
# Usage: bash tests/run_tests.sh
#
# bash tests/run_tests.sh # everything
# bash tests/run_tests.sh formation # only tests matching a substring
# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang
# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too
# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself
#
# Three things make this fast, in descending order of how much they matter
# (measured, not guessed - see tests/_profile_startup.gd):
#
# 1. It runs from a COPY on Termux's own filesystem, not from the project
# directory. The project lives under ~/storage/shared, which is Android's
# FUSE bridge; read through proot on top of that, `load("res://Main.tscn")`
# alone took 8.5s per test, against ~2s from a native path. Nearly every
# test loads Main.tscn, and none of that time is gameplay. The copy is
# ~1.4s for the whole suite and its .godot import cache is kept between
# runs.
# 2. Tests run JOBS at a time. Each is its own Godot process - they call
# quit() and stamp on the GameState autoload, so they cannot share one -
# which makes the suite a pile of independent, single-core-bound jobs on
# an 8-core phone.
# 3. The whole run happens in ONE `proot-distro login`. Entering the
# container costs ~0.5s, which used to be paid once per test.
#
# Output is buffered per test and printed only for failures; six interleaved
# logs are unreadable.
set -uo pipefail
set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
status=0
for test_script in "$PROJECT_DIR"/tests/test_*.gd; do
name="$(basename "$test_script")"
echo "=== Running $name ==="
if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then
status=1
fi
echo
# Leave headroom: Godot is memory-hungry and this is a phone, not a build
# server. Override with JOBS= if you know better.
JOBS="${JOBS:-6}"
VERBOSE="${VERBOSE:-0}"
IN_PLACE="${IN_PLACE:-0}"
WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}"
filter="${1:-}"
tests=()
for path in "$PROJECT_DIR"/tests/test_*.gd; do
name="$(basename "$path")"
if [[ -z "$filter" || "$name" == *"$filter"* ]]; then
tests+=("$name")
fi
done
if (( ${#tests[@]} == 0 )); then
echo "no tests match '$filter'"
exit 2
fi
start=$(date +%s)
run_dir="$PROJECT_DIR"
if [[ "$IN_PLACE" != "1" ]]; then
run_dir="$WORK_DIR"
mkdir -p "$run_dir"
# Clear last run's sources but keep .godot, so imports stay warm. Without
# the clear, a test deleted from the project would live on in the copy and
# keep being run.
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
# android/ and builds/ are export artefacts no test touches, and the .apk
# is 30MB of nothing useful here.
tar -C "$PROJECT_DIR" \
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
fi
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
status=$?
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"
exit $status

View file

@ -28,6 +28,13 @@ WAIT_FRAMES="${3:-60}"
RESOLUTION="${4:-1280x720}"
proot-distro login ubuntu -- bash -c "
# A killed run (or a session that ended mid-screenshot) leaves /tmp/.X99-lock
# behind, and Xvfb then refuses to claim :99 - which surfaces much later and
# far less obviously as Godot's 'X11 Display is not available'. Nothing else
# uses :99 here, so a lock with no live Xvfb behind it is always stale.
if ! pgrep -x Xvfb >/dev/null; then
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
fi
Xvfb :99 -screen 0 ${RESOLUTION}x24 &
XVFB_PID=\$!
sleep 2

View file

@ -0,0 +1,114 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_asteroid_safe_spawn.gd
# Asteroid chunks are populated around wherever the fleet is - including the
# chunk it is standing in - and positions used to be uniform within a chunk.
# So an asteroid could be created directly on top of a ship and destroy it
# with no chance to react. Worst on the frame a scene loads: coming back from
# a planet's surface could cost a vessel instantly, which is also what made
# test_return_position_matches_planet fail intermittently.
#
# Verifies:
# 1. _safe_position never returns a point inside the bubble, for candidates
# all around and including the exact centre. This is checked directly
# rather than by looking at where asteroids happened to land: with ~54
# asteroids spread over 25 chunks, any single run only rarely produces a
# near miss, so an observational check passes just as happily with the
# guard removed and proves nothing.
# 2. candidates already outside the bubble are left exactly where they are,
# so the field is not distorted away from the fleet
# 3. the field still spawns a full complement, and a stationary fleet loses
# no ships
var scene: Node
var fleet: Node
var field: Node
var stage: int = 0
var elapsed: float = 0.0
var ships_at_start: int = 0
func _initialize() -> void:
root.get_node("GameState").reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
field = scene.get_node("AsteroidField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("EnemySpawner").set_process(false)
ships_at_start = fleet.ships.size()
elapsed = 0.0
stage = 1
return false
1:
# Let the field populate around the fleet a few times over.
if elapsed < 1.5:
return false
var centre: Vector2 = fleet.get_centroid()
var safe: float = field.safe_spawn_radius
# --- the guard itself, deterministically ----------------------
var worst_inside: float = 1e9
var moved_an_outside_one: bool = false
for i in range(72):
var a: float = (float(i) / 72.0) * TAU
var dir: Vector2 = Vector2(cos(a), sin(a))
# Candidates well inside, just inside, and exactly on centre.
for d in [0.0, 1.0, safe * 0.25, safe * 0.99]:
var got: Vector2 = field._safe_position(centre + dir * d, centre)
worst_inside = minf(worst_inside, got.distance_to(centre))
# Candidates already outside must be returned untouched.
var outside: Vector2 = centre + dir * (safe * 1.5)
if field._safe_position(outside, centre).distance_to(outside) > 0.001:
moved_an_outside_one = true
print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [
worst_inside, safe
])
if worst_inside < safe - 0.001:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)")
quit(1)
return true
if moved_an_outside_one:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)")
quit(1)
return true
var asteroids: Array = get_nodes_in_group("asteroids")
var closest: float = 1e9
for a in asteroids:
if is_instance_valid(a):
closest = minf(closest, a.position.distance_to(centre))
print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [
asteroids.size(), closest, safe
])
if asteroids.is_empty():
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)")
quit(1)
return true
# Asteroids drift, so one may have travelled inward since it was
# created. Allow for that, but nothing should be anywhere near
# on top of the fleet.
if closest < safe * 0.5:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)")
quit(1)
return true
var kept_ships: bool = fleet.ships.size() == ships_at_start
print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships])
if not kept_ships:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)")
quit(1)
return true
print("TEST_ASTEROID_SAFE_SPAWN: PASS")
quit(0)
return true
return false

View file

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

View file

@ -39,6 +39,12 @@ func _initialize() -> void:
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
# Structures are gated behind the mothership's research tree. That gate
# is test_skill_tree's subject, not this one's - here every option needs
# to be available so the popup itself can be exercised.
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)

View file

@ -0,0 +1,110 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_building_redraw_cost.gd
# Animated buildings must not re-run _draw() to animate (Forgejo issues #6
# and #10).
#
# Every NeonDraw call expands into four antialiased polylines, so redrawing
# a whole shipyard each frame to advance a crane re-tessellates all of it.
# The moving elements are now separate NeonPiece children, rasterised once
# and thereafter only moved, scaled, faded or hidden - operations that reuse
# the cached geometry. Ship.gd and Asteroid.gd already worked this way.
#
# Verifies:
# 1. an animated building's geometry is rasterised exactly ONCE, however
# long it animates
# 2. it is genuinely still animating (so this cannot be "passed" by
# freezing the animation)
# 3. the moving parts really do move - the crane travels and the
# mothership's satellites orbit - proving the animation reaches the
# nodes rather than being silently dropped
# 4. the static command centre needs no per-frame work either
var scene: Node
var field: Node
var crafter: Node2D
var mothership: Node2D
var command: Node2D
var stage: int = 0
var elapsed: float = 0.0
var crane_x_start: float = 0.0
var satellite_rotation_start: float = 0.0
func _initialize() -> void:
var game_state: Node = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0))
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-600.0, 0.0))
field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 600.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
for b in field.spawned:
if not is_instance_valid(b):
continue
match b.building_type:
GameState.BUILDING_SHIP_CRAFTER:
crafter = b
GameState.BUILDING_MOTHERSHIP:
mothership = b
GameState.BUILDING_COMMAND_CENTER:
command = b
if crafter == null or mothership == null or command == null:
print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)")
quit(1)
return true
crane_x_start = crafter._crane.position.x
satellite_rotation_start = mothership._satellites.rotation
elapsed = 0.0
stage = 2
return false
2:
# Long enough to cross several assembly phases and a good arc of
# the satellites' orbit.
if elapsed < 2.5:
return false
print("rasterisations after %.1fs of animation - crafter:%d mothership:%d command:%d" % [
elapsed, crafter.draw_count, mothership.draw_count, command.draw_count
])
for b in [crafter, mothership, command]:
if b.draw_count != 1:
print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [
b.building_type, b.draw_count
])
quit(1)
return true
var animating: bool = crafter.anim_time > 0.0 and mothership.phase_time > 0.0
var crane_moved: bool = absf(crafter._crane.position.x - crane_x_start) > 1.0
var orbit_moved: bool = absf(mothership._satellites.rotation - satellite_rotation_start) > 0.05
print("still animating=%s crane travelled=%s satellites orbited=%s" % [
animating, crane_moved, orbit_moved
])
if not (animating and crane_moved and orbit_moved):
print("TEST_BUILDING_REDRAW_COST: FAIL (nothing actually moved - not a fix)")
quit(1)
return true
print("TEST_BUILDING_REDRAW_COST: PASS")
quit(0)
return true
return false

View file

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

View file

@ -0,0 +1,247 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_building_stock_queue.gd
# Forgejo #16 (part 3 of #12): a building produces out of its OWN stock, and
# an order it cannot pay for yet is queued rather than refused.
#
# Before this, BuildController deducted from the bank at the moment of
# purchase and refused outright with "Not enough rocks", so nothing ever had
# to be delivered anywhere.
#
# Verifies:
# 1. ordering with an empty stock queues instead of failing, and produces
# nothing yet
# 2. it does NOT quietly take the cost out of the bank - the whole point is
# that the building has to be supplied
# 3. delivering enough stock completes the order on its own
# 4. one delivery covering several queued orders clears them all, rather
# than dribbling out one per frame
# 5. a part-delivery is not enough: stock below the cost leaves the order
# queued and the ore untouched
# 6. stock and queue survive the space scene being torn down (the trip to
# a planet's surface)
# 7. production completes with NO fleet in existence, and the fleet picks
# the vessel up when it comes back - orders must not depend on which
# scene the player is standing in
var game_state: Node
var scene: Node
var fleet: Node
var field: Node
var crafter_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var ships_at_start: int = 0
var bank_at_start: int = 0
var ticks: int = 0
const CRAFTER_AT: Vector2 = Vector2(900.0, 0.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.rocks = 500
game_state.fleet_cargo = 0
game_state.ships_owed = 0
# Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet
# starts at exactly 5, so without this every completed order would have
# nowhere to put its vessel and the test would be measuring the cap
# rather than the queue.
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
_clear_hazards()
fleet.spawn_ships(2, Vector2.ZERO)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
ships_at_start = fleet.ships.size()
bank_at_start = game_state.rocks
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- 1 + 2. an unaffordable order queues, and costs nothing --
if not game_state.queue_order(crafter_id):
print("TEST_BUILDING_STOCK_QUEUE: FAIL (the crafter refused an order outright)")
quit(1)
return true
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
if game_state.pending_orders(crafter_id) != 1:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (order did not stay queued: %d pending)" % game_state.pending_orders(crafter_id))
quit(1)
return true
if fleet.ships.size() != ships_at_start:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (a vessel appeared with an empty stock)")
quit(1)
return true
if game_state.rocks != bank_at_start:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (queueing took %d out of the bank - the building is supposed to be supplied)" % (bank_at_start - game_state.rocks))
quit(1)
return true
print("empty stock -> queued=%d ships=%d bank=%d (untouched)" % [
game_state.pending_orders(crafter_id), fleet.ships.size(), game_state.rocks
])
# --- 5. a part-delivery is not enough -----------------------
game_state.deposit_to_building(crafter_id, GameState.SHIP_COST - 1)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.3:
return false
if game_state.pending_orders(crafter_id) != 1 or fleet.ships.size() != ships_at_start:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (produced on a part-delivery of %d against a cost of %d)" % [
GameState.SHIP_COST - 1, GameState.SHIP_COST
])
quit(1)
return true
if game_state.building_stock(crafter_id) != GameState.SHIP_COST - 1:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (part-delivered ore was consumed anyway)")
quit(1)
return true
print("part delivery (%d of %d) -> still queued, ore intact" % [
game_state.building_stock(crafter_id), GameState.SHIP_COST
])
# --- 3. topping it up completes the order -------------------
game_state.deposit_to_building(crafter_id, 1)
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.4:
return false
if game_state.pending_orders(crafter_id) != 0:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (order not completed once the stock arrived)")
quit(1)
return true
if fleet.ships.size() != ships_at_start + 1:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (no vessel delivered: %d -> %d)" % [
ships_at_start, fleet.ships.size()
])
quit(1)
return true
if game_state.building_stock(crafter_id) != 0:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (stock not consumed: %d left)" % game_state.building_stock(crafter_id))
quit(1)
return true
print("supplied -> ships %d->%d, stock spent" % [ships_at_start, fleet.ships.size()])
# --- 4. one delivery clears ALL the orders it covers ---------
# Measured in TICKS, not seconds, and with more orders than the
# ticks allowed: production that handled one order per frame
# would still clear them all inside any generous wall-clock wait,
# so a timed check cannot tell "cleared at once" from "dribbled
# out". Three orders, two ticks - only clearing them together
# passes.
ships_at_start = fleet.ships.size()
game_state.queue_order(crafter_id)
game_state.queue_order(crafter_id)
game_state.queue_order(crafter_id)
game_state.deposit_to_building(crafter_id, GameState.SHIP_COST * 3)
ticks = 0
stage = 5
return false
5:
ticks += 1
if ticks < 2:
return false
if game_state.pending_orders(crafter_id) != 0 or fleet.ships.size() != ships_at_start + 3:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (a delivery covering 3 orders left %d queued after %d ticks, ships %d->%d)" % [
game_state.pending_orders(crafter_id), ticks, ships_at_start, fleet.ships.size()
])
quit(1)
return true
print("one delivery cleared 3 orders in %d ticks -> ships %d->%d" % [
ticks, ships_at_start, fleet.ships.size()
])
# --- 6 + 7. no scene, no fleet: production still completes --
# The scene has to be GONE before the order is placed. With a
# fleet still alive and under its cap it claims the vessel the
# same frame production finishes, which would leave ships_owed
# back at 0 and prove nothing about orders surviving without it.
ships_at_start = fleet.ships.size()
scene.queue_free()
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.3:
return false
if is_instance_valid(fleet):
return false
game_state.queue_order(crafter_id)
game_state.deposit_to_building(crafter_id, GameState.SHIP_COST)
elapsed = 0.0
stage = 61
return false
61:
if elapsed < 0.4:
return false
# The queue and stock live in the autoload, so tearing the space
# scene down must not touch them - and the order must complete
# even with no Fleet node anywhere to hand the vessel to.
if game_state.pending_orders(crafter_id) != 0:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)")
quit(1)
return true
if game_state.ships_owed < 1:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)")
quit(1)
return true
print("no scene at all -> order completed, %d vessel owed" % game_state.ships_owed)
# Come back to space: the fleet should claim what it is owed.
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
elapsed = 0.0
stage = 8
return false
8:
if elapsed < 0.5:
return false
if game_state.ships_owed != 0:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.ships_owed)
quit(1)
return true
print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size())
print("TEST_BUILDING_STOCK_QUEUE: PASS")
quit(0)
return true
return false
func _clear_hazards() -> void:
# set_process(false) on AsteroidField only stops NEW chunks spawning -
# rocks already in the world keep drifting and can destroy a parked ship,
# which silently changes the fleet size this test asserts on. This is a
# logistics test, not a survival one.
for a in get_nodes_in_group("asteroids"):
if is_instance_valid(a):
a.queue_free()
for e in get_nodes_in_group("enemies"):
if is_instance_valid(e):
e.queue_free()

View file

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

117
tests/test_cargo_readout.gd Normal file
View file

@ -0,0 +1,117 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_cargo_readout.gd
# Forgejo #15 (part 2 of #12): what the fleet is carrying has to be visible
# separately from what is banked.
#
# Since #14, ore is cargo until the fleet delivers it to the mothership. The
# banked readout does not move on pickup, so without a second readout mining
# looks broken - you gather ore and no number on screen changes.
#
# --headless has no GPU and draws no pixels, so this asserts on the two
# readouts' state, not on their appearance. tests/screenshot.sh covers
# whether they are legible.
#
# Verifies:
# 1. the banked readout keeps showing the BANK, not the total
# 2. the cargo readout shows the hold, and appears only while carrying
# 3. an empty hold hides it rather than parking a permanent 0 beside the
# real total
# 4. delivering moves the number from one readout to the other
# 5. the two are actually told apart on screen, rather than being the same
# number twice in the same colour at the same place
var game_state: Node
var scene: Node
var banked: Control
var cargo: Control
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.rocks = 42
game_state.fleet_cargo = 0
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
banked = scene.get_node("UI/ResourceCounter")
cargo = scene.get_node("UI/CargoCounter")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
# --- 3. nothing carried -> no cargo readout -----------------
if cargo.visible:
print("TEST_CARGO_READOUT: FAIL (cargo readout showing with an empty hold)")
quit(1)
return true
if banked.amount != 42:
print("TEST_CARGO_READOUT: FAIL (banked readout shows %d, bank is 42)" % banked.amount)
quit(1)
return true
print("empty hold -> cargo readout hidden, banked shows %d" % banked.amount)
game_state.collect_cargo(7)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- 1 + 2. carried and banked are separate numbers ---------
if not cargo.visible:
print("TEST_CARGO_READOUT: FAIL (carrying 7 and the cargo readout is hidden)")
quit(1)
return true
if cargo.amount != 7:
print("TEST_CARGO_READOUT: FAIL (cargo readout shows %d, hold is 7)" % cargo.amount)
quit(1)
return true
if banked.amount != 42:
print("TEST_CARGO_READOUT: FAIL (banked readout moved to %d on a pickup - it is showing the total, not the bank)" % banked.amount)
quit(1)
return true
print("carrying 7 -> cargo=%d banked=%d" % [cargo.amount, banked.amount])
# --- 5. genuinely distinguishable ---------------------------
if cargo.glow_color == banked.glow_color:
print("TEST_CARGO_READOUT: FAIL (both readouts drawn in the same colour)")
quit(1)
return true
if cargo.position == banked.position:
print("TEST_CARGO_READOUT: FAIL (both readouts in the same place)")
quit(1)
return true
print("distinct: colours %s vs %s, at %s vs %s" % [
cargo.glow_color, banked.glow_color, cargo.position, banked.position
])
# --- 4. delivery moves it across ----------------------------
game_state.deliver_cargo()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
if cargo.visible:
print("TEST_CARGO_READOUT: FAIL (cargo readout still showing after delivery)")
quit(1)
return true
if banked.amount != 49:
print("TEST_CARGO_READOUT: FAIL (banked shows %d after delivering 7 into 42)" % banked.amount)
quit(1)
return true
print("delivered -> cargo hidden, banked shows %d" % banked.amount)
print("TEST_CARGO_READOUT: PASS")
quit(0)
return true
return false

View file

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

View file

@ -42,6 +42,11 @@ func _initialize() -> void:
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
# Extractors are gated behind research (see test_skill_tree). This test
# is about the planet-proximity rule, so grant the research up front.
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
main_scene = load("res://Main.tscn").instantiate()
root.add_child(main_scene)

View file

@ -14,7 +14,13 @@ extends SceneTree
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var mothership_pos: Vector2 = Vector2(4000.0, 0.0)
# The test has to sit through a whole there-and-back hauler cycle, and the
# cycle length is distance/HAULER_SPEED - so this fixture distance IS the
# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s
# for the 4000 it used to be. Nothing here asserts on the distance itself,
# only on payout and drawn position tracking the same `progress`, so a short
# route exercises exactly the same code.
var mothership_pos: Vector2 = Vector2(600.0, 0.0)
var planet_pos: Vector2 = Vector2.ZERO
var rocks_before_arrival: int = 0

179
tests/test_fleet_cargo.gd Normal file
View file

@ -0,0 +1,179 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_fleet_cargo.gd
# Forgejo #14 (part 1 of #12): mined ore is CARGO the fleet is carrying, not
# instant global income. It only becomes spendable once the fleet flies it
# back to the mothership.
#
# Before this, Rock.gd did `GameState.rocks += 1` on pickup, so ore was
# banked instantly from anywhere on the map and a mining run had nothing to
# lose.
#
# Verifies:
# 1. mining fills the hold and leaves the bank alone
# 2. reaching the mothership banks the lot, and empties the hold
# 3. with no mothership there is nowhere to deliver, so cargo just
# accumulates - it is never quietly banked anyway
# 4. sitting on a DIFFERENT building is not a delivery
# 5. ore is conserved: nothing is created or destroyed in the transfer
# 6. cargo survives the trip to a planet's surface and back, which is
# exactly the trip a mining run is
var game_state: Node
var scene: Node
var fleet: Node
var field: Node
var stage: int = 0
var elapsed: float = 0.0
var banked_before: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(-4000.0, 0.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.rocks = 100
game_state.fleet_cargo = 0
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
func _park_fleet_on(at: Vector2) -> void:
for ship in fleet.ships:
ship.position = at
fleet.move_to(at)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- 1. mining fills the hold, not the bank -----------------
# Through a REAL Rock, flown over by a real ship, rather than by
# calling collect_cargo() directly: the whole change is which
# pool Rock.gd credits on pickup, and a test that calls the new
# API by hand passes just as happily with Rock.gd left untouched.
banked_before = game_state.rocks
var rock: Area2D = load("res://Rock.tscn").instantiate()
rock.build_shape(1)
rock.global_position = fleet.ships[0].global_position
scene.add_child(rock)
elapsed = 0.0
stage = 5
return false
5:
# A tick for the Area2D overlap to register.
if elapsed < 0.25:
return false
if game_state.fleet_cargo != 1:
print("TEST_FLEET_CARGO: FAIL (flying over a rock put %d in the hold)" % game_state.fleet_cargo)
quit(1)
return true
if game_state.rocks != banked_before:
print("TEST_FLEET_CARGO: FAIL (a mined rock was banked instantly: %d -> %d)" % [
banked_before, game_state.rocks
])
quit(1)
return true
print("flew over a real rock -> hold=%d bank=%d (unchanged)" % [
game_state.fleet_cargo, game_state.rocks
])
game_state.collect_cargo(1)
game_state.collect_cargo(1)
if game_state.fleet_cargo != 3:
print("TEST_FLEET_CARGO: FAIL (mining did not fill the hold: %d)" % game_state.fleet_cargo)
quit(1)
return true
if game_state.rocks != banked_before:
print("TEST_FLEET_CARGO: FAIL (mining banked %d straight away)" % (game_state.rocks - banked_before))
quit(1)
return true
print("mined 3 -> hold=%d bank=%d (unchanged)" % [game_state.fleet_cargo, game_state.rocks])
# --- 3. no mothership means nowhere to deliver --------------
_park_fleet_on(Vector2.ZERO)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
if game_state.fleet_cargo != 3 or game_state.rocks != banked_before:
print("TEST_FLEET_CARGO: FAIL (delivered with no mothership built)")
quit(1)
return true
print("no mothership -> hold still %d, bank still %d" % [game_state.fleet_cargo, game_state.rocks])
# --- 4. a different building is not a delivery point --------
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
if game_state.fleet_cargo != 3:
print("TEST_FLEET_CARGO: FAIL (the ship crafter accepted a delivery)")
quit(1)
return true
print("parked on the crafter -> hold still %d" % game_state.fleet_cargo)
# --- 2. the mothership banks it -----------------------------
_park_fleet_on(MOTHERSHIP_AT)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.4:
return false
var total_before: int = banked_before + 3
print("delivered: hold=%d bank=%d (expected hold=0 bank=%d)" % [
game_state.fleet_cargo, game_state.rocks, total_before
])
if game_state.fleet_cargo != 0:
print("TEST_FLEET_CARGO: FAIL (hold not emptied on delivery)")
quit(1)
return true
# --- 5. conservation ----------------------------------------
if game_state.rocks != total_before:
print("TEST_FLEET_CARGO: FAIL (ore was created or destroyed in the transfer)")
quit(1)
return true
# --- 6. cargo survives the scene swap -----------------------
# Not simulated by loading PlanetSurface.tscn here: the point is
# that cargo lives in the autoload rather than in Main.tscn, so
# tearing Main.tscn down must not touch it.
# Fly off the mothership first, or this new cargo is (correctly)
# banked the moment BuildingField next runs, and the teardown
# check below would be measuring a delivery instead.
_park_fleet_on(CRAFTER_AT)
game_state.collect_cargo(5)
scene.queue_free()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.3:
return false
if game_state.fleet_cargo != 5:
print("TEST_FLEET_CARGO: FAIL (cargo lost when the space scene was torn down: %d)" % game_state.fleet_cargo)
quit(1)
return true
print("cargo survived the scene teardown: hold=%d" % game_state.fleet_cargo)
print("TEST_FLEET_CARGO: PASS")
quit(0)
return true
return false

View file

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

143
tests/test_hub_capacity.gd Normal file
View file

@ -0,0 +1,143 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_hub_capacity.gd
# Forgejo #20 (part 7 of #12): a hub stations a limited number of
# transporters. More hubs, or research, raise it.
#
# Verifies:
# 1. capacity is per hub, so a second hub raises the total
# 2. ordering past the cap is REFUSED, not silently swallowed - the ticket
# is explicit that the player has to be told
# 3. a queued-but-unbuilt transporter still holds its berth, or ten could
# be ordered against two and only fail on arrival
# 4. researching Freight Yards raises capacity for hubs ALREADY BUILT, not
# just ones placed afterwards
# 5. the extra berth is immediately usable
var game_state: Node
var scene: Node
var field: Node
var crafter_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
const CRAFTER_AT: Vector2 = Vector2(400.0, 0.0)
const HUB_A: Vector2 = Vector2(-500.0, 0.0)
const HUB_B: Vector2 = Vector2(-500.0, 600.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.rocks = 900
game_state.fleet_cargo = 0
game_state.ships_owed = 0
game_state.unlocked = ["shipyards", "logistics", "freight"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
func _fill_berths(n: int) -> void:
# Queue and immediately fund n transporters.
for i in range(n):
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_A)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
var per_hub: int = game_state.hub_capacity()
if game_state.transport_capacity() != per_hub:
print("TEST_HUB_CAPACITY: FAIL (one hub gives %d berths, per-hub capacity is %d)" % [
game_state.transport_capacity(), per_hub
])
quit(1)
return true
print("1 hub -> %d berths (%d per hub)" % [game_state.transport_capacity(), per_hub])
# --- 2 + 3. fill it, then try to exceed it ------------------
_fill_berths(per_hub)
if game_state.transporters_claimed() != per_hub:
print("TEST_HUB_CAPACITY: FAIL (claimed %d berths after ordering %d)" % [
game_state.transporters_claimed(), per_hub
])
quit(1)
return true
# Still only QUEUED at this point - the berth must already count.
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
print("TEST_HUB_CAPACITY: FAIL (queued a transporter past the %d-berth cap)" % per_hub)
quit(1)
return true
print("cap enforced at %d, including orders not yet built" % per_hub)
# --- 1. a second hub raises the total -----------------------
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_B)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
if game_state.transport_capacity() != game_state.hub_capacity() * 2:
print("TEST_HUB_CAPACITY: FAIL (2 hubs give %d berths, expected %d)" % [
game_state.transport_capacity(), game_state.hub_capacity() * 2
])
quit(1)
return true
print("2 hubs -> %d berths" % game_state.transport_capacity())
if not game_state.can_station_transporter():
print("TEST_HUB_CAPACITY: FAIL (second hub did not free a berth)")
quit(1)
return true
# Fill the second hub too, so capacity is exhausted again.
_fill_berths(game_state.transport_capacity() - game_state.transporters_claimed())
if game_state.can_station_transporter():
print("TEST_HUB_CAPACITY: FAIL (still room after filling every berth)")
quit(1)
return true
# --- 4. research raises it for hubs ALREADY built -----------
var before: int = game_state.transport_capacity()
game_state.unlocked.append("freight_yards")
var after: int = game_state.transport_capacity()
print("researched Freight Yards: %d -> %d berths across %d existing hubs" % [
before, after, game_state.hub_count()
])
if after != before + game_state.hub_count():
print("TEST_HUB_CAPACITY: FAIL (research gave %d berths, expected +1 per existing hub)" % (after - before))
quit(1)
return true
# --- 5. and the new berth is usable straight away -----------
if not game_state.can_station_transporter():
print("TEST_HUB_CAPACITY: FAIL (research raised the number but left no usable berth)")
quit(1)
return true
if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
print("TEST_HUB_CAPACITY: FAIL (could not order into the berth research just added)")
quit(1)
return true
print("ordered into the newly researched berth")
print("TEST_HUB_CAPACITY: PASS")
quit(0)
return true
return false

View file

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

View file

@ -120,9 +120,17 @@ func _process(delta: float) -> bool:
for ship in fleet.ships:
speed = max(speed, ship.velocity.length())
var stopped: bool = speed < 1.0
var coasted: bool = fleet.get_centroid().x > moving_centroid.x
print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [
speed, stopped, coasted
# Not merely "moved forward at all": the fleet cruises at
# cruise_speed and brakes at Ship.acceleration, so the coast is
# cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a
# real distance is what makes this a test of coasting - the old
# `> moving_centroid.x` was satisfied by any float wobble, and
# passed happily while the fleet stopped dead.
var coast: float = fleet.get_centroid().x - moving_centroid.x
var coasted: bool = coast > 40.0
print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [
speed, stopped, coast,
(fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration)
])
if stopped and coasted:
print("TEST_JOYSTICK_CONTROL: PASS")

View file

@ -0,0 +1,205 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_manual_supply_route.gd
# Forgejo #17 (part 4 of #12): the player can supply a building by hand -
# withdraw banked ore into the fleet's hold at the mothership, fly it over,
# and have the building take it.
#
# This is what keeps the game unblockable. #16 lets an order queue waiting
# for ore, and until transporters exist (#19) there is otherwise no way to
# fulfil one - a player with a queued order and no Transport Hub would be
# stuck forever.
#
# Verifies:
# 1. withdrawing moves ore bank -> hold, and conserves the total
# 2. withdrawing is capped by what is actually banked (no overdraft)
# 3. arriving at the mothership banks the hold, but doing so is EDGE
# triggered - ore withdrawn while parked there is not instantly taken
# back, which would make the button look broken
# 4. a building takes the fleet's whole hold when opened
# 5. end to end: a queued order the bank cannot reach is fulfilled using
# only the fleet, with no Transport Hub in existence
# 6. nothing is created or destroyed anywhere along the route
var game_state: Node
var scene: Node
var fleet: Node
var field: Node
var controller: Node
var crafter_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var ships_before: int = 0
var total_before: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0)
func _total() -> int:
var sum: int = game_state.rocks + game_state.fleet_cargo
for b in game_state.buildings:
sum += int(b.get("stock", 0))
return sum
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.rocks = 40
game_state.fleet_cargo = 0
game_state.ships_owed = 0
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
controller = scene.get_node("BuildController")
func _park_fleet_on(at: Vector2) -> void:
for ship in fleet.ships:
ship.position = at
fleet.move_to(at)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
_clear_hazards()
fleet.spawn_ships(2, MOTHERSHIP_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
total_before = _total()
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
# --- 5. an order the crafter cannot pay for -----------------
ships_before = fleet.ships.size()
game_state.queue_order(crafter_id)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
if game_state.pending_orders(crafter_id) != 1:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order did not queue)")
quit(1)
return true
# --- 1 + 2. withdraw at the mothership ----------------------
_park_fleet_on(MOTHERSHIP_AT)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.3:
return false
var bank_before: int = game_state.rocks
var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST)
if took != GameState.SHIP_COST or game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo])
quit(1)
return true
if game_state.rocks != bank_before - GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (bank did not pay for the withdrawal)")
quit(1)
return true
print("withdrew %d at the mothership -> bank=%d hold=%d" % [
took, game_state.rocks, game_state.fleet_cargo
])
elapsed = 0.0
stage = 4
return false
4:
# --- 3. still parked on the mothership ----------------------
if elapsed < 0.5:
return false
if game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was instantly banked again - delivery is not edge triggered)")
quit(1)
return true
print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo)
# --- 4. carry it to the crafter and open it -----------------
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.6:
return false
var moved: int = game_state.unload_into_building(crafter_id)
if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo])
quit(1)
return true
print("unloaded %d into the crafter" % moved)
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.5:
return false
# --- 5. the queued order completed off the fleet's own ore ---
if game_state.pending_orders(crafter_id) != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order still queued after a hand delivery)")
quit(1)
return true
if fleet.ships.size() != ships_before + 1:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel: %d -> %d)" % [ships_before, fleet.ships.size()])
quit(1)
return true
print("queued order fulfilled by hand, no transport hub: ships %d->%d" % [
ships_before, fleet.ships.size()
])
# --- 6. conservation ----------------------------------------
var expected: int = total_before - GameState.SHIP_COST
if _total() != expected:
print("bank=%d hold=%d stocks total=%d, expected %d" % [
game_state.rocks, game_state.fleet_cargo, _total(), expected
])
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore created or destroyed along the route)")
quit(1)
return true
print("ore conserved: %d -> %d, exactly the %d the vessel cost" % [
total_before, _total(), GameState.SHIP_COST
])
# --- 2. no overdraft ----------------------------------------
game_state.rocks = 3
var over: int = game_state.withdraw_to_fleet(999)
if over != 3 or game_state.rocks != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks])
quit(1)
return true
print("withdrawing 999 from a bank of 3 took %d" % over)
print("TEST_MANUAL_SUPPLY_ROUTE: PASS")
quit(0)
return true
return false
func _clear_hazards() -> void:
# set_process(false) on AsteroidField only stops NEW chunks spawning -
# rocks already in the world keep drifting and can destroy a parked ship,
# which silently changes the fleet size this test asserts on. This is a
# logistics test, not a survival one.
for a in get_nodes_in_group("asteroids"):
if is_instance_valid(a):
a.queue_free()
for e in get_nodes_in_group("enemies"):
if is_instance_valid(e):
e.queue_free()

View file

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

View file

@ -0,0 +1,99 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_map_building_markers.gd
# Forgejo issue #11: a building the player has placed must appear on the
# world map. Without it the map showed explored chunks, planets and the
# fleet, but nothing the player had actually built - so there was no way to
# find your own base again once you had flown away from it.
#
# Buildings are not tied to a chunk (they are placed freely in space), so
# they cannot ride along with the chunk markers and need their own pass.
#
# --headless has no GPU and draws no pixels, so this cannot assert on what
# was rendered. It asserts on building_markers(), the step that turns
# GameState.buildings into map-local marker data, which is the part that
# _draw() then simply stamps out.
#
# Verifies:
# 1. no buildings -> no markers (so a passing count is not just "always 3")
# 2. every placed building produces exactly one marker
# 3. markers land at the building's world position scaled into map space
# 4. the three building types are told apart by colour, rather than all
# being drawn as the same anonymous dot
var scene: Node
var game_state: Node
var map_content: Node2D
var stage: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(2000.0, -1200.0)
const CRAFTER_AT: Vector2 = Vector2(-800.0, 400.0)
const COMMAND_AT: Vector2 = Vector2(150.0, 3000.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
map_content = scene.get_node("UI/WorldMap/MapContent")
func _process(_delta: float) -> bool:
match stage:
0:
# Nothing built yet: the map must show no building markers at all.
var empty: Array = map_content.building_markers()
if not empty.is_empty():
print("TEST_MAP_BUILDING_MARKERS: FAIL (%d markers with nothing built)" % empty.size())
quit(1)
return true
game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
game_state.add_building(GameState.BUILDING_COMMAND_CENTER, COMMAND_AT)
stage = 1
return false
1:
var markers: Array = map_content.building_markers()
print("markers=%d for %d buildings" % [markers.size(), game_state.buildings.size()])
if markers.size() != 3:
print("TEST_MAP_BUILDING_MARKERS: FAIL (expected one marker per building)")
quit(1)
return true
var scale: float = map_content.WORLD_TO_LOCAL
var expected: Dictionary = {
GameState.BUILDING_MOTHERSHIP: MOTHERSHIP_AT * scale,
GameState.BUILDING_SHIP_CRAFTER: CRAFTER_AT * scale,
GameState.BUILDING_COMMAND_CENTER: COMMAND_AT * scale,
}
var colours: Array = []
for m in markers:
if not expected.has(m.type):
print("TEST_MAP_BUILDING_MARKERS: FAIL (unexpected marker type %s)" % m.type)
quit(1)
return true
var want: Vector2 = expected[m.type]
if m.position.distance_to(want) > 0.001:
print("%s marker at %s, expected %s" % [m.type, m.position, want])
print("TEST_MAP_BUILDING_MARKERS: FAIL (marker is not where the building is)")
quit(1)
return true
colours.append(m.color)
# Three buildings drawn in one indistinguishable colour would
# satisfy every check above and still be useless to read.
if colours[0] == colours[1] or colours[1] == colours[2] or colours[0] == colours[2]:
print("colours=%s" % [colours])
print("TEST_MAP_BUILDING_MARKERS: FAIL (building types are not visually distinct)")
quit(1)
return true
print("all 3 markers positioned correctly, colours distinct: %s" % [colours])
print("TEST_MAP_BUILDING_MARKERS: PASS")
quit(0)
return true
return false

View file

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

View file

@ -0,0 +1,138 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_modal_blocks_input.gd
# With a panel open, nothing behind it may react to a touch.
#
# Two separate leaks, because there are two separate input paths:
#
# 1. GUI clicks. The skill and formation panels cannot block these the
# normal way (mouse_filter = STOP), because the skill tree and the
# formation editor are driven from raw _unhandled_input, and a Control
# that consumes touches through the GUI system swallows them before
# they ever get there. So the panels stay transparent and the buttons
# check whether they are behind one.
# 2. Raw input. A touch landing on the panel but missing a skill node was
# left unhandled, so it fell through to GameController - which sits on
# the ROOT node and therefore sees everything its children ignore - and
# moved the fleet or pinch-zoomed the world behind the menu.
#
# Verifies:
# 1. a button outside an open panel does not fire
# 2. a button INSIDE the open panel still does (or the panel is unusable)
# 3. with every panel closed, that same outside button fires normally, so
# a pass cannot just mean "the button never works"
# 4. a touch on an open panel does not reach the world behind it
var scene: Node
var game_state: Node
var fleet: Node
var skill_menu: Control
var map_button: Control
var skill_close: Control
var stage: int = 0
var elapsed: float = 0.0
var outside_fired: bool = false
var inside_fired: bool = false
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
skill_menu = scene.get_node("UI/SkillMenu")
map_button = scene.get_node("UI/MapButton")
skill_close = scene.get_node("UI/SkillMenu/CloseButton")
map_button.pressed.connect(func(): outside_fired = true)
skill_close.pressed.connect(func(): inside_fired = true)
func _tap(button: Control) -> void:
# Straight at the button's own _gui_input, which is where a real tap
# lands once Godot has routed it.
var centre: Vector2 = button.size / 2.0
for pressed in [true, false]:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.pressed = pressed
ev.position = centre
button._gui_input(ev)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- 3. baseline: it works with nothing open ----------------
outside_fired = false
_tap(map_button)
if not outside_fired:
print("TEST_MODAL_BLOCKS_INPUT: FAIL (the button never fires even with no panel open - the test proves nothing)")
quit(1)
return true
print("no panel open -> outside button fires")
# That tap actually OPENED the world map, which is itself a modal
# panel - leave it open and it correctly blocks the research
# panel's own buttons, which is a true result but not the one
# being measured here.
scene.get_node("UI/WorldMap").visible = false
skill_menu.visible = true
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- 1. behind an open panel --------------------------------
outside_fired = false
_tap(map_button)
if outside_fired:
print("TEST_MODAL_BLOCKS_INPUT: FAIL (a button behind the open research panel still fired)")
quit(1)
return true
print("research panel open -> button behind it is inert")
# --- 2. inside it ------------------------------------------
inside_fired = false
_tap(skill_close)
if not inside_fired:
print("TEST_MODAL_BLOCKS_INPUT: FAIL (the panel's own close button stopped working)")
quit(1)
return true
print("panel's own button still works")
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
# --- 4. raw input must not reach the world ------------------
skill_menu.visible = true
var before: Vector2 = fleet.fleet_target
var controller: Node = scene.get_node("BuildController")
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.pressed = true
ev.position = Vector2(360.0, 900.0)
controller._unhandled_input(ev)
if not root.get_viewport().is_input_handled():
print("TEST_MODAL_BLOCKS_INPUT: FAIL (a touch on the open panel was left unhandled - it falls through to the world behind)")
quit(1)
return true
if fleet.fleet_target != before:
print("TEST_MODAL_BLOCKS_INPUT: FAIL (the fleet moved from a touch on the panel)")
quit(1)
return true
print("touch on the panel consumed, fleet target unchanged")
print("TEST_MODAL_BLOCKS_INPUT: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1,155 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_extractor_marker.gd
# Forgejo issue #9: a planet with an extractor built on it must show that
# something is built there. The extractor lives on PlanetSurface.tscn, a
# scene the player is not looking at from space, so an already-mined planet
# was indistinguishable from an untouched one - you had to land to find out.
#
# Covered in both places the player looks at a planet:
# - in space, as a derrick on the planet's own outline (Planet.gd)
# - on the world map, as a badge on the planet marker (WorldMapContent.gd)
#
# --headless has no GPU, so this asserts on the state and geometry that
# _draw() consumes, not on pixels. tests/screenshot.sh covers whether it is
# actually visible.
#
# Verifies:
# 1. a planet with no extractor shows neither derrick nor badge (so a pass
# cannot just mean "always on")
# 2. building one turns both on, keyed by planet NAME - the same key
# GameState.has_extractor_at uses
# 3. the derrick sits outside the planet's outline rather than buried in
# it, which is the difference between visible and not
var game_state: Node
var scene: Node
var map_content: Node2D
var planet_chunk: Vector2i
var planet_name: String = ""
var stage: int = 0
var already_spawned: Node2D
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
planet_chunk = chunk
planet_name = WorldGen.planet_data_at_chunk(chunk).name
found = true
break
if found:
break
if not found:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no planet chunk to test with)")
quit(1)
return
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
map_content = scene.get_node("UI/WorldMap/MapContent")
func _spawn_planet() -> Node2D:
var planet: Node2D = load("res://Planet.tscn").instantiate()
planet.planet_name = planet_name
planet.shape_seed = 12345
scene.add_child(planet)
return planet
func _process(_delta: float) -> bool:
match stage:
0:
# --- nothing built there yet --------------------------------
var clean: Node2D = _spawn_planet()
if clean.shows_extractor:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick on a planet with no extractor)")
quit(1)
return true
if map_content.chunk_has_extractor_badge(planet_chunk):
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (map badge with no extractor)")
quit(1)
return true
print("no extractor -> no derrick, no badge (planet '%s')" % planet_name)
# Deliberately keep this planet ALIVE across the build. An
# extractor is built from space with the fleet parked on the
# planet (BuildController._planet_under_fleet()), so the node is
# already in the tree with _ready() long since run. Resolving the
# marker only at _ready() meant the planet kept looking untouched
# until it happened to despawn and respawn - the build read as
# having done nothing at all. Spawning a fresh planet afterwards
# (as the checks below do) cannot catch that.
already_spawned = clean
game_state.add_extractor(planet_name, WorldGen.chunk_origin(planet_chunk))
stage = 1
return false
1:
# --- the planet that was already on screen when it was built --
if not already_spawned.shows_extractor:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (a planet already in the tree never picked up its derrick - the build reads as doing nothing)")
quit(1)
return true
print("planet already in the tree picked up its derrick on build")
already_spawned.queue_free()
# --- extractor built ----------------------------------------
var mined: Node2D = _spawn_planet()
if not mined.shows_extractor:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no derrick after building an extractor)")
quit(1)
return true
if not map_content.chunk_has_extractor_badge(planet_chunk):
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no map badge after building an extractor)")
quit(1)
return true
# The derrick has to clear the outline to be seen at all. Compare
# its tip against the planet's own radius in that direction, not
# against a constant - the outline is a lumpy seeded shape.
var derrick: PackedVector2Array = mined.extractor_derrick_points()
if derrick.size() < 3:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick has no shape)")
quit(1)
return true
# Measure the TIP, not the whole polygon. The base corners sit a
# half-width to either side of the rim vertex, so they are
# sqrt(|base|^2 + w^2) from the centre and clear the rim on their
# own - a max over every point called a derrick pointing straight
# into the ground "visible", which is exactly the bug this check
# exists to catch.
var furthest: float = mined.extractor_derrick_tip().length()
var rim: float = 0.0
for p in mined.shape_points:
rim = maxf(rim, p.length())
print("derrick tip reaches %.1f, planet outline reaches %.1f" % [furthest, rim])
if furthest <= rim:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick buried inside the planet)")
quit(1)
return true
# A different planet must NOT inherit the marker: the flag is keyed
# by name, and getting that wrong would light up every planet.
var other: Node2D = load("res://Planet.tscn").instantiate()
other.planet_name = planet_name + " II"
other.shape_seed = 999
scene.add_child(other)
if other.shows_extractor:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (marker leaked onto a different planet)")
quit(1)
return true
print("extractor -> derrick + badge, and only on the right planet")
print("TEST_PLANET_EXTRACTOR_MARKER: PASS")
quit(0)
return true
return false

View file

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

View file

@ -3,7 +3,9 @@ extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_resources_and_buildings.gd
# Covers the resource/building loop end to end:
# 1. flying a ship over a surface rock banks it in GameState
# 1. flying a ship over a surface rock loads it into the fleet's hold
# (banking it needs a trip to the mothership - see #14 and
# tests/test_fleet_cargo.gd)
# 2. buildings are paid for, persist in GameState, and respawn with the
# space scene (they must survive the trip to a planet and back)
# 3. a ship crafter adds a vessel to the fleet for rocks
@ -21,6 +23,7 @@ var mothership_pos: Vector2 = Vector2(900.0, -400.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 0
game_state.fleet_cargo = 0
game_state.buildings.clear()
game_state.has_return_position = false
@ -49,12 +52,14 @@ func _process(delta: float) -> bool:
1:
if elapsed < 1.5:
return false
if game_state.rocks != 1:
print("rocks=%d after flying a ship onto one" % game_state.rocks)
# Into the hold, not the bank: since #14 ore is cargo until the
# fleet delivers it to the mothership.
if game_state.fleet_cargo != 1:
print("fleet_cargo=%d after flying a ship onto one" % game_state.fleet_cargo)
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)")
quit(1)
return true
print("gathered rock -> rocks=%d" % game_state.rocks)
print("gathered rock -> hold=%d bank=%d" % [game_state.fleet_cargo, game_state.rocks])
# Bank enough to build with, then move to the space scene.
game_state.rocks = 60

169
tests/test_skill_tree.gd Normal file
View file

@ -0,0 +1,169 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_skill_tree.gd
# The mothership's research tree (Forgejo issue #4). It gates which
# structures may be built at all, and carries permanent upgrades that have
# no building of their own.
#
# Verifies:
# 1. from a fresh start only the mothership is buildable - the others are
# greyed, and pressing one explains which research opens it rather than
# silently failing
# 2. a locked option cannot be built even if the confirm is forced
# 3. research costs rocks, needs its prerequisite, and unlocks its building
# 4. the mothership's action button opens the tree (it used to have no
# action at all)
# 5. fleet capacity upgrades raise the cap, and the extra vessel can
# actually be crafted afterwards
var scene: Node
var game_state: Node
var controller: Node
var fleet: Node
var field: Node
var popup: Control
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
popup = scene.get_node("UI/BuildMenu/InfoPopup")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- everything but the mothership starts locked --------------
var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP)
var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("fresh start: mothership buildable=%s crafter locked=%s" % [
mothership_free, crafter_locked
])
if not (mothership_free and crafter_locked):
print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)")
quit(1)
return true
controller._on_build_pressed()
var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled
controller._on_place_crafter()
var explains: bool = popup.visible \
and "research" in popup.get_node("Panel/MessageLabel").text.to_lower()
var offered: bool = popup.get_node("Panel/BuildButton").visible
print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [
greyed, explains, offered
])
if not (greyed and explains) or offered:
print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)")
quit(1)
return true
# Forcing the confirm must still build nothing.
var before: int = game_state.buildings.size()
controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER
controller._confirm_build()
if game_state.buildings.size() != before:
print("TEST_SKILL_TREE: FAIL (built a locked structure)")
quit(1)
return true
controller._close_info()
# --- the mothership opens the tree ----------------------------
var mothership: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
mothership = b
controller.current_building = mothership
controller._on_action_pressed()
var tree_open: bool = scene.get_node("UI/SkillMenu").visible
print("mothership action opens research: %s" % tree_open)
if not tree_open:
print("TEST_SKILL_TREE: FAIL (mothership has no research action)")
quit(1)
return true
# --- prerequisites --------------------------------------------
if game_state.can_unlock("wing"):
print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)")
quit(1)
return true
# --- research costs rocks and unlocks its building ------------
var rocks_before: int = game_state.rocks
var cost: int = int(game_state.skill_node("shipyards").cost)
var ok: bool = game_state.unlock("shipyards")
var charged: bool = game_state.rocks == rocks_before - cost
var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [
ok, rocks_before, game_state.rocks, charged, now_buildable
])
if not (ok and charged and now_buildable):
print("TEST_SKILL_TREE: FAIL (research did not unlock its building)")
quit(1)
return true
# The menu must show it as buildable now, without reopening.
controller._on_build_pressed()
if not scene.get_node("UI/BuildMenu/CrafterButton").enabled:
print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)")
quit(1)
return true
controller._close_info()
scene.get_node("UI/BuildMenu").visible = false
# --- fleet capacity -------------------------------------------
var base_cap: int = game_state.max_fleet_size()
game_state.unlock("coordination")
var wing_ok: bool = game_state.unlock("wing")
var raised_cap: int = game_state.max_fleet_size()
print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [
base_cap, raised_cap, wing_ok
])
if not (wing_ok and raised_cap == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)")
quit(1)
return true
# And the extra vessel must actually be craftable.
fleet.spawn_ships(base_cap, Vector2.ZERO)
var added: bool = fleet.add_ship()
print("fleet %d ships, add one more -> %s (now %d)" % [
base_cap, added, fleet.ships.size()
])
if not (added and fleet.ships.size() == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)")
quit(1)
return true
if fleet.add_ship():
print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)")
quit(1)
return true
print("TEST_SKILL_TREE: PASS")
quit(0)
return true
return false

View file

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

View file

@ -0,0 +1,157 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_transport_hub_building.gd
# Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of
# its own. Only the building here - the vessels that do the hauling are #19.
#
# Verifies:
# 1. it is gated behind its research node, like every other structure
# 2. researching it makes it buildable, and placing it charges its cost
# 3. it is drawn as ITSELF, not as the crafter - Building._draw()'s match
# has a catch-all that renders anything unrecognised as a ship crafter,
# so a missing case is silent and looks almost right
# 4. its geometry is rasterised ONCE however long it animates (#10), and
# its stationed transports really do move
# 5. it has its own build-menu icon and its own map marker rather than
# borrowing another building's - the menu was once unreadable because
# two buildings shared a glyph
var game_state: Node
var scene: Node
var field: Node
var controller: Node
var map_content: Node2D
var hub: Node2D
var stage: int = 0
var elapsed: float = 0.0
var spin_at_start: float = 0.0
const HUB_AT: Vector2 = Vector2(700.0, 0.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.rocks = 500
game_state.fleet_cargo = 0
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
controller = scene.get_node("BuildController")
map_content = scene.get_node("UI/WorldMap/MapContent")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- 1. locked until researched ------------------------------
if game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (buildable with no research at all)")
quit(1)
return true
var gate: String = game_state.research_for_building(GameState.BUILDING_TRANSPORT_HUB)
if gate == "":
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no research node gates it)")
quit(1)
return true
print("locked behind research: '%s'" % gate)
# --- 5. its own icon, distinct from every other building -----
var icons: Dictionary = {}
for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER,
GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]:
var icon: int = controller._action_icon_for(type)
if icons.has(icon):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]])
quit(1)
return true
icons[icon] = type
print("action icons distinct across %d buildings" % icons.size())
# --- 2. research it, then build it ---------------------------
game_state.unlocked = ["shipyards", "logistics", "freight"]
if not game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate)
quit(1)
return true
var before: int = game_state.rocks
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB))
if game_state.rocks != before - GameState.TRANSPORT_HUB_COST:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [
before - game_state.rocks, GameState.TRANSPORT_HUB_COST
])
quit(1)
return true
print("researched and placed for %d ore" % GameState.TRANSPORT_HUB_COST)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB:
hub = b
if hub == null:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)")
quit(1)
return true
# --- 3. drawn as itself, not via the crafter catch-all -------
# _hub_traffic only exists if _ready() took the transport-hub
# branch; a missing case falls through to the crafter and would
# leave it null while still rendering something plausible.
if hub._hub_traffic == null:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (rendered through the crafter catch-all, not as a hub)")
quit(1)
return true
# --- 5. its own map marker ----------------------------------
var markers: Array = map_content.building_markers()
var found: bool = false
for m in markers:
if m.type == GameState.BUILDING_TRANSPORT_HUB:
found = true
if m.color == map_content.CRAFTER_MARKER_COLOR \
or m.color == map_content.MOTHERSHIP_MARKER_COLOR \
or m.color == map_content.COMMAND_MARKER_COLOR:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)")
quit(1)
return true
if not found:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no map marker - see #11)")
quit(1)
return true
print("has its own map marker")
spin_at_start = hub._hub_traffic.rotation
elapsed = 0.0
stage = 2
return false
2:
# --- 4. animates without ever redrawing (#10) ----------------
if elapsed < 2.0:
return false
print("rasterisations after %.1fs: %d" % [elapsed, hub.draw_count])
if hub.draw_count != 1:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (redrew its geometry %d times to animate)" % hub.draw_count)
quit(1)
return true
var spun: float = absf(hub._hub_traffic.rotation - spin_at_start)
print("stationed transports turned %.3f rad" % spun)
if spun < 0.05:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (nothing moved - a frozen hub trivially passes the redraw check)")
quit(1)
return true
print("TEST_TRANSPORT_HUB_BUILDING: PASS")
quit(0)
return true
return false

View file

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

View file

@ -0,0 +1,207 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_transporter_supply.gd
# Forgejo #19 (part 6 of #12): transporters stationed at a hub fetch ore from
# the mothership and deliver it to whichever building is waiting on it, with
# no player input at all.
#
# Verifies:
# 1. a transporter is built at the CRAFTER and takes itself to the nearest
# hub, rather than appearing at the hub out of nowhere
# 2. a queued order the crafter cannot pay for is fulfilled end to end
# automatically - the whole point of the ticket
# 3. ore is conserved across the entire route (bank + hold + every
# building's stock), at every step, not just at the end
# 4. ore leaves the bank when it is LOADED, not when the order is placed -
# the vessel is carrying something real
# 5. transporters are not ordered when there is no hub to station them at
# 6. no unbounded node accumulation: the fleet of transporters is exactly
# what was built, however many runs they make
var game_state: Node
var scene: Node
var fleet: Node
var field: Node
var crafter_id: int = 0
var hub_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var total_at_start: int = 0
var bank_when_ordered: int = 0
var conservation_broken: bool = false
# Ore legitimately consumed by completed production. Conservation means
# "nothing vanishes in TRANSIT", not "the total never falls" - building a
# vessel spends ore, and that is the one way it is allowed to leave.
var consumed: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0)
const HUB_AT: Vector2 = Vector2(600.0, 200.0)
const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0)
func _total() -> int:
var sum: int = game_state.rocks + game_state.fleet_cargo
for b in game_state.buildings:
sum += int(b.get("stock", 0))
for t in game_state.transporters:
sum += int(t.get("carrying", 0))
return sum
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.rocks = 200
game_state.fleet_cargo = 0
game_state.ships_owed = 0
game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
game_state.order_completed.connect(_on_order_completed)
func _on_order_completed(_building_id: int, product: String) -> void:
consumed += game_state.product_cost(product)
func _process(delta: float) -> bool:
elapsed += delta
# --- 3. conservation, checked EVERY tick ------------------------------
# Ore in flight is the easy thing to lose: a leg that loads without
# debiting, or delivers without crediting, would show up here and nowhere
# else. Checked continuously rather than at the end, so it cannot be
# masked by a later correction.
if stage >= 2 and _total() + consumed != total_at_start:
conservation_broken = true
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
for a in get_nodes_in_group("asteroids"):
if is_instance_valid(a):
a.queue_free()
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
# --- 5. no hub yet: a transporter cannot be ordered ----------
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
print("TEST_TRANSPORTER_SUPPLY: FAIL (ordered a transporter with no hub to station it at)")
quit(1)
return true
print("no hub -> transporter order refused")
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
hub_id = int(game_state.buildings[-1].id)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- 1. build a transporter at the crafter ------------------
if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
print("TEST_TRANSPORTER_SUPPLY: FAIL (could not order a transporter with a hub built)")
quit(1)
return true
game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST)
elapsed = 0.0
stage = 2
total_at_start = _total()
return false
2:
if elapsed < 0.3:
return false
if game_state.transporters.size() != 1:
print("TEST_TRANSPORTER_SUPPLY: FAIL (built %d transporters from one order)" % game_state.transporters.size())
quit(1)
return true
var t: Dictionary = game_state.transporters[0]
if int(t.hub_id) != hub_id:
print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter did not take the nearest hub)")
quit(1)
return true
# It must fly FROM the crafter TO the hub, not appear at the hub.
# Checked on the leg's endpoints rather than its current position,
# which is already partway there by now - and being partway is
# the correct behaviour.
if (t.from as Vector2).distance_to(CRAFTER_AT) > 1.0:
print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg starts at %s, not at the crafter that built it)" % t.from)
quit(1)
return true
if (t.to as Vector2).distance_to(HUB_AT) > 1.0:
print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg does not end at the hub: %s)" % t.to)
quit(1)
return true
print("transporter built at the crafter, heading for hub %d" % hub_id)
elapsed = 0.0
stage = 3
return false
3:
# Give it time to reach the hub and go idle.
if elapsed < 4.0:
return false
if game_state.transporters[0].state != GameState.TRANSPORTER_IDLE:
print("TEST_TRANSPORTER_SUPPLY: FAIL (never settled at its hub: state=%s)" % game_state.transporters[0].state)
quit(1)
return true
print("stationed at the hub, idle")
# --- 2 + 4. an order it cannot pay for ----------------------
game_state.queue_order(crafter_id, GameState.PRODUCT_SHIP)
bank_when_ordered = game_state.rocks
elapsed = 0.0
stage = 4
return false
4:
# One tick: placing the order must not touch the bank.
if elapsed < 0.1:
return false
if game_state.rocks != bank_when_ordered:
print("TEST_TRANSPORTER_SUPPLY: FAIL (bank moved on placing the order, before anything was loaded)")
quit(1)
return true
print("order placed, bank untouched at %d" % game_state.rocks)
elapsed = 0.0
stage = 5
return false
5:
# --- 2. the whole run, with no player input -----------------
if game_state.pending_orders(crafter_id) > 0:
if elapsed > 25.0:
print("TEST_TRANSPORTER_SUPPLY: FAIL (order never fulfilled: transporter state=%s carrying=%d crafter stock=%d)" % [
game_state.transporters[0].state,
int(game_state.transporters[0].carrying),
game_state.building_stock(crafter_id),
])
quit(1)
return true
return false
if conservation_broken:
print("TEST_TRANSPORTER_SUPPLY: FAIL (ore created or destroyed somewhere along the route)")
quit(1)
return true
print("order fulfilled automatically in %.1fs, no player input" % elapsed)
# --- 6. no vessel accumulation ------------------------------
if game_state.transporters.size() != 1:
print("TEST_TRANSPORTER_SUPPLY: FAIL (%d transporters exist after the run, one was built)" % game_state.transporters.size())
quit(1)
return true
print("still exactly 1 transporter after a full round trip")
print("bank=%d crafter stock=%d total=%d + %d consumed = %d (started %d)" % [
game_state.rocks, game_state.building_stock(crafter_id), _total(),
consumed, _total() + consumed, total_at_start
])
print("TEST_TRANSPORTER_SUPPLY: PASS")
quit(0)
return true
return false

View file

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

View file

@ -1,15 +1,8 @@
[gd_resource type="Theme" format=3]
[gd_resource type="Theme" load_steps=2 format=3]
; Project-wide default font size for every Control.
;
; This exists because the `gui/theme/default_font_size` project setting does
; NOT reach the default theme in this build - labels without an explicit
; override resolve to the engine fallback of 16 no matter what that setting
; says, which is why earlier attempts to enlarge the UI text did nothing.
; A custom Theme assigned to `gui/theme/custom` does apply.
;
; Individual labels that cannot fit their own text at this size carry a
; smaller theme_override_font_sizes/font_size in the scene.
[ext_resource type="FontFile" path="res://fonts/CarroisGothicSC-Regular.ttf" id="1"]
[resource]
default_font = ExtResource("1")
default_font_size = 78
Label/colors/font_color = Color(0.36, 0.9, 1, 1)