Commit graph spaceship/controllers
Author SHA1 Message Date
Loïc Wisniewski
1ece202731 #138: structures and doctrines cost more than rocks, and more the deeper they are
Both halves of the ticket, in the shape vessels have used since gas bombers
and laser ships: a definition carries an ore figure and, when it wants more
than ore, a dictionary that IS the price. `building_costs()` and
`skill_costs()` answer it the way `product_costs()` already did, so there is
one question with one answer whatever is being bought, and every payment
path now goes through `spend_resources` rather than `spend`.

Seven of ten structures and thirteen of seventeen doctrines now want gas,
energy or dark matter alongside their rocks, and the totals climb with tree
depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530.

The constraint that shaped the table is REACHABILITY, and it is the reason
some prices stayed ore-only. Energy exists only where a gas burner stands,
and a gas burner needs Power Systems - so nothing at or before that depth
may be priced in energy, or it is a wall with no way through. Dark matter is
rarer still and appears once, on the deepest node in the game. The extractor
stays rocks-only despite its depth allowing gas, because the quest chain
reaches it before it has sent the player for any.

test_progressive_costs checks those as properties rather than transcribing
the numbers, so rebalancing a price costs nothing while breaking the shape
fails: nothing unreachable at its own depth, prices rising with depth, the
ore field agreeing with the dictionary (repairs quote the field alone, since
a repair drone carries ore and nothing else), and the quest chain never
asking for a structure it has not funded.

Two things cleaned up on the way:

- The per-building cost constants in GameState are gone. Nothing but tests
  used them, and they were a second copy of a number the definitions already
  hold - exactly what would drift once a price gained a resource.
- research_for_building() returns a display NAME, and the id-keyed half of
  the tree wants an id. That mix-up is silent - skill_depth() of a name finds
  no node and answers 0, so a deep structure reads as an ungated one, which
  is how the first draft of the test let an unreachable price through.
  research_node_for_building() is the id, and the name is expressed from it.

PriceTag no longer rebuilds its labels when re-told the same price; it
refreshes the held figures instead. The craft menu re-states prices every
frame and the build menu now does too, so that was a dozen Labels being torn
down and rebuilt sixty times a second.
2026-08-28 15:30:30 +02:00
Loïc Wisniewski
ea25318201 #136, #137, #134: one build grid that cannot stack, one way to show a price
#136 - the gas burner drawn on top of the transport hub. The build menu's
rows were a written-down list of tops read with `tops[mini(row, size - 1)]`,
and with every structure researched the menu wanted a fourth row where the
list had three, so the last entry was placed on the third row's centre slot.
A list that silently stacks whatever will not fit in it is not a layout.
Rows are computed now, from a first top and a pitch that tightens to fit and
is floored at the row's own height - so two rows cannot occupy the same
space however far the catalog grows - and the column count widens rather
than adding a row the band cannot hold. Ten structures now sit in four
columns of three rows. The test unlocks the whole tree and checks every pair
of entries for overlap, which is the shape of the bug rather than a
coordinate that happens to be right today.

#137 - the game had three answers to "what does this cost". The build menu
drew a NeonNumber under each option, the craft menu drew glyph strips, and
the ship lab spelled prices out inside the caption. PriceTag is that answer
once: the resource's glyph, then what you HOLD over what it COSTS. It
replaces all three, plus the info popup and the skill panel, and
VesselPriceStrip - which was the same idea for one menu - goes with them.
The build menu also gains the resource bar the other two already had, in the
same place and at the same size, and the craft menu's - the only one not
running the full width of its panel - now matches. The bar drops its LOCAL
column where there is no building to report on, for the same reason #95
dropped it where it only repeated BANKED.

#134 - the skill panel's explanation and its price were both centred in the
same band, so a long description landed on the number. The text is the left
of the band now and the tag is the right. The shortfall it used to spell out
in a sentence - one per case, ore or research points - is the red on the tag
instead, and research points ride along as an entry of their own, so the tag
says WHICH thing is missing without a line of prose per possibility.

