Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
extends Node
|
|
|
|
|
|
#84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.
buildings core controllers enemies logistics planets
resources shaders surface ui vessels vfx world
332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.
Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.
The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
|
|
|
const INVENTORY = preload("res://resources/Inventory.gd")
|
|
|
|
|
const RESOURCE_DEFINITIONS = preload("res://resources/ResourceDefinitions.gd")
|
|
|
|
|
const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd")
|
|
|
|
|
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
|
|
|
|
|
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# By path, not by its `WorldGen` global name: an autoload compiles before the
|
|
|
|
|
# global class table is necessarily populated, the same hazard PlanetSetups.gd
|
|
|
|
|
# works around.
|
#84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.
buildings core controllers enemies logistics planets
resources shaders surface ui vessels vfx world
332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.
Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.
The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
|
|
|
const WORLD_GEN = preload("res://world/WorldGen.gd")
|
#129 (#131, #132, #133): a quest chain, and a World Ship out of reach
Sixteen quests that walk a new game through the things it contains: ore,
the mothership, exploration, a yard and a second hull, planetfall and
conquest, extractors, gas, freight, power, the lab, a new vessel type, an
outpost, and a survey.
The chain turns on the second quest. The fleet builds the mothership to
call the World Ship and hears nothing - and the array is not at fault. The
breach put them further out than that signal will carry, so every quest
after it is a search that has to be flown rather than transmitted.
Three pieces:
Quests.gd the chain, as data and nothing else. Two rules it obeys:
nothing may ask for something unreachable - research points
come only from conquering a planet, so every doctrine that
costs them sits after the conquest quest - and a quest names
one goal, with the research it implies said in the brief
rather than split into an errand of its own.
GameState how far along the chain the player is, and the answer to each
goal kind. Progress latches for free: the chain is walked one
quest at a time, so collecting five ore and spending it cannot
un-complete the quest that asked for five - by then nothing is
asking. It lives here so a quest finished on a planet surface
is still finished after take-off.
the HUD QuestTracker, the standing objective, in the wave alert's
idiom; QuestDirector, which reads the lines out.
Lines are QUEUED by GameState and drained by the director rather than
emitted at a banner, because quests advance in the autoload, which runs on
a planet surface too - where a line emitted straight at a banner would be
lost. Both scenes now carry a banner, a tracker and a director, so a quest
finished underground is announced underground.
The chain is armed by reset_new_game(), the same gate the prologue uses -
the only door into a real game - so instantiating a scene is not by itself
a reason to start briefing somebody.
2026-08-28 14:41:03 +02:00
|
|
|
const QUESTS = preload("res://core/Quests.gd")
|
2026-08-14 20:32:58 +02:00
|
|
|
|
2026-08-14 16:05:32 +02:00
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
# Autoload singleton - the one thing that survives change_scene_to_file()
|
|
|
|
|
# scene swaps between space (Main.tscn) and a planet's surface
|
|
|
|
|
# (PlanetSurface.tscn). Everything else in either scene gets torn down
|
|
|
|
|
# and rebuilt from scratch on each swap, so anything that needs to
|
|
|
|
|
# survive the trip has to live here instead.
|
|
|
|
|
|
|
|
|
|
var has_return_position: bool = false
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
var surviving_ship_count: int = STARTING_SHIP_COUNT
|
2026-08-14 23:15:12 +02:00
|
|
|
var surviving_vessel_types: PackedStringArray = PackedStringArray()
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
# #106: a mirror of what is flying, pushed by Fleet._sync_composition_state()
|
|
|
|
|
# whenever the composition changes. GameState cannot see scene nodes, and the
|
|
|
|
|
# census the Planning Center works from has to include the fleet.
|
|
|
|
|
var fleet_vessel_types: PackedStringArray = PackedStringArray()
|
#108: the prologue - the convoy, the ambush, and the breach
Landed as one piece across #122, #123 and #124: the frame is useless without
a script, and the script has nowhere to run without the frame.
The director drives the REAL scene rather than being a scene of its own. It
wants the real fleet, the real enemies and the real asteroid field, and it
makes "then the game starts" cost nothing - the director stops steering,
clears the flag, and what is left running is the ordinary game.
The script is a list of beats, each with a deadline as well as its own
completion test, so a beat whose condition is never met - a player who
refuses to fly at the ambush - runs out rather than wedging the prologue.
skip() ends the whole thing at any point and lands in exactly the state
playing it through does, so the game has one way to start rather than two
that have to be kept in step. The test pins that equivalence.
"No action button available, only movements" is enforced where the buttons
are decided, not by the director reaching in: the ability controllers set
their own visibility every frame and would have put them straight back, and
the map button belongs to the map. The joystick, the pivot and the fader
stay.
The convoy is ten hulls where a new game's fleet is one, so it needs an
explicit fleet-size override - a plain number rather than a fake doctrine,
because nothing about the player's own progression should be bent to fit a
cutscene. It is carriers rather than transporters: transporters belong to
hubs and are not fleet-eligible, so a convoy of them would be silently
filtered down to its escorts.
The ambush is chasers and only chasers. "Easy enough" is the requirement,
and a sniper against a convoy the player cannot yet fight properly is
neither easy nor a good first impression.
The breach takes the wheel through one check in GameController, covering the
stick, the keyboard and the released-again path together - three separate
gates would be three chances for one of them to keep steering. The shake is
written to camera.offset, never to its position, because GameController owns
where the camera is and would smooth a shake away.
The vessel that survives is chosen as the interceptor rather than whichever
happened to be first in the list, and the prologue ends on exactly a new
game's opening position.
Also: PlanetAtmosphereController no longer spawns the opening fleet while
the prologue is arming, and the tests that are about what the GAME starts
from now say so by turning the prologue off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:59:12 +02:00
|
|
|
# #122: the prologue is running. Set by reset_new_game() so it plays once at
|
|
|
|
|
# the start of a NEW game and never on a resumed one, and cleared by the
|
|
|
|
|
# director when the prologue hands over.
|
|
|
|
|
#
|
|
|
|
|
# It is a flag rather than a separate scene because the prologue DIRECTS the
|
|
|
|
|
# real game: the real fleet, the real enemies, the real asteroid field. That
|
|
|
|
|
# is also what makes "then the game starts" cost nothing - the director stops
|
|
|
|
|
# steering and there is nothing left to load.
|
|
|
|
|
var intro_active: bool = false
|
|
|
|
|
# #124: the breach takes the wheel. Movement input is ignored while this is
|
|
|
|
|
# set, so the fleet goes where the script sends it rather than fighting the
|
|
|
|
|
# player for the last few seconds of the prologue.
|
|
|
|
|
var intro_locks_movement: bool = false
|
|
|
|
|
# #123: the prologue's convoy is bigger than any fleet the player can field
|
|
|
|
|
# yet - it is not their fleet, it is the one they are about to lose. An
|
|
|
|
|
# explicit override rather than a fake doctrine, so nothing about the
|
|
|
|
|
# player's own progression has to be bent to fit a cutscene.
|
|
|
|
|
var intro_fleet_size: int = 0
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
var return_world_position: Vector2 = Vector2.ZERO
|
|
|
|
|
var return_camera_zoom: float = 1.0
|
|
|
|
|
|
|
|
|
|
var planet_name: String = ""
|
2026-08-14 22:16:40 +02:00
|
|
|
var planet_id: String = ""
|
2026-08-14 16:13:24 +02:00
|
|
|
var planet_definition_id: String = "rocky"
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
2026-08-16 17:59:04 +02:00
|
|
|
# #59: 1.0 is today's behaviour. Applied at the point of use (Fleet's cruise
|
|
|
|
|
# anchor, Ship's own max_speed/acceleration) rather than baked into stored
|
|
|
|
|
# stats, so a speed upgrade researched while boosted can't corrupt the base
|
|
|
|
|
# value - see Fleet.gd/Ship.gd for the multiply sites.
|
Implement #77, #78 and #79
#77 - the Distorder becomes the Energy Propulser. Renamed everywhere the
player reads it, reskinned to match: a symmetric hull flaring into an engine
skirt instead of the lopsided warped lump, an engine bell with a thrust plume
instead of the wobbling ring glyph, and energy's own yellow instead of purple,
since energy is what it burns. The internal id stays "distorder" - it is the
capability string, the unlock key and the component scene name, and renaming
the wiring would be a large mechanical change with no player-visible effect.
Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used
to add a flat 1.0, so a fleet that could afford six simply drove at six times
cruise and the vessel stopped being a choice. Base-2 makes the rule one
sentence - every doubling adds another 1.0 - and leaves two propulsers worth
exactly what they were worth before, so nothing already built is devalued.
#78 - a gas burner can be paused. It converts gas the player may want for
bombs into energy they may not need yet, and the only way to stop it was to
demolish it. The action is data-driven like every other: the visual's
ActionProvider lists "pause", and any converter can adopt it. Pause is the
burner's PRIMARY action, so it is one tap rather than a menu; the button shows
what it will DO, bars while running and a play triangle while stopped; and the
building itself goes visibly cold, because a paused building that looked
identical to a running one would be a setting to remember rather than see.
Conversion progress is left where it was rather than reset, so switching off
and on again does not discard a part-finished unit.
#79 - picking something up says so. A "+N" with the resource's own mark rises
and fades where it happened. Built from the SAME glyph the resource tables and
transfer rows use, so a material added to the catalog later cannot end up with
one mark in the HUD and a different one in the world. A pickup a full hold
REFUSES raises nothing.
Two Godot traps cost real time here and are now written down where the next
person will hit them:
- a `#` comment inside a .tres [resource] block is not a comment. It parses
as part of the preceding value and silently blanks the property after it.
One in DistorderVesselDefinition.tres left definition_id empty, which
surfaced as "<blank> did not resolve its definition" in a test far from
the edit. The note lives in VesselDefinition.gd now.
- a global class_name is not reliably resolvable, INCLUDING a script's own
inside its own static function. ResourcePickupVFX.new() failed to compile
when reached through a preview scene, taking Rock and the gas vents with
it. It has no class_name now and constructs itself by path.
Suite 114/114.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
|
|
|
# The ceiling ONE Energy Propulser reaches. Every DOUBLING of them raises it
|
|
|
|
|
# by another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
# figure rather than an absolute cap so the fader's meaning at a single
|
|
|
|
|
# vessel is unchanged.
|
2026-08-16 17:59:04 +02:00
|
|
|
const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
# How far the fader can actually go right now, given the fleet's current
|
|
|
|
|
# composition. Pushed by Fleet whenever that composition changes, the same
|
|
|
|
|
# direction as every other fleet-derived figure here - GameState does not
|
|
|
|
|
# reach into the scene tree to count ships.
|
|
|
|
|
var max_fleet_speed_multiplier: float = MAX_FLEET_SPEED_MULTIPLIER
|
2026-08-16 17:59:04 +02:00
|
|
|
var fleet_speed_multiplier: float = 1.0
|
|
|
|
|
|
2026-08-16 19:21:54 +02:00
|
|
|
# #62: energy cost of sustaining a boost, scaled by how far above 1.0 the
|
|
|
|
|
# multiplier is - a small boost costs proportionally less, full boost costs
|
|
|
|
|
# ENERGY_DRAIN_PER_SECOND. Sitting at exactly 1.0 costs nothing.
|
|
|
|
|
const ENERGY_DRAIN_PER_SECOND: float = 2.0
|
|
|
|
|
var _distorder_drain_progress: float = 0.0
|
|
|
|
|
# One-shot notice, consumed and cleared by SpeedFader.gd: true the instant
|
|
|
|
|
# a sustained boost is force-reset for running the fleet hold's energy
|
|
|
|
|
# to zero, so the control (which may not even be part of the current
|
|
|
|
|
# scene tree at that exact frame) can show why on its next _process().
|
|
|
|
|
var boost_depleted_notice: bool = false
|
|
|
|
|
|
2026-08-16 17:59:04 +02:00
|
|
|
|
|
|
|
|
func set_fleet_speed_multiplier(value: float) -> void:
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
fleet_speed_multiplier = clampf(value, 1.0, max_fleet_speed_multiplier)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Every distorder vessel past the first widens the fleet's speed ceiling by
|
|
|
|
|
# 1.0, so stacking them is worth doing rather than the second one being dead
|
|
|
|
|
# weight. Zero vessels returns the ceiling to its one-vessel value; the
|
|
|
|
|
# multiplier itself is reset separately by Fleet when the last one is lost.
|
|
|
|
|
#
|
|
|
|
|
# Re-clamps the CURRENT multiplier, so losing a distorder vessel mid-boost
|
|
|
|
|
# pulls the fleet down to the new ceiling instead of leaving it travelling
|
|
|
|
|
# faster than its remaining crew can sustain.
|
Implement #77, #78 and #79
#77 - the Distorder becomes the Energy Propulser. Renamed everywhere the
player reads it, reskinned to match: a symmetric hull flaring into an engine
skirt instead of the lopsided warped lump, an engine bell with a thrust plume
instead of the wobbling ring glyph, and energy's own yellow instead of purple,
since energy is what it burns. The internal id stays "distorder" - it is the
capability string, the unlock key and the component scene name, and renaming
the wiring would be a large mechanical change with no player-visible effect.
Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used
to add a flat 1.0, so a fleet that could afford six simply drove at six times
cruise and the vessel stopped being a choice. Base-2 makes the rule one
sentence - every doubling adds another 1.0 - and leaves two propulsers worth
exactly what they were worth before, so nothing already built is devalued.
#78 - a gas burner can be paused. It converts gas the player may want for
bombs into energy they may not need yet, and the only way to stop it was to
demolish it. The action is data-driven like every other: the visual's
ActionProvider lists "pause", and any converter can adopt it. Pause is the
burner's PRIMARY action, so it is one tap rather than a menu; the button shows
what it will DO, bars while running and a play triangle while stopped; and the
building itself goes visibly cold, because a paused building that looked
identical to a running one would be a setting to remember rather than see.
Conversion progress is left where it was rather than reset, so switching off
and on again does not discard a part-finished unit.
#79 - picking something up says so. A "+N" with the resource's own mark rises
and fades where it happened. Built from the SAME glyph the resource tables and
transfer rows use, so a material added to the catalog later cannot end up with
one mark in the HUD and a different one in the world. A pickup a full hold
REFUSES raises nothing.
Two Godot traps cost real time here and are now written down where the next
person will hit them:
- a `#` comment inside a .tres [resource] block is not a comment. It parses
as part of the preceding value and silently blanks the property after it.
One in DistorderVesselDefinition.tres left definition_id empty, which
surfaced as "<blank> did not resolve its definition" in a test far from
the edit. The note lives in VesselDefinition.gd now.
- a global class_name is not reliably resolvable, INCLUDING a script's own
inside its own static function. ResourcePickupVFX.new() failed to compile
when reached through a preview scene, taking Rock and the gas vents with
it. It has no class_name now and constructs itself by path.
Suite 114/114.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
|
|
|
# #77: the ceiling grows with the LOGARITHM of how many propulsers fly, not
|
|
|
|
|
# linearly. Each one used to add a flat 1.0, so a fleet that could afford six
|
|
|
|
|
# of them simply drove at six times cruise and the vessel stopped being a
|
|
|
|
|
# choice. Base-2, which makes the rule one sentence: every DOUBLING of
|
|
|
|
|
# propulsers buys another 1.0.
|
|
|
|
|
#
|
|
|
|
|
# 1 -> 3.0 2 -> 4.0 4 -> 5.0 8 -> 6.0
|
|
|
|
|
#
|
|
|
|
|
# Two propulsers are worth exactly what they were worth before, so nothing a
|
|
|
|
|
# player has already built is devalued; it is the fifth and sixth that stop
|
|
|
|
|
# paying for themselves.
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
func set_distorder_vessel_count(count: int) -> void:
|
Implement #77, #78 and #79
#77 - the Distorder becomes the Energy Propulser. Renamed everywhere the
player reads it, reskinned to match: a symmetric hull flaring into an engine
skirt instead of the lopsided warped lump, an engine bell with a thrust plume
instead of the wobbling ring glyph, and energy's own yellow instead of purple,
since energy is what it burns. The internal id stays "distorder" - it is the
capability string, the unlock key and the component scene name, and renaming
the wiring would be a large mechanical change with no player-visible effect.
Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used
to add a flat 1.0, so a fleet that could afford six simply drove at six times
cruise and the vessel stopped being a choice. Base-2 makes the rule one
sentence - every doubling adds another 1.0 - and leaves two propulsers worth
exactly what they were worth before, so nothing already built is devalued.
#78 - a gas burner can be paused. It converts gas the player may want for
bombs into energy they may not need yet, and the only way to stop it was to
demolish it. The action is data-driven like every other: the visual's
ActionProvider lists "pause", and any converter can adopt it. Pause is the
burner's PRIMARY action, so it is one tap rather than a menu; the button shows
what it will DO, bars while running and a play triangle while stopped; and the
building itself goes visibly cold, because a paused building that looked
identical to a running one would be a setting to remember rather than see.
Conversion progress is left where it was rather than reset, so switching off
and on again does not discard a part-finished unit.
#79 - picking something up says so. A "+N" with the resource's own mark rises
and fades where it happened. Built from the SAME glyph the resource tables and
transfer rows use, so a material added to the catalog later cannot end up with
one mark in the HUD and a different one in the world. A pickup a full hold
REFUSES raises nothing.
Two Godot traps cost real time here and are now written down where the next
person will hit them:
- a `#` comment inside a .tres [resource] block is not a comment. It parses
as part of the preceding value and silently blanks the property after it.
One in DistorderVesselDefinition.tres left definition_id empty, which
surfaced as "<blank> did not resolve its definition" in a test far from
the edit. The note lives in VesselDefinition.gd now.
- a global class_name is not reliably resolvable, INCLUDING a script's own
inside its own static function. ResourcePickupVFX.new() failed to compile
when reached through a preview scene, taking Rock and the gas vents with
it. It has no class_name now and constructs itself by path.
Suite 114/114.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
|
|
|
if count <= 1:
|
|
|
|
|
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER
|
|
|
|
|
return
|
|
|
|
|
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER \
|
|
|
|
|
+ log(float(count)) / log(2.0)
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
set_fleet_speed_multiplier(fleet_speed_multiplier)
|
2026-08-16 17:59:04 +02:00
|
|
|
|
2026-08-16 20:14:29 +02:00
|
|
|
|
|
|
|
|
# #63: currency gated behind conquering a research_lab planet - required by
|
|
|
|
|
# both progression paths (skill tree nodes, vessel/upgrade research) on top
|
|
|
|
|
# of their existing ore/resource costs.
|
|
|
|
|
signal research_points_changed(total: int)
|
|
|
|
|
var research_points: int = 0
|
|
|
|
|
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# Set only by start_dev_game(), which the main menu offers only when running
|
|
|
|
|
# from Godot. Makes WorldGen plant one of every planet type around the spawn.
|
|
|
|
|
# Cleared by reset_new_game(), so starting a normal game after a dev one
|
|
|
|
|
# cannot leave a dev world behind.
|
|
|
|
|
#
|
|
|
|
|
# The setter mirrors it into WorldGen, which cannot read it back: world
|
|
|
|
|
# generation is static and dependency-free by design. This stays the single
|
|
|
|
|
# place to set it - assigning dev_mode anywhere, tests included, reaches
|
|
|
|
|
# world generation.
|
|
|
|
|
var dev_mode: bool = false:
|
|
|
|
|
set(value):
|
|
|
|
|
dev_mode = value
|
|
|
|
|
WORLD_GEN.dev_planets_enabled = value
|
|
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
# Explored-chunk history for the world map's fog-of-war reveal. Lives
|
|
|
|
|
# here (not on ExplorationTracker, which gets destroyed and recreated on
|
|
|
|
|
# every scene swap) so exploration progress isn't lost every time the
|
|
|
|
|
# player lands on and leaves a planet.
|
|
|
|
|
var explored_chunks: Dictionary = {}
|
|
|
|
|
|
|
|
|
|
# --- Resources -------------------------------------------------------------
|
|
|
|
|
|
2026-08-14 20:32:58 +02:00
|
|
|
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
|
|
|
|
|
const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID
|
2026-08-16 13:13:38 +02:00
|
|
|
const RESOURCE_DARK_MATTER: String = RESOURCE_DEFINITIONS.DARK_MATTER_ID
|
2026-08-14 20:32:58 +02:00
|
|
|
const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
|
|
|
|
|
|
|
|
|
|
# All resource-bearing locations use the same container. The old public
|
|
|
|
|
# names below are computed compatibility accessors, not separate storage.
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# Starts EMPTY. A new game used to open with 500 ore banked, which quietly
|
|
|
|
|
# skipped the opening of the game - the first mining run is what teaches the
|
|
|
|
|
# fleet hold, the return trip and the mothership. Dev mode (start_dev_game)
|
|
|
|
|
# is where a stocked start belongs.
|
|
|
|
|
var bank_inventory: RefCounted = INVENTORY.new()
|
2026-08-14 20:32:58 +02:00
|
|
|
var fleet_inventory: RefCounted = INVENTORY.new()
|
|
|
|
|
|
|
|
|
|
var rocks: int:
|
|
|
|
|
get: return bank_inventory.amount(RESOURCE_ORE)
|
|
|
|
|
set(value): bank_inventory.set_amount(RESOURCE_ORE, value)
|
|
|
|
|
|
|
|
|
|
var gas: int:
|
|
|
|
|
get: return bank_inventory.amount(RESOURCE_GAS)
|
|
|
|
|
set(value): bank_inventory.set_amount(RESOURCE_GAS, value)
|
|
|
|
|
|
|
|
|
|
var fleet_cargo: int:
|
|
|
|
|
get: return fleet_inventory.amount(RESOURCE_ORE)
|
|
|
|
|
set(value): fleet_inventory.set_amount(RESOURCE_ORE, value)
|
|
|
|
|
|
|
|
|
|
var fleet_gas_cargo: int:
|
|
|
|
|
get: return fleet_inventory.amount(RESOURCE_GAS)
|
|
|
|
|
set(value): fleet_inventory.set_amount(RESOURCE_GAS, value)
|
2026-08-14 11:54:31 +02:00
|
|
|
|
2026-08-16 14:53:26 +02:00
|
|
|
# Both return the amount actually accepted (#56: the fleet hold now has a
|
|
|
|
|
# finite total_capacity), so a collector can tell a full hold apart from a
|
|
|
|
|
# successful pickup instead of assuming everything offered was taken.
|
|
|
|
|
func collect_cargo(amount: int = 1) -> int:
|
|
|
|
|
return fleet_inventory.deposit(RESOURCE_ORE, amount)
|
2026-08-11 15:55:45 +02:00
|
|
|
|
2026-08-16 14:53:26 +02:00
|
|
|
func collect_gas_cargo(amount: int = 1) -> int:
|
|
|
|
|
return fleet_inventory.deposit(RESOURCE_GAS, amount)
|
2026-08-14 20:32:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func resource_types() -> PackedStringArray:
|
|
|
|
|
return RESOURCE_DEFINITIONS.ids()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func resource_definition(resource_type: String) -> Resource:
|
|
|
|
|
return RESOURCE_DEFINITIONS.get_definition(resource_type)
|
2026-08-13 22:17:24 +02:00
|
|
|
|
2026-08-11 15:55:45 +02:00
|
|
|
|
2026-08-14 11:54:31 +02:00
|
|
|
func resource_name(resource_type: String) -> String:
|
2026-08-14 20:32:58 +02:00
|
|
|
var definition: Resource = resource_definition(resource_type)
|
|
|
|
|
return String(definition.display_name) if definition != null else resource_type.capitalize()
|
2026-08-14 11:54:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func resource_color(resource_type: String) -> Color:
|
2026-08-14 20:32:58 +02:00
|
|
|
var definition: Resource = resource_definition(resource_type)
|
|
|
|
|
return definition.color if definition != null else Color.WHITE
|
2026-08-14 11:54:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func banked_resource(resource_type: String) -> int:
|
2026-08-14 20:32:58 +02:00
|
|
|
return bank_inventory.amount(resource_type)
|
2026-08-14 11:54:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func fleet_resource(resource_type: String) -> int:
|
2026-08-14 20:32:58 +02:00
|
|
|
return fleet_inventory.amount(resource_type)
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 18:54:44 +02:00
|
|
|
# #60: consumed outright (ammunition), not moved to another inventory like
|
|
|
|
|
# transfer_resource_to_building/_fleet - returns the amount actually taken.
|
|
|
|
|
func withdraw_fleet_resource(resource_type: String, amount: int) -> int:
|
|
|
|
|
return fleet_inventory.withdraw(resource_type, amount)
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 19:11:31 +02:00
|
|
|
# #61: generic counterparts, for resources (energy) with no dedicated
|
|
|
|
|
# fleet_cargo/fleet_gas_cargo-style property of their own. Deliberately NOT
|
|
|
|
|
# the same as deposit_resource_to_building() on a "bank" building - that
|
|
|
|
|
# deposits into the building's OWN per-record inventory, not bank_inventory
|
|
|
|
|
# itself (only building_resource()/building_inventory() special-case "bank"
|
|
|
|
|
# capability to redirect there).
|
|
|
|
|
func deposit_fleet_resource(resource_type: String, amount: int) -> int:
|
|
|
|
|
return fleet_inventory.deposit(resource_type, amount)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func deposit_bank_resource(resource_type: String, amount: int) -> int:
|
|
|
|
|
return bank_inventory.deposit(resource_type, amount)
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 14:53:26 +02:00
|
|
|
# #56: total across every resource type in the hold, and the combined cap
|
|
|
|
|
# that limits it - a HUD readout wants both, not any one resource's amount.
|
|
|
|
|
func fleet_cargo_used() -> int:
|
|
|
|
|
return fleet_inventory.total()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func fleet_cargo_total_capacity() -> int:
|
|
|
|
|
return fleet_inventory.total_capacity
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 20:32:58 +02:00
|
|
|
func _inventory_for_record(record: Dictionary) -> RefCounted:
|
|
|
|
|
var inventory: RefCounted = record.get("inventory") as RefCounted
|
|
|
|
|
if inventory == null:
|
|
|
|
|
inventory = INVENTORY.new()
|
|
|
|
|
record.inventory = inventory
|
Garrison laser vessels and gas bombers, on a shared ability seam
#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.
The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.
Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.
Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.
Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.
Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
|
|
|
# An outpost holds exactly what its garrison can use, and the garrison
|
|
|
|
|
# changes. Re-applied on every access rather than when the crew changes,
|
|
|
|
|
# because there is no single place a crew change goes through - a vessel
|
|
|
|
|
# arriving, one being released and one being shot down are three.
|
|
|
|
|
if is_outpost(record):
|
|
|
|
|
# Every resource gets a ceiling, not only the ones a magazine wants:
|
|
|
|
|
# an outpost is not a depot, and ore parked at one has nothing to do
|
|
|
|
|
# there and no transporter coming for it.
|
|
|
|
|
var capacities: Dictionary = {}
|
|
|
|
|
var magazines: Dictionary = garrison_magazines(int(record.id))
|
|
|
|
|
for resource_type in resource_types():
|
|
|
|
|
capacities[resource_type] = int(magazines.get(resource_type, 0))
|
|
|
|
|
inventory.resource_capacities = capacities
|
2026-08-14 20:32:58 +02:00
|
|
|
# Compatibility migration for state made before #33. Erase each legacy
|
|
|
|
|
# field once absorbed so it cannot drift out of step with the inventory.
|
|
|
|
|
if record.has("stock"):
|
|
|
|
|
inventory.set_amount(RESOURCE_ORE, int(record.stock))
|
|
|
|
|
record.erase("stock")
|
|
|
|
|
if record.has("gas_stock"):
|
|
|
|
|
inventory.set_amount(RESOURCE_GAS, int(record.gas_stock))
|
|
|
|
|
record.erase("gas_stock")
|
|
|
|
|
return inventory
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func building_inventory(id: int) -> RefCounted:
|
2026-08-14 11:54:31 +02:00
|
|
|
var building: Dictionary = find_building(id)
|
|
|
|
|
if building.is_empty():
|
2026-08-14 20:32:58 +02:00
|
|
|
return null
|
2026-08-14 21:44:28 +02:00
|
|
|
if building_has_capability(building, "bank"):
|
2026-08-14 20:32:58 +02:00
|
|
|
return bank_inventory
|
|
|
|
|
return _inventory_for_record(building)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func building_resource(id: int, resource_type: String) -> int:
|
|
|
|
|
var inventory: RefCounted = building_inventory(id)
|
|
|
|
|
return 0 if inventory == null else inventory.amount(resource_type)
|
2026-08-14 11:54:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int:
|
2026-08-14 20:32:58 +02:00
|
|
|
return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount)
|
2026-08-14 11:54:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int:
|
2026-08-14 20:32:58 +02:00
|
|
|
var source: RefCounted = building_inventory(id)
|
|
|
|
|
return 0 if source == null else source.transfer_to(fleet_inventory, resource_type, amount)
|
2026-08-14 11:54:31 +02:00
|
|
|
|
|
|
|
|
|
2026-08-12 13:07:52 +02:00
|
|
|
# The batch size the transfer menu offers, next to "All". A batch rather
|
|
|
|
|
# than only all-or-nothing: the hold is the part that can be lost, and
|
|
|
|
|
# emptying the bank in one tap would strand research, which spends from it.
|
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
|
|
|
const CARGO_LOAD_BATCH: int = 25
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 13:07:52 +02:00
|
|
|
# --- moving ore by hand, in either direction (#24) --------------------------
|
|
|
|
|
#
|
|
|
|
|
# One pair of primitives the transfer menu drives, rather than the two
|
|
|
|
|
# one-way special cases that used to live inside the skill and craft panels
|
|
|
|
|
# ("load ore" at the mothership, "store ore here" at a crafter).
|
|
|
|
|
#
|
|
|
|
|
# The mothership is deliberately not a special case in the UI: it is simply
|
|
|
|
|
# the building whose holdings ARE the bank. Depositing at one banks the ore,
|
|
|
|
|
# withdrawing from one draws on the bank, and every other building keeps its
|
|
|
|
|
# own stock (#16). That is what lets one menu serve every structure.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func building_holdings(id: int) -> int:
|
2026-08-14 11:54:31 +02:00
|
|
|
return building_resource(id, RESOURCE_ORE)
|
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
|
|
|
|
2026-08-13 22:17:24 +02:00
|
|
|
func building_gas_holdings(id: int) -> int:
|
2026-08-14 11:54:31 +02:00
|
|
|
return building_resource(id, RESOURCE_GAS)
|
2026-08-13 22:17:24 +02:00
|
|
|
|
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
|
|
|
|
2026-08-12 13:07:52 +02:00
|
|
|
func transfer_to_building(id: int, amount: int) -> int:
|
|
|
|
|
# Hold -> this building. Returns how much actually moved, which is capped
|
|
|
|
|
# by the hold: a caller asking for "all" passes a number too big on
|
|
|
|
|
# purpose and reads the result back.
|
2026-08-14 11:54:31 +02:00
|
|
|
return transfer_resource_to_building(id, RESOURCE_ORE, amount)
|
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
|
|
|
|
|
|
|
|
|
2026-08-12 13:07:52 +02:00
|
|
|
func transfer_to_fleet(id: int, amount: int) -> int:
|
|
|
|
|
# This building -> hold. Capped by what the building actually holds, so
|
|
|
|
|
# there is no overdraft in either direction.
|
2026-08-14 11:54:31 +02:00
|
|
|
return transfer_resource_to_fleet(id, RESOURCE_ORE, amount)
|
2026-08-11 15:55:45 +02:00
|
|
|
|
2026-08-13 22:17:24 +02:00
|
|
|
func transfer_gas_to_building(id: int, amount: int) -> int:
|
2026-08-14 11:54:31 +02:00
|
|
|
return transfer_resource_to_building(id, RESOURCE_GAS, amount)
|
2026-08-13 22:17:24 +02:00
|
|
|
|
|
|
|
|
func transfer_gas_to_fleet(id: int, amount: int) -> int:
|
2026-08-14 11:54:31 +02:00
|
|
|
return transfer_resource_to_fleet(id, RESOURCE_GAS, amount)
|
2026-08-13 22:17:24 +02:00
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
const MOTHERSHIP_COST: int = 20
|
|
|
|
|
const SHIP_CRAFTER_COST: int = 12
|
|
|
|
|
const SHIP_COST: int = 6
|
|
|
|
|
|
|
|
|
|
# --- Buildings -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# Player-placed structures, as [{"type": String, "position": Vector2}].
|
|
|
|
|
# Buildings exist only in space (Main.tscn) but are stored here so they
|
|
|
|
|
# survive the scene swap to a planet's surface and back.
|
|
|
|
|
const BUILDING_MOTHERSHIP: String = "mothership"
|
|
|
|
|
const BUILDING_SHIP_CRAFTER: String = "ship_crafter"
|
|
|
|
|
const BUILDING_COMMAND_CENTER: String = "command_center"
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
# #102: a forward position the fleet can garrison, researched early because
|
|
|
|
|
# it is the answer to waves going after what you have built.
|
|
|
|
|
const BUILDING_OUTPOST: String = "outpost"
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
# #106: where standing orders for the whole fleet are kept.
|
|
|
|
|
const BUILDING_PLANNING_CENTER: String = "planning_center"
|
2026-08-23 21:58:58 +02:00
|
|
|
# #116: repairs what the waves knock down.
|
|
|
|
|
const BUILDING_WORKSHOP: String = "spacial_workshop"
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
# 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"
|
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
|
|
|
# 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"
|
2026-08-11 23:10:07 +02:00
|
|
|
# Where vessel types are researched and new ones unlocked (#5).
|
|
|
|
|
const BUILDING_SHIP_LAB: String = "ship_lab"
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
# Converts gas into energy, both stored/moved through the building's own
|
|
|
|
|
# inventory exactly like every other building (#54).
|
|
|
|
|
const BUILDING_GAS_BURNER: String = "gas_burner"
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
const COMMAND_CENTER_COST: int = 15
|
|
|
|
|
const EXTRACTOR_COST: int = 25
|
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
|
|
|
const TRANSPORT_HUB_COST: int = 30
|
2026-08-11 23:10:07 +02:00
|
|
|
const SHIP_LAB_COST: int = 35
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
const GAS_BURNER_COST: int = 30
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
var buildings: Array = []
|
|
|
|
|
|
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
|
|
|
# --- Skill tree ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# 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).
|
2026-08-16 20:14:29 +02:00
|
|
|
# #63: research_points (default 0 where omitted) is a SEPARATE currency from
|
|
|
|
|
# cost (ore), spent only from GameState.research_points - earned by
|
|
|
|
|
# conquering a research_lab planet, never bought. Balance, decided
|
|
|
|
|
# deliberately: shipyards and logistics stay at 0 so a fresh game can still
|
|
|
|
|
# reach a ship crafter and an extractor with zero research points (see the
|
|
|
|
|
# closing comment on #63 for the reasoning); every other node - none of them
|
|
|
|
|
# required for that opening - costs at least 1.
|
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
|
|
|
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",
|
2026-08-16 20:14:29 +02:00
|
|
|
"x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1,
|
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
|
|
|
"desc": "Build command centers, where the fleet formation is arranged.",
|
|
|
|
|
},
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
{
|
|
|
|
|
# Early on purpose (#97: "pretty early in the tech tree"). It hangs off
|
|
|
|
|
# coordination rather than off a deep branch, so the answer to waves
|
|
|
|
|
# attacking your structures is available at about the time the waves
|
|
|
|
|
# start being able to hurt them.
|
|
|
|
|
"id": "forward_bases", "name": "Forward Bases", "cost": 30, "requires": "coordination",
|
|
|
|
|
"x": 0.66, "unlocks": BUILDING_OUTPOST, "research_points": 1,
|
|
|
|
|
"desc": "Build outposts: forward positions vessels can be stationed at, to hold ground you are not standing on.",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
# The same shape as freight_yards for hubs: a permanent bonus that
|
|
|
|
|
# applies to outposts already standing, not only to new ones, so
|
|
|
|
|
# researching it never means demolishing and rebuilding.
|
|
|
|
|
"id": "garrison_bays", "name": "Garrison Bays", "cost": 110, "requires": "forward_bases",
|
|
|
|
|
"x": 0.66, "outpost_bonus": 1, "research_points": 2,
|
2026-08-23 21:58:58 +02:00
|
|
|
"icon": 40,
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
"desc": "Permanent: +1 berth at every outpost, including ones already built.",
|
|
|
|
|
},
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
{
|
|
|
|
|
# Deep on purpose, unlike forward_bases: standing orders are a thing
|
|
|
|
|
# you want once the fleet is large enough that keeping track of it by
|
|
|
|
|
# hand has become work, and it has shipyards behind it because it
|
|
|
|
|
# manages what shipyards do.
|
|
|
|
|
"id": "logistics_command", "name": "Logistics Command", "cost": 140,
|
|
|
|
|
"requires": "vessel_science", "x": 0.94,
|
|
|
|
|
"unlocks": BUILDING_PLANNING_CENTER, "research_points": 3,
|
|
|
|
|
"desc": "Build a planning center: set how many of each vessel should always exist, and the crafters are kept working until they do.",
|
|
|
|
|
},
|
2026-08-23 21:58:58 +02:00
|
|
|
{
|
|
|
|
|
# Behind power_systems rather than off the fleet branch: a workshop
|
|
|
|
|
# is infrastructure that looks after infrastructure, and it should
|
|
|
|
|
# arrive after the player has some worth looking after.
|
|
|
|
|
"id": "field_engineering", "name": "Field Engineering", "cost": 95,
|
|
|
|
|
"requires": "power_systems", "x": 0.34,
|
|
|
|
|
"unlocks": BUILDING_WORKSHOP, "research_points": 2,
|
|
|
|
|
"desc": "Build spacial workshops. Their drones fly out and repair damaged structures, spending ore delivered to the workshop.",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
# Same shape as freight_yards and garrison_bays: a permanent bonus
|
|
|
|
|
# that reaches workshops already standing.
|
|
|
|
|
"id": "drone_bays", "name": "Drone Bays", "cost": 150,
|
|
|
|
|
"requires": "field_engineering", "x": 0.34,
|
|
|
|
|
"workshop_bonus": 1, "research_points": 2, "icon": 43,
|
|
|
|
|
"desc": "Permanent: +1 repair drone at every spacial workshop, including ones already built.",
|
|
|
|
|
},
|
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
|
|
|
{
|
|
|
|
|
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
|
2026-08-16 20:14:29 +02:00
|
|
|
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1,
|
2026-08-15 00:09:10 +02:00
|
|
|
"desc": "Build transport hubs. Each hub includes its own automatic transporters.",
|
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
|
|
|
},
|
2026-08-11 23:10:07 +02:00
|
|
|
{
|
|
|
|
|
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
|
2026-08-16 20:14:29 +02:00
|
|
|
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
|
2026-08-11 23:10:07 +02:00
|
|
|
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
|
|
|
|
|
},
|
2026-08-11 19:43:55 +02:00
|
|
|
{
|
|
|
|
|
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
|
2026-08-16 20:14:29 +02:00
|
|
|
"x": 0.18, "hub_bonus": 1, "research_points": 2,
|
2026-08-23 21:58:58 +02:00
|
|
|
"icon": 12,
|
2026-08-11 19:43:55 +02:00
|
|
|
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
|
|
|
|
|
},
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
{
|
|
|
|
|
"id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight",
|
2026-08-16 20:14:29 +02:00
|
|
|
"x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2,
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
|
|
|
|
|
},
|
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
|
|
|
{
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# Cheaper than it was (30 ore / 1 point): the fleet now starts at
|
|
|
|
|
# BASE_FLEET_SIZE 3 rather than 5, so the first two doctrines are no
|
|
|
|
|
# longer a mid-game luxury but the ordinary way out of the opening.
|
|
|
|
|
# Pricing them as a luxury made the early game feel stuck.
|
|
|
|
|
"id": "wing", "name": "Wing Doctrine", "cost": 15, "requires": "coordination",
|
2026-08-16 20:14:29 +02:00
|
|
|
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
|
2026-08-23 21:58:58 +02:00
|
|
|
"icon": 2,
|
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
|
|
|
"desc": "Permanent: +1 vessel in the fleet.",
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-08-17 19:46:33 +02:00
|
|
|
# #70: was x=0.50 - the same lane freight's branch already occupies at
|
|
|
|
|
# this same depth (power_systems), so the two nodes were drawn
|
|
|
|
|
# directly on top of each other. Kept in line with wing (its own
|
|
|
|
|
# prerequisite, also x=0.82) instead, so the whole fleet-size
|
|
|
|
|
# doctrine branch now runs straight rather than zigzagging into
|
|
|
|
|
# someone else's lane.
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# Also cut (70 ore / 2 points), for the same reason as wing.
|
|
|
|
|
"id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing",
|
|
|
|
|
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
|
2026-08-23 21:58:58 +02:00
|
|
|
"icon": 2,
|
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
|
|
|
"desc": "Permanent: +1 vessel in the fleet.",
|
|
|
|
|
},
|
|
|
|
|
{
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
"id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron",
|
|
|
|
|
"x": 0.82, "fleet_bonus": 1, "research_points": 2,
|
2026-08-23 21:58:58 +02:00
|
|
|
"icon": 2,
|
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
|
|
|
"desc": "Permanent: +1 vessel in the fleet.",
|
|
|
|
|
},
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
{
|
|
|
|
|
# The last two tiers grant +2 rather than +1. Seven single-vessel
|
|
|
|
|
# nodes in one lane would be a long, monotonous chain for a branch
|
|
|
|
|
# that only ever does one thing; two larger, costlier steps carry the
|
|
|
|
|
# ceiling from 6 to 10 while keeping the branch a readable length.
|
|
|
|
|
"id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada",
|
|
|
|
|
"x": 0.82, "fleet_bonus": 2, "research_points": 4,
|
2026-08-23 21:58:58 +02:00
|
|
|
"icon": 2,
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
"desc": "Permanent: +2 vessels in the fleet.",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup",
|
|
|
|
|
"x": 0.82, "fleet_bonus": 2, "research_points": 6,
|
2026-08-23 21:58:58 +02:00
|
|
|
"icon": 2,
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
"desc": "Permanent: +2 vessels in the fleet.",
|
|
|
|
|
},
|
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
|
|
|
]
|
|
|
|
|
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# How many SLOTS the fleet has before any doctrine is researched. Distinct
|
|
|
|
|
# from STARTING_SHIP_COUNT below: this is capacity, that is how many vessels
|
|
|
|
|
# are actually flying. The two used to be one constant, which forced a new
|
|
|
|
|
# game to open with a full fleet and left the first crafter with nothing to
|
|
|
|
|
# do - there was no room to put a vessel until a doctrine was researched.
|
|
|
|
|
const BASE_FLEET_SIZE: int = 3
|
|
|
|
|
# A new game opens with a SINGLE vessel. Building the fleet up is the early
|
|
|
|
|
# game, so the two empty slots are the first thing a ship crafter is for.
|
|
|
|
|
const STARTING_SHIP_COUNT: int = 1
|
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
|
|
|
|
|
|
|
|
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:
|
2026-08-16 20:14:29 +02:00
|
|
|
var node: Dictionary = skill_node(id)
|
|
|
|
|
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \
|
2026-08-16 21:38:07 +02:00
|
|
|
and research_points >= skill_research_point_cost(id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func skill_research_point_cost(id: String) -> int:
|
|
|
|
|
return int(skill_node(id).get("research_points", 0))
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
func unlock(id: String) -> bool:
|
|
|
|
|
if not can_unlock(id):
|
|
|
|
|
return false
|
2026-08-16 20:14:29 +02:00
|
|
|
var node: Dictionary = skill_node(id)
|
|
|
|
|
if not spend(int(node.cost)):
|
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
|
|
|
return false
|
2026-08-16 21:38:07 +02:00
|
|
|
var points_needed: int = skill_research_point_cost(id)
|
2026-08-16 20:14:29 +02:00
|
|
|
if points_needed > 0:
|
|
|
|
|
research_points -= points_needed
|
|
|
|
|
research_points_changed.emit(research_points)
|
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
|
|
|
unlocked.append(id)
|
2026-08-16 20:14:29 +02:00
|
|
|
if int(node.get("hub_bonus", 0)) > 0:
|
2026-08-15 00:09:10 +02:00
|
|
|
_sync_hub_transporters()
|
2026-08-23 21:58:58 +02:00
|
|
|
if int(node.get("workshop_bonus", 0)) > 0:
|
|
|
|
|
_sync_workshop_drones()
|
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
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 21:58:58 +02:00
|
|
|
# #112: the mark drawn inside a skill node. A node that unlocks a structure
|
|
|
|
|
# borrows that structure's own menu icon, so the tree and the build menu
|
|
|
|
|
# cannot disagree about what a shipyard looks like; the rest - doctrines and
|
|
|
|
|
# permanent bonuses, which have no building to point at - carry an explicit
|
|
|
|
|
# one. -1 means "no mark", and the node is drawn hollow as it always was.
|
|
|
|
|
func skill_node_icon(node: Dictionary) -> int:
|
|
|
|
|
if node.is_empty():
|
|
|
|
|
return -1
|
|
|
|
|
var unlocks: String = String(node.get("unlocks", ""))
|
|
|
|
|
if unlocks != "":
|
|
|
|
|
var definition: Resource = building_definition(unlocks)
|
|
|
|
|
if definition != null:
|
|
|
|
|
return int(definition.menu_icon)
|
|
|
|
|
return int(node.get("icon", -1))
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
func is_building_unlocked(type: String) -> bool:
|
|
|
|
|
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:
|
#108: the prologue - the convoy, the ambush, and the breach
Landed as one piece across #122, #123 and #124: the frame is useless without
a script, and the script has nowhere to run without the frame.
The director drives the REAL scene rather than being a scene of its own. It
wants the real fleet, the real enemies and the real asteroid field, and it
makes "then the game starts" cost nothing - the director stops steering,
clears the flag, and what is left running is the ordinary game.
The script is a list of beats, each with a deadline as well as its own
completion test, so a beat whose condition is never met - a player who
refuses to fly at the ambush - runs out rather than wedging the prologue.
skip() ends the whole thing at any point and lands in exactly the state
playing it through does, so the game has one way to start rather than two
that have to be kept in step. The test pins that equivalence.
"No action button available, only movements" is enforced where the buttons
are decided, not by the director reaching in: the ability controllers set
their own visibility every frame and would have put them straight back, and
the map button belongs to the map. The joystick, the pivot and the fader
stay.
The convoy is ten hulls where a new game's fleet is one, so it needs an
explicit fleet-size override - a plain number rather than a fake doctrine,
because nothing about the player's own progression should be bent to fit a
cutscene. It is carriers rather than transporters: transporters belong to
hubs and are not fleet-eligible, so a convoy of them would be silently
filtered down to its escorts.
The ambush is chasers and only chasers. "Easy enough" is the requirement,
and a sniper against a convoy the player cannot yet fight properly is
neither easy nor a good first impression.
The breach takes the wheel through one check in GameController, covering the
stick, the keyboard and the released-again path together - three separate
gates would be three chances for one of them to keep steering. The shake is
written to camera.offset, never to its position, because GameController owns
where the camera is and would smooth a shake away.
The vessel that survives is chosen as the interceptor rather than whichever
happened to be first in the list, and the prologue ends on exactly a new
game's opening position.
Also: PlanetAtmosphereController no longer spawns the opening fleet while
the prologue is arming, and the tests that are about what the GAME starts
from now say so by turning the prologue off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:59:12 +02:00
|
|
|
if intro_fleet_size > 0:
|
|
|
|
|
return mini(intro_fleet_size, DEFAULT_FORMATION.size())
|
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
|
|
|
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()
|
2026-08-11 22:50:09 +02:00
|
|
|
vessel_levels.clear()
|
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
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
2026-08-15 14:43:46 +02:00
|
|
|
# Called from the main menu's Play button (#47): the autoload survives
|
|
|
|
|
# change_scene_to_file(), so without this a second playthrough in the same
|
|
|
|
|
# process would start from wherever the last one left off instead of a
|
|
|
|
|
# fresh game.
|
|
|
|
|
func reset_new_game() -> void:
|
#108: the prologue - the convoy, the ambush, and the breach
Landed as one piece across #122, #123 and #124: the frame is useless without
a script, and the script has nowhere to run without the frame.
The director drives the REAL scene rather than being a scene of its own. It
wants the real fleet, the real enemies and the real asteroid field, and it
makes "then the game starts" cost nothing - the director stops steering,
clears the flag, and what is left running is the ordinary game.
The script is a list of beats, each with a deadline as well as its own
completion test, so a beat whose condition is never met - a player who
refuses to fly at the ambush - runs out rather than wedging the prologue.
skip() ends the whole thing at any point and lands in exactly the state
playing it through does, so the game has one way to start rather than two
that have to be kept in step. The test pins that equivalence.
"No action button available, only movements" is enforced where the buttons
are decided, not by the director reaching in: the ability controllers set
their own visibility every frame and would have put them straight back, and
the map button belongs to the map. The joystick, the pivot and the fader
stay.
The convoy is ten hulls where a new game's fleet is one, so it needs an
explicit fleet-size override - a plain number rather than a fake doctrine,
because nothing about the player's own progression should be bent to fit a
cutscene. It is carriers rather than transporters: transporters belong to
hubs and are not fleet-eligible, so a convoy of them would be silently
filtered down to its escorts.
The ambush is chasers and only chasers. "Easy enough" is the requirement,
and a sniper against a convoy the player cannot yet fight properly is
neither easy nor a good first impression.
The breach takes the wheel through one check in GameController, covering the
stick, the keyboard and the released-again path together - three separate
gates would be three chances for one of them to keep steering. The shake is
written to camera.offset, never to its position, because GameController owns
where the camera is and would smooth a shake away.
The vessel that survives is chosen as the interceptor rather than whichever
happened to be first in the list, and the prologue ends on exactly a new
game's opening position.
Also: PlanetAtmosphereController no longer spawns the opening fleet while
the prologue is arming, and the tests that are about what the GAME starts
from now say so by turning the prologue off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:59:12 +02:00
|
|
|
intro_active = true
|
|
|
|
|
intro_locks_movement = false
|
|
|
|
|
intro_fleet_size = 0
|
2026-08-15 14:43:46 +02:00
|
|
|
has_return_position = false
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
surviving_ship_count = STARTING_SHIP_COUNT
|
2026-08-15 14:43:46 +02:00
|
|
|
surviving_vessel_types = PackedStringArray()
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
fleet_vessel_types = PackedStringArray()
|
2026-08-15 14:43:46 +02:00
|
|
|
return_world_position = Vector2.ZERO
|
|
|
|
|
return_camera_zoom = 1.0
|
|
|
|
|
planet_name = ""
|
|
|
|
|
planet_id = ""
|
|
|
|
|
planet_definition_id = "rocky"
|
2026-08-16 17:59:04 +02:00
|
|
|
fleet_speed_multiplier = 1.0
|
2026-08-16 21:38:07 +02:00
|
|
|
_distorder_drain_progress = 0.0
|
|
|
|
|
boost_depleted_notice = false
|
2026-08-16 20:14:29 +02:00
|
|
|
research_points = 0
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
dev_mode = false
|
2026-08-15 14:43:46 +02:00
|
|
|
explored_chunks.clear()
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
bank_inventory = INVENTORY.new()
|
2026-08-15 14:43:46 +02:00
|
|
|
fleet_inventory = INVENTORY.new()
|
|
|
|
|
buildings.clear()
|
|
|
|
|
next_building_id = 1
|
|
|
|
|
stored_vessels.clear()
|
|
|
|
|
vessel_deliveries.clear()
|
|
|
|
|
extractors.clear()
|
|
|
|
|
transporters.clear()
|
2026-08-23 21:58:58 +02:00
|
|
|
repair_drones.clear()
|
2026-08-15 14:43:46 +02:00
|
|
|
planet_capture.clear()
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
vessel_targets.clear()
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
reset_waves()
|
#129 (#131, #132, #133): a quest chain, and a World Ship out of reach
Sixteen quests that walk a new game through the things it contains: ore,
the mothership, exploration, a yard and a second hull, planetfall and
conquest, extractors, gas, freight, power, the lab, a new vessel type, an
outpost, and a survey.
The chain turns on the second quest. The fleet builds the mothership to
call the World Ship and hears nothing - and the array is not at fault. The
breach put them further out than that signal will carry, so every quest
after it is a search that has to be flown rather than transmitted.
Three pieces:
Quests.gd the chain, as data and nothing else. Two rules it obeys:
nothing may ask for something unreachable - research points
come only from conquering a planet, so every doctrine that
costs them sits after the conquest quest - and a quest names
one goal, with the research it implies said in the brief
rather than split into an errand of its own.
GameState how far along the chain the player is, and the answer to each
goal kind. Progress latches for free: the chain is walked one
quest at a time, so collecting five ore and spending it cannot
un-complete the quest that asked for five - by then nothing is
asking. It lives here so a quest finished on a planet surface
is still finished after take-off.
the HUD QuestTracker, the standing objective, in the wave alert's
idiom; QuestDirector, which reads the lines out.
Lines are QUEUED by GameState and drained by the director rather than
emitted at a banner, because quests advance in the autoload, which runs on
a planet surface too - where a line emitted straight at a banner would be
lost. Both scenes now carry a banner, a tracker and a director, so a quest
finished underground is announced underground.
The chain is armed by reset_new_game(), the same gate the prologue uses -
the only door into a real game - so instantiating a scene is not by itself
a reason to start briefing somebody.
2026-08-28 14:41:03 +02:00
|
|
|
reset_quests(true)
|
2026-08-15 14:43:46 +02:00
|
|
|
reset_progression()
|
|
|
|
|
reset_formation()
|
|
|
|
|
|
|
|
|
|
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# Amounts a dev game opens with: enough of everything to reach any part of
|
|
|
|
|
# the game immediately, without being so large that costs stop registering.
|
|
|
|
|
const DEV_RESOURCE_AMOUNT: int = 2000
|
|
|
|
|
const DEV_RESEARCH_POINTS: int = 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# A new game stocked for play-testing: every resource banked, research points
|
|
|
|
|
# to spend, and one of each planet type next to the spawn (WorldGen reads
|
|
|
|
|
# dev_mode for that). Offered by the main menu only when running from Godot -
|
|
|
|
|
# see MainMenu.gd - so it cannot be reached in a shipped build.
|
|
|
|
|
#
|
|
|
|
|
# Deliberately built ON TOP of reset_new_game() rather than beside it, so a
|
|
|
|
|
# dev game is a normal game plus a stock rather than a second, separately
|
|
|
|
|
# maintained definition of what a fresh game is.
|
|
|
|
|
func start_dev_game() -> void:
|
|
|
|
|
reset_new_game()
|
|
|
|
|
dev_mode = true
|
|
|
|
|
for resource_type in resource_types():
|
|
|
|
|
bank_inventory.set_amount(resource_type, DEV_RESOURCE_AMOUNT)
|
|
|
|
|
research_points = DEV_RESEARCH_POINTS
|
|
|
|
|
research_points_changed.emit(research_points)
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
# --- 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
|
|
|
|
|
|
2026-08-12 22:47:46 +02:00
|
|
|
# Finished vessels waiting at the mothership for the player to collect
|
|
|
|
|
# (#23), as a list of VESSEL_* types - a shield vessel must not come back as
|
|
|
|
|
# an interceptor. They are NOT handed to the fleet automatically: the player
|
|
|
|
|
# picks what to crew from the hangar, so a full fleet no longer stops a
|
|
|
|
|
# crafter from building.
|
|
|
|
|
#
|
|
|
|
|
# Production is simulated here, in the autoload, the same way extractor
|
|
|
|
|
# haulers are, so a build finishes whether or not the player is in space.
|
|
|
|
|
var stored_vessels: Array = []
|
2026-08-13 21:39:05 +02:00
|
|
|
# Finished crafter orders travel to the mothership before appearing in its
|
|
|
|
|
# hangar. State is authoritative here so delivery continues off-screen.
|
|
|
|
|
# {"type", "from", "to", "progress"}
|
|
|
|
|
var vessel_deliveries: Array = []
|
2026-08-14 22:30:19 +02:00
|
|
|
const VESSEL_DELIVERY_SPEED: float = 180.0
|
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
|
|
|
|
|
|
|
|
signal order_completed(building_id: int, type: String)
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
# #99: fired after the record is already gone, so a listener that reacts by
|
|
|
|
|
# asking GameState about the world sees the world without it.
|
|
|
|
|
signal building_destroyed(building_id: int, type: String)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
func add_building(type: String, at_position: Vector2) -> int:
|
|
|
|
|
var id: int = next_building_id
|
|
|
|
|
next_building_id += 1
|
|
|
|
|
buildings.append({
|
2026-08-14 20:32:58 +02:00
|
|
|
"id": id, "type": type, "position": at_position,
|
|
|
|
|
"inventory": INVENTORY.new(), "orders": [],
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
"hp": building_max_hp(type),
|
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
|
|
|
})
|
2026-08-15 00:09:10 +02:00
|
|
|
var building: Dictionary = buildings[-1]
|
|
|
|
|
if building_has_capability(building, "transport_hub"):
|
|
|
|
|
_provision_hub_transporters(building)
|
2026-08-23 21:58:58 +02:00
|
|
|
if building_has_capability(building, "repair"):
|
|
|
|
|
_provision_workshop_drones(building)
|
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
|
|
|
return id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func find_building(id: int) -> Dictionary:
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if b.get("id", 0) == id:
|
|
|
|
|
return b
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
# --- what a structure can take before it comes down (#99) ------------------
|
|
|
|
|
#
|
|
|
|
|
# Health lives on the RECORD, not on the Building node. Main.tscn is torn
|
|
|
|
|
# down and rebuilt on every trip to a planet surface, so damage kept on a
|
|
|
|
|
# node would heal itself the moment the player landed - the same reason the
|
|
|
|
|
# buildings themselves live here.
|
|
|
|
|
|
|
|
|
|
func building_definition(type: String) -> Resource:
|
|
|
|
|
return BUILDING_DEFINITIONS.get_definition(type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func building_max_hp(type: String) -> int:
|
|
|
|
|
var definition: Resource = building_definition(type)
|
|
|
|
|
return 1 if definition == null else maxi(int(definition.max_hp), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func building_hp(id: int) -> int:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return 0
|
|
|
|
|
# Records written before #99 carry no "hp" at all. Reading that as zero
|
|
|
|
|
# would present every structure in an existing save as rubble, so a
|
|
|
|
|
# missing field means undamaged.
|
|
|
|
|
return int(b.get("hp", building_max_hp(String(b.type))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func building_health_fraction(id: int) -> float:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return 0.0
|
|
|
|
|
return clampf(float(building_hp(id)) / float(building_max_hp(String(b.type))), 0.0, 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# The mothership is where the bank, the hangar and research all live, and it
|
|
|
|
|
# is where a wiped-out fleet is rebuilt. Losing it would delete the player's
|
|
|
|
|
# savings and their stored vessels with no route back, so it bottoms out at
|
|
|
|
|
# one hit point instead of dying. Keyed on the capability rather than on the
|
|
|
|
|
# type: whatever building holds the bank is the one that cannot be lost.
|
|
|
|
|
func building_is_indestructible(building: Dictionary) -> bool:
|
|
|
|
|
return building_has_capability(building, "bank")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Returns true when this killed the building.
|
|
|
|
|
func damage_building(id: int, amount: int) -> bool:
|
|
|
|
|
if amount <= 0:
|
|
|
|
|
return false
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return false
|
|
|
|
|
var floor_hp: int = 1 if building_is_indestructible(b) else 0
|
|
|
|
|
b.hp = maxi(building_hp(id) - amount, floor_hp)
|
|
|
|
|
if int(b.hp) > 0:
|
|
|
|
|
return false
|
|
|
|
|
return destroy_building(id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func repair_building(id: int, amount: int) -> int:
|
|
|
|
|
if amount <= 0:
|
|
|
|
|
return 0
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return 0
|
|
|
|
|
var before: int = building_hp(id)
|
|
|
|
|
b.hp = mini(before + amount, building_max_hp(String(b.type)))
|
|
|
|
|
return int(b.hp) - before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func destroy_building(id: int) -> bool:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty() or building_is_indestructible(b):
|
|
|
|
|
return false
|
|
|
|
|
var type: String = String(b.type)
|
|
|
|
|
var was_hub: bool = building_has_capability(b, "transport_hub")
|
|
|
|
|
buildings.erase(b)
|
|
|
|
|
if was_hub:
|
|
|
|
|
# Its transporters belonged to it and have nowhere to go home to.
|
|
|
|
|
var kept: Array = []
|
|
|
|
|
for transporter in transporters:
|
|
|
|
|
if int(transporter.get("hub_id", 0)) != id:
|
|
|
|
|
kept.append(transporter)
|
|
|
|
|
transporters = kept
|
|
|
|
|
# Everything else keyed on a building id already copes with the building
|
|
|
|
|
# being gone: a transporter mid-delivery puts its cargo back rather than
|
|
|
|
|
# destroying it (see _arrive), and find_building simply returns empty.
|
|
|
|
|
building_destroyed.emit(id, type)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
# --- outpost garrisons (#103) ----------------------------------------------
|
|
|
|
|
#
|
|
|
|
|
# Two separate things live on an outpost's record, and conflating them was
|
|
|
|
|
# the first thing to get wrong here:
|
|
|
|
|
#
|
|
|
|
|
# garrison what the player ASKED for - {vessel_type: count}. Set in
|
|
|
|
|
# the outpost's own menu and otherwise untouched.
|
|
|
|
|
# garrison_crew who is actually THERE - a list of vessel types. #104
|
|
|
|
|
# fills it from the mothership; nothing else writes it.
|
|
|
|
|
#
|
|
|
|
|
# Keeping the order as a wish rather than as an inventory is what lets an
|
|
|
|
|
# outpost ask for a vessel nobody has yet and simply wait, instead of the
|
|
|
|
|
# request evaporating.
|
|
|
|
|
|
|
|
|
|
const GARRISON_BASE_SLOTS: int = 2
|
|
|
|
|
# Which vessels can hold a position. #97: "Only ships that attack can be
|
|
|
|
|
# assigned" - so a shield vessel, a hauler or an engine block cannot, however
|
|
|
|
|
# much the player might want the shield out there. Written as capabilities
|
|
|
|
|
# rather than as a list of types, so a new armed vessel is eligible the day
|
|
|
|
|
# it is added and an unarmed one never is by accident.
|
2026-08-23 14:13:46 +02:00
|
|
|
#
|
Garrison laser vessels and gas bombers, on a shared ability seam
#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.
The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.
Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.
Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.
Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.
Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
|
|
|
# #105 shipped with "weapon" only, because firing the laser and the gas bomb
|
|
|
|
|
# lived in the player's controllers rather than in the vessels' own
|
|
|
|
|
# components - a garrisoned laser would have held its position and never
|
|
|
|
|
# shot. Both effects now live in the world as LaserBeamEffect/GasBombEffect
|
|
|
|
|
# and both components expose the same fire_ability() seam, so a garrison can
|
|
|
|
|
# pull either trigger and the list is no longer a apology for a missing
|
|
|
|
|
# feature.
|
|
|
|
|
const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser", "gas_bomber"]
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func outpost_slot_capacity() -> int:
|
|
|
|
|
var n: int = GARRISON_BASE_SLOTS
|
|
|
|
|
for id in unlocked:
|
|
|
|
|
n += int(skill_node(id).get("outpost_bonus", 0))
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_can_garrison(vessel_type: String) -> bool:
|
|
|
|
|
var definition: Resource = vessel_definition(vessel_type)
|
|
|
|
|
if definition == null or not definition.fleet_eligible:
|
|
|
|
|
return false
|
|
|
|
|
for capability in GARRISON_CAPABILITIES:
|
|
|
|
|
if capability in definition.capabilities:
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func garrison_vessel_types() -> PackedStringArray:
|
|
|
|
|
var out: PackedStringArray = PackedStringArray()
|
|
|
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
|
|
|
if vessel_can_garrison(String(definition.definition_id)):
|
|
|
|
|
out.append(String(definition.definition_id))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_outpost(building: Dictionary) -> bool:
|
|
|
|
|
return building_has_capability(building, "garrison")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func garrison_desired(id: int) -> Dictionary:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty() or not is_outpost(b):
|
|
|
|
|
return {}
|
|
|
|
|
if not b.has("garrison"):
|
|
|
|
|
b.garrison = {}
|
|
|
|
|
return b.garrison
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func garrison_desired_total(id: int) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for count in garrison_desired(id).values():
|
|
|
|
|
n += int(count)
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func garrison_desired_of(id: int, vessel_type: String) -> int:
|
|
|
|
|
return int(garrison_desired(id).get(vessel_type, 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Returns the count actually set, so the caller can show what happened when
|
|
|
|
|
# the request was clamped rather than silently doing something else.
|
|
|
|
|
func set_garrison_desired(id: int, vessel_type: String, count: int) -> int:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty() or not is_outpost(b) or not vessel_can_garrison(vessel_type):
|
|
|
|
|
return 0
|
|
|
|
|
var wanted: Dictionary = garrison_desired(id)
|
|
|
|
|
var others: int = garrison_desired_total(id) - int(wanted.get(vessel_type, 0))
|
|
|
|
|
var room: int = maxi(outpost_slot_capacity() - others, 0)
|
|
|
|
|
var final_count: int = clampi(count, 0, room)
|
|
|
|
|
if final_count <= 0:
|
|
|
|
|
wanted.erase(vessel_type)
|
|
|
|
|
else:
|
|
|
|
|
wanted[vessel_type] = final_count
|
|
|
|
|
return final_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func adjust_garrison_desired(id: int, vessel_type: String, delta: int) -> int:
|
|
|
|
|
return set_garrison_desired(id, vessel_type,
|
|
|
|
|
garrison_desired_of(id, vessel_type) + delta)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func garrison_crew(id: int) -> Array:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty() or not is_outpost(b):
|
|
|
|
|
return []
|
|
|
|
|
if not b.has("garrison_crew"):
|
|
|
|
|
b.garrison_crew = []
|
|
|
|
|
return b.garrison_crew
|
|
|
|
|
|
|
|
|
|
|
Garrison laser vessels and gas bombers, on a shared ability seam
#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.
The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.
Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.
Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.
Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.
Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
|
|
|
# --- what a garrison keeps beside it ---------------------------------------
|
|
|
|
|
#
|
|
|
|
|
# An outpost stores ammunition for the vessels actually stationed there: 50
|
|
|
|
|
# gas for each gas bomber, 50 energy for each laser. The numbers come off the
|
|
|
|
|
# VESSEL definitions, so the outpost never learns which vessel needs what -
|
|
|
|
|
# add an armed vessel with a magazine and its outpost storage follows.
|
|
|
|
|
#
|
|
|
|
|
# This is a CAPACITY, not a stock. Hub transporters fill it the same way they
|
|
|
|
|
# fill a gas burner's buffer (see building_logistics_deficit), and the
|
|
|
|
|
# garrison AI spends from it.
|
|
|
|
|
|
|
|
|
|
func garrison_magazine_capacity(id: int, resource_type: String) -> int:
|
|
|
|
|
var total: int = 0
|
|
|
|
|
for vessel_type in garrison_crew(id):
|
|
|
|
|
var definition: Resource = vessel_definition(String(vessel_type))
|
|
|
|
|
if definition == null:
|
|
|
|
|
continue
|
|
|
|
|
if String(definition.garrison_ammo_resource) != resource_type:
|
|
|
|
|
continue
|
|
|
|
|
total += int(definition.garrison_ammo_capacity)
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Every resource this outpost wants held, as {resource_type: capacity}.
|
|
|
|
|
func garrison_magazines(id: int) -> Dictionary:
|
|
|
|
|
var out: Dictionary = {}
|
|
|
|
|
for vessel_type in garrison_crew(id):
|
|
|
|
|
var definition: Resource = vessel_definition(String(vessel_type))
|
|
|
|
|
if definition == null:
|
|
|
|
|
continue
|
|
|
|
|
var resource_type: String = String(definition.garrison_ammo_resource)
|
|
|
|
|
if resource_type == "" or int(definition.garrison_ammo_capacity) <= 0:
|
|
|
|
|
continue
|
|
|
|
|
out[resource_type] = int(out.get(resource_type, 0)) \
|
|
|
|
|
+ int(definition.garrison_ammo_capacity)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Spends from the outpost's own store. Returns false when it cannot pay in
|
|
|
|
|
# full - a half-priced shot would be worse than no shot.
|
|
|
|
|
func spend_garrison_ammo(id: int, resource_type: String, amount: int) -> bool:
|
|
|
|
|
if amount <= 0:
|
|
|
|
|
return true
|
|
|
|
|
var inventory: RefCounted = building_inventory(id)
|
|
|
|
|
if inventory == null or inventory.amount(resource_type) < amount:
|
|
|
|
|
return false
|
|
|
|
|
inventory.withdraw(resource_type, amount)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
func garrison_crew_of(id: int, vessel_type: String) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for crewed in garrison_crew(id):
|
|
|
|
|
if String(crewed) == vessel_type:
|
|
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# What this outpost is still short of, as {vessel_type: count}. The one
|
|
|
|
|
# question #104 has to answer every tick, so it is asked here rather than
|
|
|
|
|
# recomputed by whoever needs it.
|
|
|
|
|
func garrison_shortfall(id: int) -> Dictionary:
|
|
|
|
|
var out: Dictionary = {}
|
|
|
|
|
for vessel_type in garrison_desired(id):
|
2026-08-23 14:13:46 +02:00
|
|
|
# What is already on its way counts. Without this the dispatcher
|
|
|
|
|
# would send the whole hangar to fill one berth, because the crew
|
|
|
|
|
# does not grow until the first arrival lands.
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
var missing: int = int(garrison_desired(id)[vessel_type]) \
|
2026-08-23 14:13:46 +02:00
|
|
|
- garrison_crew_of(id, String(vessel_type)) \
|
|
|
|
|
- garrison_inbound_of(id, String(vessel_type))
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
if missing > 0:
|
|
|
|
|
out[vessel_type] = missing
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 14:13:46 +02:00
|
|
|
func garrison_inbound_of(id: int, vessel_type: String) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for delivery in vessel_deliveries:
|
|
|
|
|
if int(delivery.get("to_outpost", 0)) == id \
|
|
|
|
|
and String(delivery.type) == vessel_type:
|
|
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func garrison_inbound(id: int) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for delivery in vessel_deliveries:
|
|
|
|
|
if int(delivery.get("to_outpost", 0)) == id:
|
|
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.
#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.
#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.
"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.
Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.
The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
|
|
|
func outpost_ids() -> Array:
|
|
|
|
|
var out: Array = []
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if is_outpost(b):
|
|
|
|
|
out.append(int(b.id))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 21:44:28 +02:00
|
|
|
func building_has_capability(building: Dictionary, capability: String) -> bool:
|
|
|
|
|
if building.is_empty():
|
|
|
|
|
return false
|
|
|
|
|
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
|
|
|
|
|
return definition != null and capability in definition.capabilities
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 21:38:07 +02:00
|
|
|
func building_logistics_profile(building: Dictionary) -> Resource:
|
|
|
|
|
if building.is_empty():
|
|
|
|
|
return null
|
|
|
|
|
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
|
|
|
|
|
return null if definition == null else definition.logistics
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 22:50:09 +02:00
|
|
|
# --- vessel types and their upgrades (#5) ---------------------------------
|
|
|
|
|
|
2026-08-15 00:09:10 +02:00
|
|
|
# Combat vessels can join the player fleet. Transporter metadata lives in the
|
|
|
|
|
# same definition catalog so the lab can upgrade its cargo and travel stats,
|
|
|
|
|
# but transporters themselves belong to hubs and are never production orders.
|
2026-08-11 22:50:09 +02:00
|
|
|
const VESSEL_INTERCEPTOR: String = "interceptor"
|
|
|
|
|
const VESSEL_SHIELD: String = "shield"
|
2026-08-15 04:45:15 +02:00
|
|
|
const VESSEL_GAS_BOMBER: String = "gas_bomber"
|
2026-08-11 22:50:09 +02:00
|
|
|
const VESSEL_TRANSPORTER: String = "transporter"
|
2026-08-16 16:20:39 +02:00
|
|
|
const VESSEL_CARRIER: String = "carrier"
|
Add laser ship vessel and its skill shot (#58)
A manually aimed instant beam, modelled on the gas bomber's press-drag-
release aiming (GasBombController/VesselGasBomberComponent) but firing
the moment the touch releases - no projectile travel time. Damage,
beam_range and cooldown are resolved per ship from base_stats/upgrades
(VesselLaserComponent.apply_stats(), the same pattern
VesselWeaponComponent uses) rather than hardcoded in the controller, so
the new damage/range/cooldown ship-lab upgrades actually reach them.
Cooldown lives on the component (per ship), not the controller (per
fleet) - two laser ships give two shots, the exact bug already fixed
for bombers in eb79efd.
LaserController.gd is reused unmodified by both Main.tscn and
PlanetSurface.tscn (relative ../Fleet, ../Camera2D, ../UI/LaserButton
paths, matching GasBombController), so it works identically in space
and on a landed surface. Wired into GameController's touch/drag
first-refusal chain alongside the bomb controller.
The crafter, hangar and ship-lab menus were already generic over
VesselDefinitions.fleet()/.all() - only the per-vessel Main.tscn nodes
are hand-placed, not the logic - so this repacks those rows to
5/5/6 columns and extends BuildController's onready arrays. No new
signal handlers were needed. Also fixes test_vessel_composition.gd's
hardcoded fleet-catalog size again (4 -> 5) now that laser is a fifth
fleet-eligible vessel.
Note for later tickets: a brand-new class_name script (LaserController)
resolves fine as a static type when its own file is the godot -s entry
point, but the identical type hint fails to parse ("Could not find
type") when the script holding it is only reached indirectly - e.g.
Main.tscn loaded from inside a _screenshot_tool.gd preview. Untyped
Node + duck-typed calls sidesteps it; GameController.gd's
laser_controller field documents this in place.
2026-08-16 17:29:53 +02:00
|
|
|
const VESSEL_LASER: String = "laser"
|
2026-08-16 17:59:04 +02:00
|
|
|
const VESSEL_DISTORDER: String = "distorder"
|
2026-08-11 22:50:09 +02:00
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
# Compatibility constants for callers and old tests. Runtime metadata is
|
|
|
|
|
# resolved from VesselDefinition below.
|
2026-08-11 22:50:09 +02:00
|
|
|
const SHIELD_COST: int = 14
|
2026-08-13 22:04:21 +02:00
|
|
|
const LAB_RESEARCH_TIME: float = 8.0
|
2026-08-12 22:47:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Test seam. Headless tests need the timed path - queued, started, finishing
|
|
|
|
|
# - without sitting through a real 10-second build each, and a suite that
|
|
|
|
|
# slow stops being run. Left at 1.0 in the game.
|
|
|
|
|
var build_time_scale: float = 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func build_time(product: String) -> float:
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = VESSEL_DEFINITIONS.get_definition(product)
|
|
|
|
|
return (definition.build_time if definition != null else 10.0) * build_time_scale
|
2026-08-12 22:47:46 +02:00
|
|
|
|
2026-08-13 22:04:21 +02:00
|
|
|
|
|
|
|
|
func lab_research_time() -> float:
|
|
|
|
|
return LAB_RESEARCH_TIME * build_time_scale
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
# Compatibility constants. VesselDefinition owns the live base figures.
|
2026-08-11 22:50:09 +02:00
|
|
|
const SHIP_BASE_DAMAGE: int = 1
|
|
|
|
|
const SHIP_BASE_COOLDOWN: float = 0.5
|
|
|
|
|
const SHIP_BASE_RANGE: float = 320.0
|
|
|
|
|
const SHIP_BASE_SPEED: float = 460.0
|
|
|
|
|
const SHIP_BASE_ACCEL: float = 450.0
|
|
|
|
|
const SHIELD_BASE_CHARGES: int = 1
|
|
|
|
|
const SHIELD_BASE_RECHARGE: float = 6.0
|
|
|
|
|
var vessel_levels: Dictionary = {}
|
|
|
|
|
|
|
|
|
|
signal vessel_upgraded(upgrade_id: String)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 23:10:07 +02:00
|
|
|
func has_ship_lab() -> bool:
|
|
|
|
|
for b in buildings:
|
2026-08-14 21:44:28 +02:00
|
|
|
if building_has_capability(b, "vessel_research"):
|
2026-08-11 23:10:07 +02:00
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func shield_vessel_unlocked() -> bool:
|
2026-08-14 22:12:57 +02:00
|
|
|
return vessel_is_unlocked(VESSEL_SHIELD)
|
2026-08-11 23:10:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
|
|
|
|
|
const UNLOCK_SHIELD_COST: int = 45
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func unlock_shield_vessel() -> bool:
|
2026-08-14 22:12:57 +02:00
|
|
|
return unlock_vessel(VESSEL_SHIELD)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_definition(vessel: String) -> Resource:
|
|
|
|
|
return VESSEL_DEFINITIONS.get_definition(vessel)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_name(vessel: String) -> String:
|
|
|
|
|
var definition: Resource = vessel_definition(vessel)
|
|
|
|
|
return definition.display_name if definition != null else vessel.capitalize()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_has_capability(vessel: String, capability: String) -> bool:
|
|
|
|
|
var definition: Resource = vessel_definition(vessel)
|
|
|
|
|
return definition != null and definition.has_capability(capability)
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 00:09:10 +02:00
|
|
|
func vessel_is_fleet_eligible(vessel: String) -> bool:
|
|
|
|
|
var definition: Resource = vessel_definition(vessel)
|
|
|
|
|
return definition != null and definition.fleet_eligible
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func vessel_is_unlocked(vessel: String) -> bool:
|
|
|
|
|
var definition: Resource = vessel_definition(vessel)
|
|
|
|
|
if definition == null:
|
2026-08-11 23:10:07 +02:00
|
|
|
return false
|
2026-08-14 22:12:57 +02:00
|
|
|
return definition.unlock_id == "" or int(vessel_levels.get(definition.unlock_id, 0)) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_for_unlock(unlock_id: String) -> Resource:
|
|
|
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
|
|
|
if definition.unlock_id == unlock_id:
|
|
|
|
|
return definition
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func unlock_vessel(vessel: String) -> bool:
|
|
|
|
|
var definition: Resource = vessel_definition(vessel)
|
|
|
|
|
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
|
|
|
|
|
return false
|
2026-08-15 04:45:15 +02:00
|
|
|
var costs: Dictionary = research_costs(definition.unlock_id)
|
2026-08-16 20:14:29 +02:00
|
|
|
var points_needed: int = int(definition.research_point_cost)
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
if research_points < points_needed or not spend_resources(costs):
|
2026-08-11 23:10:07 +02:00
|
|
|
return false
|
2026-08-16 20:14:29 +02:00
|
|
|
if points_needed > 0:
|
|
|
|
|
research_points -= points_needed
|
|
|
|
|
research_points_changed.emit(research_points)
|
2026-08-14 22:12:57 +02:00
|
|
|
vessel_levels[definition.unlock_id] = 1
|
|
|
|
|
vessel_upgraded.emit(definition.unlock_id)
|
2026-08-11 23:10:07 +02:00
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 22:50:09 +02:00
|
|
|
func upgrade_def(id: String) -> Dictionary:
|
2026-08-14 22:12:57 +02:00
|
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
|
|
|
for upgrade in definition.upgrades:
|
|
|
|
|
if upgrade.upgrade_id == id:
|
|
|
|
|
return {"id": upgrade.upgrade_id, "vessel": definition.definition_id,
|
|
|
|
|
"name": upgrade.display_name, "desc": upgrade.description,
|
|
|
|
|
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
|
|
|
|
|
"icon": upgrade.icon, "resource": upgrade}
|
2026-08-11 22:50:09 +02:00
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func upgrades_for(vessel: String) -> Array:
|
|
|
|
|
var out: Array = []
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = vessel_definition(vessel)
|
|
|
|
|
if definition == null:
|
|
|
|
|
return out
|
|
|
|
|
for upgrade in definition.upgrades:
|
|
|
|
|
out.append({"id": upgrade.upgrade_id, "vessel": vessel,
|
|
|
|
|
"name": upgrade.display_name, "desc": upgrade.description,
|
|
|
|
|
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
|
|
|
|
|
"icon": upgrade.icon, "resource": upgrade})
|
2026-08-11 22:50:09 +02:00
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func upgrade_level(id: String) -> int:
|
|
|
|
|
return int(vessel_levels.get(id, 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func upgrade_maxed(id: String) -> bool:
|
|
|
|
|
var d: Dictionary = upgrade_def(id)
|
|
|
|
|
return d.is_empty() or upgrade_level(id) >= int(d.max_level)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func upgrade_cost(id: String) -> int:
|
|
|
|
|
var d: Dictionary = upgrade_def(id)
|
|
|
|
|
if d.is_empty():
|
|
|
|
|
return 0
|
|
|
|
|
return int(d.cost) * (upgrade_level(id) + 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func can_buy_upgrade(id: String) -> bool:
|
2026-08-16 20:14:29 +02:00
|
|
|
return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) \
|
|
|
|
|
and research_points >= research_point_cost(id)
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func buy_upgrade(id: String) -> bool:
|
|
|
|
|
if not can_buy_upgrade(id):
|
|
|
|
|
return false
|
|
|
|
|
if not spend(upgrade_cost(id)):
|
|
|
|
|
return false
|
2026-08-16 20:14:29 +02:00
|
|
|
var points_needed: int = research_point_cost(id)
|
|
|
|
|
if points_needed > 0:
|
|
|
|
|
research_points -= points_needed
|
|
|
|
|
research_points_changed.emit(research_points)
|
2026-08-11 22:50:09 +02:00
|
|
|
vessel_levels[id] = upgrade_level(id) + 1
|
|
|
|
|
vessel_upgraded.emit(id)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func vessel_stat(vessel: String, stat: String) -> float:
|
|
|
|
|
var definition: Resource = vessel_definition(vessel)
|
|
|
|
|
if definition == null:
|
|
|
|
|
return 0.0
|
|
|
|
|
var value: float = float(definition.base_stats.get(stat, 0.0))
|
|
|
|
|
for upgrade in definition.upgrades:
|
|
|
|
|
var level: int = upgrade_level(upgrade.upgrade_id)
|
|
|
|
|
value += float(upgrade.additive_modifiers.get(stat, 0.0)) * level
|
|
|
|
|
if upgrade.multiplicative_modifiers.has(stat):
|
|
|
|
|
value *= pow(float(upgrade.multiplicative_modifiers[stat]), level)
|
|
|
|
|
if definition.minimum_stats.has(stat):
|
|
|
|
|
value = maxf(value, float(definition.minimum_stats[stat]))
|
|
|
|
|
return value
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func ship_damage(vessel: String) -> int: return int(vessel_stat(vessel, "damage"))
|
|
|
|
|
func ship_fire_cooldown(vessel: String) -> float: return vessel_stat(vessel, "fire_cooldown")
|
|
|
|
|
func ship_fire_range(vessel: String) -> float: return vessel_stat(vessel, "fire_range")
|
|
|
|
|
func ship_max_speed(vessel: String) -> float: return vessel_stat(vessel, "max_speed")
|
|
|
|
|
func ship_acceleration(vessel: String) -> float: return vessel_stat(vessel, "acceleration")
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
func shield_max_charges() -> int:
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
|
|
|
|
|
return int(vessel_stat(definition.definition_id, "shield_charges"))
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
func shield_recharge_time() -> float:
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
|
|
|
|
|
return vessel_stat(definition.definition_id, "shield_recharge")
|
2026-08-11 22:50:09 +02:00
|
|
|
|
2026-08-16 14:53:26 +02:00
|
|
|
# #56: sums cargo_capacity over ships that actually declare the "cargo"
|
|
|
|
|
# capability, so combat specialists (no capability, whatever their stat
|
|
|
|
|
# dict says) contribute nothing - data-driven rather than a vessel-id list.
|
|
|
|
|
# Takes the composition as an argument rather than reading it off a live
|
|
|
|
|
# Fleet node: GameState has no fixed reference to "the" Fleet (space and a
|
|
|
|
|
# planet surface each own a separate one), so the caller passes its own
|
|
|
|
|
# Fleet.vessel_types().
|
|
|
|
|
func fleet_cargo_capacity(vessel_types: PackedStringArray) -> int:
|
|
|
|
|
var total: int = 0
|
|
|
|
|
for vessel_type in vessel_types:
|
|
|
|
|
if vessel_has_capability(vessel_type, "cargo"):
|
|
|
|
|
total += int(vessel_stat(vessel_type, "cargo_capacity"))
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func sync_fleet_cargo_capacity(vessel_types: PackedStringArray) -> void:
|
|
|
|
|
fleet_inventory.total_capacity = fleet_cargo_capacity(vessel_types)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 22:50:09 +02:00
|
|
|
func transporter_load_limit() -> int:
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
|
|
|
|
|
return int(vessel_stat(definition.definition_id, "cargo_capacity"))
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
func transporter_speed() -> float:
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
|
|
|
|
|
return vessel_stat(definition.definition_id, "transport_speed")
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
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
|
|
|
const PRODUCT_SHIP: String = "interceptor"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func product_cost(product: String) -> int:
|
2026-08-15 04:45:15 +02:00
|
|
|
return int(product_costs(product).get(RESOURCE_ORE, 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func product_costs(product: String) -> Dictionary:
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = vessel_definition(product)
|
2026-08-15 04:45:15 +02:00
|
|
|
if definition == null:
|
|
|
|
|
return {}
|
|
|
|
|
if not definition.build_resource_costs.is_empty():
|
|
|
|
|
return definition.build_resource_costs.duplicate()
|
|
|
|
|
return {RESOURCE_ORE: definition.build_cost} if definition.build_cost > 0 else {}
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
func can_produce(type: String) -> bool:
|
2026-08-14 21:44:28 +02:00
|
|
|
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
|
|
|
|
|
return definition != null and "production" in definition.capabilities
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
func building_stock(id: int) -> int:
|
2026-08-14 20:32:58 +02:00
|
|
|
return building_resource(id, RESOURCE_ORE)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
func pending_orders(id: int) -> int:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
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
|
|
|
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", []):
|
2026-08-12 22:47:46 +02:00
|
|
|
if String(o.product) == product:
|
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
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 22:47:46 +02:00
|
|
|
func order_list(id: int) -> Array:
|
|
|
|
|
# What the queue panel draws: product, and how far along the one being
|
|
|
|
|
# built is. Everything behind it reads as simply waiting.
|
|
|
|
|
var out: Array = []
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return out
|
|
|
|
|
for o in b.get("orders", []):
|
|
|
|
|
var product: String = String(o.product)
|
|
|
|
|
out.append({
|
|
|
|
|
"product": product,
|
|
|
|
|
"started": bool(o.started),
|
|
|
|
|
"progress": clampf(float(o.elapsed) / maxf(build_time(product), 0.001), 0.0, 1.0),
|
|
|
|
|
})
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func cancel_order(id: int, index: int) -> bool:
|
|
|
|
|
# Refunds whatever was actually taken: a build that has started has its
|
|
|
|
|
# ore in the building already, one still waiting never paid.
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return false
|
|
|
|
|
var orders: Array = b.get("orders", [])
|
|
|
|
|
if index < 0 or index >= orders.size():
|
|
|
|
|
return false
|
|
|
|
|
var order: Dictionary = orders[index]
|
|
|
|
|
if bool(order.started):
|
2026-08-15 04:45:15 +02:00
|
|
|
var inventory: RefCounted = _inventory_for_record(b)
|
|
|
|
|
var costs: Dictionary = product_costs(String(order.product))
|
|
|
|
|
for resource_type in costs:
|
|
|
|
|
inventory.deposit(String(resource_type), int(costs[resource_type]))
|
2026-08-12 22:47:46 +02:00
|
|
|
orders.remove_at(index)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
func outstanding_cost(id: int) -> int:
|
2026-08-15 04:45:15 +02:00
|
|
|
return outstanding_resource_cost(id, RESOURCE_ORE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func outstanding_costs(id: int) -> Dictionary:
|
|
|
|
|
var result: Dictionary = {}
|
|
|
|
|
for resource_type in resource_types():
|
|
|
|
|
var need: int = outstanding_resource_cost(id, resource_type)
|
|
|
|
|
if need > 0:
|
|
|
|
|
result[resource_type] = need
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func outstanding_resource_cost(id: int, resource_type: String) -> int:
|
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
|
|
|
# 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", []):
|
2026-08-12 22:47:46 +02:00
|
|
|
if not bool(o.started):
|
2026-08-15 04:45:15 +02:00
|
|
|
need += int(product_costs(String(o.product)).get(resource_type, 0))
|
2026-08-14 22:46:25 +02:00
|
|
|
for order in b.get("research_orders", []):
|
|
|
|
|
if not bool(order.started):
|
2026-08-15 04:45:15 +02:00
|
|
|
need += int(research_costs(String(order.id)).get(resource_type, 0))
|
|
|
|
|
return maxi(need - _inventory_for_record(b).amount(resource_type), 0)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
func deposit_to_building(id: int, amount: int) -> int:
|
2026-08-15 04:45:15 +02:00
|
|
|
return deposit_resource_to_building(id, RESOURCE_ORE, amount)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func deposit_resource_to_building(id: int, resource_type: String, amount: int) -> int:
|
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
|
|
|
if amount <= 0:
|
|
|
|
|
return 0
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return 0
|
2026-08-15 04:45:15 +02:00
|
|
|
return _inventory_for_record(b).deposit(resource_type, amount)
|
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
|
|
|
|
|
|
|
|
|
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
|
|
|
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
|
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
|
|
|
var b: Dictionary = find_building(id)
|
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
|
|
|
if b.is_empty() or not can_produce(b.type):
|
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
|
|
|
return false
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = vessel_definition(product)
|
2026-08-15 00:09:10 +02:00
|
|
|
if definition == null or not definition.fleet_eligible \
|
|
|
|
|
or not vessel_is_unlocked(product):
|
2026-08-11 19:43:55 +02:00
|
|
|
return false
|
2026-08-12 22:47:46 +02:00
|
|
|
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
|
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
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 22:47:46 +02:00
|
|
|
func _advance_production(delta: float) -> void:
|
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
|
|
|
for b in buildings:
|
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
|
|
|
if not can_produce(b.type):
|
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
|
|
|
continue
|
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
|
|
|
var orders: Array = b.get("orders", [])
|
2026-08-12 22:47:46 +02:00
|
|
|
if orders.is_empty():
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# ONE at a time, in order. A queue the player can watch and cancel
|
|
|
|
|
# has to have a thing being built and things waiting behind it -
|
|
|
|
|
# clearing the whole queue the instant the ore lands (which is what
|
|
|
|
|
# this did before #23) leaves nothing to see or cancel.
|
|
|
|
|
var order: Dictionary = orders[0]
|
|
|
|
|
var product: String = String(order.product)
|
2026-08-15 04:45:15 +02:00
|
|
|
var costs: Dictionary = product_costs(product)
|
2026-08-14 20:32:58 +02:00
|
|
|
var inventory: RefCounted = _inventory_for_record(b)
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource = vessel_definition(product)
|
2026-08-12 22:47:46 +02:00
|
|
|
|
|
|
|
|
if not bool(order.started):
|
2026-08-15 04:45:15 +02:00
|
|
|
var affordable := true
|
|
|
|
|
for resource_type in costs:
|
|
|
|
|
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
|
|
|
|
|
affordable = false
|
|
|
|
|
break
|
|
|
|
|
if not affordable:
|
2026-08-12 22:47:46 +02:00
|
|
|
continue
|
2026-08-15 04:45:15 +02:00
|
|
|
for resource_type in costs:
|
|
|
|
|
inventory.withdraw(String(resource_type), int(costs[resource_type]))
|
2026-08-12 22:47:46 +02:00
|
|
|
order.started = true
|
|
|
|
|
|
|
|
|
|
order.elapsed = float(order.elapsed) + delta
|
|
|
|
|
if float(order.elapsed) < build_time(product):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
orders.pop_front()
|
2026-08-15 00:09:10 +02:00
|
|
|
# A finished combat hull launches from the crafter and physically flies
|
|
|
|
|
# to the mothership hangar. Hub transporters are provisioned by hubs and
|
|
|
|
|
# never enter this production path.
|
|
|
|
|
var mothership: Variant = get_mothership_position()
|
|
|
|
|
if mothership == null:
|
|
|
|
|
stored_vessels.append(product)
|
2026-08-12 22:47:46 +02:00
|
|
|
else:
|
2026-08-15 00:09:10 +02:00
|
|
|
vessel_deliveries.append({"type": product, "from": b.position,
|
|
|
|
|
"to": mothership, "progress": 0.0})
|
2026-08-12 22:47:46 +02:00
|
|
|
order_completed.emit(int(b.id), product)
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 14:13:46 +02:00
|
|
|
# --- crewing an outpost from the mothership (#104) --------------------------
|
|
|
|
|
#
|
|
|
|
|
# "the game should transfer, whenever available, ships from the mothership to
|
|
|
|
|
# the outpost to always fullfill the outpost conditions if possible."
|
|
|
|
|
#
|
|
|
|
|
# Vessels come from the HANGAR - stored_vessels - and never from the fleet.
|
|
|
|
|
# The fleet is what the player is flying; taking a vessel out of it to crew a
|
|
|
|
|
# building would be the game undoing the player's own arrangement. "Whenever
|
|
|
|
|
# available" therefore means "when one is sitting at the mothership", and an
|
|
|
|
|
# outpost asking for a hull nobody has built simply waits.
|
|
|
|
|
#
|
|
|
|
|
# They travel rather than teleporting, down the same vessel_deliveries path a
|
|
|
|
|
# finished crafter order already uses to reach the mothership.
|
|
|
|
|
|
|
|
|
|
func _garrison_still_wants(id: int, vessel_type: String) -> bool:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty() or not is_outpost(b):
|
|
|
|
|
return false
|
|
|
|
|
return garrison_crew_of(id, vessel_type) < garrison_desired_of(id, vessel_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _take_stored_vessel(vessel_type: String) -> bool:
|
|
|
|
|
var index: int = stored_vessels.find(vessel_type)
|
|
|
|
|
if index < 0:
|
|
|
|
|
return false
|
|
|
|
|
stored_vessels.remove_at(index)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _advance_garrison_dispatch() -> void:
|
|
|
|
|
var mothership: Variant = get_mothership_position()
|
|
|
|
|
if mothership == null:
|
|
|
|
|
return
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if not is_outpost(b):
|
|
|
|
|
continue
|
|
|
|
|
var id: int = int(b.id)
|
|
|
|
|
# Over strength first: an order the player has just lowered should
|
|
|
|
|
# release the vessel back to the hangar rather than stranding it.
|
|
|
|
|
_return_surplus_garrison(b)
|
|
|
|
|
for vessel_type in garrison_shortfall(id):
|
|
|
|
|
var needed: int = int(garrison_shortfall(id)[vessel_type])
|
|
|
|
|
for i in range(needed):
|
|
|
|
|
if not _take_stored_vessel(String(vessel_type)):
|
|
|
|
|
break
|
|
|
|
|
vessel_deliveries.append({
|
|
|
|
|
"type": String(vessel_type), "from": mothership,
|
|
|
|
|
"to": b.position, "progress": 0.0, "to_outpost": id,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _return_surplus_garrison(b: Dictionary) -> void:
|
|
|
|
|
var id: int = int(b.id)
|
|
|
|
|
var mothership: Variant = get_mothership_position()
|
|
|
|
|
if mothership == null:
|
|
|
|
|
return
|
|
|
|
|
var crew: Array = garrison_crew(id)
|
|
|
|
|
for i in range(crew.size() - 1, -1, -1):
|
|
|
|
|
var vessel_type: String = String(crew[i])
|
|
|
|
|
if garrison_crew_of(id, vessel_type) <= garrison_desired_of(id, vessel_type):
|
|
|
|
|
continue
|
|
|
|
|
crew.remove_at(i)
|
|
|
|
|
vessel_deliveries.append({
|
|
|
|
|
"type": vessel_type, "from": b.position, "to": mothership,
|
|
|
|
|
"progress": 0.0, "to_outpost": 0,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
# --- standing orders, and the census behind them (#106) --------------------
|
|
|
|
|
#
|
|
|
|
|
# "how many ships of a give type should always be available over the whole
|
|
|
|
|
# game (fleet + storage + outposts)".
|
|
|
|
|
#
|
|
|
|
|
# The targets are GLOBAL, not per building. There is one plan for the whole
|
|
|
|
|
# game; a second Planning Center is a second place to read and edit the same
|
|
|
|
|
# plan, not a competing one. Storing them per building would mean two
|
|
|
|
|
# centres could hold contradictory orders and the crafters would be told to
|
|
|
|
|
# satisfy both.
|
|
|
|
|
#
|
|
|
|
|
# The census is the harder half, and the rule is that every vessel is
|
|
|
|
|
# counted exactly once, wherever it happens to be at that instant - flying
|
|
|
|
|
# in the fleet, parked in the hangar, stationed at an outpost, or in the air
|
|
|
|
|
# between any two of those. Miss the in-flight ones and the planner queues a
|
|
|
|
|
# replacement for a vessel that is merely en route.
|
|
|
|
|
|
|
|
|
|
var vessel_targets: Dictionary = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func has_planning_center() -> bool:
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if building_has_capability(b, "planning"):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_target(vessel_type: String) -> int:
|
|
|
|
|
return int(vessel_targets.get(vessel_type, 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_vessel_target(vessel_type: String, count: int) -> int:
|
|
|
|
|
if not vessel_is_fleet_eligible(vessel_type):
|
|
|
|
|
return 0
|
|
|
|
|
var final_count: int = maxi(count, 0)
|
|
|
|
|
if final_count == 0:
|
|
|
|
|
vessel_targets.erase(vessel_type)
|
|
|
|
|
else:
|
|
|
|
|
vessel_targets[vessel_type] = final_count
|
|
|
|
|
return final_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func adjust_vessel_target(vessel_type: String, delta: int) -> int:
|
|
|
|
|
return set_vessel_target(vessel_type, vessel_target(vessel_type) + delta)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func planned_vessel_types() -> PackedStringArray:
|
|
|
|
|
var out: PackedStringArray = PackedStringArray()
|
|
|
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
|
|
|
if bool(definition.fleet_eligible):
|
|
|
|
|
out.append(String(definition.definition_id))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# How many of this type exist anywhere. Broken out per place rather than
|
|
|
|
|
# summed inline so the census can be read - and tested - one location at a
|
|
|
|
|
# time.
|
|
|
|
|
func vessel_census(vessel_type: String) -> int:
|
|
|
|
|
return vessel_count_in_fleet(vessel_type) \
|
|
|
|
|
+ vessel_count_in_hangar(vessel_type) \
|
|
|
|
|
+ vessel_count_in_garrisons(vessel_type) \
|
|
|
|
|
+ vessel_count_in_transit(vessel_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_count_in_fleet(vessel_type: String) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for flying in fleet_vessel_types:
|
|
|
|
|
if String(flying) == vessel_type:
|
|
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_count_in_hangar(vessel_type: String) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for stored in stored_vessels:
|
|
|
|
|
if String(stored) == vessel_type:
|
|
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_count_in_garrisons(vessel_type: String) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for id in outpost_ids():
|
|
|
|
|
n += garrison_crew_of(id, vessel_type)
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Both directions of flight: a finished hull on its way to the mothership,
|
|
|
|
|
# and a vessel being moved to or from an outpost. All of them are vessels
|
|
|
|
|
# that exist and will land somewhere.
|
|
|
|
|
func vessel_count_in_transit(vessel_type: String) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for delivery in vessel_deliveries:
|
|
|
|
|
if String(delivery.type) == vessel_type:
|
|
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Ordered but not yet built. Kept separate from the census on purpose: these
|
|
|
|
|
# do not exist yet, so a readout has to be able to say "4 of 6, 2 building"
|
|
|
|
|
# rather than claiming six.
|
|
|
|
|
func vessel_count_on_order(vessel_type: String) -> int:
|
|
|
|
|
var n: int = 0
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if not can_produce(String(b.type)):
|
|
|
|
|
continue
|
|
|
|
|
for order in b.get("orders", []):
|
|
|
|
|
if String(order.product) == vessel_type:
|
|
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func vessel_shortfall(vessel_type: String) -> int:
|
|
|
|
|
return maxi(vessel_target(vessel_type)
|
|
|
|
|
- vessel_census(vessel_type) - vessel_count_on_order(vessel_type), 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- the planner working the crafters (#107) -------------------------------
|
|
|
|
|
#
|
|
|
|
|
# "the planning center automatically manage the ship craft building queues to
|
|
|
|
|
# ensure that the needed vessels are produced to fullfill the constraints."
|
|
|
|
|
#
|
|
|
|
|
# Three rules keep this from fighting the player:
|
|
|
|
|
#
|
|
|
|
|
# It TOPS UP, never rewrites. Orders placed by hand are left exactly where
|
|
|
|
|
# they are; the planner only ever appends, and only for a type that is
|
|
|
|
|
# genuinely short. Cancelling one of the player's orders to make room for
|
|
|
|
|
# one of its own would be the game arguing with them.
|
|
|
|
|
#
|
|
|
|
|
# Everything counts. A vessel already queued, already flying to the
|
|
|
|
|
# mothership, or already stationed at an outpost is a vessel that exists or
|
|
|
|
|
# shortly will - see vessel_shortfall(). Without that it re-queues the same
|
|
|
|
|
# shortfall every tick and buries the player in hulls.
|
|
|
|
|
#
|
|
|
|
|
# Nothing happens without a planning centre. Destroy it and the standing
|
|
|
|
|
# orders stop being acted on, though they are remembered, so rebuilding
|
|
|
|
|
# picks up where it left off rather than starting from a blank plan.
|
|
|
|
|
|
|
|
|
|
func _crafter_for_planning() -> Dictionary:
|
|
|
|
|
# The shortest queue, so orders spread across the crafters that exist
|
|
|
|
|
# rather than piling onto whichever happens to be first in the list.
|
|
|
|
|
var best: Dictionary = {}
|
|
|
|
|
var best_queue: int = 0
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if not can_produce(String(b.type)):
|
|
|
|
|
continue
|
|
|
|
|
var queued: int = (b.get("orders", []) as Array).size()
|
|
|
|
|
if best.is_empty() or queued < best_queue:
|
|
|
|
|
best = b
|
|
|
|
|
best_queue = queued
|
|
|
|
|
return best
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _advance_planning() -> void:
|
|
|
|
|
if vessel_targets.is_empty() or not has_planning_center():
|
|
|
|
|
return
|
|
|
|
|
for vessel_type in vessel_targets:
|
|
|
|
|
var type_id: String = String(vessel_type)
|
|
|
|
|
if not vessel_is_unlocked(type_id):
|
|
|
|
|
continue
|
|
|
|
|
var missing: int = vessel_shortfall(type_id)
|
|
|
|
|
if missing <= 0:
|
|
|
|
|
continue
|
|
|
|
|
var crafter: Dictionary = _crafter_for_planning()
|
|
|
|
|
if crafter.is_empty():
|
|
|
|
|
return
|
|
|
|
|
# ONE per pass, not the whole shortfall. The queue is spread across
|
|
|
|
|
# crafters by _crafter_for_planning(), and asking for the whole
|
|
|
|
|
# shortfall at once would put it all on the shortest queue before
|
|
|
|
|
# that had a chance to change.
|
|
|
|
|
queue_order(int(crafter.id), type_id)
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 21:58:58 +02:00
|
|
|
# --- the spacial workshop and its repair drones (#116) ---------------------
|
|
|
|
|
#
|
|
|
|
|
# Modelled on the transport hub, deliberately and closely: drones belong to a
|
|
|
|
|
# workshop rather than to the player, are provisioned when it is built, are
|
|
|
|
|
# never a production order, and can be upgraded at a ship lab like any other
|
|
|
|
|
# vessel. Anything that already knows how hub transporters behave knows how
|
|
|
|
|
# these do.
|
|
|
|
|
#
|
|
|
|
|
# What a repair COSTS is the one new idea: a share of the building's own
|
|
|
|
|
# price, in proportion to how much of its health is gone. Levelling a crafter
|
|
|
|
|
# and rebuilding it costs its full price; patching one up after a wave costs
|
|
|
|
|
# a fraction, and the fraction is the damage.
|
|
|
|
|
|
|
|
|
|
const BASE_WORKSHOP_DRONES: int = 2
|
|
|
|
|
const REPAIR_DRONE: String = "repair_drone"
|
|
|
|
|
# What a drone puts back per trip, before upgrades.
|
|
|
|
|
const BASE_REPAIR_AMOUNT: int = 25
|
|
|
|
|
const DRONE_IDLE: String = "idle"
|
|
|
|
|
const DRONE_OUTBOUND: String = "outbound"
|
|
|
|
|
const DRONE_RETURNING: String = "returning"
|
|
|
|
|
|
|
|
|
|
var repair_drones: Array = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func workshop_drone_capacity() -> int:
|
|
|
|
|
var n: int = BASE_WORKSHOP_DRONES
|
|
|
|
|
for id in unlocked:
|
|
|
|
|
n += int(skill_node(id).get("workshop_bonus", 0))
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func has_workshop() -> bool:
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if building_has_capability(b, "repair"):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func workshop_ids() -> Array:
|
|
|
|
|
var out: Array = []
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if building_has_capability(b, "repair"):
|
|
|
|
|
out.append(int(b.id))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# The ore a full repair of this building would take: its own price, scaled by
|
|
|
|
|
# the share of its health that is missing. A structure at full health costs
|
|
|
|
|
# nothing, one levelled to a sliver costs nearly what it did to build.
|
|
|
|
|
func repair_cost(id: int) -> int:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return 0
|
|
|
|
|
var definition: Resource = building_definition(String(b.type))
|
|
|
|
|
if definition == null:
|
|
|
|
|
return 0
|
|
|
|
|
var missing: float = 1.0 - building_health_fraction(id)
|
|
|
|
|
return int(ceil(float(definition.cost) * missing))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func repair_amount() -> int:
|
|
|
|
|
return int(vessel_stat(REPAIR_DRONE, "repair_amount"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# What one drone-load of repair costs, for a building that needs one.
|
|
|
|
|
func repair_trip_cost(id: int) -> int:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty():
|
|
|
|
|
return 0
|
|
|
|
|
var definition: Resource = building_definition(String(b.type))
|
|
|
|
|
if definition == null:
|
|
|
|
|
return 0
|
|
|
|
|
var per_point: float = float(definition.cost) / float(maxi(building_max_hp(String(b.type)), 1))
|
|
|
|
|
var putting_back: int = mini(repair_amount(),
|
|
|
|
|
building_max_hp(String(b.type)) - building_hp(id))
|
|
|
|
|
return maxi(int(ceil(per_point * float(putting_back))), 1) if putting_back > 0 else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _damaged_buildings() -> Array:
|
|
|
|
|
var out: Array = []
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if building_health_fraction(int(b.id)) < 0.999:
|
|
|
|
|
out.append(b)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# #116: "Required resources are sent to the spacial workshop". The workshop
|
|
|
|
|
# asks hub logistics for the ore its outstanding repairs will need, exactly
|
|
|
|
|
# as a gas burner asks for gas - see building_logistics_deficit.
|
|
|
|
|
func workshop_ore_demand(id: int) -> int:
|
|
|
|
|
var b: Dictionary = find_building(id)
|
|
|
|
|
if b.is_empty() or not building_has_capability(b, "repair"):
|
|
|
|
|
return 0
|
|
|
|
|
var needed: int = 0
|
|
|
|
|
for damaged in _damaged_buildings():
|
|
|
|
|
needed += repair_cost(int(damaged.id))
|
|
|
|
|
return mini(needed, WORKSHOP_ORE_BUFFER)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# A ceiling, so one badly-mauled base does not pull the entire bank into a
|
|
|
|
|
# single workshop and starve everything else.
|
|
|
|
|
const WORKSHOP_ORE_BUFFER: int = 120
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _provision_workshop_drones(workshop: Dictionary) -> void:
|
|
|
|
|
var stationed: int = 0
|
|
|
|
|
for drone in repair_drones:
|
|
|
|
|
if int(drone.get("workshop_id", 0)) == int(workshop.id):
|
|
|
|
|
stationed += 1
|
|
|
|
|
while stationed < workshop_drone_capacity():
|
|
|
|
|
repair_drones.append({
|
|
|
|
|
"workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0,
|
|
|
|
|
"from": workshop.position, "to": workshop.position, "progress": 1.0,
|
|
|
|
|
"carrying": 0,
|
|
|
|
|
})
|
|
|
|
|
stationed += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _sync_workshop_drones() -> void:
|
|
|
|
|
for workshop in buildings:
|
|
|
|
|
if building_has_capability(workshop, "repair"):
|
|
|
|
|
_provision_workshop_drones(workshop)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func repair_drone_position(drone: Dictionary) -> Vector2:
|
|
|
|
|
return (drone.from as Vector2).lerp(drone.to as Vector2,
|
|
|
|
|
clampf(float(drone.progress), 0.0, 1.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _drone_inbound_to(building_id: int) -> bool:
|
|
|
|
|
for drone in repair_drones:
|
|
|
|
|
if String(drone.state) == DRONE_OUTBOUND and int(drone.target_id) == building_id:
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _advance_repairs(delta: float) -> void:
|
|
|
|
|
if repair_drones.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var speed: float = vessel_stat(REPAIR_DRONE, "transport_speed")
|
|
|
|
|
for drone in repair_drones:
|
|
|
|
|
var workshop: Dictionary = find_building(int(drone.workshop_id))
|
|
|
|
|
if workshop.is_empty():
|
|
|
|
|
continue
|
|
|
|
|
if String(drone.state) == DRONE_IDLE:
|
|
|
|
|
_dispatch_drone(drone, workshop)
|
|
|
|
|
continue
|
|
|
|
|
var distance: float = (drone.from as Vector2).distance_to(drone.to as Vector2)
|
|
|
|
|
drone.progress = float(drone.progress) + delta / maxf(distance / maxf(speed, 1.0), 0.001)
|
|
|
|
|
if float(drone.progress) < 1.0:
|
|
|
|
|
continue
|
|
|
|
|
drone.progress = 1.0
|
|
|
|
|
if String(drone.state) == DRONE_OUTBOUND:
|
|
|
|
|
# The ore was taken when it left, so a building demolished in
|
|
|
|
|
# transit does not silently consume it - it goes home with the
|
|
|
|
|
# load and the deposit below puts it back.
|
|
|
|
|
var target: Dictionary = find_building(int(drone.target_id))
|
|
|
|
|
if not target.is_empty():
|
|
|
|
|
repair_building(int(drone.target_id), repair_amount())
|
|
|
|
|
else:
|
|
|
|
|
deposit_resource_to_building(int(drone.workshop_id), RESOURCE_ORE,
|
|
|
|
|
int(drone.carrying))
|
|
|
|
|
drone.carrying = 0
|
|
|
|
|
drone.target_id = 0
|
|
|
|
|
_send_drone(drone, workshop.position, DRONE_RETURNING)
|
|
|
|
|
else:
|
|
|
|
|
drone.state = DRONE_IDLE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _dispatch_drone(drone: Dictionary, workshop: Dictionary) -> void:
|
|
|
|
|
var inventory: RefCounted = building_inventory(int(workshop.id))
|
|
|
|
|
if inventory == null:
|
|
|
|
|
return
|
|
|
|
|
var best: Dictionary = {}
|
|
|
|
|
var best_health: float = 1.0
|
|
|
|
|
for damaged in _damaged_buildings():
|
|
|
|
|
var id: int = int(damaged.id)
|
|
|
|
|
# One drone per building at a time: two arriving at the same wall
|
|
|
|
|
# would spend twice the ore to put back one drone-load of health.
|
|
|
|
|
if _drone_inbound_to(id):
|
|
|
|
|
continue
|
|
|
|
|
var cost: int = repair_trip_cost(id)
|
|
|
|
|
if cost <= 0 or inventory.amount(RESOURCE_ORE) < cost:
|
|
|
|
|
continue
|
|
|
|
|
var health: float = building_health_fraction(id)
|
|
|
|
|
if best.is_empty() or health < best_health:
|
|
|
|
|
best = damaged
|
|
|
|
|
best_health = health
|
|
|
|
|
if best.is_empty():
|
|
|
|
|
return
|
|
|
|
|
# Worst first: the structure closest to coming down is the one a repair
|
|
|
|
|
# is worth most on.
|
|
|
|
|
var spend: int = repair_trip_cost(int(best.id))
|
|
|
|
|
inventory.withdraw(RESOURCE_ORE, spend)
|
|
|
|
|
drone.carrying = spend
|
|
|
|
|
drone.target_id = int(best.id)
|
|
|
|
|
drone.from = workshop.position
|
|
|
|
|
_send_drone(drone, best.position, DRONE_OUTBOUND)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _send_drone(drone: Dictionary, to: Vector2, state: String) -> void:
|
|
|
|
|
drone.from = repair_drone_position(drone)
|
|
|
|
|
drone.to = to
|
|
|
|
|
drone.progress = 0.0
|
|
|
|
|
drone.state = state
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 22:04:21 +02:00
|
|
|
func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
|
|
|
|
|
var lab: Dictionary = find_building(lab_id)
|
2026-08-14 21:44:28 +02:00
|
|
|
if not building_has_capability(lab, "vessel_research") or upgrade_id == "":
|
2026-08-13 22:04:21 +02:00
|
|
|
return false
|
|
|
|
|
var orders: Array = lab.get("research_orders", [])
|
2026-08-14 22:12:57 +02:00
|
|
|
var unlock_definition: Resource = vessel_for_unlock(upgrade_id)
|
|
|
|
|
if not orders.is_empty() or (unlock_definition == null and upgrade_maxed(upgrade_id)):
|
2026-08-13 22:04:21 +02:00
|
|
|
return false
|
|
|
|
|
orders.append({"id": upgrade_id, "elapsed": 0.0, "started": false})
|
|
|
|
|
lab.research_orders = orders
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func lab_research_order(lab_id: int) -> Dictionary:
|
|
|
|
|
var lab: Dictionary = find_building(lab_id)
|
|
|
|
|
var orders: Array = lab.get("research_orders", [])
|
|
|
|
|
return {} if orders.is_empty() else orders[0]
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:46:25 +02:00
|
|
|
func research_cost(id: String) -> int:
|
2026-08-15 04:45:15 +02:00
|
|
|
return int(research_costs(id).get(RESOURCE_ORE, 0))
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 20:14:29 +02:00
|
|
|
func research_point_cost(id: String) -> int:
|
|
|
|
|
var unlock_definition: Resource = vessel_for_unlock(id)
|
|
|
|
|
if unlock_definition != null:
|
|
|
|
|
return int(unlock_definition.research_point_cost)
|
|
|
|
|
var d: Dictionary = upgrade_def(id)
|
|
|
|
|
return int(d.resource.research_point_cost) if not d.is_empty() else 0
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 04:45:15 +02:00
|
|
|
func research_costs(id: String) -> Dictionary:
|
2026-08-14 22:46:25 +02:00
|
|
|
var unlock_definition: Resource = vessel_for_unlock(id)
|
2026-08-15 04:45:15 +02:00
|
|
|
if unlock_definition != null:
|
|
|
|
|
if not unlock_definition.unlock_resource_costs.is_empty():
|
|
|
|
|
return unlock_definition.unlock_resource_costs.duplicate()
|
|
|
|
|
return {RESOURCE_ORE: unlock_definition.unlock_cost} if unlock_definition.unlock_cost > 0 else {}
|
|
|
|
|
var cost: int = upgrade_cost(id)
|
|
|
|
|
return {RESOURCE_ORE: cost} if cost > 0 else {}
|
2026-08-14 22:46:25 +02:00
|
|
|
|
|
|
|
|
|
2026-08-13 22:04:21 +02:00
|
|
|
func _advance_lab_research(delta: float) -> void:
|
|
|
|
|
for lab in buildings:
|
2026-08-14 21:44:28 +02:00
|
|
|
if not building_has_capability(lab, "vessel_research"):
|
2026-08-13 22:04:21 +02:00
|
|
|
continue
|
|
|
|
|
var orders: Array = lab.get("research_orders", [])
|
|
|
|
|
if orders.is_empty():
|
|
|
|
|
continue
|
|
|
|
|
var order: Dictionary = orders[0]
|
|
|
|
|
var id: String = String(order.id)
|
2026-08-14 22:12:57 +02:00
|
|
|
var unlock_definition: Resource = vessel_for_unlock(id)
|
2026-08-15 04:45:15 +02:00
|
|
|
var costs: Dictionary = research_costs(id)
|
2026-08-14 20:32:58 +02:00
|
|
|
var inventory: RefCounted = _inventory_for_record(lab)
|
2026-08-13 22:04:21 +02:00
|
|
|
if not bool(order.started):
|
2026-08-15 04:45:15 +02:00
|
|
|
var affordable := true
|
|
|
|
|
for resource_type in costs:
|
|
|
|
|
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
|
|
|
|
|
affordable = false
|
|
|
|
|
break
|
2026-08-16 20:14:29 +02:00
|
|
|
# #63: research points are a fleet-wide currency, not delivered
|
|
|
|
|
# to the lab like ore/gas - checked and spent here too so a
|
|
|
|
|
# shortfall stalls the order the same way an undelivered
|
|
|
|
|
# resource does, rather than letting it start regardless.
|
|
|
|
|
var points_needed: int = research_point_cost(id)
|
|
|
|
|
if points_needed > research_points:
|
|
|
|
|
affordable = false
|
2026-08-15 04:45:15 +02:00
|
|
|
if not affordable:
|
2026-08-13 22:04:21 +02:00
|
|
|
continue
|
2026-08-15 04:45:15 +02:00
|
|
|
for resource_type in costs:
|
|
|
|
|
inventory.withdraw(String(resource_type), int(costs[resource_type]))
|
2026-08-16 20:14:29 +02:00
|
|
|
if points_needed > 0:
|
|
|
|
|
research_points -= points_needed
|
|
|
|
|
research_points_changed.emit(research_points)
|
2026-08-13 22:04:21 +02:00
|
|
|
order.started = true
|
|
|
|
|
order.elapsed = float(order.elapsed) + delta
|
|
|
|
|
if float(order.elapsed) < lab_research_time():
|
|
|
|
|
continue
|
2026-08-14 22:12:57 +02:00
|
|
|
if unlock_definition != null:
|
2026-08-13 22:04:21 +02:00
|
|
|
vessel_levels[id] = 1
|
|
|
|
|
else:
|
|
|
|
|
vessel_levels[id] = upgrade_level(id) + 1
|
|
|
|
|
vessel_upgraded.emit(id)
|
|
|
|
|
orders.pop_front()
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 22:47:46 +02:00
|
|
|
func claim_stored_vessel(index: int) -> String:
|
|
|
|
|
# Hands one finished vessel over to be crewed. Returns its type, or ""
|
|
|
|
|
# when there is nothing there - the caller decides whether the fleet
|
|
|
|
|
# actually has room.
|
|
|
|
|
if index < 0 or index >= stored_vessels.size():
|
|
|
|
|
return ""
|
|
|
|
|
return String(stored_vessels[index])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func take_stored_vessel(index: int) -> String:
|
|
|
|
|
var product: String = claim_stored_vessel(index)
|
|
|
|
|
if product == "":
|
|
|
|
|
return ""
|
|
|
|
|
stored_vessels.remove_at(index)
|
|
|
|
|
return product
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func has_mothership() -> bool:
|
|
|
|
|
return get_mothership_position() != null
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 21:44:28 +02:00
|
|
|
func has_building_of_type(type: String) -> bool:
|
|
|
|
|
for building in buildings:
|
|
|
|
|
if building.type == type:
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
func remove_buildings_of_type(type: String) -> int:
|
|
|
|
|
var kept: Array = []
|
2026-08-16 21:38:07 +02:00
|
|
|
var removed_hub_ids: Array = []
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
var removed: int = 0
|
|
|
|
|
for b in buildings:
|
|
|
|
|
if b.type == type:
|
|
|
|
|
removed += 1
|
2026-08-16 21:38:07 +02:00
|
|
|
if building_has_capability(b, "transport_hub"):
|
|
|
|
|
removed_hub_ids.append(int(b.id))
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
else:
|
|
|
|
|
kept.append(b)
|
|
|
|
|
buildings = kept
|
2026-08-16 21:38:07 +02:00
|
|
|
if not removed_hub_ids.is_empty():
|
2026-08-15 00:09:10 +02:00
|
|
|
var kept_transporters: Array = []
|
|
|
|
|
for transporter in transporters:
|
2026-08-16 21:38:07 +02:00
|
|
|
if int(transporter.get("hub_id", 0)) not in removed_hub_ids:
|
2026-08-15 00:09:10 +02:00
|
|
|
kept_transporters.append(transporter)
|
|
|
|
|
transporters = kept_transporters
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
return removed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_mothership_position() -> Variant:
|
|
|
|
|
# Where a wiped-out fleet gets rebuilt. Returns null when no mothership
|
|
|
|
|
# has been built yet, so the caller can fall back to the world origin.
|
|
|
|
|
for b in buildings:
|
2026-08-14 21:44:28 +02:00
|
|
|
if building_has_capability(b, "bank"):
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
return b.position
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Fleet formation -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# Where each vessel sits relative to the fleet centre, editable at a command
|
|
|
|
|
# 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.
|
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
|
|
|
# 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.
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
# Ten slots, because max_fleet_size() caps itself on this array's length -
|
|
|
|
|
# so the wedge has to be long enough for every doctrine to be worth buying.
|
|
|
|
|
# The last pair continues the V rather than starting a new shape.
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
const DEFAULT_FORMATION: Array = [
|
|
|
|
|
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
|
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
|
|
|
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
|
Rework how a game starts: dev mode, a bare opening, and fleet growth
Adds a Dev Mode entry to the main menu and takes the play-testing
conveniences out of a normal game, which turn out to be the same change
from two directions.
DEV MODE. Banks 2000 of every resource, grants 500 research points, and
plants one of each planet type in the chunks around the spawn. Built ON TOP
of reset_new_game() rather than beside it, so a dev game is a normal game
plus a stock rather than a second definition of "fresh game" to keep in
sync. Offered only when running from Godot - OS.has_feature("editor"), the
tag an export clears - as is the map editor. Hidden AND guarded: pressing
either anyway is refused, because visibility is presentation and the rule is
the rule.
The menu now lays its stack out in code. Hiding two of four entries with
scene-authored offsets would leave holes in a shipped build - the same
failure the HUD columns had - so it measures the entries that actually exist
and centres on them.
A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both
reset_new_game and the autoload's own default, so the two cannot disagree),
and no gas giant guaranteed in the origin chunk. Both were play-testing
conveniences that had become part of the real world's shape: every player
got the same landmark in the same place. Play-testing wanted all four planet
types anyway, and a normal game wanted none of them - different needs, now
different mechanisms.
WorldGen cannot read GameState back (an autoload is not resolvable as a
global identifier that early, the hazard PlanetSetups.gd already works
around), so GameState.dev_mode's setter PUSHES the flag down. World
generation stays static and dependency-free, GameState stays the single
source of truth, and dev planting is applied after every rng draw so a dev
world and a normal one differ only in those four chunks.
FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND
how many ships you start with - which forced a new game to open with a full
fleet, leaving the first ship crafter nothing to do. Split into
BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening
has two empty slots to fill. A third copy of the old value turned up in
PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently
degraded into "however many slots exist" once max_fleet_size() clamped it.
The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron
(70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game
luxury but the ordinary way out of the opening, and pricing them as a luxury
made the early game feel stuck. Armada follows at 90/2. Two new tiers,
Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in
one lane would be a long monotonous chain for a branch that only ever does
one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size()
clamps to its length and a doctrine past that would charge real resources
for nothing; test_fleet_growth.gd asserts that invariant directly.
TEST FALLOUT, all of it from the deliberate change rather than a defect:
- Tests that silently inherited the old 5-ship default now ask for the fleet
they need. Several had to crew it from their first frame rather than
_initialize(), where the engine's deferred tree-entry finalization undoes
it - the same hazard those tests already documented for set_process(false).
- test_planet_landing_proximity and test_return_position_matches_planet take
the FIRST chunk holding a planet and land there. The guaranteed gas giant
had been masking that this can now be a black hole, which destroys ships on
contact. They exclude it, and clear the asteroids the field already placed.
- test_pivot_controls measured rotation with angle_difference, which wraps at
+/-pi, so a hold that turned a full circle read as no rotation. Latent
flakiness that only ever showed under load; it now reads the controller's
unwrapped pivot_hold_amount.
- test_research_points was failing on ore, not points: can_unlock() checks
the ore price too, and a fresh game banks nothing now.
- test_gas_system's "a reachable gas source near the spawn" premise moved to
dev mode, which is where that guarantee now lives.
Full suite green at 97/97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
|
|
|
Vector2(-120, 112), Vector2(120, 112),
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
var formation: Array = DEFAULT_FORMATION.duplicate()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func reset_formation() -> void:
|
|
|
|
|
formation = DEFAULT_FORMATION.duplicate()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func rotate_formation(radians: float) -> void:
|
|
|
|
|
# Pivots every slot about the fleet centre, keeping the shape intact
|
|
|
|
|
# and only changing which way it faces.
|
|
|
|
|
for i in range(formation.size()):
|
|
|
|
|
formation[i] = formation[i].rotated(radians)
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 16:38:41 +02:00
|
|
|
# --- Extractors ------------------------------------------------------------
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
2026-08-13 16:38:41 +02:00
|
|
|
# Extractors mine into their OWN local stock. They do not own a magical
|
|
|
|
|
# hauler: ore becomes spendable only when a hub-stationed transporter picks
|
|
|
|
|
# up a full load and physically delivers it to the mothership. Entries are
|
2026-08-14 22:16:40 +02:00
|
|
|
# {"position", "planet_id", "planet_name", "resource_type", "inventory", "mining_progress"}.
|
Recentre a released stick, slow extractors again, and unbreak the lab layout
Both sticks float - the base jumps to wherever the finger lands, so a player
does not have to hit an exact spot on a screen they cannot feel. Releasing put
only the KNOB back onto the base and left the base parked wherever that touch
had been, so an idle stick sat off-centre in a different place every time and
read as a control still in use. Release now returns base and knob to the
middle of the rect.
Extractors produce at 0.5 ore per second, halved again: at 1.0 they still
outran the rest of the economy. An extractor now pays back its 25-ore cost in
fifty seconds rather than twelve.
The ship lab and the crafter were both overlapping themselves. Enlarging the
shared resource table to 136px (so it could breathe and show bigger type)
pushed its bottom edge 34px into the vessel row beneath it in one and the
status text in the other - my regression, visible immediately on a real
screen and not at all in any test. The lab's second row of upgrade cards was
also already running into the detail text below it, by 13px, before any of
this. Both menus are re-spaced end to end rather than nudged, since moving one
collision into another is how the second one got there.
Two tests carried waits tuned to an extraction rate that has now changed
twice. Both derive them from EXTRACTOR_RATE instead, so the next balance pass
does not report a broken extractor that is working perfectly - the same fix
test_extractor_income already needed one round ago, in the file next to it.
Suite 112/112.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:31:46 +02:00
|
|
|
# Ore per second, per extractor.
|
|
|
|
|
#
|
|
|
|
|
# Halved once alongside the planet-density change in WorldGen (roughly 2.5x as
|
|
|
|
|
# many planets to build on, so holding the rate would have multiplied passive
|
|
|
|
|
# income rather than moving where it comes from), and halved again because it
|
|
|
|
|
# still outran the rest of the economy. At 0.5 an extractor pays back its own
|
|
|
|
|
# 25-ore cost in fifty seconds and yields 30 a minute after that, so a
|
|
|
|
|
# single lucky early site cannot carry a game and expanding is what grows one.
|
|
|
|
|
const EXTRACTOR_RATE: float = 0.5
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
var extractors: Array = []
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:16:40 +02:00
|
|
|
func has_extractor_at(planet_key: String) -> bool:
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
for e in extractors:
|
2026-08-14 22:16:40 +02:00
|
|
|
# Old saves have no planet_id; their name remains a migration fallback.
|
|
|
|
|
if String(e.get("planet_id", e.get("planet_name", ""))) == planet_key:
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:21:01 +02:00
|
|
|
func extractor_index_at(planet_key: String) -> int:
|
|
|
|
|
for i in range(extractors.size()):
|
|
|
|
|
var extractor: Dictionary = extractors[i]
|
|
|
|
|
if String(extractor.get("planet_id", extractor.get("planet_name", ""))) == planet_key:
|
|
|
|
|
return i
|
|
|
|
|
return -1
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 15:43:43 +02:00
|
|
|
signal extractor_added(planet_name: String)
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:16:40 +02:00
|
|
|
func add_extractor(planet_name: String, at_position: Vector2, resource_type: String = "ore",
|
|
|
|
|
planet_key: String = "") -> void:
|
|
|
|
|
var stable_id: String = planet_key if planet_key != "" else planet_name
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
extractors.append({
|
|
|
|
|
"position": at_position,
|
2026-08-14 22:16:40 +02:00
|
|
|
"planet_id": stable_id,
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
"planet_name": planet_name,
|
2026-08-13 21:07:35 +02:00
|
|
|
"resource_type": resource_type,
|
2026-08-14 20:32:58 +02:00
|
|
|
"inventory": INVENTORY.new(),
|
2026-08-13 16:38:41 +02:00
|
|
|
"mining_progress": 0.0,
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
})
|
2026-08-11 15:43:43 +02:00
|
|
|
# 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.
|
2026-08-14 22:16:40 +02:00
|
|
|
extractor_added.emit(stable_id)
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
2026-08-13 16:38:41 +02:00
|
|
|
func extractor_stock(index: int) -> int:
|
|
|
|
|
if index < 0 or index >= extractors.size():
|
|
|
|
|
return 0
|
2026-08-14 20:32:58 +02:00
|
|
|
var extractor: Dictionary = extractors[index]
|
|
|
|
|
return _extractor_inventory(extractor).amount(String(extractor.get("resource_type", RESOURCE_ORE)))
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:21:01 +02:00
|
|
|
func extractor_resource(index: int, resource_type: String) -> int:
|
|
|
|
|
if index < 0 or index >= extractors.size():
|
|
|
|
|
return 0
|
|
|
|
|
return _extractor_inventory(extractors[index]).amount(resource_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func transfer_resource_to_extractor(index: int, resource_type: String, amount: int) -> int:
|
|
|
|
|
if index < 0 or index >= extractors.size():
|
|
|
|
|
return 0
|
|
|
|
|
return fleet_inventory.transfer_to(_extractor_inventory(extractors[index]), resource_type, amount)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func transfer_resource_from_extractor(index: int, resource_type: String, amount: int) -> int:
|
|
|
|
|
if index < 0 or index >= extractors.size():
|
|
|
|
|
return 0
|
|
|
|
|
return _extractor_inventory(extractors[index]).transfer_to(fleet_inventory, resource_type, amount)
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 20:32:58 +02:00
|
|
|
func _extractor_inventory(extractor: Dictionary) -> RefCounted:
|
|
|
|
|
var inventory: RefCounted = extractor.get("inventory") as RefCounted
|
|
|
|
|
if inventory == null:
|
|
|
|
|
inventory = INVENTORY.new()
|
|
|
|
|
extractor.inventory = inventory
|
|
|
|
|
# Old extractor state had one stock field whose material was described by
|
|
|
|
|
# resource_type. Absorb it into that key on first access.
|
|
|
|
|
if extractor.has("stock"):
|
|
|
|
|
inventory.set_amount(String(extractor.get("resource_type", RESOURCE_ORE)), int(extractor.stock))
|
|
|
|
|
extractor.erase("stock")
|
|
|
|
|
return inventory
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
2026-08-16 21:38:07 +02:00
|
|
|
# --- building logistics and resource conversion ---------------------------
|
|
|
|
|
|
|
|
|
|
func building_logistics_deficit(id: int, resource_type: String) -> int:
|
|
|
|
|
var building: Dictionary = find_building(id)
|
Garrison laser vessels and gas bombers, on a shared ability seam
#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.
The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.
Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.
Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.
Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.
Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
|
|
|
var target: int = 0
|
2026-08-16 21:38:07 +02:00
|
|
|
var profile: Resource = building_logistics_profile(building)
|
Garrison laser vessels and gas bombers, on a shared ability seam
#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.
The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.
Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.
Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.
Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.
Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
|
|
|
if profile != null:
|
|
|
|
|
target = int(profile.input_buffers.get(resource_type, 0))
|
|
|
|
|
# An outpost has no logistics profile at all - what it wants held depends
|
|
|
|
|
# on who is stationed there, which is not something a definition can
|
|
|
|
|
# state. Taking the larger of the two keeps the static case untouched.
|
|
|
|
|
target = maxi(target, garrison_magazine_capacity(id, resource_type))
|
2026-08-23 21:58:58 +02:00
|
|
|
# #116: and a workshop asks for the ore its outstanding repairs will
|
|
|
|
|
# take, which likewise depends on the world rather than on a definition.
|
|
|
|
|
if resource_type == RESOURCE_ORE:
|
|
|
|
|
target = maxi(target, workshop_ore_demand(id))
|
2026-08-16 21:38:07 +02:00
|
|
|
return maxi(0, target - building_resource(id, resource_type))
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
|
|
|
|
|
|
2026-08-16 21:38:07 +02:00
|
|
|
func building_requested_resource(id: int, resource_type: String) -> int:
|
|
|
|
|
return maxi(outstanding_resource_cost(id, resource_type),
|
|
|
|
|
building_logistics_deficit(id, resource_type))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String:
|
|
|
|
|
var profile: Resource = building_logistics_profile(building)
|
|
|
|
|
if profile == null:
|
|
|
|
|
return ""
|
|
|
|
|
for resource_type in profile.collected_outputs:
|
|
|
|
|
if building_resource(int(building.id), resource_type) >= minimum_amount:
|
|
|
|
|
return resource_type
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
Implement #77, #78 and #79
#77 - the Distorder becomes the Energy Propulser. Renamed everywhere the
player reads it, reskinned to match: a symmetric hull flaring into an engine
skirt instead of the lopsided warped lump, an engine bell with a thrust plume
instead of the wobbling ring glyph, and energy's own yellow instead of purple,
since energy is what it burns. The internal id stays "distorder" - it is the
capability string, the unlock key and the component scene name, and renaming
the wiring would be a large mechanical change with no player-visible effect.
Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used
to add a flat 1.0, so a fleet that could afford six simply drove at six times
cruise and the vessel stopped being a choice. Base-2 makes the rule one
sentence - every doubling adds another 1.0 - and leaves two propulsers worth
exactly what they were worth before, so nothing already built is devalued.
#78 - a gas burner can be paused. It converts gas the player may want for
bombs into energy they may not need yet, and the only way to stop it was to
demolish it. The action is data-driven like every other: the visual's
ActionProvider lists "pause", and any converter can adopt it. Pause is the
burner's PRIMARY action, so it is one tap rather than a menu; the button shows
what it will DO, bars while running and a play triangle while stopped; and the
building itself goes visibly cold, because a paused building that looked
identical to a running one would be a setting to remember rather than see.
Conversion progress is left where it was rather than reset, so switching off
and on again does not discard a part-finished unit.
#79 - picking something up says so. A "+N" with the resource's own mark rises
and fades where it happened. Built from the SAME glyph the resource tables and
transfer rows use, so a material added to the catalog later cannot end up with
one mark in the HUD and a different one in the world. A pickup a full hold
REFUSES raises nothing.
Two Godot traps cost real time here and are now written down where the next
person will hit them:
- a `#` comment inside a .tres [resource] block is not a comment. It parses
as part of the preceding value and silently blanks the property after it.
One in DistorderVesselDefinition.tres left definition_id empty, which
surfaced as "<blank> did not resolve its definition" in a test far from
the edit. The note lives in VesselDefinition.gd now.
- a global class_name is not reliably resolvable, INCLUDING a script's own
inside its own static function. ResourcePickupVFX.new() failed to compile
when reached through a preview scene, taking Rock and the gas vents with
it. It has no class_name now and constructs itself by path.
Suite 114/114.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
|
|
|
# #78: a converter can be switched off. A gas burner turns gas the player may
|
|
|
|
|
# want for bombs into energy they may not need yet, and until now it did that
|
|
|
|
|
# relentlessly - the only way to stop it was to demolish it. Paused is stored
|
|
|
|
|
# on the building record like every other piece of its state.
|
|
|
|
|
func is_building_paused(id: int) -> bool:
|
|
|
|
|
return bool(find_building(id).get("paused", false))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_building_paused(id: int, paused: bool) -> void:
|
|
|
|
|
var building: Dictionary = find_building(id)
|
|
|
|
|
if building.is_empty():
|
|
|
|
|
return
|
|
|
|
|
building.paused = paused
|
|
|
|
|
building_paused_changed.emit(id, paused)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func toggle_building_paused(id: int) -> bool:
|
|
|
|
|
var paused: bool = not is_building_paused(id)
|
|
|
|
|
set_building_paused(id, paused)
|
|
|
|
|
return paused
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
signal building_paused_changed(id: int, paused: bool)
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 21:38:07 +02:00
|
|
|
func _advance_resource_converters(delta: float) -> void:
|
|
|
|
|
for building in buildings:
|
|
|
|
|
var profile: Resource = building_logistics_profile(building)
|
|
|
|
|
if profile == null or profile.conversion_input == "" \
|
|
|
|
|
or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \
|
|
|
|
|
or profile.conversion_yield <= 0:
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
continue
|
Implement #77, #78 and #79
#77 - the Distorder becomes the Energy Propulser. Renamed everywhere the
player reads it, reskinned to match: a symmetric hull flaring into an engine
skirt instead of the lopsided warped lump, an engine bell with a thrust plume
instead of the wobbling ring glyph, and energy's own yellow instead of purple,
since energy is what it burns. The internal id stays "distorder" - it is the
capability string, the unlock key and the component scene name, and renaming
the wiring would be a large mechanical change with no player-visible effect.
Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used
to add a flat 1.0, so a fleet that could afford six simply drove at six times
cruise and the vessel stopped being a choice. Base-2 makes the rule one
sentence - every doubling adds another 1.0 - and leaves two propulsers worth
exactly what they were worth before, so nothing already built is devalued.
#78 - a gas burner can be paused. It converts gas the player may want for
bombs into energy they may not need yet, and the only way to stop it was to
demolish it. The action is data-driven like every other: the visual's
ActionProvider lists "pause", and any converter can adopt it. Pause is the
burner's PRIMARY action, so it is one tap rather than a menu; the button shows
what it will DO, bars while running and a play triangle while stopped; and the
building itself goes visibly cold, because a paused building that looked
identical to a running one would be a setting to remember rather than see.
Conversion progress is left where it was rather than reset, so switching off
and on again does not discard a part-finished unit.
#79 - picking something up says so. A "+N" with the resource's own mark rises
and fades where it happened. Built from the SAME glyph the resource tables and
transfer rows use, so a material added to the catalog later cannot end up with
one mark in the HUD and a different one in the world. A pickup a full hold
REFUSES raises nothing.
Two Godot traps cost real time here and are now written down where the next
person will hit them:
- a `#` comment inside a .tres [resource] block is not a comment. It parses
as part of the preceding value and silently blanks the property after it.
One in DistorderVesselDefinition.tres left definition_id empty, which
surfaced as "<blank> did not resolve its definition" in a test far from
the edit. The note lives in VesselDefinition.gd now.
- a global class_name is not reliably resolvable, INCLUDING a script's own
inside its own static function. ResourcePickupVFX.new() failed to compile
when reached through a preview scene, taking Rock and the gas vents with
it. It has no class_name now and constructs itself by path.
Suite 114/114.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
|
|
|
if bool(building.get("paused", false)):
|
|
|
|
|
# Progress is left exactly where it was rather than reset, so
|
|
|
|
|
# switching a burner off and on again does not throw away the
|
|
|
|
|
# fraction of a unit it had already worked through.
|
|
|
|
|
continue
|
2026-08-16 21:38:07 +02:00
|
|
|
var inventory: RefCounted = _inventory_for_record(building)
|
|
|
|
|
if inventory.amount(profile.conversion_input) <= 0:
|
|
|
|
|
continue
|
|
|
|
|
var progress: float = float(building.get("conversion_progress", 0.0)) \
|
|
|
|
|
+ delta * profile.conversion_rate
|
|
|
|
|
var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input))
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
if whole > 0:
|
2026-08-16 21:38:07 +02:00
|
|
|
inventory.withdraw(profile.conversion_input, whole)
|
|
|
|
|
inventory.deposit(profile.conversion_output, whole * profile.conversion_yield)
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
progress -= whole
|
2026-08-16 21:38:07 +02:00
|
|
|
building.conversion_progress = progress
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
|
|
|
|
|
|
2026-08-16 19:21:54 +02:00
|
|
|
func _advance_distorder_drain(delta: float) -> void:
|
|
|
|
|
if fleet_speed_multiplier <= 1.0:
|
|
|
|
|
# Nothing owed at 1.0x - and no stale fractional progress should
|
|
|
|
|
# carry over into a LATER, unrelated boost.
|
|
|
|
|
_distorder_drain_progress = 0.0
|
|
|
|
|
return
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
# Normalized against the CURRENT ceiling, so "full boost" always costs
|
|
|
|
|
# ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it -
|
|
|
|
|
# a bigger fleet buys more speed, not cheaper speed.
|
|
|
|
|
var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \
|
2026-08-16 19:21:54 +02:00
|
|
|
* ENERGY_DRAIN_PER_SECOND
|
|
|
|
|
_distorder_drain_progress += rate * delta
|
|
|
|
|
var whole: int = int(floor(_distorder_drain_progress))
|
|
|
|
|
if whole <= 0:
|
|
|
|
|
return
|
|
|
|
|
_distorder_drain_progress -= whole
|
|
|
|
|
var available: int = fleet_inventory.amount(RESOURCE_ENERGY)
|
|
|
|
|
if available >= whole:
|
|
|
|
|
fleet_inventory.withdraw(RESOURCE_ENERGY, whole)
|
|
|
|
|
return
|
|
|
|
|
# Ran out mid-drain: take whatever is left (down to exactly zero) and
|
|
|
|
|
# force the boost back to its default, rather than let the fleet keep
|
|
|
|
|
# travelling fast on credit.
|
|
|
|
|
fleet_inventory.withdraw(RESOURCE_ENERGY, available)
|
|
|
|
|
fleet_speed_multiplier = 1.0
|
|
|
|
|
_distorder_drain_progress = 0.0
|
|
|
|
|
boost_depleted_notice = true
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 14:41:19 +02:00
|
|
|
# Planet conquest: a planet must have every one of its capture points held
|
|
|
|
|
# before an extractor can be built there (see BuildController's placement
|
|
|
|
|
# gates). Keyed by the same stable planet_id as `extractors`, entirely
|
|
|
|
|
# separate from it - a planet can be conquered long before anyone builds an
|
|
|
|
|
# extractor on it, or never get one at all.
|
|
|
|
|
var planet_capture: Dictionary = {}
|
|
|
|
|
|
|
|
|
|
signal planet_conquered(planet_id: String)
|
|
|
|
|
|
|
|
|
|
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
func register_capture_points(planet_key: String, total: int, definition_id: String = "",
|
|
|
|
|
reward_override: int = 0) -> void:
|
2026-08-15 14:41:19 +02:00
|
|
|
# Idempotent: landing again on a planet must not wipe points already
|
|
|
|
|
# captured on a previous visit.
|
|
|
|
|
if planet_capture.has(planet_key):
|
|
|
|
|
return
|
|
|
|
|
var captured: Array = []
|
|
|
|
|
captured.resize(total)
|
|
|
|
|
captured.fill(false)
|
2026-08-16 13:13:38 +02:00
|
|
|
# A planet demanding zero points is vacuously already held (#55: a
|
|
|
|
|
# black hole's "void" profile has no authored PlanetSetup at all, so
|
|
|
|
|
# this is the only registration it ever gets) - every planet WITH real
|
2026-08-16 20:14:29 +02:00
|
|
|
# points still starts unconquered, exactly as before. The planet definition
|
|
|
|
|
# supplies an optional conquest reward, avoiding planet-id special cases in
|
|
|
|
|
# the capture system.
|
|
|
|
|
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
|
|
|
|
|
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
# A map may be worth more than its planet type's flat rate - a harder
|
|
|
|
|
# lab should pay better, and only the authored layout knows how hard it
|
|
|
|
|
# is. Zero means the setup did not express an opinion.
|
|
|
|
|
if reward_override > 0:
|
|
|
|
|
point_reward = reward_override
|
2026-08-16 20:14:29 +02:00
|
|
|
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0,
|
|
|
|
|
"research_point_reward": point_reward, "research_awarded": false}
|
2026-08-15 14:41:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func capture_state(planet_key: String) -> Dictionary:
|
|
|
|
|
return planet_capture.get(planet_key, {"captured": [], "conquered": false})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_point_captured(planet_key: String, index: int) -> bool:
|
|
|
|
|
var captured: Array = capture_state(planet_key).get("captured", [])
|
|
|
|
|
if index < 0 or index >= captured.size():
|
|
|
|
|
return false
|
|
|
|
|
return bool(captured[index])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func mark_point_captured(planet_key: String, index: int) -> void:
|
|
|
|
|
if not planet_capture.has(planet_key):
|
|
|
|
|
return
|
|
|
|
|
var state: Dictionary = planet_capture[planet_key]
|
|
|
|
|
var captured: Array = state.captured
|
|
|
|
|
if index < 0 or index >= captured.size():
|
|
|
|
|
return
|
|
|
|
|
captured[index] = true
|
|
|
|
|
if state.conquered:
|
|
|
|
|
return
|
|
|
|
|
if captured.all(func(c): return bool(c)):
|
|
|
|
|
state.conquered = true
|
|
|
|
|
planet_conquered.emit(planet_key)
|
2026-08-16 20:14:29 +02:00
|
|
|
# #63: definition-authored research points, exactly once per planet.
|
|
|
|
|
# state.conquered flipping above already makes a second call to this
|
|
|
|
|
# whole function a no-op via the early return above it, but
|
|
|
|
|
# research_awarded is its own explicit guard on the award itself
|
|
|
|
|
# regardless of how conquest was reached.
|
|
|
|
|
var reward: int = int(state.get("research_point_reward", 0))
|
|
|
|
|
if reward > 0 and not bool(state.get("research_awarded", false)):
|
|
|
|
|
state.research_awarded = true
|
|
|
|
|
research_points += reward
|
|
|
|
|
research_points_changed.emit(research_points)
|
2026-08-15 14:41:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_planet_conquered(planet_key: String) -> bool:
|
|
|
|
|
return bool(capture_state(planet_key).get("conquered", false))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func captured_point_count(planet_key: String) -> int:
|
|
|
|
|
var captured: Array = capture_state(planet_key).get("captured", [])
|
|
|
|
|
var count: int = 0
|
|
|
|
|
for c in captured:
|
|
|
|
|
if c:
|
|
|
|
|
count += 1
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 21:22:10 +02:00
|
|
|
func total_capture_points(planet_key: String) -> int:
|
|
|
|
|
return capture_state(planet_key).get("captured", []).size()
|
|
|
|
|
|
|
|
|
|
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
# --- waves (#100) ----------------------------------------------------------
|
|
|
|
|
#
|
|
|
|
|
# #97 replaces the roaming spawner - one enemy every few seconds, forever -
|
|
|
|
|
# with waves: a long quiet, a warning, then an attack. The CLOCK lives here
|
|
|
|
|
# rather than in the space scene for the usual reason, that Main.tscn is
|
|
|
|
|
# destroyed on every landing; a wave must not be cancelled, nor restarted
|
|
|
|
|
# from zero, by a trip to a planet.
|
|
|
|
|
#
|
|
|
|
|
# What is deliberately NOT here is the spawning. GameState cannot see the
|
|
|
|
|
# world, so EnemySpawner reads the phase and puts the enemies in it. That
|
|
|
|
|
# also settles what happens if the wave turns while the player is on a
|
|
|
|
|
# surface: the phase goes to ACTIVE with nothing to spawn it, and the wave
|
|
|
|
|
# is waiting in space when they come back.
|
|
|
|
|
const WAVE_PHASE_PEACE: String = "peace"
|
|
|
|
|
const WAVE_PHASE_WARNING: String = "warning"
|
|
|
|
|
const WAVE_PHASE_ACTIVE: String = "active"
|
|
|
|
|
|
|
|
|
|
const WAVE_PEACE_SECONDS: float = 50.0
|
|
|
|
|
# How long the alert is up before anything arrives. Long enough to fly home
|
|
|
|
|
# and take a position, short enough that it is a warning and not a chore.
|
|
|
|
|
const WAVE_WARNING_SECONDS: float = 9.0
|
|
|
|
|
# A wave that wandered off and never died must not stall the next one for
|
|
|
|
|
# ever. The director normally ends the phase by clearing the wave.
|
|
|
|
|
const WAVE_MAX_SECONDS: float = 240.0
|
|
|
|
|
|
|
|
|
|
var wave_number: int = 0
|
|
|
|
|
var wave_phase: String = WAVE_PHASE_PEACE
|
|
|
|
|
var wave_timer: float = 0.0
|
|
|
|
|
# Set when a wave turns ACTIVE, cleared by whoever puts it in the world.
|
|
|
|
|
# Without it a director arriving mid-wave (the player just came back from a
|
|
|
|
|
# planet) could not tell "this wave still owes you enemies" from "this wave
|
|
|
|
|
# is already out there and half dead".
|
|
|
|
|
var wave_pending: bool = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func reset_waves() -> void:
|
|
|
|
|
wave_number = 0
|
|
|
|
|
wave_phase = WAVE_PHASE_PEACE
|
|
|
|
|
wave_timer = 0.0
|
|
|
|
|
wave_pending = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func wave_is_incoming() -> bool:
|
|
|
|
|
return wave_phase == WAVE_PHASE_WARNING
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Deliberately NOT a remaining-seconds accessor. #97: "No timer should be
|
|
|
|
|
# visible" - so the one number that could be rendered as a countdown is not
|
|
|
|
|
# offered to the UI at all, and the alert cannot drift into being a clock.
|
|
|
|
|
func wave_warning_pulse() -> float:
|
|
|
|
|
if wave_phase != WAVE_PHASE_WARNING:
|
|
|
|
|
return 0.0
|
|
|
|
|
return 0.5 + 0.5 * sin(wave_timer * 7.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Strength is a function of the FLEET, per #97 - a bigger fleet is a bigger
|
|
|
|
|
# target, so growing it raises the stakes rather than trivialising them.
|
|
|
|
|
# The wave number is a slower second term, so a player who never grows still
|
|
|
|
|
# meets escalation rather than the same two chasers all game.
|
|
|
|
|
func wave_strength(fleet_size: int) -> int:
|
|
|
|
|
# The flat term matters: without it the opening wave against a one-vessel
|
|
|
|
|
# fleet is a single chaser, which reads as the system not working rather
|
|
|
|
|
# than as an easy first wave.
|
|
|
|
|
return 3 + maxi(fleet_size, 1) * 3 + wave_number * 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Spent most-expensive-first, so a large budget buys heavier enemies rather
|
|
|
|
|
# than an ever-larger swarm of chasers. min_strength keeps the openings
|
|
|
|
|
# honest: the first waves are chasers whatever the arithmetic says.
|
|
|
|
|
const WAVE_ROSTER: Array = [
|
|
|
|
|
{"id": "artillery", "cost": 9, "min_strength": 26},
|
|
|
|
|
{"id": "sniper", "cost": 6, "min_strength": 16},
|
|
|
|
|
{"id": "lancer", "cost": 4, "min_strength": 12},
|
|
|
|
|
{"id": "chaser", "cost": 3, "min_strength": 0},
|
|
|
|
|
]
|
|
|
|
|
const WAVE_MAX_ENEMIES: int = 18
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func wave_composition(fleet_size: int) -> PackedStringArray:
|
|
|
|
|
var budget: int = wave_strength(fleet_size)
|
|
|
|
|
var strength: int = budget
|
|
|
|
|
var out: PackedStringArray = PackedStringArray()
|
|
|
|
|
for entry in WAVE_ROSTER:
|
|
|
|
|
if strength < int(entry.min_strength):
|
|
|
|
|
continue
|
|
|
|
|
var cost: int = int(entry.cost)
|
|
|
|
|
# The cheapest tier is last and takes whatever is left, so the budget
|
|
|
|
|
# is always spent rather than rounded away.
|
|
|
|
|
var share: int = budget if String(entry.id) == "chaser" else budget / 2
|
|
|
|
|
while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES:
|
|
|
|
|
out.append(String(entry.id))
|
|
|
|
|
budget -= cost
|
|
|
|
|
share -= cost
|
|
|
|
|
if out.is_empty():
|
|
|
|
|
out.append("chaser")
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func advance_waves(delta: float) -> void:
|
|
|
|
|
wave_timer += delta
|
|
|
|
|
match wave_phase:
|
|
|
|
|
WAVE_PHASE_PEACE:
|
|
|
|
|
if wave_timer >= WAVE_PEACE_SECONDS:
|
|
|
|
|
wave_phase = WAVE_PHASE_WARNING
|
|
|
|
|
wave_timer = 0.0
|
|
|
|
|
WAVE_PHASE_WARNING:
|
|
|
|
|
if wave_timer >= WAVE_WARNING_SECONDS:
|
|
|
|
|
wave_phase = WAVE_PHASE_ACTIVE
|
|
|
|
|
wave_timer = 0.0
|
|
|
|
|
wave_number += 1
|
|
|
|
|
wave_pending = true
|
|
|
|
|
WAVE_PHASE_ACTIVE:
|
|
|
|
|
if wave_timer >= WAVE_MAX_SECONDS:
|
|
|
|
|
wave_cleared()
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 21:58:58 +02:00
|
|
|
# #110: put a wave on the board now, for testing. Skips the peace AND the
|
|
|
|
|
# warning: the point of the button is to see the wave, and waiting out a
|
|
|
|
|
# nine-second alert every time would make it useless as a tool.
|
|
|
|
|
func force_wave_now() -> void:
|
|
|
|
|
wave_phase = WAVE_PHASE_ACTIVE
|
|
|
|
|
wave_timer = 0.0
|
|
|
|
|
wave_number += 1
|
|
|
|
|
wave_pending = true
|
|
|
|
|
|
|
|
|
|
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
func wave_cleared() -> void:
|
|
|
|
|
wave_phase = WAVE_PHASE_PEACE
|
|
|
|
|
wave_timer = 0.0
|
|
|
|
|
wave_pending = false
|
|
|
|
|
|
|
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
func _process(delta: float) -> void:
|
2026-08-13 16:38:41 +02:00
|
|
|
# Runs in the autoload, so mining and transport continue while the player
|
|
|
|
|
# is on a planet's surface or nowhere near either.
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
advance_waves(delta)
|
2026-08-12 22:47:46 +02:00
|
|
|
_advance_production(delta)
|
2026-08-13 22:04:21 +02:00
|
|
|
_advance_lab_research(delta)
|
2026-08-13 21:39:05 +02:00
|
|
|
_advance_vessel_deliveries(delta)
|
2026-08-23 14:13:46 +02:00
|
|
|
_advance_garrison_dispatch()
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
_advance_planning()
|
2026-08-23 21:58:58 +02:00
|
|
|
_advance_repairs(delta)
|
2026-08-16 21:38:07 +02:00
|
|
|
_advance_resource_converters(delta)
|
2026-08-16 19:21:54 +02:00
|
|
|
_advance_distorder_drain(delta)
|
#129 (#131, #132, #133): a quest chain, and a World Ship out of reach
Sixteen quests that walk a new game through the things it contains: ore,
the mothership, exploration, a yard and a second hull, planetfall and
conquest, extractors, gas, freight, power, the lab, a new vessel type, an
outpost, and a survey.
The chain turns on the second quest. The fleet builds the mothership to
call the World Ship and hears nothing - and the array is not at fault. The
breach put them further out than that signal will carry, so every quest
after it is a search that has to be flown rather than transmitted.
Three pieces:
Quests.gd the chain, as data and nothing else. Two rules it obeys:
nothing may ask for something unreachable - research points
come only from conquering a planet, so every doctrine that
costs them sits after the conquest quest - and a quest names
one goal, with the research it implies said in the brief
rather than split into an errand of its own.
GameState how far along the chain the player is, and the answer to each
goal kind. Progress latches for free: the chain is walked one
quest at a time, so collecting five ore and spending it cannot
un-complete the quest that asked for five - by then nothing is
asking. It lives here so a quest finished on a planet surface
is still finished after take-off.
the HUD QuestTracker, the standing objective, in the wave alert's
idiom; QuestDirector, which reads the lines out.
Lines are QUEUED by GameState and drained by the director rather than
emitted at a banner, because quests advance in the autoload, which runs on
a planet surface too - where a line emitted straight at a banner would be
lost. Both scenes now carry a banner, a tracker and a director, so a quest
finished underground is announced underground.
The chain is armed by reset_new_game(), the same gate the prologue uses -
the only door into a real game - so instantiating a scene is not by itself
a reason to start briefing somebody.
2026-08-28 14:41:03 +02:00
|
|
|
_advance_quests()
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
for e in extractors:
|
2026-08-13 16:38:41 +02:00
|
|
|
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
|
|
|
|
|
var whole: int = int(floor(float(e.mining_progress)))
|
|
|
|
|
if whole > 0:
|
2026-08-14 20:32:58 +02:00
|
|
|
_extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole)
|
2026-08-13 16:38:41 +02:00
|
|
|
e.mining_progress = float(e.mining_progress) - whole
|
|
|
|
|
_advance_transporters(delta)
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
2026-08-13 21:39:05 +02:00
|
|
|
func vessel_delivery_position(delivery: Dictionary) -> Vector2:
|
|
|
|
|
return (delivery.from as Vector2).lerp(delivery.to as Vector2,
|
|
|
|
|
clampf(float(delivery.progress), 0.0, 1.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _advance_vessel_deliveries(delta: float) -> void:
|
|
|
|
|
for i in range(vessel_deliveries.size() - 1, -1, -1):
|
|
|
|
|
var delivery: Dictionary = vessel_deliveries[i]
|
|
|
|
|
var distance: float = (delivery.from as Vector2).distance_to(delivery.to as Vector2)
|
2026-08-14 22:30:19 +02:00
|
|
|
var delivery_speed: float = vessel_stat(String(delivery.type), "delivery_speed")
|
|
|
|
|
if delivery_speed <= 0.0:
|
|
|
|
|
delivery_speed = VESSEL_DELIVERY_SPEED
|
|
|
|
|
delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001)
|
2026-08-23 14:13:46 +02:00
|
|
|
if float(delivery.progress) < 1.0:
|
|
|
|
|
continue
|
|
|
|
|
vessel_deliveries.remove_at(i)
|
|
|
|
|
# #104: a vessel bound for an outpost joins its crew. If the outpost
|
|
|
|
|
# was levelled while it was in the air, or the order was cancelled
|
|
|
|
|
# under it, it lands in the hangar instead - the vessel is never
|
|
|
|
|
# destroyed by its destination going away, the same rule a
|
|
|
|
|
# transporter mid-delivery follows with its cargo.
|
|
|
|
|
var outpost_id: int = int(delivery.get("to_outpost", 0))
|
|
|
|
|
if outpost_id != 0 and _garrison_still_wants(outpost_id, String(delivery.type)):
|
|
|
|
|
garrison_crew(outpost_id).append(String(delivery.type))
|
|
|
|
|
else:
|
2026-08-13 21:39:05 +02:00
|
|
|
stored_vessels.append(String(delivery.type))
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
# --- 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"}.
|
2026-08-15 00:09:10 +02:00
|
|
|
# How many transporters each hub owns. Raised for EVERY hub - built already
|
|
|
|
|
# or not - by research, and provisioned automatically rather than crafted.
|
2026-08-11 19:43:55 +02:00
|
|
|
const BASE_HUB_CAPACITY: int = 2
|
|
|
|
|
|
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
|
|
|
const TRANSPORTER_IDLE: String = "idle"
|
|
|
|
|
const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership"
|
|
|
|
|
const TRANSPORTER_TO_BUILDING: String = "to_building"
|
|
|
|
|
const TRANSPORTER_RETURNING: String = "returning"
|
2026-08-13 16:38:41 +02:00
|
|
|
const TRANSPORTER_TO_EXTRACTOR: String = "to_extractor"
|
|
|
|
|
const TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: String = "extractor_to_mothership"
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
# Collecting an output resource (energy) directly from a regular building's
|
|
|
|
|
# own inventory - a gas burner, not an extractor - and banking it. Reuses
|
|
|
|
|
# `target_id` exactly like the ordinary supply path does, rather than
|
|
|
|
|
# `extractor_index`: the source is a building, already addressable that way.
|
|
|
|
|
const TRANSPORTER_TO_BUILDING_PICKUP: String = "to_building_pickup"
|
|
|
|
|
const TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: String = "building_to_mothership"
|
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
|
|
|
|
|
|
|
|
var transporters: Array = []
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 19:43:55 +02:00
|
|
|
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:
|
2026-08-14 21:44:28 +02:00
|
|
|
if building_has_capability(b, "transport_hub"):
|
2026-08-11 19:43:55 +02:00
|
|
|
n += 1
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 00:09:10 +02:00
|
|
|
func _add_hub_transporter(hub: Dictionary) -> void:
|
|
|
|
|
transporters.append({
|
|
|
|
|
"hub_id": int(hub.id), "state": TRANSPORTER_IDLE, "target_id": 0,
|
|
|
|
|
"extractor_index": -1, "carrying": 0, "resource_type": RESOURCE_ORE,
|
|
|
|
|
"progress": 1.0, "from": hub.position, "to": hub.position,
|
|
|
|
|
})
|
2026-08-11 19:43:55 +02:00
|
|
|
|
|
|
|
|
|
2026-08-15 00:09:10 +02:00
|
|
|
func _provision_hub_transporters(hub: Dictionary) -> void:
|
|
|
|
|
var stationed: int = 0
|
|
|
|
|
for transporter in transporters:
|
|
|
|
|
if int(transporter.get("hub_id", 0)) == int(hub.id):
|
|
|
|
|
stationed += 1
|
|
|
|
|
while stationed < hub_capacity():
|
|
|
|
|
_add_hub_transporter(hub)
|
|
|
|
|
stationed += 1
|
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
|
|
|
|
|
|
|
|
|
2026-08-15 00:09:10 +02:00
|
|
|
func _sync_hub_transporters() -> void:
|
|
|
|
|
for hub in buildings:
|
|
|
|
|
if building_has_capability(hub, "transport_hub"):
|
|
|
|
|
_provision_hub_transporters(hub)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
# Delivery-purpose states only (#54): a building can have BOTH a
|
|
|
|
|
# transporter delivering it something (gas, for a burner) and a
|
|
|
|
|
# separate one collecting something from it (energy) at once, since
|
|
|
|
|
# they are two different transporters and two different legs. Matching
|
|
|
|
|
# on target_id alone, regardless of state, would make either purpose
|
|
|
|
|
# block the other from ever being dispatched to the same building.
|
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
|
|
|
for t in transporters:
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
if (t.state == TRANSPORTER_TO_MOTHERSHIP or t.state == TRANSPORTER_TO_BUILDING) \
|
|
|
|
|
and int(t.target_id) == building_id:
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _transporter_inbound_for_pickup(building_id: int) -> bool:
|
|
|
|
|
for t in transporters:
|
|
|
|
|
if (t.state == TRANSPORTER_TO_BUILDING_PICKUP or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING) \
|
|
|
|
|
and int(t.target_id) == building_id:
|
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
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 16:38:41 +02:00
|
|
|
func _transporter_inbound_to_extractor(index: int) -> bool:
|
|
|
|
|
for t in transporters:
|
|
|
|
|
if t.state == TRANSPORTER_TO_EXTRACTOR \
|
|
|
|
|
or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
|
|
|
|
|
if int(t.get("extractor_index", -1)) == index:
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
func _dispatch_transporters() -> void:
|
|
|
|
|
var mothership: Variant = get_mothership_position()
|
|
|
|
|
if mothership == null:
|
|
|
|
|
return # nowhere to load from
|
2026-08-15 10:41:18 +02:00
|
|
|
# Supply already-queued production and research before collecting passive
|
|
|
|
|
# extractor output. Extractors can refill forever, so servicing them first
|
|
|
|
|
# can otherwise consume every transporter each time one becomes idle and
|
|
|
|
|
# permanently starve a partly supplied order.
|
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
|
|
|
for b in buildings:
|
2026-08-15 04:45:15 +02:00
|
|
|
var needed_resource := ""
|
|
|
|
|
for resource_type in resource_types():
|
2026-08-16 21:38:07 +02:00
|
|
|
var need: int = building_requested_resource(int(b.id), resource_type)
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
if need > 0 and bank_inventory.amount(resource_type) > 0:
|
2026-08-15 04:45:15 +02:00
|
|
|
needed_resource = resource_type
|
|
|
|
|
break
|
|
|
|
|
if needed_resource == "":
|
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
|
|
|
continue
|
|
|
|
|
# One at a time per building: without this every idle transporter
|
|
|
|
|
# would be sent after the same shortfall and they would all arrive
|
2026-08-15 10:41:18 +02:00
|
|
|
# carrying resources nobody needs any more.
|
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
|
|
|
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)
|
2026-08-15 04:45:15 +02:00
|
|
|
chosen.resource_type = needed_resource
|
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
|
|
|
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
|
|
|
|
|
|
2026-08-15 10:41:18 +02:00
|
|
|
# A distant extractor is worth visiting only once it has a full hold ready.
|
|
|
|
|
# This keeps the transporter system meaningful: mining is local until a
|
|
|
|
|
# vessel physically carries a researched-sized load home.
|
|
|
|
|
var load: int = transporter_load_limit()
|
|
|
|
|
for i in range(extractors.size()):
|
|
|
|
|
var extractor: Dictionary = extractors[i]
|
|
|
|
|
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
|
|
|
|
|
if _extractor_inventory(extractor).amount(resource_id) < load or _transporter_inbound_to_extractor(i):
|
|
|
|
|
continue
|
|
|
|
|
var chosen_extractor: Dictionary = {}
|
|
|
|
|
var best_extractor_distance: float = INF
|
|
|
|
|
for t in transporters:
|
|
|
|
|
if t.state != TRANSPORTER_IDLE:
|
|
|
|
|
continue
|
|
|
|
|
var d: float = transporter_position(t).distance_squared_to(extractor.position)
|
|
|
|
|
if d < best_extractor_distance:
|
|
|
|
|
best_extractor_distance = d
|
|
|
|
|
chosen_extractor = t
|
|
|
|
|
if chosen_extractor.is_empty():
|
|
|
|
|
return
|
|
|
|
|
chosen_extractor.extractor_index = i
|
|
|
|
|
chosen_extractor.resource_type = resource_id
|
|
|
|
|
chosen_extractor.target_id = 0
|
|
|
|
|
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
|
|
|
|
|
TRANSPORTER_TO_EXTRACTOR)
|
|
|
|
|
|
2026-08-16 21:38:07 +02:00
|
|
|
# Collect building outputs last: queued deliveries and extractor collection
|
|
|
|
|
# both outrank resources that are merely ready for pickup.
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
for b in buildings:
|
2026-08-16 21:38:07 +02:00
|
|
|
var pickup_resource: String = _collectable_building_resource(b, load)
|
|
|
|
|
if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)):
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
continue
|
|
|
|
|
var chosen_pickup: Dictionary = {}
|
|
|
|
|
var best_pickup_distance: float = INF
|
|
|
|
|
for t in transporters:
|
|
|
|
|
if t.state != TRANSPORTER_IDLE:
|
|
|
|
|
continue
|
|
|
|
|
var d: float = transporter_position(t).distance_squared_to(b.position)
|
|
|
|
|
if d < best_pickup_distance:
|
|
|
|
|
best_pickup_distance = d
|
|
|
|
|
chosen_pickup = t
|
|
|
|
|
if chosen_pickup.is_empty():
|
|
|
|
|
return
|
|
|
|
|
chosen_pickup.target_id = int(b.id)
|
2026-08-16 21:38:07 +02:00
|
|
|
chosen_pickup.resource_type = pickup_resource
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
chosen_pickup.extractor_index = -1
|
|
|
|
|
_begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position,
|
|
|
|
|
TRANSPORTER_TO_BUILDING_PICKUP)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
func _advance_transporters(delta: float) -> void:
|
2026-08-15 00:09:10 +02:00
|
|
|
_sync_hub_transporters()
|
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
|
|
|
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)
|
2026-08-11 22:50:09 +02:00
|
|
|
var leg: float = maxf(distance / transporter_speed(), 0.0001)
|
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
|
|
|
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))
|
2026-08-15 04:45:15 +02:00
|
|
|
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
|
2026-08-16 21:38:07 +02:00
|
|
|
var want: int = building_requested_resource(int(t.target_id), resource_type)
|
2026-08-15 04:45:15 +02:00
|
|
|
var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit())
|
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
|
|
|
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
|
2026-08-15 04:45:15 +02:00
|
|
|
bank_inventory.withdraw(resource_type, take)
|
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
|
|
|
t.carrying = take
|
|
|
|
|
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
|
|
|
|
|
TRANSPORTER_TO_BUILDING:
|
2026-08-15 04:45:15 +02:00
|
|
|
# The cargo moves at exactly the moment the vessel arrives.
|
|
|
|
|
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
|
|
|
|
|
if deposit_resource_to_building(int(t.target_id), resource_type, int(t.carrying)) <= 0:
|
|
|
|
|
# Building demolished in transit - put the cargo back rather
|
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
|
|
|
# than quietly destroying it.
|
2026-08-15 04:45:15 +02:00
|
|
|
bank_inventory.deposit(resource_type, int(t.carrying))
|
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
|
|
|
t.carrying = 0
|
|
|
|
|
t.target_id = 0
|
|
|
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
2026-08-13 16:38:41 +02:00
|
|
|
TRANSPORTER_TO_EXTRACTOR:
|
|
|
|
|
var extractor_index: int = int(t.get("extractor_index", -1))
|
|
|
|
|
if extractor_index < 0 or extractor_index >= extractors.size():
|
|
|
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
|
|
|
|
return
|
|
|
|
|
var extractor: Dictionary = extractors[extractor_index]
|
|
|
|
|
var load: int = transporter_load_limit()
|
2026-08-14 20:32:58 +02:00
|
|
|
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
|
|
|
|
|
var extractor_inventory: RefCounted = _extractor_inventory(extractor)
|
|
|
|
|
if extractor_inventory.amount(resource_id) < load:
|
2026-08-13 16:38:41 +02:00
|
|
|
t.extractor_index = -1
|
|
|
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
|
|
|
|
return
|
2026-08-14 20:32:58 +02:00
|
|
|
extractor_inventory.withdraw(resource_id, load)
|
2026-08-13 16:38:41 +02:00
|
|
|
t.carrying = load
|
2026-08-14 20:32:58 +02:00
|
|
|
t.resource_type = resource_id
|
2026-08-13 16:38:41 +02:00
|
|
|
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
|
|
|
|
|
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
|
2026-08-13 21:07:35 +02:00
|
|
|
# Bank only on arrival; cargo retains the extractor's resource type.
|
2026-08-14 20:32:58 +02:00
|
|
|
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ORE)), int(t.carrying))
|
2026-08-13 16:38:41 +02:00
|
|
|
t.carrying = 0
|
|
|
|
|
t.extractor_index = -1
|
|
|
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
Gas burner building and the energy resource (#54)
A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.
The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.
The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.
Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.
Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.
Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.
Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
matching target_id, regardless of purpose. Once pickup states shared
target_id with delivery states (both address a building, not an
extractor), a transporter delivering gas made the SAME building look
"busy" to the unrelated energy-pickup dispatch, starving it even
with a second, genuinely free transporter sitting idle at the hub.
Split into _transporter_inbound_to() (delivery states only) and a
new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
sizing) had no room for a 7th building - despite the ticket's own
note that no layout changes should be needed. A naive 4th row ran
off the bottom of a 720x1280 viewport; fitting one meant both adding
a 4th row top AND shrinking the shared button/label/cost sizing so
each row's content no longer taller than the compressed row spacing.
Caught only by actually screenshotting it - a real build menu, not
headless, since --headless renders nothing.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:10:31 +02:00
|
|
|
TRANSPORTER_TO_BUILDING_PICKUP:
|
|
|
|
|
var source: Dictionary = find_building(int(t.target_id))
|
|
|
|
|
var resource_type: String = String(t.get("resource_type", RESOURCE_ENERGY))
|
|
|
|
|
var take: int = 0 if source.is_empty() \
|
|
|
|
|
else mini(building_resource(int(t.target_id), resource_type), transporter_load_limit())
|
|
|
|
|
if take <= 0 or source.is_empty():
|
|
|
|
|
t.target_id = 0
|
|
|
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
|
|
|
|
return
|
|
|
|
|
_inventory_for_record(source).withdraw(resource_type, take)
|
|
|
|
|
t.carrying = take
|
|
|
|
|
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING)
|
|
|
|
|
TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING:
|
|
|
|
|
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ENERGY)), 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 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
|
|
|
TRANSPORTER_RETURNING:
|
|
|
|
|
t.state = TRANSPORTER_IDLE
|
|
|
|
|
t.progress = 1.0
|
|
|
|
|
|
|
|
|
|
|
#129 (#131, #132, #133): a quest chain, and a World Ship out of reach
Sixteen quests that walk a new game through the things it contains: ore,
the mothership, exploration, a yard and a second hull, planetfall and
conquest, extractors, gas, freight, power, the lab, a new vessel type, an
outpost, and a survey.
The chain turns on the second quest. The fleet builds the mothership to
call the World Ship and hears nothing - and the array is not at fault. The
breach put them further out than that signal will carry, so every quest
after it is a search that has to be flown rather than transmitted.
Three pieces:
Quests.gd the chain, as data and nothing else. Two rules it obeys:
nothing may ask for something unreachable - research points
come only from conquering a planet, so every doctrine that
costs them sits after the conquest quest - and a quest names
one goal, with the research it implies said in the brief
rather than split into an errand of its own.
GameState how far along the chain the player is, and the answer to each
goal kind. Progress latches for free: the chain is walked one
quest at a time, so collecting five ore and spending it cannot
un-complete the quest that asked for five - by then nothing is
asking. It lives here so a quest finished on a planet surface
is still finished after take-off.
the HUD QuestTracker, the standing objective, in the wave alert's
idiom; QuestDirector, which reads the lines out.
Lines are QUEUED by GameState and drained by the director rather than
emitted at a banner, because quests advance in the autoload, which runs on
a planet surface too - where a line emitted straight at a banner would be
lost. Both scenes now carry a banner, a tracker and a director, so a quest
finished underground is announced underground.
The chain is armed by reset_new_game(), the same gate the prologue uses -
the only door into a real game - so instantiating a scene is not by itself
a reason to start briefing somebody.
2026-08-28 14:41:03 +02:00
|
|
|
# --- quests (#129, #131) ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# The chain lives in Quests.gd; what lives here is how far along it the
|
|
|
|
|
# player is, and the answer to "is this one done yet".
|
|
|
|
|
#
|
|
|
|
|
# Progress LATCHES, and it latches for free: the chain is walked one quest
|
|
|
|
|
# at a time, and the moment a goal is met the index moves on and never comes
|
|
|
|
|
# back. So collecting five ore and then spending it cannot un-complete the
|
|
|
|
|
# quest that asked for five ore - by the time it is spent, nothing is
|
|
|
|
|
# asking any more. That is why goals are allowed to be phrased against
|
|
|
|
|
# current holdings rather than against a running total nobody would keep in
|
|
|
|
|
# step.
|
|
|
|
|
#
|
|
|
|
|
# It sits in GameState with everything else that has to survive the
|
|
|
|
|
# Main.tscn <-> PlanetSurface.tscn swap - a quest completed on a surface
|
|
|
|
|
# must still be completed after take-off.
|
|
|
|
|
|
|
|
|
|
signal quest_started(id: String)
|
|
|
|
|
signal quest_completed(id: String)
|
|
|
|
|
|
|
|
|
|
# Whether this playthrough has a quest chain at all. Armed by
|
|
|
|
|
# reset_new_game(), which is the only door into a real game (the main menu's
|
|
|
|
|
# Play, and start_dev_game on top of it) - exactly the gate intro_active
|
|
|
|
|
# already uses. Without it, every scene ever instantiated would open on the
|
|
|
|
|
# first quest's briefing, including the ones in the test suite that only
|
|
|
|
|
# want a world to look at.
|
|
|
|
|
var quests_armed: bool = false
|
|
|
|
|
var quest_index: int = 0
|
|
|
|
|
# Whether the current quest has been ANNOUNCED. Kept apart from the index so
|
|
|
|
|
# a quest that is already satisfied the moment it starts still gets a frame
|
|
|
|
|
# on screen with its own name on it, rather than being skipped silently.
|
|
|
|
|
var quest_announced: bool = false
|
|
|
|
|
|
|
|
|
|
# Lines the chain wants said, waiting for something that can say them.
|
|
|
|
|
#
|
|
|
|
|
# Queued here rather than emitted straight at a banner because quests
|
|
|
|
|
# advance in this autoload, which runs on the planet surface too - where
|
|
|
|
|
# there is no narration banner at all. A line emitted there would simply be
|
|
|
|
|
# lost. QuestDirector drains this whenever it is alive, so a quest completed
|
|
|
|
|
# underground is read out on the way back up.
|
|
|
|
|
var quest_messages: Array = []
|
|
|
|
|
|
|
|
|
|
# Which planets have been LANDED ON, and which kinds of world they were.
|
|
|
|
|
# Distinct from explored_chunks, which is only about having flown past.
|
|
|
|
|
var visited_planets: Dictionary = {}
|
|
|
|
|
var visited_planet_kinds: Dictionary = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func reset_quests(armed: bool = false) -> void:
|
|
|
|
|
quests_armed = armed
|
|
|
|
|
quest_index = 0
|
|
|
|
|
quest_announced = false
|
|
|
|
|
quest_messages.clear()
|
|
|
|
|
visited_planets.clear()
|
|
|
|
|
visited_planet_kinds.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Called when the fleet actually sets down on a world.
|
|
|
|
|
func note_planet_visited(id: String, definition_id: String) -> void:
|
|
|
|
|
if id != "":
|
|
|
|
|
visited_planets[id] = true
|
|
|
|
|
if definition_id != "":
|
|
|
|
|
visited_planet_kinds[definition_id] = true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func quest_count() -> int:
|
|
|
|
|
return QUESTS.count()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func quest_chain_complete() -> bool:
|
|
|
|
|
return quest_index >= QUESTS.count()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# The quest the player is on, or {} once the chain is done.
|
|
|
|
|
func active_quest() -> Dictionary:
|
|
|
|
|
return QUESTS.at(quest_index)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# How far along one quest is, as (have, need). One line per goal kind, and
|
|
|
|
|
# every one of them is answered from state this autoload already holds - a
|
|
|
|
|
# goal that needed a node to ask would be a goal that stops working on a
|
|
|
|
|
# planet surface.
|
|
|
|
|
func quest_progress(quest: Dictionary) -> Vector2i:
|
|
|
|
|
if quest.is_empty():
|
|
|
|
|
return Vector2i.ZERO
|
|
|
|
|
var goal: Dictionary = quest.get("goal", {})
|
|
|
|
|
match String(goal.get("kind", "")):
|
|
|
|
|
QUESTS.GOAL_RESOURCE:
|
|
|
|
|
var want: int = int(goal.get("amount", 1))
|
|
|
|
|
return Vector2i(mini(available_resource(String(goal.get("resource",
|
|
|
|
|
RESOURCE_ORE))), want), want)
|
|
|
|
|
QUESTS.GOAL_BUILDING:
|
|
|
|
|
return Vector2i(1 if has_building_of_type(String(goal.get("building", ""))) else 0, 1)
|
|
|
|
|
QUESTS.GOAL_FLEET_VESSEL:
|
|
|
|
|
var need: int = int(goal.get("count", 1))
|
|
|
|
|
return Vector2i(mini(vessel_count_in_fleet(String(goal.get("vessel", ""))),
|
|
|
|
|
need), need)
|
|
|
|
|
QUESTS.GOAL_VESSEL_UNLOCKED:
|
|
|
|
|
return Vector2i(1 if vessel_is_unlocked(String(goal.get("vessel", ""))) else 0, 1)
|
|
|
|
|
QUESTS.GOAL_EXPLORE:
|
|
|
|
|
var sectors: int = int(goal.get("sectors", 1))
|
|
|
|
|
return Vector2i(mini(explored_chunks.size(), sectors), sectors)
|
|
|
|
|
QUESTS.GOAL_PLANET_VISITS:
|
|
|
|
|
var visits: int = int(goal.get("count", 1))
|
|
|
|
|
return Vector2i(mini(visited_planets.size(), visits), visits)
|
|
|
|
|
QUESTS.GOAL_PLANET_KINDS:
|
|
|
|
|
var kinds: int = int(goal.get("count", 1))
|
|
|
|
|
return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds)
|
|
|
|
|
QUESTS.GOAL_CONQUERED:
|
|
|
|
|
var wanted: int = int(goal.get("count", 1))
|
|
|
|
|
var held: int = 0
|
|
|
|
|
for key in planet_capture:
|
|
|
|
|
if bool(planet_capture[key].get("conquered", false)):
|
|
|
|
|
held += 1
|
|
|
|
|
return Vector2i(mini(held, wanted), wanted)
|
|
|
|
|
# An unknown goal kind must not be quietly "already done" - that would
|
|
|
|
|
# run the whole chain off in one frame.
|
|
|
|
|
return Vector2i(0, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func quest_goal_met(quest: Dictionary) -> bool:
|
|
|
|
|
if quest.is_empty():
|
|
|
|
|
return false
|
|
|
|
|
var progress: Vector2i = quest_progress(quest)
|
|
|
|
|
return progress.x >= progress.y
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func say_quest_line(text: String) -> void:
|
|
|
|
|
if text.strip_edges() == "":
|
|
|
|
|
return
|
|
|
|
|
quest_messages.append({"line": text, "speaker": QUESTS.SPEAKER})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Hands over every line waiting to be said, and empties the queue. Drained
|
|
|
|
|
# rather than read so two directors could never read the same line twice.
|
|
|
|
|
func take_quest_messages() -> Array:
|
|
|
|
|
var taken: Array = quest_messages.duplicate()
|
|
|
|
|
quest_messages.clear()
|
|
|
|
|
return taken
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Ends the current quest whether or not its goal was met. The chain's only
|
|
|
|
|
# way forward, used by _advance_quests below and by the tests.
|
|
|
|
|
func complete_active_quest() -> bool:
|
|
|
|
|
var quest: Dictionary = active_quest()
|
|
|
|
|
if quest.is_empty():
|
|
|
|
|
return false
|
|
|
|
|
say_quest_line(String(quest.get("done", "")))
|
|
|
|
|
quest_index += 1
|
|
|
|
|
quest_announced = false
|
|
|
|
|
quest_completed.emit(String(quest.id))
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _advance_quests() -> void:
|
|
|
|
|
# The prologue is already talking, and the fleet in it is not the
|
|
|
|
|
# player's. The chain starts when the prologue hands over.
|
|
|
|
|
if not quests_armed or intro_active or quest_chain_complete():
|
|
|
|
|
return
|
|
|
|
|
var quest: Dictionary = active_quest()
|
|
|
|
|
if not quest_announced:
|
|
|
|
|
quest_announced = true
|
|
|
|
|
say_quest_line(String(quest.get("brief", "")))
|
|
|
|
|
quest_started.emit(String(quest.id))
|
|
|
|
|
# Deliberately no completion check this frame: a quest whose goal
|
|
|
|
|
# happens to be met already still gets read out before it closes.
|
|
|
|
|
return
|
|
|
|
|
if quest_goal_met(quest):
|
|
|
|
|
complete_active_quest()
|
|
|
|
|
|
|
|
|
|
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
# --- paying for things ------------------------------------------------------
|
|
|
|
|
#
|
|
|
|
|
# EVERY payment draws on the bank AND on what the ships are carrying. The
|
|
|
|
|
# player can already move resources between the two by hand at any building
|
|
|
|
|
# that takes them, so refusing a purchase that a couple of taps would have
|
|
|
|
|
# made affordable was pure ceremony.
|
|
|
|
|
#
|
|
|
|
|
# In one case it was worse than ceremony. A fresh game starts with an empty
|
|
|
|
|
# bank, ore is mined into the fleet hold, and the only way to bank it is to
|
|
|
|
|
# deliver it to a mothership - so charging construction to the bank alone
|
|
|
|
|
# made the first mothership cost ore that could not yet exist. Mine forever,
|
|
|
|
|
# build nothing.
|
|
|
|
|
#
|
|
|
|
|
# Bank first, hold second. The hold is also the fleet's mining capacity, so
|
|
|
|
|
# leaving it as full as possible means the next trip out can still carry
|
|
|
|
|
# something back.
|
|
|
|
|
#
|
|
|
|
|
# The one deliberate exception is AMMO - see AimedFleetAbilityController,
|
|
|
|
|
# which checks fleet_resource() directly. A gas bomb is fired from what the
|
|
|
|
|
# ships are physically carrying; that is a fact about where the matter is,
|
|
|
|
|
# not a convenience about where the money is.
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
func available_resource(resource_type: String) -> int:
|
|
|
|
|
return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type)
|
2026-08-15 04:45:15 +02:00
|
|
|
|
|
|
|
|
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
func available_ore() -> int:
|
|
|
|
|
return available_resource(RESOURCE_ORE)
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
|
|
|
|
|
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
func can_afford(cost: int) -> bool:
|
|
|
|
|
return available_ore() >= cost
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
|
|
|
|
|
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
func can_afford_resources(costs: Dictionary) -> bool:
|
|
|
|
|
for resource_type in costs:
|
|
|
|
|
if available_resource(String(resource_type)) < int(costs[resource_type]):
|
|
|
|
|
return false
|
|
|
|
|
return true
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
|
|
|
|
|
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
func spend(cost: int) -> bool:
|
|
|
|
|
return spend_resources({RESOURCE_ORE: cost})
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
|
|
|
|
|
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
func spend_resources(costs: Dictionary) -> bool:
|
|
|
|
|
# Affordability is settled in full BEFORE anything is taken. Charging for
|
|
|
|
|
# part of a purchase that then fails is worse than refusing it, and with
|
|
|
|
|
# several resource types in one price that is a real possibility rather
|
|
|
|
|
# than a theoretical one.
|
|
|
|
|
if not can_afford_resources(costs):
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
return false
|
Pay for everything from the bank and the fleet hold, not just buildings
Construction already pooled the two; research, ship-lab upgrades and vessel
unlocks still charged the bank alone. That split was arbitrary from the
player's side - the resources are a couple of taps away from being banked
either way, so refusing a purchase over where they happen to be sitting was
ceremony. Worse, it was inconsistent: the same ore could buy a mothership and
not a skill node.
So the pooling moved down into can_afford/spend themselves, and the
construction-specific pair is gone. can_afford_resources now pools per
resource type, and a new spend_resources() charges bank first and hold for
the shortfall, settling affordability in full before taking anything - a
multi-resource price must not charge for the half it can cover and then fail.
unlock_vessel goes through it instead of withdrawing from the bank directly.
The skill menu's "you have %d" quoted the bank, which would have told the
player they could not afford something they could.
Ammo stays out of it. AimedFleetAbilityController reads the fleet hold
directly, and should: a gas bomb is fired from what the ships are physically
carrying, which is a fact about where the matter is rather than a convenience
about where the money is.
test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks
every route - skill tree, ship lab, vessel research, multi-resource
atomicity, and ammo staying unpooled. Two of its own setup steps were being
silently clamped by the fleet hold's capacity, which is what made an
affordable price look unaffordable; capacity is not what those checks are
about, so it is taken out of the way explicitly.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
|
|
|
for resource_type in costs:
|
|
|
|
|
var id: String = String(resource_type)
|
|
|
|
|
var owed: int = int(costs[resource_type])
|
|
|
|
|
owed -= bank_inventory.withdraw(id, owed)
|
|
|
|
|
if owed > 0:
|
|
|
|
|
fleet_inventory.withdraw(id, owed)
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
return true
|