One trap worth recording: PriceTag and ResourceBar read the GameState
autoload, and an `extends SceneTree` test script that so much as names such
a class - or preloads the file - fails to compile, because GameState is not
an identifier in that context. GDScript then caches the failure, so the
scene the test instantiates gets a Control with no script where its resource
bar should be. The tests compare script PATHS, as strings.
2026-08-28 15:05:20 +02:00
Loïc Wisniewski
1e89780bd7 #128: a dialog stops the game until it is acknowledged
While a line is on the banner the whole tree is paused, which freezes the
fleet, the enemies and GameState's own production and wave clocks, and stops
input reaching any node but the banner. So a press while a dialog is up
acknowledges it and does nothing else - it cannot also steer the fleet or
open a menu on the way through.

The pause is owned by NarrationBanner rather than by its callers: the thing
that must not get out of step is "a line is showing" and "the game is
stopped", and the banner is the only code that knows the first. It releases
on the last line, on clear(), and on leaving the tree - a scene swapped out
mid-line must not leave the game stopped with nothing left to start it.

Two consequences worth naming. A beat's deadline in the prologue is now game
time, so it can no longer run out from under an unread line; it remains the
way past a beat the player will not finish. And "the prologue leaves you
movement and nothing else" was applied by _process on the first frame, which
no longer comes - the prologue opens ON a dialog - so BuildController and
WorldMap apply it at _ready too.
2026-08-28 14:40:46 +02:00
Loïc Wisniewski
8412280e71 #118/#121: the speed fader and the formation editor under a mouse
Both read raw touches, and the project no longer turns clicks into them - the
same routing gap #93 fixed in the research tree. The boost was unreachable on
a desktop and the formation could not be rearranged at all.

Wrappers over the same press/drag logic in each case, never a second
implementation, so the touch and mouse paths cannot drift apart. The wheel
replaces the pinch in the formation editor: a mouse has no second finger, and
zoom is the other half of what that panel does.

The fader gets first refusal on a mouse press in GameController, so dragging
the boost does not also pan or zoom the world behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:59:24 +02:00
Loïc Wisniewski
209169206b #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
Loïc Wisniewski
b50597b757 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
Loïc Wisniewski
0c595d775d #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
Loïc Wisniewski
3c632276af #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
Loïc Wisniewski
c8d4731232 #90: give the laser an ignition instead of a blink
The shot was one flat line that appeared at full length and faded. Nothing
happened at the ship, nothing moved, and a hit looked exactly like a miss.

The blade now runs out of the hull over the first 30% of the flash on a
cubic ease-out, holds white-hot, then falls away, with a hum in its width,
a bright cap at the leading end, a flare at the emitter, and sparks off the
hull that fired it. MuzzleSparkVFX takes an optional colour so the laser
throws cyan where the ordinary gun (#87) keeps its gold.

The extra light is a white-hot rod down the middle at 42% of the width,
deliberately not a wider halo: the note at BEAM_WIDTH_SCALE records that the
drawn beam spans exactly what the beam hits, and a halo past that would put
the "it visibly missed" complaint back the other way round.

The damage is untouched - dealt in full, along the whole beam, on the frame
of the shot. A beam that had to arrive would change how the weapon plays,
which the ticket did not ask for, so the test pins it down: a target at the
far end of the range must be hit before the drawn blade has visibly reached
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:55 +02:00
Loïc Wisniewski
4883197ef3 #93: drive the research menu with a mouse, and close the hole under it
Two separate faults, both reported on the same ticket.

The menu was inert under a mouse. Input reaches the tree as raw events
through BuildController._unhandled_input, because a Control that consumes
them through the GUI system swallows the drag, and the tree only ever read
touch events. That was invisible while the project set
emulate_touch_from_mouse and every click arrived as a synthetic touch; with
that setting gone, nothing selects and nothing scrolls, and there is no
other way to research anything on a desktop. The press and the pan are now
the behaviour, with touch and mouse as two ways in to them. Motion pans only
while the button is held - a hovering pointer must not drag the tree out
from under the reader - and the wheel scrolls, which is the first thing a
desktop player reaches for.

The panel also still had the buy button's hole in it. That button went away
in #83 and nothing moved down into the space, leaving ~180px of dead panel
below the readout while the tree above it stayed cramped. The readout moves
down 154px and the canvas takes the height: 1190px to 1344px.

The test drives clicks through BuildController rather than into the view,
since the missing piece was the routing - calling the view directly would
have passed against the bug as reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:34 +02:00
Loïc Wisniewski
5f3615026d #89: the default gun bends onto a target near the aimed heading
Aiming with a thumb on a stick is never exact, and at 950 units a couple of
degrees is a long miss. A shot fired roughly toward an enemy now goes at it.

28 degrees of cone, chosen so that pointing at something hits it while the
shot still visibly goes where the player pointed. Past about 45 the gun starts
picking its own targets, which is the behaviour #80 deliberately removed.

Enemies only, never asteroids. Snapping onto a rock while the player is trying
to hit the thing shooting at them would be the assist working against them,
and rocks are large and stationary enough to hit on purpose.

The aim guide is drawn through the SAME helper the gun fires through, so the
dotted line bends with the shot. A guide showing the raw stick heading while
the bolt took an assisted one would be the same class of mismatch #86 removed
between the guide's length and a bolt's actual reach - and this one would have
been harder to notice, because the line would look right most of the time.

Range 620 -> 950, the other half of the ticket.

Also swept the project root again: Godot keeps resurrecting pre-#84 copies of
moved files there from its own cache. None this time.

Suite 120/120.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:19:52 +02:00
Loïc Wisniewski
3f5e7ac80b Implement #85, #86, #87 and #88
#85 - on a computer the sticks and the pivot pair are dead weight: WASD
steers, the mouse aims, Q/E pivot. All four go together the first time a
keyboard or a mouse is used, and only where there is no touchscreen. A touch
device with a keyboard attached keeps everything, because a finger is still a
way to play it.

#86 - the default gun shoots faster (500 -> 850) and further (320 -> 620).

The interesting half is that a bolt now STOPS where the gun's range ends.
Projectile lifetime is derived from fire_range at launch rather than set
independently, so the distance a shot covers and the distance the aim guide
draws are one number. They were two, and they disagreed: the guide promised
320 while a bolt actually flew speed * lifetime = 750, so shots landed on
things well past the line saying where they would stop.

#87 - the ship that fires throws sparks, so a shot is visibly attached to the
vessel it came from; with a whole fleet shooting there was nothing tying the
two together, which started mattering once the player had to aim. Enemy deaths
gained a shockwave ring and roughly double the debris - a kill used to read as
a small red puff that was easy to miss in a fight.

#88 - everything moves 1.5x faster: fleet cruise, every vessel's max_speed and
acceleration, and every enemy's. Scaling the enemies too keeps it a change of
TEMPO rather than a difficulty cut, and scaling acceleration alongside speed
keeps the handling from #77's tuning intact.

Three tests carried assertions that were really about one balance pass:
test_enemy_mothership pinned a literal 35.0 and now compares against the
chaser, which is what "never reaches ramming speed" actually means;
test_planet_atmosphere_transition flew far enough at the new speed to reach
the landed garrison, and now clears it, since that test is about the handoff
and not about a fight it never meant to pick.

Also repaired real data loss, caught only because test_gas_burner failed:
Godot had silently re-serialised GasBurnerBuildingDefinition.tres and dropped
collected_outputs, capabilities, map_radius and map_phase, and had recreated a
stale copy of it at the pre-#84 root path. Without collected_outputs no
transporter ever collects a burner's energy. Both files restored from git.

It had also resurrected pre-#84 SNAPSHOTS of Main.tscn, Planet.tscn and
lunar_surface.gdshader at their old root paths - the root Main.tscn still
carried 27 references to the flat layout. They are dead weight the engine
never loads, since project.godot and every preload point into the folders, but
they are exactly the kind of thing that wastes an hour later when someone
edits the wrong copy. Deleted, and the root is now down to ui_theme.tres,
icon.svg, web_shell.html and the two config files.

Suite 119/119.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 02:20:12 +02:00
Loïc Wisniewski
2f161621c3 #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