Compare commits

...
Author SHA1 Message Date
Loïc Wisniewski
2e0c666c2b #156: bank it before you are asked to spend it
The extractor is the first thing the chain asks for that cannot be paid for
out of a single hold. Every hull carries twenty and the fleet is small; the
mothership is where anything larger than one trip has to accumulate, and
nothing in the game had ever said so.

A deposit quest sits before it now, asking for sixty ore BANKED - which is
a different question from the one GOAL_RESOURCE answers, since that counts
the hold too, and the hold being too small is the whole point. So it needed
a goal kind of its own.

Sixty is three full holds, so it cannot be satisfied by accident on the way
past, and it leaves enough behind to actually buy the extractor afterwards.
2026-08-29 14:39:36 +02:00
Loïc Wisniewski
7489528e60 #155, #152: shots reach further and sooner, and the other explosions catch up
#155 - the projectile flew at 850, which took 1.1 seconds to cross the gun's
own range: long enough that a shot read as leaving the ship rather than as
hitting something. 1400 now, and the interceptor's range goes 950 to 1200,
so it crosses in 0.86s and comfortably outranges the 620 the standoff
enemies hold at. A projectile's lifetime is derived from its range and
speed, so faster does not mean falling short - the test pins that
arithmetic rather than trusting it.

#152 asked for VFX on three events, and the finding is worth recording: all
three already existed and were already wired. What had happened is that #130
gave a lost VESSEL thirty particles and a shockwave, which left everything
else in the game visibly smaller than it - an enemy kill, the thing the
player is being rewarded for, and a broken rock, the thing they do more than
anything else, were both still on or near the base class's twelve-particle
default with no shockwave at all.

So this is about PROPORTION rather than about adding anything, and
proportion is what the test checks: a lost vessel outweighs a kill,
a kill outweighs a rock, and nothing sits on the base defaults. Numbers get
rebalanced; an ordering that says a rock outweighs a battleship is a bug.
Particles now run 38 > 32 > 24 against a default of 12.
2026-08-29 14:39:35 +02:00
Loïc Wisniewski
9cf032c203 #150, #151: the sounds were a toy and a puff, and both were fair
"Shot sounds sound like a toy" and "death sounds from ship are really
cheap", and there was no arguing with either. The shot was a bare
descending sine, which is the sound every toy ray gun has ever made. The
death was one band of filtered noise with a sine under it - thin, and over
before the eye had finished with the explosion.

The synthesis is rewritten rather than tuned, because the shape was the
problem:

  LAYERS, not a waveform. A bang is a crack, then a body, then a tail, and
  they have different lengths. One envelope over one source cannot be any
  of those things, so the explosions now run three clocks - the crack is
  done in a fifth of the length, the roar runs most of it, and sparse
  debris keeps falling afterwards so there is an aftermath rather than a
  stop.

  A SUB. Below about 80Hz is where "big" lives; without it an explosion is
  a hiss however loud you play it. Measured: 74% of a ship death's energy
  and 86% of a structure's now sits under 200Hz, against almost none
  before.

  SATURATION. Soft-clipping folds in harmonics, so a sound reads as loud
  rather than merely being loud - and stops peak normalisation flattening
  everything into the same quiet mush.

  DETUNED PARTIALS on the shot. Three of them at deliberately wrong ratios
  beat against each other and read as a mechanism; the single sine was the
  whole of the toy problem. A noise transient and a sub thump under it give
  it something to hit with.

Lengths went up with the weight: the ship death from 0.85s to 1.45s and the
structure from 1.6s to 2.3s, both audible to the end rather than gone in the
first third.
2026-08-29 14:39:33 +02:00
Loïc Wisniewski
b721760803 #149, #153, #154: dialogs arrive, sit on top, and give the keyboard back
#149 - a line is typed out at about three and a half words a second, with a
scan sweep running down the panel and faint rules across it while it
decodes. The frame carries the charge while it works and settles when the
line lands; the caret is a solid write-head during the decode and only
starts blinking once it is waiting on the reader.

Two implementation notes worth keeping. The full text is set and then
REVEALED with visible_characters rather than the string being grown - a
growing string re-wraps on nearly every frame, so words jump between rows as
they are typed. And a press on a line still arriving FINISHES it instead of
dismissing it: without that, a player clicking at their usual pace skips the
second line having never seen a word of it, which is the exact thing #127
exists to prevent.

#153 - "after a dialog popup the input system is lost on keypress". Nothing
was lost; it was never delivered. A dialog stops the tree (#128), a paused
node receives no input, so a key RELEASED while the line was up never
reached the controller - keys_down kept it and the fleet flew on in that
direction once the game restarted.

NOTIFICATION_UNPAUSED arrives whatever a node's process mode is, which makes
it the one moment the question can be asked, and the physical keyboard is
the authority on what is actually held. That fixes anything that stops the
tree, not only dialogs, and the test checks it that way.

#154 - the banner was the ninth child of the UI layer and every panel came
after it, so all twelve of them drew over the top. It is the last child now,
in both scenes. Input order was never the problem and does not change: the
UI layer already sees unhandled input before the controllers do.
2026-08-29 14:39:07 +02:00
Loïc Wisniewski
721913c040 Guard the definition catalog against silent .tres property loss
Opening the project in the Godot editor re-saves .tres files, and its writer
DROPS properties it considers default-valued. An empty-looking
PackedStringArray is the usual casualty, and it has now cost this project
twice - both times silently, and both times the damage surfaced somewhere
else entirely:

  GasBurnerBuildingDefinition lost `capabilities` and `collected_outputs`,
  which took the burner's whole conversion with it.

  InterceptorVesselDefinition lost `capabilities`, `hull_points`,
  `build_time`, `icon` and `glow_color` - so the starting vessel had no
  cargo hold, and a full suite came back with ten unrelated tests broken
  because nothing anywhere could mine.

Neither raised an error. The resource still loaded; it was just quietly less
than it had been.

test_definitions_intact asserts the SHAPE of the catalog rather than any one
value: every vessel has a name, stats, a hull and its components; a hold and
a "cargo" capability imply each other; every building has a description, a
price, hit points, its build-menu wiring and at least one capability; and a
converter keeps the fields its conversion needs. Whatever the editor eats
now fails here, next to the thing that was eaten.

Verified against both real incidents by reproducing them: deleting the
interceptor's capabilities line fails with "cargo_capacity 20.0 but
capabilities []", and deleting the burner's collected_outputs fails with
"converts but lists no collected outputs".

The first draft of that last check looked for the conversion fields on the
DEFINITION, where they are not - they live on its logistics sub-resource -
so it answered null and never fired. It passed while the burner sat there
broken, which is the same class of quiet as the bug it is guarding.

Also restores what the editor took from the gas burner this time round.
2026-08-29 14:38:51 +02:00
Loïc Wisniewski
b8038c1f94 #148: keep the theme's master in the repository
SPACE_4.wav is the source the shipped theme is made from, so it belongs
under version control - but not in the game.

It lives in audio/masters/ with an empty .gdignore beside it, which makes
Godot skip the folder outright: nothing in it is imported into res://, and
nothing in it is carried into an export. Left where it was, in the project
root, its 59MB would be imported and then packed into every Android and web
build for a file nothing opens - the game loads audio/space_4.mp3, the same
5m36s at 6.7MB.

Its .import file goes with it, since there is no longer anything to import,
and the test runner stops copying the folder for the same reason it already
skips android/ and builds/: 59MB copied on every single run, for a folder no
test can even see.

README.md next to it records which shipped file each master became and the
command that made it, so the pair cannot drift apart silently.
2026-08-29 13:54:37 +02:00
Loïc Wisniewski
940540d60c #146: shots, deaths and collapses are heard
Three events get a sound: a shot leaving a gun, a vessel dying, a structure
coming down.

The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py
generates all three from scratch, and is committed alongside them. The
ticket asked for free sources and the good ones are in its comments
(Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0),
but a generated sound has no licence to track, no attribution to keep, and
no binary in the repository whose provenance nobody can check a year from
now. It also makes the sounds DATA: too sharp a shot is a number in that
file and a re-run, not a hunt through a library. Dropping in a pack file
later means replacing a .wav and nothing else.

A voice POOL, not a player, because these overlap constantly - a fleet
firing is several shots inside one shot's length. Twelve voices, and when
they are all busy the one furthest through its own sound is recycled, which
is the one with the least left to lose.

Parented to the tree root, for the reason that matters: every one of these
sounds is started by something that is being freed on the same frame. A
dying ship cannot own the sound of its own death.

Levels are per sound rather than shared - a shot fires constantly and a
death happens once, so equal volumes are not proportionate ones - and shots
get a little pitch spread so a stream of them does not flatten into one
machine-gun note. The deaths get none: a wobbling explosion sounds broken
rather than varied.

The test goes at the failures a play-through would hide for weeks: firing
three times the pool size must leave exactly the pool's worth sounding and
no leaked voices, and the ship-death case is checked from a node that really
has been freed on that frame.
2026-08-29 13:05:47 +02:00
Loïc Wisniewski
080e3bb928 #147: one resource per line, and the price back under its button
Both halves of the report had the same cause: a craft cell laid out with
more slack than its row pitch had room for.

The caption box was 62px for a one-line vessel name, which pushed the price
70px clear of the button it belonged to - "far from the button they are
about". And the price was given 184px of width in a 190px column, so a
two-resource price ran out of its own box and into its neighbour's.

Prices now put each resource on its own line. Not only where they would
otherwise overflow, which was my first attempt: two resources came to 174px
in a 184px box, so it fitted - until the player was holding three figures
rather than one, and then it did not. A layout that is correct until you get
rich is not correct, and the ticket asked for the line break outright.

The caption is one line tall now with the price directly under it, and rows
are placed off a MEASURED band rather than a written-down pair of tops - the
same fix #144 needed for the build menu, and now the same code. A
centre-anchored grid under a top-anchored header has a gap between them that
is half the panel height, so it can never be a constant.

The test pins what the ticket describes rather than the numbers behind it: a
multi-resource price is stacked, its content stays inside its own box, it
sits within 80px of its button, and no craft entry overlaps another - the
price of one row into the button of the next included, which the old
geometry did by 14px whatever was priced.
2026-08-29 13:05:46 +02:00
Loïc Wisniewski
aabd5804cf #148: the main theme plays, and keeps playing
"All the time" is the whole of the requirement and it decides the shape.
The theme has to survive the main menu becoming the game, the game becoming
a planet surface, and the surface becoming the game again - so the player
cannot live in any of those scenes, because change_scene_to_file() frees
whichever one is current.

It is parented to the tree root instead, beside the GameState autoload,
which is the same trick for the same reason. Music.ensure() is idempotent by
node name, so each scene asks for the music on the way up and gets the one
already playing rather than a second copy over the top of it. Two call
sites cover everything: MainMenu, and GameController - which is the root
script of BOTH the space scene and the planet surface.

Done this way rather than as an autoload deliberately: project.godot carries
the developer's own uncommitted window settings, and adding music should not
drag those into a commit.

It is also PROCESS_MODE_ALWAYS. Since #128 a dialog stops the tree, and a
soundtrack that cuts out every time someone reads a line is worse than no
soundtrack.

The theme ships as MP3 rather than the 59MB WAV that was sitting in the
project root. Same 5m36s, 6.7MB, encoded with LAME at 160k - 89% smaller,
which is the difference between a repository that carries its music and one
that carries a WAV forever in its history. The original file is untouched
and still there, untracked, so nothing is lost.

The test checks CONTINUITY rather than "a sound started": the same player,
never a second one, through two scene changes and a dialog.
2026-08-29 13:05:21 +02:00
Loïc Wisniewski
2206b55873 #145, #144, #143: a quest that could not be finished, and two boxes that overhung
#145 - the extractor quest could never be completed. Extractors are placed
onto a planet from space and live in GameState.extractors, with their own
position, stock and id; they are not in `buildings`. So the goal asked
has_building_of_type("extractor"), which is false however many the player
builds. Not the death the reporter guessed at - the quest was simply asking
a question that had no yes.

structure_exists() is the question anything wanting "does the player have
one of these" should ask, and the quest model asks it now. The regression
test builds every structure quest's target THE WAY THE GAME BUILDS IT and
watches the goal flip, rather than knowing which array each one lands in -
the knowing is what got this wrong in the first place.

#144 - the build menu's status line is anchored to the top of the panel and
its grid to the panel's centre, so the gap between the two is half the panel
height. It was a written-down constant, correct only at 1280 tall; at 1080
the grid started four pixels ABOVE the status line and drew through it.

Nothing about that geometry is written against a screen size any more. The
band is measured from the header that is actually there, the pitch closes up
to fit it (never below a row's own height, which is what makes overlap
impossible rather than unlikely), and the column count rises until the rows
fit - so a short window gets a wide grid instead of one running off the
bottom. Ten structures now lay out as 5 columns at 900px tall, 4 at 1080, 3
at 1280 and 2 at 1600, clearing the header at every one, which is what the
test walks.

#143 - a hangar caption is "Gas Bomber\n2 available": two lines before
autowrap and three after it in a narrow column, at a size fixed in the
scene. Labels do not clip by default, so the third line was drawn over the
icon of the row below. The size is fitted to the box the layout works out,
and the box clips - the first keeps it legible, the second keeps it correct
however the vessel names grow.

Its box also overhung by exactly HANGAR_LABEL_GAP: the layout subtracted one
gap where it needed two, above the caption and below it. Two pixels, but two
pixels inside the next row's icon.
2026-08-29 12:51:09 +02:00
Loïc Wisniewski
90c34f6c74 #141, #142: the chain teaches research, and asks for the pile before the build
Two gaps between the first quests, both of the same kind: a step the chain
assumed rather than asked for.

#142 - "collect 5 ore" was followed straight by "build the mothership",
which costs twenty. The first thing the chain ever asked for was four times
what it had just taught, with nothing in between saying so. A stockpile
quest sits between them now.

#141 - the crafter quest's brief said "spend ore on the Shipyards doctrine,
then put a ship crafter down", which is two unrelated things off one line:
open a menu the player has never opened, and place a structure. Researching
Shipyards is its own quest now, and it is the one that says where the
research tree lives. Every later quest that needs a doctrine still only
mentions it in passing - once is enough.

That needed a goal kind for researching a doctrine, which the model did not
have.

Both are pinned as properties rather than as positions in a list. The chain
must ask for at least as much ore as the first structure it wants costs; and
the first structure that is gated behind a doctrine must have that doctrine
as a quest of its own. Only the first - demanding one per building would
double the chain for no teaching.

One thing that fell out of writing those: the pile is exactly the starting
fleet's hold. An interceptor carries 20 and the mothership costs 20, and
before the mothership exists there is nowhere to bank anything, so the hold
is the only store there is. It fits with no slack at all, so there is now an
assertion saying so - a pile larger than the hold would be a quest that
cannot be finished, and nothing else would have caught it.
2026-08-28 15:47:38 +02:00
Loïc Wisniewski
970eafc1de #140: the objective moves behind a button next to the map's
The standing tracker #132 put on the HUD sat in the right-hand column, on
top of the dev wave button - and there is no free strip of that edge to move
it to, because everything down it is already something. So the objective
stops living on the HUD: a quest button beside the map's, and a panel when
you press it.

The panel shows the current objective, its progress, and the road behind it.
Deliberately NOT what comes next - the chain is meant to open the game up a
step at a time (#133), and a list of everything still to come is that
discovery given away on the first press.

It owns its own button, the way WorldMap owns the map button rather than
being wired through BuildController, and it hides that button during the
prologue for the same reason and in the same two places (at _ready as well
as in _process, since #128 means a dialog stops the tree before the first
_process frame ever comes).

Both scenes carry it. On a planet surface there is no map button to sit
beside, so it goes under the planet's name.

The test now checks the button is beside the map's and clear of the wave
button by rectangle rather than by eye, which is the bug the ticket reported.
2026-08-28 15:37:13 +02:00
Loïc Wisniewski
ac4b96ef49 #139: the prologue's debris belt keeps off the screen too
The belt was laid out with its own arithmetic - a centre BELT_AHEAD in front
of the fleet, rocks spread BELT_DEPTH either side of it - so its near edge
reached back to within a screen of the convoy. At the aspect it was
eyeballed on that was just outside the view; at a wider one, or any zoom
out, rocks appeared in plain sight.

#126 already answered this question for the rest of the game, so the answer
is borrowed rather than restated: AsteroidField's keep-out rect and its
push-out-through-the-nearest-edge placement are public now, and the belt
runs through them. That also means the two cannot drift apart later.

The distance moves as well, 2200 to 2900. The clamp alone would work, but it
would work by pressing every stray rock onto the boundary line; with the
belt properly out in front, the clamp has almost nothing left to do and the
belt still looks laid out ahead rather than pinned to the edge of the
screen. Measured: nearest rock 1551 units out, against a keep-out that
reaches 1100.
2026-08-28 15:37:11 +02:00
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
710dcbf230 #130, #135: a vessel's death takes a moment, and leaves an interceptor
#130 asked for the hull to break away from the fleet, drift a few metres,
and go up further off with something more explosive than a light animation.

The wreck is a separate node rather than the Ship staying alive to animate,
and that is the load-bearing part. A dying Ship still in the tree is still
an Area2D in the player_ships group with live weapon components - it would
keep firing, keep being fired at, keep counting as a target, and keep
colliding on the way out. So the ship's own lifecycle is untouched: it
leaves the fleet, emits destroyed, and is freed on exactly the frame it
always was. What is left behind is scenery, and the test pins that.

The explosion at the end was also the quieter of the two the game has.
Killing an enemy was made deliberately loud by #87 while losing one of your
own kept the base class's twelve-particle default - the reward louder than
the loss. It is now the bigger of the two.

#135 is the same scene from the other end: the prologue's convoy flies its
escorts on the corners of the box, which is the exposed part of it, and the
debris belt can take all four. With no interceptor left, the only hulls the
vortex could spare were holds, and the player flew out of the prologue in a
carrier. Two guards now: one interceptor is put back into the convoy at the
top of the vortex, where a vessel appearing among ten being thrown around a
black hole is not something anyone will catch; and the handover checks the
TYPES it hands over rather than only the count - one surviving carrier is
also a fleet of one, and it is not the fleet a new game starts with.
2026-08-28 15:05:02 +02:00
Loïc Wisniewski
397983b611 #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
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
902d6922e7 #127: dialogs wait for the reader, and say who is talking
A line now stays until it is DISMISSED - there is no timer under it at all.
A prologue whose lines expire on their own reads at the game's pace rather
than at the reader's, and that was most of what made the opening feel
rushed even after #125 slowed the beats down.

The beats that are about the guide talking already hold while the banner is
speaking, so dismissing a line is what advances the prologue. A player who
never clicks is still never stuck: each beat's own deadline is the way out,
and the test pins that the deadline sits past the floor.

A dismissed line eats the press, so clicking to continue does not also steer
the fleet on the way through.

The speaker's name sits in a notch cut out of the top border at the left,
the way a label is riveted to a panel - the frame is one polygon, so the
fill and the stroke cannot disagree about where that notch is. Everything up
to the breach is the convoy's controller; after it there is nobody left to
talk to, so the closing lines are the ship's own log, which is the point
being made.

The tick strip along the bottom is gone. It read as an underline attached to
the last line rather than as part of the frame. What replaces it is a
blinking caret at the bottom right - the one thing that says a click is what
moves this on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:57:56 +02:00
Loïc Wisniewski
ddc3de595e #126: asteroids are only created off screen
The guard was a 420px bubble around the fleet, which is smaller than the
window - so a rock could and did materialise in plain sight, several hundred
pixels away but well inside the view. "Not on the fleet" was never the rule
the player notices; "not in view" is.

The keep-out area is now the visible world rect, grown by a margin so a rock
placed just outside does not drift into view on the frame it was created.
The old bubble survives as a FLOOR under it: what to use when the viewport
cannot be measured - no camera yet on a scene's first frame, or a headless
run with no window - and for a heavily zoomed-in view where the screen is
narrower than the distance a rock needs to be safe.

Candidates inside are pushed out through the nearest edge rather than
dropped, so the field keeps its density around the fleet instead of thinning
out exactly where the player is looking.

Measured: keep-out 2200x2200 over a 1920 screen, nearest asteroid 1899 units
out where it used to be allowed at 420.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:57:55 +02:00
Loïc Wisniewski
988baa8fd6 Add the .uid files for the newest tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:50:51 +02:00
Loïc Wisniewski
eb17b3d607 Add the .uid files for the newest tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:25:31 +02:00
Loïc Wisniewski
d455361328 #125: slow the prologue down, and give the breach a real black hole
Five things, all of them "this happens too fast or looks too thin".

PACING. Every beat had a completion test that could be satisfied while the
guide was still mid-sentence, which is why everything landed seconds after
the thing before it. Beats now have a FLOOR as well as a deadline - a
minimum length - and a beat whose point is the guide talking will not end
while a line is still on screen. One rule in one place rather than tuning
each condition. The run is 3400px instead of 1500, which at cruise was about
four seconds, and a held line is 6.5s instead of 4.5.

THE CONVOY. Escorts at the four corners of a box, holds packed in the middle
of it, roughly three times the spread of the fleet's own wedge - this is
freight under guard, not a fighting formation. CONVOY and CONVOY_FORMATION
pair index-for-index, which is all it takes for spawn_ships to seat the guns
on the outside, and the test pins that every hold sits further in than the
furthest-out escort.

THE DIALOG. Cut corners, brackets holding the short edges open, a tick strip
along the bottom and a caret in the corner - geometry rather than art, so it
scales with the banner and costs nothing.

THE BREACH. It was a few lines turning around a point. It is now two halves
driven by one number: DimensionalBreach, a growing black hole out in the
world built on BlackHoleVisual's idiom - empty core, counter-rotating arc
segments hotter toward the centre - with infalling streamers it does not
have, because a planet's disc is steady and this one has to say "now"; and
breach.gdshader over the top, bending the ACTUAL SCENE around it. Ships,
rocks and the starfield all get dragged in, which is most of what makes a
hole in space read as a hole rather than as a decal drawn on one.

Inside the horizon the shader samples nothing at all - the same decision
BlackHoleVisual makes, for the same reason: a black hole with something
visible in the middle stops being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:25:10 +02:00
Loïc Wisniewski
7d344b2a0a Add the .uid files Godot generated for the newest scripts
Godot writes one beside every script and the repo tracks them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:10:41 +02:00
Loïc Wisniewski
ec7a8f1f17 Cover the last batch of tickets with tests
The previous ten tickets shipped with almost no coverage - only #109, #113,
#115 and #117 touched a test. That was a gap, not a decision: everything
before it had its behaviour pinned, and #116 in particular added a whole
subsystem nothing was watching.

Four new files and one extended case:

  test_spacial_workshop        #116 - the repair economy end to end: the
                               cost of a repair scales with the damage, the
                               workshop asks logistics for that ore and
                               stops at its buffer, a drone spends when it
                               LEAVES so two cannot double-charge one wall,
                               and a target levelled in flight sends its
                               load home rather than swallowing it
  test_desktop_controls        #118/#121 - both driven through the real
                               routing, because the routing is what was
                               missing; a test calling handle_mouse_button()
                               itself would have passed against both reports
  test_enemy_bomber            #114 - it flies, it faces where it flies, its
                               bombs arrive, and it bombs structures with no
                               fleet in sight
  test_menu_polish             #111/#112/#119/#120 - what each panel shows
  test_wave_system (case G)    #110 - the dev button, and that it does not
                               exist outside dev mode

One assertion worth recording: the craft caption is checked as an EXACT
match against the vessel's display name rather than by hunting for resource
words in it. The first version failed on "Gas Bomber" - a vessel named after
a resource, and always will be. What #111 removed was the price spelled out
underneath, not the name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:10:31 +02:00
Loïc Wisniewski
0810c99f08 #116: the Spacial Workshop, and the drones that mend what waves break
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 are upgraded at a ship lab like any other
vessel. Anything that already knows how hub transporters behave knows how
these do, and Drone Bays raises the count at workshops already standing -
the same shape as Freight Yards and Garrison Bays.

What a repair COSTS is the one new idea. It is 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, and patching one up after a wave
costs a fraction, the fraction being the damage. The ore is spent when a
drone LEAVES, so two drones cannot be sent to the same wall and spend twice
for one load of repair, and a building demolished in transit sends its load
home rather than swallowing it.

"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, because building_logistics_deficit now takes the larger
of the definition's static buffer and what the world actually demands. There
is a ceiling on that, so one badly-mauled base cannot pull the whole bank
into a single workshop and starve everything else.

Worst first: the structure closest to coming down is the one a repair is
worth most on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:59:51 +02:00
Loïc Wisniewski
551360f2e9 #112: every skill node carries an icon
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. Doctrines and permanent bonuses, which have no building to point at,
carry an explicit one.

Drawn as child IconButtons with their frame turned off rather than
reimplemented in the tree's _draw: the glyphs already exist there and are
not trivial, and a dozen of them copied here would be two places to change
every time one is redrawn. They are inert - MOUSE_FILTER_IGNORE means the
tree's raw-input handling still sees every press, exactly as it did when
there was nothing on top of the nodes - and hidden rather than clipped when
scrolled off the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:59:27 +02:00
Loïc Wisniewski
c5c41b85e7 #111/#120: the crafter and hangar panels get room to breathe
#111. Six vessels were authored as one row of 65px buttons with 105px
captions overlapping each other, so "Energy Propulser" was clipped and the
price was crushed onto a second line under it. Laid out in code now, in two
rows of three, and the price is GLYPHS rather than words - every resource
already has a mark the resource bar and the transfer list draw, so a price
reads as a number beside a symbol instead of "15 ore · 5 gas" eating the two
lines the vessel's own name needed.

#120. The hangar's icons give up height so their captions have somewhere to
go - vessel names are two and three words long and were wrapping into a box
barely tall enough for one line, so "Gas Bomber" read as "Gas". And there is
a rule between the two halves now: what is parked here and what is flying
are different questions, and with only a heading between them the grids read
as one long list.

The divider is drawn by the menu itself rather than by a separate node,
because its y comes from the same layout pass that places both grids - a
line positioned independently would drift away from the sections it divides
the first time either changed size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:59:26 +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
39ad050dc6 #117: the crafter and the lab animate only while they are working
A factory whose cranes swing over an empty cradle reads as working on
nothing, and a lab sweeping instruments over a vessel it is not studying
reads the same way.

The CLOCK stops rather than the drawing. Every moving element is a NeonPiece
whose position is pushed from that clock, so a frozen clock leaves the
building standing with its cranes parked; stopping apply_animation() instead
would leave them wherever the last frame happened to put them, which is not
the same thing as idle.

The state is read by tree lookup rather than through the bare `GameState`
identifier. These visual scripts are reached through a BuildingDefinition's
visual_scene, which is preloaded while the definition catalog is still
loading - early enough that the autoload is not in the global class table
yet, and a bare reference fails to COMPILE the whole file. That failure also
hung test_building_redraw_cost, because a visual that never built its pieces
left a null crane being dereferenced every frame.

Both animation tests now say what they mean: the redraw-cost test queues an
order first, because its point is that animating is cheap rather than that
it happens unprompted, and the crafter test additionally pins that an idle
crafter really is still.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:58:59 +02:00
Loïc Wisniewski
f9ed46746d #110: a dev-mode button that puts a wave on the board now
Waves are on a fifty-second clock, which makes anything that happens during
one - the alert, the pathing, buildings taking damage, an outpost garrison
earning its keep - slow to look at even once.

It skips the warning as well as the peace: 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. Gated on dev mode, the same gate the main menu already
uses for the map editor, so it does not exist in a shipped build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:58:58 +02:00
Loïc Wisniewski
0c48b5e739 #114: the enemy bomber flies, and its bombs arrive
It was a siege piece that crept at 20px/s and lobbed a shell taking five
seconds to cross the gap. That read as a thing that was stuck rather than a
thing that was dangerous: by the time a shell landed the fleet had wandered
off, and the tower was still approaching.

It now moves at 150px/s, faces where it is going, and its bombs travel at
460 - fast enough to arrive while the target is roughly where it was aimed,
slow enough that the impact cross is a warning rather than a formality.

The hull is a bomber rather than a lumpy octagon: swept wings, a short
fuselage, a bay under it. The bomb has fins and a hot core down its smoke
trail, and the blast throws real debris - a BombBlastVFX built from the same
LightBurstVFX as every other explosion, plus a second ring chasing the first
outwards and a white flash that is gone almost at once.

It also bombs what the player has BUILT, through the shared targeting rather
than the fleet alone, and its blast damages structures - it had been asking
Fleet directly and hitting only player_ships, which was already out of step
with #101.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:58:56 +02:00
Loïc Wisniewski
1a2a27b6e8 #113: a wave converges on the nearest structure from any distance
A wave is an attack on what the player has built, not a hazard sitting in
space waiting to be flown near. Wave enemies now ignore the detection radius
their components otherwise respect and close on the nearest building from
wherever they arrived.

The flag is per INSTANCE rather than per definition: the same chaser is a
wave when the director launches it and a garrison when a map author places
it, and a planet garrison that chased the player across the system would be
a very different thing from what the editor is used to place. The test pins
both halves - a wave chaser 6000px out closed on a crafter, and an
identically built one nobody sent held its station.

What it fights on the way is unchanged: EnemyTargets already prefers a
vessel inside 520px over a building further off, and already searches the
player_ships group, so the fleet and an outpost's garrison both count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:38:09 +02:00
Loïc Wisniewski
7a78d1b1a6 #115: the lancer winds up once, and leaves hard
One back and forth rather than two and a half, over 0.85s rather than 1.2.
The tell is a recoil and a spring; repeating it turned a wind-up into a
fidget and the player stopped reading it as "about to happen".

And the charge is 1650px/s, up from 950, which read as a fast drift rather
than as the thing the whole wind-up was promising.

The test counts how many times the wind-up crosses its anchor: one back and
forth crosses exactly once, two would cross three times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:38:08 +02:00
Loïc Wisniewski
457ae7023c #109: garrison vessels wander their post instead of walking a ring
Two things were wrong and the second was the real one. A ring is not what a
picket does - but each step around it was shorter than Ship's arrive_radius,
so every frame the vessel SNAPPED onto its target and zeroed its velocity,
and Ship only turns to face a heading while it is actually moving. The
result slid around the outpost pointing whichever way it had spawned.

So: real destinations, far enough apart to be flown to. The vessel picks a
point somewhere in its patrol area, flies there, pauses, and picks another.
A leg is never shorter than wander_min_leg, which is what keeps the
snap-and-stop from coming straight back, and a leg that cannot be reached
times out rather than stranding the picket.

Breaking off a fight now starts a fresh leg from where the fight ended,
rather than flying back to a waypoint chosen before it began.

Measured: 485px of wander, 46-271px from its post, up to 690px/s, pointing
where it was going for 88% of its travel. The test measures that facing as a
FRACTION rather than a worst instant - a vessel just given a new heading is
legitimately still swinging onto it at its own rotation_speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:38:07 +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
d8982fb800 Add the .uid files Godot generated for the new scripts
Godot writes one beside every script and the repo tracks them; these are the
ones for the files added across #99-#107 and the ability seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:50 +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
bd0cc842ff #105 (#97g): garrison vessels patrol and defend their outpost
A garrisoned vessel circles its post, engages what comes into range, and
goes back to circling. It steers by writing move_target and lets Ship's own
steering fly it, so it accelerates, banks and brakes exactly like a vessel
in the formation.

It is not in Fleet.ships. It does not follow the formation, does not count
against max_fleet_size(), and does not move when the player flies away -
which is the whole point of stationing it. A garrison that ate a fleet slot
would mean stationing a vessel cost the player a place in their own wedge.

The leash is the load-bearing part. Enemies are judged from the POST rather
than from the vessel, so a garrison that has drifted out to its leash cannot
see further still and be walked away from its outpost one enemy at a time,
and an engagement is clamped to a circle around the post rather than to a
straight line, so it still manoeuvres while pinned.

The orbit starts from where the vessel actually is rather than a random
bearing. A random one makes the first move_target a point on the far side of
the circle, and the vessel flies straight across the middle - through the
outpost it is guarding.

EnemyTargets.nearest_ship now searches the player_ships GROUP instead of
Fleet.ships: an enemy that ignored the guns shooting at it, because they
were not in the player's formation, would let a garrison hold a position
without ever being fought back.

The laser vessel is deliberately not garrisonable. Firing the laser lives in
LaserController, a fleet ability driven by the laser button, rather than in
VesselLaserComponent - so a garrisoned laser would hold its position and
never shoot, and offering a vessel that cannot fight is worse than a short
list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:13:47 +02:00
Loïc Wisniewski
8122e280b7 #104 (#97f): outposts are crewed automatically from the mothership
Vessels come from the HANGAR and never from the fleet. The fleet is what the
player is flying, and taking a vessel out of it to crew a building would be
the game undoing the player's own arrangement. "Whenever available" means
"when one is sitting at the mothership", and an outpost asking for a hull
nobody has built simply waits.

They travel, down the same vessel_deliveries path a finished crafter order
already uses to reach the mothership, rather than teleporting.

What is already in the air counts against the shortfall. Without that the
dispatcher sends the whole hangar to fill one berth, because the crew does
not grow until the first arrival lands.

Two directions, not one: lowering the order flies the surplus home rather
than stranding it, and a vessel whose outpost is levelled under it lands in
the hangar instead of being destroyed - the same rule a transporter
mid-delivery already follows with its cargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:13:46 +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
3debcd7edd #101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.

EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.

Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.

What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.

Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
Loïc Wisniewski
26f3728546 #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
Loïc Wisniewski
c221584848 #99 (#97a): buildings have health, and can be destroyed
Nothing can attack a structure that cannot be hurt, so this is the floor the
rest of #97 stands on.

max_hp is per definition - an extractor bolted to a rock is not the
mothership - and the current value lives on the RECORD in GameState, 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.

A record written before this carries no hp field at all. That reads as
undamaged rather than as zero, or an existing save would open on a field of
wrecks.

The mothership cannot be destroyed - it floors at 1hp. The bank, the hangar
and research all live there and a wiped fleet rebuilds there, so losing it
would be a soft-lock with no route back. Keyed on the "bank" capability
rather than on the type: whatever building holds the bank is the one that
cannot be lost.

Destroying a hub retires its transporters. 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:40 +02:00
Loïc Wisniewski
88b53abf52 Retry a launch that came up and then said nothing
The same interop launch flake that produced exit 127 with an empty log has a
second face: a non-zero exit after the engine banner and not one line more.
test_fleet_growth went that way once in six full-suite runs and passed alone
every time.

The line between a flake and a failure is output, not the exit code and not
the banner - every test says something before it stops, its own FAIL
verdict, a SCRIPT ERROR, an engine complaint. A log holding nothing but the
banner is a run that did not happen, so it is retried and, if it stays
silent, reported as NOSTART rather than as a test that failed. A
deterministic silent crash still fails all three attempts.

The check reads grep's output rather than its exit status: `grep -q -v` does
not agree across implementations - ugrep, which is what `grep` resolves to
on the WSL box, reports success for an empty file and failure for a file
full of matching lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:19 +02:00
Loïc Wisniewski
890e1c812f #96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.

The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.

EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.

The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
Loïc Wisniewski
5f1b5a9b29 #95: stop printing the bank twice in the research menu
The research table's LOCAL column was not merely equal to BANKED, it was the
same store: research lives on the mothership, the mothership is also the
bank, and building_inventory() redirects any bank-capable building to
bank_inventory itself. ResourceBar now drops LOCAL wherever it resolves to
the bank and lets the remaining two columns share the width. Anywhere else -
a crafter, a lab, the transfer panel - the column is untouched.

The "Research" heading went with it. It named a panel you can only reach by
pressing the research button, and it cost the tree canvas 128px, which the
table and the tree have now taken back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:35:14 +02:00
Loïc Wisniewski
85c9bd9d8d Stop the suite failing on things that are not test failures
Two sources of random red on WSL, neither of them a test.

A launch that never happened. The Godot binary is a Windows executable
started across the interop boundary, and with six going at once that start
occasionally just fails: exit 127, an empty log, no engine banner, nothing
run. It was indistinguishable from a failure and moved between tests from
run to run. Anything that actually ran got as far as printing the banner, so
the two are easy to tell apart; a start that produced no banner is retried
up to three times and reported as NOSTART if it never comes up. Retries are
printed, never hidden - a test that needed one is saying the machine is
struggling even where it went on to pass.

The working tree as a run directory. WSL ran the suite in place, so six
Godot processes wrote .godot and created and deleted files under
PlanetSetups/ inside the live repo, alongside git and whatever else was
using it. Single tests failed at random under that - a delete that did not
take, a scene that would not instantiate - never the same one twice. WSL now
copies to %LOCALAPPDATA% first, the same isolation Termux already gets from
its own copy, on a native NTFS path so the Windows Godot still reads it at
full speed. Costs about 10s a run and leaves the repo untouched.

Four consecutive clean 124/124 runs since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:18 +02:00
Loïc Wisniewski
12bd5eb7bf Clip the cooldown fill with Geometry2D, not by hand
Every cooling vessel logged "Invalid polygon data, triangulation failed"
once per frame, for as long as its cooldown ran - 16 of them in a single
test_laser_vessel run. The fill was drawn anyway, so nothing looked wrong,
but it buried real errors in test logs.

_filled_hull cut each hull edge against the fill line itself, which is
Sutherland-Hodgman, and Sutherland-Hodgman is only sound for a CONVEX
subject. Hulls are not: the gas bomber has a tail notch and the distorder a
waist. On those it stitched the pieces back together with a zero-width
bridge, and a polygon with a zero-width bridge is not one the triangulator
accepts.

Geometry2D.intersect_polygons does the clipping properly and returns a list
of polygons, which is also the truer answer - a notched hull genuinely fills
as two puddles until the level rises past the notch. A weld-and-area guard
stays in front of the renderer for repeated vertices and slivers.

Zero triangulation errors across the vessel tests now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:05 +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
01f053e01a #92: shrink the confirm popup to the window it is in
The card is authored at a fixed 704x1200 around a portrait phone. On a
window wider than it is tall the stretched canvas is about 1080 units high,
so the card sat at y=-60 and hung over both edges - and the confirm and
cancel buttons are anchored to its own bottom edge, so they went off screen
with it. The popup was not merely ugly on a desktop, it was a dead end:
nothing on it could be pressed.

NeonPanel now fits itself to the viewport and re-fits when the window
changes. It only ever shrinks; growing the card would stretch proportions
that are tuned rather than fluid. The fitting adjusts offsets around a
fixed centre, so it applies only to a card pinned to a point - a panel
stretched between different anchors already follows its parent and is left
alone.

Verified across portrait, 16:9, ultrawide and small-laptop shapes, on the
card and on both buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:44 +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
03b1c85569 #91: keep the gas source behind the fleet
On a gas planet the vents were drawn on top of the player. SurfaceFeatures
is added after Fleet in PlanetSurface.tscn, so at the default z_index of 0
the vent field won the tie purely by being the later sibling, and the fleet
flew under its own gas source.

Same one-line fix, for the same reason, as #50's capture rings and the
rocks on a rocky surface. It goes on the field rather than on children
because this one draws in its own _draw().

The test checks the rule instead of the node that was wrong: every drawing
feature, on every surface profile that has features, must end up behind the
fleet - 22 of them today, and any added later without editing the test.
Enemies are excluded and the test says why; they are combatants and share
the fleet's plane on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:21 +02:00
Loïc Wisniewski
b1d179ec9c Add a WSL path to the test runner
run_tests.sh only knew one way to reach a Godot: `proot-distro login ubuntu`
and the arm64 build at /root. On WSL there is no container and no arm64
binary, so the suite could not be run from a desktop at all.

The platform is detected from /proc/version, and decides two things. Which
Godot: on WSL a Linux `godot` on PATH if there is one, otherwise the newest
Windows build already sitting on /mnt/c - the *_console.exe specifically,
since the plain .exe is a GUI-subsystem binary that detaches from the
terminal and would leave every test log empty. And whether to copy the
project first: that copy exists to get off Android's FUSE bridge, and a
Windows Godot already reads C: natively, so WSL runs in place.

_run_inside.sh now takes the binary as its first argument instead of
hardcoding it, and run_tests.sh calls it directly where there is no
container to enter. The scheduling, timeouts and verdicts are unchanged and
shared by both paths.

120/120 on WSL in 48s with JOBS=6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:40:44 +02:00
Loïc Wisniewski
6616952660 Skip the touch-device case where there is no touchscreen
test_desktop_hides_touch_controls failed outright on any machine whose
headless DisplayServer reports no touchscreen. Godot on Android answers
yes, so case C ran on the phone; Godot on Windows answers no, so the test
bailed before reaching cases A and B and reported a failure that said
nothing about the game.

Case C now runs where a touchscreen is reported and is skipped where none
is, which leaves A and B - and the static guard on _note_keyboard's list
and touchscreen check - exercised everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:36:57 +02:00
238 changed files with 14899 additions and 567 deletions

4
.gitignore vendored
View file

@ -13,3 +13,7 @@ android/build/
# Android's "don't index this as media" marker, not project content.
.nomedia
# Per-test verdicts from the last tests/run_tests.sh, kept only so a long run
# whose terminal output was lost can still be read back.
.test-results/

63
audio/Music.gd Normal file
View file

@ -0,0 +1,63 @@
class_name Music
extends RefCounted
# Forgejo #148: "the space_4 file in the folder is the main theme of the
# game, make it play all the time."
#
# "All the time" is the whole of the requirement, and it is what decides the
# shape of this. The theme must survive the main menu becoming the game, the
# game becoming a planet surface, and the surface becoming the game again -
# so the player cannot live in any of those scenes, because
# change_scene_to_file() frees the one that is current.
#
# It is parented to the TREE ROOT instead, alongside the GameState autoload,
# which is the same trick for the same reason. Doing it this way rather than
# by adding an autoload keeps project.godot out of it - that file carries
# the developer's own uncommitted window settings, and a change here should
# not drag those into a commit.
#
# ensure() is idempotent by NAME: whatever scene loads next asks for the
# music, and gets the one already playing rather than a second copy of it.
const THEME_PATH: String = "res://audio/space_4.mp3"
const NODE_NAME: String = "MusicPlayer"
# Under everything else. It is a bed, not an event.
const VOLUME_DB: float = -14.0
# Starts the theme if nothing is playing it yet, and returns the player
# either way. Safe to call from any scene's _ready.
static func ensure(tree: SceneTree) -> AudioStreamPlayer:
if tree == null:
return null
var existing: Node = tree.root.get_node_or_null(NODE_NAME)
if existing != null:
return existing as AudioStreamPlayer
var stream: AudioStream = load(THEME_PATH)
if stream == null:
return null
# Looping is a property of the STREAM, not of the player, so it is set
# here rather than relied on from the .import file - which Godot
# regenerates, and which would silently take the loop away with it.
if "loop" in stream:
stream.loop = true
var player := AudioStreamPlayer.new()
player.name = NODE_NAME
player.stream = stream
player.volume_db = VOLUME_DB
player.autoplay = false
# #128 stops the tree whenever a dialog is up. Music is the one thing
# that should not stop with it - a soundtrack cutting out every time
# someone reads a line is worse than no soundtrack.
player.process_mode = Node.PROCESS_MODE_ALWAYS
# A backstop for the loop above: if a stream format ever turns up that
# cannot loop itself, it still comes round again rather than falling
# silent for the rest of the session.
player.finished.connect(player.play)
# Deferred: this is called from a scene's _ready, and the root is busy
# adding that scene's own children at the time.
tree.root.add_child.call_deferred(player)
player.play.call_deferred()
return player

1
audio/Music.gd.uid Normal file
View file

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

96
audio/Sfx.gd Normal file
View file

@ -0,0 +1,96 @@
class_name Sfx
extends RefCounted
# Forgejo #146: the three things that happen to something and should be
# heard - a shot leaving a gun, a vessel dying, a structure coming down.
#
# The sounds themselves are synthesised (see audio/make_sfx.py) rather than
# downloaded. The ticket asked for free sources and the good ones are in its
# comments, but a generated sound has no licence to track, no attribution to
# keep, and no binary in the repository whose provenance nobody can check
# later. Swapping in a file from one of those packs is a matter of replacing
# the .wav - nothing here knows or cares which it is.
#
# Parented to the tree root for the same reason Music is: a sound started by
# something that is about to be freed - which is every one of these, a dying
# ship being the obvious case - cannot live on the thing that started it.
#
# A POOL rather than one player, because these overlap constantly: a fleet
# firing is several shots inside one shot's length. The oldest is recycled
# when they are all busy, which is the right thing to lose.
const NODE_NAME: String = "SfxPlayers"
const VOICES: int = 12
const SHOT: String = "shot"
const SHIP_DEATH: String = "ship_death"
const BUILDING_DEATH: String = "building_death"
const SOUNDS: Dictionary = {
SHOT: "res://audio/sfx_shot.wav",
SHIP_DEATH: "res://audio/sfx_ship_death.wav",
BUILDING_DEATH: "res://audio/sfx_building_death.wav",
}
# Per-sound level. A shot is fired constantly and a death happens once, so
# they cannot share a volume and stay in proportion.
const LEVELS: Dictionary = {
SHOT: -22.0,
SHIP_DEATH: -6.0,
BUILDING_DEATH: -4.0,
}
# Shots vary a little in pitch so a stream of them does not turn into one
# flat machine-gun note. The deaths do not: they are events, and a wobbling
# explosion sounds broken rather than varied.
const SHOT_PITCH_SPREAD: float = 0.12
static func _pool(tree: SceneTree) -> Node:
var existing: Node = tree.root.get_node_or_null(NODE_NAME)
if existing != null:
return existing
var pool := Node.new()
pool.name = NODE_NAME
# #128 stops the tree for dialogs. A sound already in flight should
# finish rather than freeze mid-bang.
pool.process_mode = Node.PROCESS_MODE_ALWAYS
tree.root.add_child(pool)
for i in range(VOICES):
var voice := AudioStreamPlayer.new()
voice.process_mode = Node.PROCESS_MODE_ALWAYS
pool.add_child(voice)
return pool
# Plays one sound. Safe from a node on its way out of the tree, and safe to
# call far more often than there are voices.
static func play(tree: SceneTree, id: String) -> void:
if tree == null or not SOUNDS.has(id):
return
var stream: AudioStream = load(String(SOUNDS[id]))
if stream == null:
return
var pool: Node = _pool(tree)
var voice: AudioStreamPlayer = null
for child in pool.get_children():
if not (child as AudioStreamPlayer).playing:
voice = child
break
if voice == null:
# All busy. The oldest is the one furthest through its own sound, so
# it is the one with the least left to lose.
var most: float = -1.0
for child in pool.get_children():
var at: float = (child as AudioStreamPlayer).get_playback_position()
if at > most:
most = at
voice = child
if voice == null:
return
voice.stream = stream
voice.volume_db = float(LEVELS.get(id, -10.0))
voice.pitch_scale = 1.0
if id == SHOT:
voice.pitch_scale = 1.0 + randf_range(-SHOT_PITCH_SPREAD, SHOT_PITCH_SPREAD)
voice.play()

1
audio/Sfx.gd.uid Normal file
View file

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

169
audio/make_sfx.py Normal file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Generates the game's sound effects, from scratch.
Forgejo #146 asked for free SFX sources, and the ones worth using are listed
in that ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound
filtered to CC0. What ships here is synthesised instead, for one reason: a
generated sound has no licence, no attribution to keep track of, and no
binary in the repository whose provenance nobody can check a year from now.
It also means the sounds are DATA rather than assets. If a shot is too sharp
or an explosion too short, the fix is a number in this file and a re-run,
not a hunt through a sound library.
#150 and #151 are why the first pass was thrown away. "Shot sounds sound
like a toy" and "death sounds from ship are really cheap", and both were
fair: the shot was a bare descending sine, which is the sound every toy ray
gun has ever made, and the death was one band of filtered noise with a sine
under it - thin, and gone before the eye had finished with the explosion.
What the rewrite adds, and why each part is there:
* LAYERS, not a waveform. A real bang is a crack, then a body, then a
tail, and they have different lengths. One envelope over one source
cannot be any of those things.
* A sub. Below about 80Hz is where "big" lives; without it an explosion is
a hiss however loud it is played.
* SATURATION. Soft-clipping folds in harmonics and stops the peak
normalisation below from turning everything into the same quiet mush.
* Detuned partials on the shot, so it is a machine rather than a flute -
the single sine was the whole of the toy problem.
Run: python3 audio/make_sfx.py
"""
import math
import random
import struct
import wave
RATE = 44100
def _write(name, samples):
peak = max(1e-9, max(abs(s) for s in samples))
frames = b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s / peak * 0.92)) * 32767))
for s in samples)
with wave.open("audio/%s.wav" % name, "w") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(frames)
print("audio/%s.wav %5.2fs %7d bytes" % (name, len(samples) / RATE, len(frames)))
def _decay(i, n, power):
return (1.0 - i / n) ** power
def _attack(i, seconds):
"""A few milliseconds of ramp, so nothing starts on a click."""
a = max(1, int(RATE * seconds))
return min(1.0, i / a)
def _saturate(x, drive):
"""Soft clip. Adds the harmonics that make a sound read as loud rather
than merely being loud."""
return math.tanh(x * drive) / math.tanh(drive)
def _mix(*layers):
n = max(len(layer) for layer in layers)
out = [0.0] * n
for layer in layers:
for i, v in enumerate(layer):
out[i] += v
return out
def _noise(n, seed, cutoff_at):
"""White noise through a one-pole low-pass whose cutoff is a function of
position. Sweeping the cutoff down is what turns a hiss into a roar."""
rng = random.Random(seed)
out = []
last = 0.0
for i in range(n):
cutoff = cutoff_at(i / n)
last += cutoff * (rng.uniform(-1.0, 1.0) - last)
out.append(last)
return out
def _sweep(n, start_hz, end_hz, curve=1.0):
out = []
phase = 0.0
for i in range(n):
t = (i / n) ** curve
hz = start_hz * (end_hz / start_hz) ** t
phase += 2.0 * math.pi * hz / RATE
out.append(math.sin(phase))
return out
def shot(seconds=0.22):
"""An energy weapon discharging: a bright transient, three detuned
partials falling together, and a short sub thump under it.
The partials are what stop it being a toy. One sine sliding down is a
"pew"; three of them at slightly wrong ratios beat against each other
and read as something with a mechanism inside it."""
n = int(RATE * seconds)
crack = [v * _decay(i, n, 14.0) * 0.5
for i, v in enumerate(_noise(n, 11, lambda t: 0.85 - 0.5 * t))]
body = [0.0] * n
for ratio, level, curve in ((1.0, 0.6, 0.9), (1.49, 0.3, 1.05), (2.02, 0.18, 0.8)):
part = _sweep(n, 900.0 * ratio, 190.0 * ratio, curve)
for i, v in enumerate(part):
body[i] += v * level * _decay(i, n, 2.6)
sub = [v * _decay(i, n, 3.2) * 0.55
for i, v in enumerate(_sweep(n, 220.0, 60.0, 0.7))]
mixed = _mix(crack, body, sub)
return [_saturate(v, 2.0) * _attack(i, 0.0015) for i, v in enumerate(mixed)]
def explosion(seconds, sub_from, sub_to, roar_seed, debris_level, drive):
"""A blast in three layers on three clocks: the crack is over in a
fifth of a second, the roar runs most of the length, and the debris
keeps falling afterwards."""
n = int(RATE * seconds)
crack_n = int(n * 0.18)
crack = [v * _decay(i, crack_n, 5.0) * 0.9
for i, v in enumerate(_noise(crack_n, 3, lambda t: 0.95 - 0.55 * t))]
crack += [0.0] * (n - crack_n)
roar = [v * _decay(i, n, 1.5)
for i, v in enumerate(_noise(n, roar_seed, lambda t: 0.30 * (1.0 - t) ** 1.4 + 0.012))]
# The sub is the size of the thing. It falls slowly and outlasts the
# crack by a long way.
sub = [v * _decay(i, n, 1.15) * 1.25
for i, v in enumerate(_sweep(n, sub_from, sub_to, 0.55))]
# Debris: sparse, dry ticks over the back half, so the sound has an
# aftermath rather than simply stopping.
rng = random.Random(roar_seed + 40)
debris = [0.0] * n
for _ in range(int(seconds * 34)):
at = rng.randint(int(n * 0.25), n - 1)
length = rng.randint(300, 1400)
pitch = rng.uniform(140.0, 900.0)
for k in range(min(length, n - at)):
debris[at + k] += (math.sin(2.0 * math.pi * pitch * k / RATE)
* (1.0 - k / length) ** 3.0
* debris_level * rng.uniform(0.5, 1.0))
mixed = _mix(crack, roar, sub, debris)
return [_saturate(v, drive) * _attack(i, 0.001) for i, v in enumerate(mixed)]
if __name__ == "__main__":
_write("sfx_shot", shot())
# A vessel coming apart: bright, fast, with real low end under it.
_write("sfx_ship_death", explosion(1.45, 260.0, 38.0, 5, 0.16, 2.6))
# A structure: longer, deeper, and it keeps coming down afterwards.
_write("sfx_building_death", explosion(2.3, 150.0, 24.0, 9, 0.24, 3.1))

0
audio/masters/.gdignore Normal file
View file

18
audio/masters/README.md Normal file
View file

@ -0,0 +1,18 @@
# Audio masters
The source files the shipped audio is made from. Versioned, but **not part of
the game**: the empty `.gdignore` beside this file tells Godot to skip this
folder entirely, so nothing here is imported into `res://` and nothing here
is carried into an export.
That split is the point. `SPACE_4.wav` is 59MB of 16-bit stereo; the theme
the game actually loads is `../space_4.mp3`, the same 5m36s at LAME 160k in
6.7MB. Shipping the WAV as well would put 59MB into every Android and web
build for a file nothing opens.
| master | shipped as | made with |
| --- | --- | --- |
| `SPACE_4.wav` | `../space_4.mp3` | `ffmpeg -i SPACE_4.wav -c:a libmp3lame -b:a 160k ../space_4.mp3` |
The sound effects have no master here - they are generated outright by
`../make_sfx.py`, which is their source.

BIN
audio/masters/SPACE_4.wav Normal file

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cucu8do3xu62b"
path="res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"
[deps]
source_file="res://audio/sfx_building_death.wav"
dest_files=["res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

BIN
audio/sfx_ship_death.wav Normal file

Binary file not shown.

View file

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cr6bt4w8n6d24"
path="res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"
[deps]
source_file="res://audio/sfx_ship_death.wav"
dest_files=["res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

BIN
audio/sfx_shot.wav Normal file

Binary file not shown.

24
audio/sfx_shot.wav.import Normal file
View file

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c0le6ax67g411"
path="res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"
[deps]
source_file="res://audio/sfx_shot.wav"
dest_files=["res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

BIN
audio/space_4.mp3 Normal file

Binary file not shown.

19
audio/space_4.mp3.import Normal file
View file

@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://bks0l0gbo0k48"
path="res://.godot/imported/space_4.mp3-c5b265d027b77338d1ac353a03658ba9.mp3str"
[deps]
source_file="res://audio/space_4.mp3"
dest_files=["res://.godot/imported/space_4.mp3-c5b265d027b77338d1ac353a03658ba9.mp3str"]
[params]
loop=true
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

View file

@ -14,6 +14,18 @@ var building_type: String:
set(value): definition_id = value
# #99: drawn only once the structure has actually been hit. A full bar over
# every building would be permanent clutter saying nothing, and the fleet
# would be flying over a field of meters in peacetime.
const HEALTH_BAR_WIDTH: float = 74.0
const HEALTH_BAR_HEIGHT: float = 7.0
const HEALTH_BAR_OFFSET: float = -78.0
const HEALTH_FULL_COLOR: Color = Color(0.45, 0.9, 0.55)
const HEALTH_HURT_COLOR: Color = Color(1.0, 0.35, 0.25)
var _drawn_health: float = 1.0
func _ready() -> void:
add_to_group("buildings")
z_index = -1
@ -24,6 +36,41 @@ func _ready() -> void:
get_node("VisualHost").add_child(visual)
func health_fraction() -> float:
return GameState.building_health_fraction(building_id) if building_id != 0 else 1.0
func is_damaged() -> bool:
return health_fraction() < 0.999
func _process(_delta: float) -> void:
# Redraw only when the number moves. A building that is not being shot at
# costs nothing, which is the same bargain Ship.gd's constant glow makes.
var health: float = health_fraction()
if is_equal_approx(health, _drawn_health):
return
_drawn_health = health
queue_redraw()
func _draw() -> void:
if not is_damaged():
return
# Above the structure rather than across it: the visual scenes all draw
# around their own origin, and a bar laid over one would be read as part
# of the building.
var health: float = health_fraction()
var left: float = -HEALTH_BAR_WIDTH * 0.5
var top: float = HEALTH_BAR_OFFSET
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT),
Color(0.02, 0.02, 0.06, 0.85))
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH * health, HEALTH_BAR_HEIGHT),
HEALTH_HURT_COLOR.lerp(HEALTH_FULL_COLOR, health))
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT),
Color(0.55, 0.78, 0.86, 0.55), false, 1.0)
func available_actions() -> PackedStringArray:
if visual == null:
return PackedStringArray()

View file

@ -1,8 +1,18 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://buildings/Building.gd" id="1"]
[ext_resource type="Script" path="res://buildings/BuildingHitbox.gd" id="2"]
[sub_resource type="CircleShape2D" id="1"]
radius = 72.0
[node name="Building" type="Node2D"]
script = ExtResource("1")
[node name="VisualHost" type="Node2D" parent="."]
[node name="Hitbox" type="Area2D" parent="."]
script = ExtResource("2")
[node name="CollisionShape2D" type="CollisionShape2D" parent="Hitbox"]
shape = SubResource("1")

View file

@ -4,7 +4,17 @@ extends Resource
@export var definition_id: String
@export var display_name: String
@export_multiline var description: String
# What it costs in ORE, and the whole price when it wants more than ore.
#
# #138: structures all charged rocks and nothing else, whatever they were.
# The dictionary is the same shape VesselDefinition has used since gas
# bombers and laser ships (build_cost / build_resource_costs): when it is
# empty the price is `cost` in ore, and when it is set it IS the price, ore
# included - and its ore entry must agree with `cost`, which the test pins,
# because repair costs are quoted from `cost` alone (a repair drone carries
# ore and nothing else).
@export var cost: int
@export var build_resource_costs: Dictionary = {}
@export var action_icon: int
@export var menu_icon: int
@export var visual_scene: PackedScene
@ -19,3 +29,7 @@ extends Resource
@export var menu_cost_name: String
@export_enum("space", "planet") var placement_mode: String = "space"
@export var unique: bool = false
# #99: what a wave has to chew through to level this. Data rather than one
# constant for every structure - an extractor bolted to a rock is not the
# mothership. See GameState.damage_building().
@export var max_hp: int = 40

View file

@ -1,11 +1,14 @@
class_name BuildingDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab", "gas_burner"]
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "extractor", "transport_hub", "ship_lab", "gas_burner"]
const DEFINITIONS: Dictionary = {
"mothership": preload("res://buildings/MothershipBuildingDefinition.tres"),
"ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"),
"command_center": preload("res://buildings/CommandBuildingDefinition.tres"),
"outpost": preload("res://buildings/OutpostBuildingDefinition.tres"),
"planning_center": preload("res://buildings/PlanningBuildingDefinition.tres"),
"spacial_workshop": preload("res://buildings/WorkshopBuildingDefinition.tres"),
"extractor": preload("res://buildings/ExtractorBuildingDefinition.tres"),
"transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"),
"ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"),

View file

@ -22,9 +22,28 @@ var spawned: Array = []
func _ready() -> void:
# #99: the record is the truth, so the node follows it out. Connected
# here rather than in Building.gd because the field owns `spawned` and
# would otherwise be left holding a freed node.
if not GameState.building_destroyed.is_connected(_on_building_destroyed):
GameState.building_destroyed.connect(_on_building_destroyed)
rebuild_all()
func _on_building_destroyed(building_id: int, _type: String) -> void:
var kept: Array = []
for b in spawned:
if not is_instance_valid(b):
continue
if int(b.building_id) == building_id:
BuildingExplosionVFX.spawn(get_tree().root, b.global_position)
Sfx.play(get_tree(), Sfx.BUILDING_DEATH)
b.queue_free()
continue
kept.append(b)
spawned = kept
func rebuild_all() -> void:
for b in spawned:
if is_instance_valid(b):

View file

@ -0,0 +1,54 @@
extends Area2D
# #101 (#97c): the part of a structure a wave can actually hit.
#
# A Building is a plain Node2D, so nothing could collide with it - enemies
# flew through the mothership without noticing. This gives it a body in the
# same shape the rest of the game already understands: an Area2D in a group,
# exactly like a Ship or an asteroid, so EnemyProjectile needed one extra
# branch rather than a new concept.
#
# Damage is applied on a TICK rather than on area_entered. Entry fires once,
# which is right for a rammer - it dies on the first strike - but would let
# a sniper that drifted inside sit there scratch-free for ever. On a tick,
# an enemy parked on a structure is besieging it, which is what #97 asks
# for.
const STRIKE_INTERVAL: float = 0.7
var _strike_timer: float = 0.0
func _ready() -> void:
add_to_group("player_buildings")
func building_id() -> int:
var building: Node = get_parent()
return 0 if building == null else int(building.building_id)
# Called by EnemyProjectile, which knows nothing about buildings beyond this
# name - the same call it makes on a Ship.
func take_damage(amount: int) -> void:
GameState.damage_building(building_id(), amount)
func _physics_process(delta: float) -> void:
_strike_timer -= delta
if _strike_timer > 0.0:
return
_strike_timer = STRIKE_INTERVAL
var id: int = building_id()
if id == 0:
return
for area in get_overlapping_areas():
if not is_instance_valid(area) or not area.is_in_group("enemies"):
continue
var damage: int = area.contact_damage() if area.has_method("contact_damage") else 6
# The structure is told first, for the same reason Ship tells the
# enemy first: resolving the enemy's own death can free it, and a
# rammer must still land its hit on the way out.
GameState.damage_building(id, damage)
if area.has_method("collide_with_player_building"):
area.collide_with_player_building()

View file

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

View file

@ -26,6 +26,46 @@ const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9)
const GAS_BURNER_COLOR: Color = Color(0.95, 0.35, 0.15)
const GAS_BURNER_ENERGY_COLOR: Color = Color(0.85, 0.95, 0.15)
# #102. Olive rather than another blue or amber: every existing structure is
# a place you go to DO something, and this is the one you leave people at.
const OUTPOST_COLOR: Color = Color(0.72, 0.86, 0.45)
# #106. A cool grey-blue: this is the one structure that does no work at all,
# it only decides what work the others do.
const PLANNING_COLOR: Color = Color(0.62, 0.74, 0.92)
# #116. Warm steel: the workshop is the only structure whose whole job is
# looking after the others, and it should read as a yard rather than a plant.
const WORKSHOP_COLOR: Color = Color(0.5, 0.95, 0.8)
# Workshop: an open service gantry with a cradle under it and drone berths
# either side. Deliberately unenclosed - what it holds is other people's
# buildings, arriving and leaving.
const YARD_GANTRY_W: float = 96.0
const YARD_GANTRY_H: float = 16.0
const YARD_LEG_DROP: float = 58.0
const YARD_CRADLE_R: float = 34.0
const YARD_BERTH_X: float = 78.0
# Planning centre: a standing board with rows of tallies on it, flanked by
# two slim pylons. Rectilinear on purpose - every other structure here is
# radial or trussed, and this one is an office.
const PLAN_BOARD_W: float = 86.0
const PLAN_BOARD_H: float = 62.0
const PLAN_ROW_COUNT: int = 4
const PLAN_PYLON_X: float = 104.0
const PLAN_PYLON_H: float = 54.0
# Outpost: a watch mast on a tripod footing, with two open berths beside it
# where a garrison sits. Deliberately the least machinery of any structure
# here - it makes nothing and refines nothing, it just holds a position -
# and deliberately not radial, so it cannot be mistaken for a small hub.
const OUTPOST_MAST_TOP: float = -84.0
const OUTPOST_MAST_FOOT: float = 46.0
const OUTPOST_HEAD_RADIUS: float = 20.0
const OUTPOST_FOOT_SPAN: float = 62.0
const OUTPOST_FOOT_DROP: float = 26.0
const OUTPOST_BERTH_X: float = 74.0
const OUTPOST_BERTH_RADIUS: float = 22.0
const OUTPOST_VANE_COUNT: int = 3
# Ship lab: a test frame with a vessel held in it and instruments sweeping
# over it. Reads as a workshop rather than a factory - the crafter already
@ -112,7 +152,42 @@ var _flash_ring: NeonPiece = null
var _wash: NeonPiece = null
# #117: "The ship crafter and the ship lab should only animate when
# something is being crafted in them."
#
# The clock stops rather than the drawing: every moving element is a NeonPiece
# whose position is pushed from this clock, so a frozen clock leaves the
# building standing exactly as it was with the cranes parked. Stopping
# apply_animation() instead would leave them wherever the last frame put
# them, which is not the same thing as idle.
#
# Default true: a mothership and a transport hub turn because that is what
# they are, not because they have an order in hand.
func is_working() -> bool:
return true
# The record this visual belongs to. The chain is Building -> VisualHost ->
# this, and the id lives on the Building.
# By tree lookup, not by the bare `GameState` identifier. These visual
# scripts are reached through a BuildingDefinition's visual_scene, which is
# preloaded while the definition catalog is still loading - early enough that
# the autoload is not yet in the global class table, and a bare reference
# fails to COMPILE the whole file. Same hazard GameState notes for WORLD_GEN.
func _state() -> Node:
var tree: SceneTree = get_tree()
return tree.root.get_node_or_null("GameState") if tree != null else null
func building_id() -> int:
var host: Node = get_parent()
var building: Node = host.get_parent() if host != null else null
return int(building.building_id) if building != null and "building_id" in building else 0
func _process(delta: float) -> void:
if not is_working():
return
anim_time = fmod(anim_time + delta, CYCLE)
phase_time += delta
apply_animation()
@ -359,6 +434,119 @@ func _draw_command_center() -> void:
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
func _draw_workshop() -> void:
var c: Color = WORKSHOP_COLOR
var lit: Color = Color(0.86, 1.0, 0.95)
# The gantry beam, and the legs it stands on.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
Vector2(-YARD_GANTRY_W, -YARD_GANTRY_H - 40.0),
Vector2(YARD_GANTRY_W, -YARD_GANTRY_H - 40.0),
Vector2(YARD_GANTRY_W, -40.0), Vector2(-YARD_GANTRY_W, -40.0),
]), c, 1.0)
for side in [-1.0, 1.0]:
var x: float = YARD_GANTRY_W * side * 0.82
NeonDraw.draw_glowing_line(self, Vector2(x, -40.0),
Vector2(x, YARD_LEG_DROP), c, 0.8, 0.42)
# Cross-bracing, so it reads as a frame rather than two posts.
NeonDraw.draw_glowing_line(self, Vector2(x, -40.0),
Vector2(x * 0.35, YARD_LEG_DROP), c, 0.45, 0.26)
# The cradle: an open arc under the beam, with the thing being worked on
# implied by the lit core rather than drawn.
NeonDraw.draw_glowing_path(self, _arc(Vector2(0.0, 6.0), YARD_CRADLE_R,
0.0, PI, 14), c, 0.85, 0.4)
NeonDraw.draw_glowing_circle(self, Vector2(0.0, 6.0), 11.0, lit, 0.9)
# A hoist line down from the beam to what is in the cradle.
NeonDraw.draw_glowing_line(self, Vector2(0.0, -40.0), Vector2(0.0, -6.0),
c, 0.6, 0.3)
# Drone berths, one either side, drawn as open brackets.
for side in [-1.0, 1.0]:
var berth: Vector2 = Vector2(YARD_BERTH_X * side, -70.0)
NeonDraw.draw_glowing_path(self, PackedVector2Array([
berth + Vector2(-14.0 * side, -12.0), berth + Vector2(6.0 * side, -12.0),
berth + Vector2(6.0 * side, 12.0), berth + Vector2(-14.0 * side, 12.0),
]), c, 0.7, 0.32)
NeonDraw.draw_glowing_polygon(self, _diamond(berth, 7.0), lit, 0.85)
func _draw_planning_center() -> void:
var c: Color = PLANNING_COLOR
var lit: Color = Color(0.88, 0.94, 1.0)
# The board.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
Vector2(-PLAN_BOARD_W, -PLAN_BOARD_H), Vector2(PLAN_BOARD_W, -PLAN_BOARD_H),
Vector2(PLAN_BOARD_W, PLAN_BOARD_H), Vector2(-PLAN_BOARD_W, PLAN_BOARD_H),
]), c, 1.0)
# Rows of tallies at differing lengths - a plan being met unevenly, which
# is what the building is for.
var fractions: Array = [0.86, 0.42, 0.68, 0.24]
for i in range(PLAN_ROW_COUNT):
var y: float = lerpf(-PLAN_BOARD_H + 16.0, PLAN_BOARD_H - 16.0,
float(i) / float(PLAN_ROW_COUNT - 1))
var left: float = -PLAN_BOARD_W + 14.0
var span: float = (PLAN_BOARD_W - 14.0) * 2.0
NeonDraw.draw_glowing_line(self, Vector2(left, y),
Vector2(left + span * float(fractions[i]), y), lit, 0.8, 0.34)
NeonDraw.draw_glowing_line(self, Vector2(left + span * float(fractions[i]), y),
Vector2(left + span, y), c, 0.35, 0.22)
# Pylons holding it up, and a footing bar under the whole thing.
for side in [-1.0, 1.0]:
var x: float = PLAN_PYLON_X * side
NeonDraw.draw_glowing_line(self, Vector2(x, -PLAN_PYLON_H),
Vector2(x, PLAN_PYLON_H), c, 0.75, 0.4)
NeonDraw.draw_glowing_line(self, Vector2(PLAN_BOARD_W * side, 0.0),
Vector2(x, 0.0), c, 0.55, 0.3)
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2(x, -PLAN_PYLON_H), 9.0), c, 0.9)
NeonDraw.draw_glowing_line(self, Vector2(-PLAN_PYLON_X, PLAN_PYLON_H),
Vector2(PLAN_PYLON_X, PLAN_PYLON_H), c, 0.5, 0.3)
func _draw_outpost() -> void:
var c: Color = OUTPOST_COLOR
var lit: Color = Color(0.94, 0.99, 0.82)
# Tripod footing.
var foot: Vector2 = Vector2(0.0, OUTPOST_MAST_FOOT)
for side in [-1.0, 0.0, 1.0]:
var toe: Vector2 = Vector2(OUTPOST_FOOT_SPAN * side, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP)
NeonDraw.draw_glowing_line(self, foot, toe, c, 0.7, 0.42)
NeonDraw.draw_glowing_line(self,
Vector2(-OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP),
Vector2(OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), c, 0.55, 0.34)
# The mast, with rungs so it reads as climbable structure rather than a
# drawn line.
var top: Vector2 = Vector2(0.0, OUTPOST_MAST_TOP)
NeonDraw.draw_glowing_line(self, foot, top, c, 0.9, 0.5)
for i in range(4):
var y: float = lerpf(OUTPOST_MAST_FOOT, OUTPOST_MAST_TOP + OUTPOST_HEAD_RADIUS,
float(i + 1) / 5.0)
NeonDraw.draw_glowing_line(self, Vector2(-11.0, y), Vector2(11.0, y), c, 0.45, 0.28)
# Sensor head: a lit diamond inside a cage, with vanes looking outward.
NeonDraw.draw_glowing_polygon(self, _ring(top, OUTPOST_HEAD_RADIUS, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _diamond(top, OUTPOST_HEAD_RADIUS * 0.5), lit, 0.95)
for i in range(OUTPOST_VANE_COUNT):
var a: float = PI + (float(i) / float(OUTPOST_VANE_COUNT - 1)) * PI
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, top + dir * OUTPOST_HEAD_RADIUS,
top + dir * (OUTPOST_HEAD_RADIUS + 16.0), c, 0.7, 0.3)
# Two open berths. Open, not enclosed: what sits in them belongs to the
# player and flies away, so they are cradles rather than hangars.
for side in [-1.0, 1.0]:
var berth: Vector2 = Vector2(OUTPOST_BERTH_X * side, 4.0)
NeonDraw.draw_glowing_path(self, _arc(berth, OUTPOST_BERTH_RADIUS,
-PI * 0.5, PI * 0.5, 10), c, 0.75, 0.36)
NeonDraw.draw_glowing_line(self, Vector2(12.0 * side, 4.0),
berth - Vector2(OUTPOST_BERTH_RADIUS * side, 0.0), c, 0.6, 0.34)
# --- mothership: a radial flagship ----------------------------------------
# Not a big fighter. A mothership is the thing everything else orbits, so it
# is built radially around a reactor core rather than pointed in a direction:

View file

@ -7,6 +7,7 @@ definition_id = "command_center"
display_name = "Command Center"
description = "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it."
cost = 15
build_resource_costs = {"ore": 15, "gas": 6}
action_icon = 10
menu_icon = 10
visual_scene = ExtResource("2")
@ -18,3 +19,4 @@ map_phase = 0.785398
menu_button_name = "CommandButton"
menu_label_name = "CommandLabel"
menu_cost_name = "CommandCost"
max_hp = 70

View file

@ -17,3 +17,4 @@ map_radius = 7.0
menu_button_name = "CrafterButton"
menu_label_name = "CrafterLabel"
menu_cost_name = "CrafterCost"
max_hp = 90

View file

@ -6,6 +6,16 @@ func _ready() -> void:
_build_crafter_pieces()
queue_redraw()
# #117: the line runs when there is something on it. An idle crafter with
# its cranes swinging read as a factory working on nothing.
func is_working() -> bool:
var id: int = building_id()
if id == 0:
return false
var state: Node = _state()
return state != null and state.pending_orders(id) > 0
func apply_animation() -> void:
_animate_crafter()

View file

@ -14,3 +14,4 @@ menu_button_name = "ExtractorButton"
menu_label_name = "ExtractorLabel"
menu_cost_name = "ExtractorCost"
placement_mode = "planet"
max_hp = 40

View file

@ -1,7 +1,8 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3]
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
[gd_resource type="Resource" script_class="BuildingDefinition" format=3 uid="uid://cia6vbju65sbe"]
[ext_resource type="Script" uid="uid://d10s0frtxiw62" path="res://buildings/BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://buildings/GasBurnerBuildingVisual.tscn" id="2"]
[ext_resource type="Script" path="res://buildings/BuildingLogisticsProfile.gd" id="3"]
[ext_resource type="Script" uid="uid://cydp18abylrgd" path="res://buildings/BuildingLogisticsProfile.gd" id="3"]
[sub_resource type="Resource" id="Resource_logistics"]
script = ExtResource("3")
@ -19,7 +20,11 @@ script = ExtResource("1")
definition_id = "gas_burner"
display_name = "Gas Burner"
description = "Burns gas into energy at its own site. Hub transporters keep it stocked with gas and collect the energy it makes."
cost = 30
cost = 40
build_resource_costs = {
"gas": 22,
"ore": 40
}
action_icon = 33
menu_icon = 33
visual_scene = ExtResource("2")
@ -27,8 +32,7 @@ capabilities = PackedStringArray("gas_burner")
logistics = SubResource("Resource_logistics")
map_color = Color(0.95, 0.35, 0.15, 1)
map_sides = 5
map_radius = 7.0
map_phase = 0.0
menu_button_name = "GasBurnerButton"
menu_label_name = "GasBurnerLabel"
menu_cost_name = "GasBurnerCost"
max_hp = 60

View file

@ -19,3 +19,4 @@ menu_button_name = "MothershipButton"
menu_label_name = "MothershipLabel"
menu_cost_name = "MothershipCost"
unique = true
max_hp = 240

View file

@ -0,0 +1,21 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://buildings/OutpostBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "outpost"
display_name = "Outpost"
description = "A forward position for the fleet to garrison. Vessels stationed here hold station and fight what comes at them, without you flying back."
cost = 30
build_resource_costs = {"ore": 30, "gas": 16}
action_icon = 40
menu_icon = 40
visual_scene = ExtResource("2")
capabilities = PackedStringArray("garrison")
map_color = Color(0.72, 0.86, 0.45, 1)
map_sides = 3
map_radius = 7.0
menu_button_name = "OutpostButton"
menu_label_name = "OutpostLabel"
menu_cost_name = "OutpostCost"
max_hp = 120

View file

@ -0,0 +1,13 @@
extends "res://buildings/BuildingVisualBase.gd"
# Drawn once and never redrawn, like the command centre: nothing about a
# watch mast animates, and per-frame redraws of art that never changes were
# a real framerate cost earlier on.
func _ready() -> void:
set_process(false)
queue_redraw()
func _draw() -> void:
register_draw()
_draw_outpost()

View file

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

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/OutpostBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("garrison", "transfer")

View file

@ -0,0 +1,23 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://buildings/PlanningBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "planning_center"
display_name = "Planning Center"
description = "Holds standing orders for the whole game: say how many of each vessel should always exist, and the ship crafters are kept working until they do."
cost = 50
build_resource_costs = {"ore": 50, "gas": 30}
action_icon = 41
menu_icon = 41
visual_scene = ExtResource("2")
capabilities = PackedStringArray("planning")
map_color = Color(0.62, 0.74, 0.92, 1)
map_sides = 4
map_radius = 7.5
map_phase = 0.785398
menu_button_name = "PlanningButton"
menu_label_name = "PlanningLabel"
menu_cost_name = "PlanningCost"
unique = true
max_hp = 80

View file

@ -0,0 +1,13 @@
extends "res://buildings/BuildingVisualBase.gd"
# Drawn once, like the command centre and the outpost: a board of numbers
# does not animate, and per-frame redraws of art that never changes were a
# real framerate cost earlier on.
func _ready() -> void:
set_process(false)
queue_redraw()
func _draw() -> void:
register_draw()
_draw_planning_center()

View file

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

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/PlanningBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("planning", "transfer")

View file

@ -6,7 +6,8 @@ script = ExtResource("1")
definition_id = "ship_lab"
display_name = "Ship Lab"
description = "Fly the fleet onto it to upgrade each vessel type and unlock new vessel designs."
cost = 35
cost = 30
build_resource_costs = {"ore": 30, "gas": 18}
action_icon = 13
menu_icon = 13
visual_scene = ExtResource("2")
@ -18,3 +19,4 @@ map_phase = -1.570796
menu_button_name = "ShipLabButton"
menu_label_name = "ShipLabLabel"
menu_cost_name = "ShipLabCost"
max_hp = 90

View file

@ -4,6 +4,16 @@ func _ready() -> void:
_build_lab_pieces()
queue_redraw()
# #117: the instruments sweep while something is being researched, and are
# parked otherwise.
func is_working() -> bool:
var id: int = building_id()
if id == 0:
return false
var state: Node = _state()
return state != null and not state.lab_research_order(id).is_empty()
func apply_animation() -> void:
_animate_ship_lab()

View file

@ -6,7 +6,8 @@ script = ExtResource("1")
definition_id = "transport_hub"
display_name = "Transport Hub"
description = "Stations transport vessels. They collect extracted resources and supply buildings that are waiting on materials."
cost = 30
cost = 28
build_resource_costs = {"ore": 28, "gas": 20}
action_icon = 12
menu_icon = 12
visual_scene = ExtResource("2")
@ -18,3 +19,4 @@ map_phase = 0.392699
menu_button_name = "TransportHubButton"
menu_label_name = "TransportHubLabel"
menu_cost_name = "TransportHubCost"
max_hp = 80

View file

@ -0,0 +1,21 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://buildings/WorkshopBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "spacial_workshop"
display_name = "Spacial Workshop"
description = "Sends repair drones out to damaged structures. Repairs cost ore in proportion to the damage, delivered here by hub transporters."
cost = 50
build_resource_costs = {"ore": 50, "gas": 28, "energy": 18}
action_icon = 42
menu_icon = 42
visual_scene = ExtResource("2")
capabilities = PackedStringArray("repair")
map_color = Color(0.5, 0.95, 0.8, 1)
map_sides = 5
map_radius = 7.0
menu_button_name = "WorkshopButton"
menu_label_name = "WorkshopLabel"
menu_cost_name = "WorkshopCost"
max_hp = 70

View file

@ -0,0 +1,13 @@
extends "res://buildings/BuildingVisualBase.gd"
# Drawn once, like the command centre and the outpost. #117 makes the
# animated buildings stop when idle; this one has nothing that moves, so
# there is nothing to gate.
func _ready() -> void:
set_process(false)
queue_redraw()
func _draw() -> void:
register_draw()
_draw_workshop()

View file

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

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/WorkshopBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("transfer")

View file

@ -33,7 +33,10 @@ func _ready() -> void:
func _process(delta: float) -> void:
var had_visuals: bool = aiming or _has_effect_visuals()
button.visible = fleet.has_capability(_component_id())
# #122: "there's no action button available, only movements". Checked
# here rather than by the director reaching in, because this line runs
# every frame and would put the button straight back.
button.visible = fleet.has_capability(_component_id()) and not GameState.intro_active
button.enabled = _next_ready_ship() != null and _can_afford_shot()
if not button.visible and aiming:
_cancel_aim()

View file

@ -32,14 +32,14 @@ const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"),
get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"),
get_node("../UI/LabMenu/VesselE"), get_node("../UI/LabMenu/VesselF"),
get_node("../UI/LabMenu/VesselG"),
get_node("../UI/LabMenu/VesselG"), get_node("../UI/LabMenu/VesselH"),
]
@onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel")
@onready var lab_vessel_labels: Array = [
get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"),
get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"),
get_node("../UI/LabMenu/VesselELabel"), get_node("../UI/LabMenu/VesselFLabel"),
get_node("../UI/LabMenu/VesselGLabel"),
get_node("../UI/LabMenu/VesselGLabel"), get_node("../UI/LabMenu/VesselHLabel"),
]
@onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel")
@onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel")
@ -109,7 +109,12 @@ var lab_building_id: int = 0
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup")
@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
@onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel")
@onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber")
# Typed Control, not PriceTag, and deliberately. PriceTag reads the GameState
# autoload, and an `extends SceneTree` test script that so much as names a
# class like that fails to compile - GameState is not an identifier in that
# context - which then poisons the scene's copy of the same script. The nodes
# are duck-typed everywhere for that reason; see tests/test_menu_price_unification.gd.
@onready var info_cost: Control = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber")
@onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton")
@onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton")
@ -156,10 +161,28 @@ var lab_building_id: int = 0
get_node("../UI/LabMenu/Bar"),
]
# #103: the outpost's own panel. garrison_building_id is remembered for the
# same reason craft_building_id is - _process() clears current_building while
# a menu is open, so by the next frame the panel would not know which outpost
# it belongs to and every button in it would bail out at its `== 0` guard.
@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu")
@onready var garrison_panel: Control = get_node("../UI/GarrisonMenu/Panel")
@onready var garrison_status: Label = get_node("../UI/GarrisonMenu/StatusLabel")
@onready var garrison_close_button: IconButton = get_node("../UI/GarrisonMenu/CloseButton")
var garrison_building_id: int = 0
# #106: the standing-order board. No building id is remembered because the
# plan is global - there is one plan for the whole game, and a second
# planning centre is another place to read it rather than a competing one.
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
@onready var planning_panel: Control = get_node("../UI/PlanningMenu/Panel")
@onready var planning_status: Label = get_node("../UI/PlanningMenu/StatusLabel")
@onready var planning_close_button: IconButton = get_node("../UI/PlanningMenu/CloseButton")
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber")
@onready var skill_cost: Control = get_node("../UI/SkillMenu/CostNumber")
@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel")
@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton")
@ -249,8 +272,15 @@ func _seat_hud_columns() -> void:
func _ready() -> void:
_seat_hud_columns()
_layout_hangar()
_build_craft_price_strips()
_build_lab_price_tags()
_layout_craft_menu()
build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(_close_build_menu)
garrison_close_button.pressed.connect(_close_garrison_menu)
garrison_panel.changed.connect(_refresh_garrison)
planning_close_button.pressed.connect(_close_planning_menu)
planning_panel.changed.connect(_refresh_planning)
_configure_build_menu_entries()
lab_close_button.pressed.connect(func(): lab_menu.visible = false)
lab_buy_button.pressed.connect(_on_lab_buy)
@ -301,6 +331,13 @@ func _ready() -> void:
transfer_button.visible = false
hangar_button.visible = false
hangar_menu.visible = false
# #128: the prologue's "movement and nothing else" used to be applied by
# _process on the first frame. A dialog now stops the tree, and the
# prologue opens ON one - so that first frame never came and every action
# button was left on screen behind the opening line. Applied here as well,
# where it does not depend on the game running.
if GameState.intro_active:
_hide_all_actions()
_layout_context_buttons()
@ -311,12 +348,12 @@ func _configure_build_menu_entries() -> void:
continue
var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name)
var label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name)
var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name)
var cost_tag: Control = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name)
button.icon = definition.menu_icon
button.pressed.connect(_show_building_info.bind(String(definition.definition_id)))
label.text = definition.display_name
cost_number.amount = definition.cost
build_menu_entries.append([definition.definition_id, button, label, cost_number, definition])
cost_tag.show_price(GameState.building_costs(String(definition.definition_id)))
build_menu_entries.append([definition.definition_id, button, label, cost_tag, definition])
# True while a modal panel owns the shared context controls. A plain building
@ -343,6 +380,14 @@ func _unhandled_input(event: InputEvent) -> void:
skill_tree.handle_touch(event)
elif event is InputEventScreenDrag:
skill_tree.handle_drag(event)
elif event is InputEventMouseButton:
# #93: a click is not a touch. The project no longer emulates one
# from the other, so without this branch the whole menu is inert
# under a mouse - nothing selects, nothing scrolls, and there is
# no other way to research anything on a desktop.
skill_tree.handle_mouse_button(event)
elif event is InputEventMouseMotion:
skill_tree.handle_mouse_motion(event)
# Handled either way. A touch that lands on the panel but misses a
# node is still a touch ON THE PANEL - letting it through moved the
# fleet and pinch-zoomed the world behind the menu, because
@ -355,6 +400,14 @@ func _unhandled_input(event: InputEvent) -> void:
formation_editor.handle_touch(event)
elif event is InputEventScreenDrag:
formation_editor.handle_drag(event)
elif event is InputEventMouseButton:
# #121: a click is not a touch. Same routing gap as #93's
# research tree - the panel is transparent to GUI input so the
# raw handlers can see the drag, which means nothing turns a
# click into one on the way.
formation_editor.handle_mouse_button(event)
elif event is InputEventMouseMotion:
formation_editor.handle_mouse_motion(event)
get_viewport().set_input_as_handled()
return
if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible \
@ -367,6 +420,19 @@ func _unhandled_input(event: InputEvent) -> void:
func _process(delta: float) -> void:
_check_fleet_wipe()
# #122: the prologue leaves the player movement and nothing else. Handled
# at the top, before any of the context detection below, so nothing can
# turn a button back on further down.
if GameState.intro_active:
_hide_all_actions()
return
# Build is permanently under the resource counter (see
# _layout_context_buttons) - it is the one action button nothing else
# ever hides, so the prologue taking it away is the only thing that has
# to give it back.
if not build_button.visible:
build_button.visible = true
if build_menu.visible:
# Keep the greyed state live while the menu is open, so flying onto
# a planet lights the option up without reopening it.
@ -578,6 +644,14 @@ func _on_action_pressed() -> void:
elif action == "formation":
formation_menu.visible = true
formation_editor.queue_redraw()
elif action == "planning":
planning_menu.visible = true
_refresh_planning()
elif action == "garrison":
garrison_building_id = current_building.building_id
garrison_panel.building_id = garrison_building_id
garrison_menu.visible = true
_refresh_garrison()
func _on_build_pressed() -> void:
@ -592,24 +666,158 @@ func _planet_under_fleet() -> Node2D:
return planet_field.get_planet_fleet_is_on()
# Grid geometry, shared with the layout below. Rows of two, and the menu is
# rebuilt from whatever is currently researched (#21) rather than being a
# fixed arrangement with holes punched in it.
const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0]
const MENU_COL_X: float = 140.0
# Grid geometry, shared with the layout below. The menu is rebuilt from
# whatever is currently researched (#21) rather than being a fixed
# arrangement with holes punched in it.
#
# Two profiles, because the grid ran out of room. #54 already tightened the
# rows to 223px to fit a fourth, explicitly to avoid a scrollable menu, and a
# ninth structure (#106's planning centre) needs a FIFTH row that will not
# fit at any spacing a button, a label and a cost can share. So past eight
# entries the grid goes to three columns instead, which takes nine into three
# rows - fewer than it uses today, not more. Narrower columns mean narrower
# labels, so those get a taller box to wrap into.
# The build menu's grid.
#
# #136: rows used to be a written-down list of tops, read with
# `tops[mini(row, tops.size() - 1)]`. That clamp silently stacked whatever
# would not fit - the gas burner drawn exactly on top of the transport hub.
#
# #144: and the band the rows live in was written down too, as a pair of
# constants measured against a 1280-tall canvas. The header above it is
# anchored to the TOP of the panel and the grid is anchored to its CENTRE,
# so the gap between them is half the canvas height - which means the
# constants were only ever right at one window shape. At 1080 tall the grid
# started 4px ABOVE the status line and drew straight through it, which is
# what the ticket reported.
#
# So nothing about the geometry is written down against a screen size any
# more. The band is measured from the header that is actually there, the row
# pitch closes up to fit it (never below a row's own height, which is what
# makes overlapping impossible rather than unlikely), and the column count
# rises until the rows fit - a short panel gets a wide grid instead of one
# that runs off the bottom of it.
const MENU_BUTTON_HALF: float = 64.0
const MENU_BUTTON_H: float = 100.0
const MENU_LABEL_HALF: float = 150.0
const MENU_LABEL_H: float = 52.0
const MENU_COST_HALF: float = 60.0
const MENU_COST_H: float = 44.0
# Gaps from each row's own top to where the label/cost start, sized to the
# shrunk MENU_*_H values above (#54: fitting a 4th row meant tightening
# MENU_ROW_TOPS' spacing to 223px, less than the original 256px a row's
# button+label+cost needed - shrinking those closed the gap without
# resorting to a scrollable menu).
const MENU_COST_HALF: float = 74.0
# #147: a price is one resource per line, so a row has to leave room for the
# most any structure asks for - three, at the workshop.
const MENU_COST_H: float = 86.0
const MENU_LABEL_OFFSET: float = 106.0
const MENU_COST_OFFSET: float = 162.0
const MENU_LABEL_GAP: float = 4.0
# The widest a column is ever given, however much room there is: past this
# the entries stop reading as a grid and start reading as a scattering.
const MENU_NATURAL_SPACING: float = 280.0
# The narrowest a column may be squeezed to - a button plus a little air.
const MENU_MIN_SPACING: float = MENU_BUTTON_HALF * 2.0 + 12.0
# A caption gets most of its column, not all of it.
const MENU_LABEL_SHARE: float = 0.46
# Below this a caption is assumed to need two lines.
const MENU_ONE_LINE_HALF: float = 120.0
const MENU_LABEL_H_ONE: float = 52.0
const MENU_LABEL_H_TWO: float = 68.0
const MENU_SIDE_MARGIN: float = 24.0
const MENU_HEADER_GAP: float = 16.0
const MENU_BOTTOM_MARGIN: float = 28.0
const MENU_MAX_COLUMNS: int = 6
# A centre-anchored grid living under a top-anchored header, which is the
# shape both the build menu and the craft menu have (#144, #147). The gap
# between the two depends on the panel's HEIGHT, so it can never be written
# down as a constant - it has to be measured from the header that is
# actually there.
func grid_band(header_bottom: float, panel_height: float) -> Vector2:
var half: float = panel_height * 0.5
if half <= 0.0:
half = 640.0
return Vector2(header_bottom + MENU_HEADER_GAP - half, half - MENU_BOTTOM_MARGIN)
# Where one row of such a grid starts. The pitch closes up to fit the band
# and is floored at the row's own height, so rows can tighten but never land
# on each other.
func grid_row_top(row: int, rows: int, row_height: float, band: Vector2,
natural_pitch: float) -> float:
var pitch: float = natural_pitch
if rows > 1:
pitch = minf(pitch, (band.y - band.x - row_height) / float(rows - 1))
return band.x + maxf(pitch, row_height) * float(row)
# The vertical band the grid may occupy, in the menu's own centre-anchored
# space: (top, bottom).
#
# MEASURED, not written down (#144). The header is anchored to the top of the
# panel and the grid to its centre, so the two only agree at one canvas
# height - and the pair of constants this replaces happened to be the ones
# for 1280. Reading the status line's own bottom edge and the panel's own
# height makes the answer right at every window shape, including the one the
# player is actually running.
func menu_band() -> Vector2:
return grid_band(build_status.offset_bottom, build_menu.size.y)
# How wide one column is given. Never wider than looks like a grid, never
# narrower than a button, and otherwise whatever the panel has room for.
func menu_column_spacing(columns: int) -> float:
var room: float = build_menu.size.x - MENU_SIDE_MARGIN * 2.0
if room <= 0.0:
room = 672.0
return maxf(MENU_MIN_SPACING, minf(MENU_NATURAL_SPACING, room / float(columns)))
func menu_label_half(columns: int) -> float:
return menu_column_spacing(columns) * MENU_LABEL_SHARE
func menu_label_height(columns: int) -> float:
return MENU_LABEL_H_ONE if menu_label_half(columns) >= MENU_ONE_LINE_HALF \
else MENU_LABEL_H_TWO
func menu_cost_offset(columns: int) -> float:
return MENU_LABEL_OFFSET + menu_label_height(columns) + MENU_LABEL_GAP
# How tall one row is, end to end - the cost strip is the last thing in it.
func menu_row_height(columns: int) -> float:
return menu_cost_offset(columns) + MENU_COST_H
# The fewest columns whose rows fit the band. Two is the shape the menu is
# meant to have; wider is what a full catalog - or a short window - forces.
func menu_columns(count: int) -> int:
var widest: int = maxi(mini(MENU_MAX_COLUMNS,
int(floor((build_menu.size.x - MENU_SIDE_MARGIN * 2.0) / MENU_MIN_SPACING))), 2)
for columns in range(2, widest + 1):
if _rows_fit(int(ceil(float(count) / float(columns))), columns):
return columns
return widest
func menu_rows(count: int) -> int:
var columns: int = menu_columns(count)
return maxi(int(ceil(float(count) / float(columns))), 1)
func _rows_fit(rows: int, columns: int) -> bool:
var height: float = menu_row_height(columns)
var band: Vector2 = menu_band()
if rows <= 1:
return band.y - band.x >= height
return (band.y - band.x - height) / float(rows - 1) >= height
# Where one row starts. Top of the band, with the pitch closed up when it is
# tight - but never below the row height, so two rows cannot land on each
# other however many structures there are, or however short the window (#136,
# #144).
func menu_row_top(row: int, rows: int, columns: int) -> float:
return grid_row_top(row, rows, menu_row_height(columns), menu_band(),
MENU_NATURAL_SPACING)
func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void:
@ -634,16 +842,24 @@ func _layout_build_menu() -> void:
if visible:
shown.append(entry)
var columns: int = menu_columns(shown.size())
var rows: int = menu_rows(shown.size())
var spacing: float = menu_column_spacing(columns)
var label_half: float = menu_label_half(columns)
var label_h: float = menu_label_height(columns)
var cost_offset: float = menu_cost_offset(columns)
for i in range(shown.size()):
var row: int = i / 2
# Centred when it is the only one on its row, rather than hanging in
# the left column looking like something is missing beside it.
var alone: bool = (i == shown.size() - 1) and (i % 2 == 0)
var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X)
var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)]
var row: int = i / columns
# A short last row is CENTRED rather than left-packed, so it does not
# read as a row with something missing beside it.
var in_row: int = mini(shown.size() - row * columns, columns)
var col: int = i % columns
var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * spacing
var top: float = menu_row_top(row, rows, columns)
_place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H)
_place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, MENU_LABEL_HALF, MENU_LABEL_H)
_place_entry(shown[i][3], cx, top + MENU_COST_OFFSET, MENU_COST_HALF, MENU_COST_H)
_place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h)
_place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, MENU_COST_H)
func _refresh_build_status() -> void:
@ -667,6 +883,11 @@ func _refresh_build_status() -> void:
func _refresh_build_option_states() -> void:
for entry in build_menu_entries:
var definition: Resource = entry[4]
# #138: a price now names several resources, and what the player
# holds of each is on the tag - so it is re-stated here, live, the
# way the craft menu's already was. Re-stating the same price is
# cheap; PriceTag only rebuilds when the price itself changes.
entry[3].show_price(GameState.building_costs(String(definition.definition_id)))
var enabled: bool = GameState.is_building_unlocked(definition.definition_id)
if definition.placement_mode == "planet":
var planet: Node2D = _planet_under_fleet()
@ -675,6 +896,21 @@ func _refresh_build_option_states() -> void:
entry[1].queue_redraw()
# Everything the player can PRESS, as opposed to steer with. Used by the
# prologue (#122), which takes them all away at once.
func _hide_all_actions() -> void:
current_building = null
pending_building = null
current_extractor_index = -1
pending_extractor_index = -1
current_landing_planet = null
context_actions_expanded = false
for control in [build_button, action_button, building_action_button,
landing_button, transfer_button, hangar_button]:
if control != null:
control.visible = false
func _close_build_menu() -> void:
# The popup is a child panel of this menu; leaving it "open" while its
# parent is hidden is what blocked every button in the game.
@ -683,16 +919,63 @@ func _close_build_menu() -> void:
build_menu.visible = false
# --- the outpost's garrison panel (#103) -----------------------------------
func _close_garrison_menu() -> void:
garrison_menu.visible = false
garrison_building_id = 0
func _refresh_garrison() -> void:
if garrison_building_id == 0:
return
garrison_panel.building_id = garrison_building_id
garrison_panel.refresh()
var ordered: int = GameState.garrison_desired_total(garrison_building_id)
var crewed: int = GameState.garrison_crew(garrison_building_id).size()
if ordered == 0:
garrison_status.text = "No vessels ordered. Only armed vessels can hold a position."
elif crewed >= ordered:
garrison_status.text = "Garrison at full strength"
else:
garrison_status.text = "%d of %d berths crewed - vessels are sent from the mothership as they become available" \
% [crewed, ordered]
# --- the planning centre's standing orders (#106) ---------------------------
func _close_planning_menu() -> void:
planning_menu.visible = false
func _refresh_planning() -> void:
planning_panel.refresh()
var short: PackedStringArray = PackedStringArray()
for vessel_type in GameState.planned_vessel_types():
var missing: int = GameState.vessel_shortfall(String(vessel_type))
if missing > 0:
short.append("%d %s" % [missing, GameState.vessel_name(String(vessel_type))])
if short.is_empty():
planning_status.text = "Every standing order is met or already being built"
else:
planning_status.text = "Short: %s" % ", ".join(short)
# The ORE half of a price, which is all some callers want (the info popup's
# headline, the tests). costs_of() below is the whole of it.
func cost_of(type: String) -> int:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return int(definition.cost) if definition != null else 0
return int(costs_of(type).get(GameState.RESOURCE_ORE, 0))
func costs_of(type: String) -> Dictionary:
return GameState.building_costs(type)
func _show_building_info(type: String) -> void:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
if definition == null:
return
var cost: int = int(definition.cost)
var costs: Dictionary = GameState.building_costs(type)
var lines: Array = [definition.description]
var can_build: bool = true
@ -723,13 +1006,15 @@ func _show_building_info(type: String) -> void:
# The same pooled test the build itself uses. Asking the bank here while
# charging bank-plus-hold below would hide the build button for something
# the player can in fact afford.
if can_build and not GameState.can_afford(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.available_ore()])
if can_build and not GameState.can_afford_resources(costs):
# #137: no sentence about the shortfall. The price tag below carries
# what is held over what is wanted, in red - saying it twice, in two
# different shapes, is what the ticket is about.
can_build = false
info_title.text = definition.display_name
info_message.text = "\n\n".join(lines)
info_cost.amount = cost
info_cost.show_price(costs)
info_build_button.visible = can_build
pending_build_type = type if can_build else ""
info_popup.visible = true
@ -771,30 +1056,44 @@ func _confirm_build() -> void:
# glyph/HUD rendering the moment it produced anything.
build_status.text = "%s has nothing to extract" % planet.planet_name
return
if not GameState.spend(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.available_ore()]
if not GameState.spend_resources(GameState.building_costs(type)):
build_status.text = _cannot_afford(type)
return
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id)
build_menu.visible = false
return
if definition.unique and GameState.has_building_of_type(type):
if not GameState.can_afford(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [
definition.cost, GameState.available_ore()
]
if not GameState.can_afford_resources(GameState.building_costs(type)):
build_status.text = _cannot_afford(type)
return
# Only now is the old one destroyed - never before the new one is
# known to be affordable, or the player would be left with none.
GameState.remove_buildings_of_type(type)
building_field.rebuild_all()
_try_place(type, cost_of(type))
_try_place(type)
func _try_place(type: String, cost: int) -> void:
if not GameState.spend(cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.available_ore()]
# #138: a structure can want more than rocks, so the refusal has to name
# which of them is short rather than always saying "rocks". Built from the
# price itself, so a cost gaining a resource needs nothing here.
func _cannot_afford(type: String) -> String:
var missing: Array = []
for resource_type in GameState.building_costs(type):
var id: String = String(resource_type)
var want: int = int(GameState.building_costs(type)[resource_type])
var have: int = GameState.available_resource(id)
if have < want:
missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have])
if missing.is_empty():
return ""
return "Not enough %s" % " and ".join(missing)
func _try_place(type: String) -> void:
if not GameState.spend_resources(GameState.building_costs(type)):
build_status.text = _cannot_afford(type)
return
building_field.place(type, fleet.get_centroid())
build_menu.visible = false
@ -820,7 +1119,7 @@ func _refresh_skill_menu(id: String = "") -> void:
# Nothing chosen yet: point the player at the tree rather than
# leaving the panel blank, which reads as broken.
skill_detail.text = "Select a node to see what it unlocks."
skill_cost.amount = 0
skill_cost.show_price({})
skill_status.text = "Fleet capacity %d · Research points %d" \
% [GameState.max_fleet_size(), GameState.research_points]
return
@ -829,25 +1128,26 @@ func _refresh_skill_menu(id: String = "") -> void:
if node.is_empty():
return
var points_needed: int = GameState.skill_research_point_cost(id)
# #134: the explanation and the price no longer share a column. The text
# is the left half of the band and the tag is the right half, so a long
# description can grow without ending up on top of the number - which is
# what it used to do, the two being centred over each other.
#
# Both shortfalls it used to spell out - ore and research points - are
# now entries on the tag, held-over-needed, in red when short. One place,
# and it says WHICH one is missing without a sentence per case.
skill_detail.text = "%s\n%s" % [node.name, node.desc]
if points_needed > 0:
skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed]
skill_cost.amount = int(node.cost)
skill_cost.show_price(GameState.skill_costs(id), points_needed)
if GameState.is_unlocked(id):
skill_status.text = "Researched"
elif not GameState.prerequisite_met(id):
skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name
elif not GameState.can_afford(int(node.cost)):
# available_ore(), not the bank alone: research is paid for the same
# pooled way as everything else, so quoting the bank here would tell
# the player they cannot afford something they can.
skill_status.text = "Not enough rocks - you have %d" % GameState.available_ore()
elif GameState.research_points < points_needed:
# #63: a distinct reason from the ore one above - conflating them
# would leave the player trying to fix the wrong shortage.
skill_status.text = "Not enough research points - you have %d, need %d" \
% [GameState.research_points, points_needed]
elif not skill_cost.is_affordable():
# Deliberately blank. The red on the tag is the whole message; the
# line here used to repeat it in words and, being centred, repeat it
# on top of the number as well.
skill_status.text = ""
else:
# The button that used to appear here was the only thing saying "this
# is buyable". With it gone the line has to say so itself, or an
@ -897,6 +1197,114 @@ func _format_missing_costs(costs: Dictionary) -> String:
return " · ".join(parts)
# --- the craft menu's own layout (#111) ------------------------------------
#
# Six vessels were authored as one row of 65px buttons with 105px captions
# overlapping each other, which left "Energy Propulser" clipped and the price
# crushed onto a second line under it. Laid out here instead, in two rows of
# three, so a cell is wide enough for the name to breathe - and the price is
# glyphs rather than words (see PriceTag, which every menu now shares).
#
# #147: "prices are far from the button they are about, and when many
# different resources are needed the costs overlap." Both were true, and
# both were the same cause - a cell laid out with more slack than the row
# pitch had room for. The caption box was 62px for a one-line name, which
# pushed the price 70px clear of the button it belonged to; and the price
# was given 184px of width in a 190px column, so a two-resource price ran
# out of its own box and into its neighbour's.
#
# The caption is one line tall now and the price sits directly under it, and
# PriceTag puts each resource on its own line when they will not fit across
# (which in a column this narrow means whenever there is more than one).
# Rows are placed off the measured band rather than a written-down pair of
# tops, for the same reason the build menu's are - see grid_band().
const CRAFT_COLUMNS: int = 3
const CRAFT_ROW_PITCH: float = 260.0
const CRAFT_COL_X: float = 190.0
const CRAFT_BUTTON_HALF: float = 56.0
const CRAFT_BUTTON_H: float = 100.0
const CRAFT_LABEL_HALF: float = 92.0
const CRAFT_LABEL_H: float = 44.0
const CRAFT_LABEL_OFFSET: float = 106.0
const CRAFT_PRICE_HALF: float = 92.0
# Room for the tallest price any vessel has, stacked. Read from the tag
# rather than assumed, so a vessel priced in three things still fits.
const CRAFT_PRICE_H: float = 86.0
const CRAFT_PRICE_OFFSET: float = 154.0
var craft_price_strips: Array = []
# #137: the ship lab's own price tags, built here rather than authored in the
# scene for the same reason the craft menu's are - four identical widgets
# differing only in where they sit.
var lab_price_tags: Array = []
func _build_craft_price_strips() -> void:
var strip_script: Script = load("res://ui/PriceTag.gd")
for i in range(craft_vessel_buttons.size()):
var strip: Control = strip_script.new()
strip.name = "CraftPrice%d" % i
# Centre anchors, matching the buttons and captions authored in the
# scene - _place_entry writes offsets, and an offset means something
# different against a top-left anchor.
strip.anchor_left = 0.5
strip.anchor_right = 0.5
strip.anchor_top = 0.5
strip.anchor_bottom = 0.5
craft_menu.add_child(strip)
craft_price_strips.append(strip)
# One tag per upgrade slot, seated in the gap the caption's second line used
# to occupy. The captions above them shrink to one line to make room, so the
# option block is the same height it was and the row below it does not move.
const LAB_TAG_H: float = 38.0
const LAB_LABEL_H: float = 40.0
func _build_lab_price_tags() -> void:
var tag_script: Script = load("res://ui/PriceTag.gd")
for i in range(lab_upgrade_labels.size()):
var label: Control = lab_upgrade_labels[i]
var tag: Control = tag_script.new()
tag.name = "LabPrice%d" % i
tag.anchor_left = label.anchor_left
tag.anchor_right = label.anchor_right
tag.anchor_top = label.anchor_top
tag.anchor_bottom = label.anchor_bottom
tag.offset_left = label.offset_left
tag.offset_right = label.offset_right
# Under the caption, which is cut to a single line here so the two
# together occupy what the two-line caption used to.
label.offset_bottom = label.offset_top + LAB_LABEL_H
tag.offset_top = label.offset_bottom + 2.0
tag.offset_bottom = tag.offset_top + LAB_TAG_H
lab_menu.add_child(tag)
lab_price_tags.append(tag)
func craft_row_height() -> float:
return CRAFT_PRICE_OFFSET + CRAFT_PRICE_H
func _layout_craft_menu() -> void:
var rows: int = maxi(int(ceil(float(craft_vessel_buttons.size())
/ float(CRAFT_COLUMNS))), 1)
var band: Vector2 = grid_band(craft_status.offset_bottom, craft_menu.size.y)
for i in range(craft_vessel_buttons.size()):
var row: int = i / CRAFT_COLUMNS
var column: int = i % CRAFT_COLUMNS
var cx: float = (float(column) - 1.0) * CRAFT_COL_X
var top: float = grid_row_top(row, rows, craft_row_height(), band,
CRAFT_ROW_PITCH)
_place_entry(craft_vessel_buttons[i], cx, top, CRAFT_BUTTON_HALF, CRAFT_BUTTON_H)
_place_entry(craft_vessel_labels[i], cx, top + CRAFT_LABEL_OFFSET,
CRAFT_LABEL_HALF, CRAFT_LABEL_H)
if i < craft_price_strips.size():
_place_entry(craft_price_strips[i], cx, top + CRAFT_PRICE_OFFSET,
CRAFT_PRICE_HALF, CRAFT_PRICE_H)
func _refresh_craft_status() -> void:
# Prices live with the buttons they describe. The top of the screen is
# intentionally reserved for the shared resource bar and a short action
@ -906,8 +1314,11 @@ func _refresh_craft_status() -> void:
for i in range(mini(definitions.size(), craft_vessel_buttons.size())):
var definition: Resource = definitions[i]
var button: IconButton = craft_vessel_buttons[i]
craft_vessel_labels[i].text = "%s\n%s" % [definition.display_name,
_format_costs(GameState.product_costs(definition.definition_id))]
# #111: the name alone. The price is the strip under it, in glyphs.
craft_vessel_labels[i].text = String(definition.display_name)
if i < craft_price_strips.size():
craft_price_strips[i].set_costs(
GameState.product_costs(definition.definition_id))
button.icon = definition.icon
button.glow_color = definition.glow_color
button.enabled = GameState.vessel_is_unlocked(definition.definition_id)
@ -1018,19 +1429,28 @@ func _refresh_lab() -> void:
var visible: bool = i < options.size()
lab_upgrade_buttons[i].visible = visible
lab_upgrade_labels[i].visible = visible
if i < lab_price_tags.size():
lab_price_tags[i].visible = visible
if not visible:
continue
var option: Dictionary = options[i]
var price_text: String = _format_costs(option.costs if bool(option.unlock) \
else GameState.research_costs(option.id))
var point_cost: int = GameState.research_point_cost(String(option.id))
if point_cost > 0:
price_text += " · %d RP" % point_cost
if not bool(option.unlock) and GameState.upgrade_maxed(option.id):
price_text = "MAX"
# #137: the price is a tag under the option, the way it is under a
# build option and under a vessel in the craft menu. It used to be a
# second line of the caption - "25 ore · 10 gas · 2 RP" - which is
# the same information in a shape no other menu used.
var maxed: bool = not bool(option.unlock) and GameState.upgrade_maxed(option.id)
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)]
lab_upgrade_labels[i].text = "%s%s\n%s" % [option.name, level_note, price_text]
lab_upgrade_labels[i].text = "%s%s" % [option.name, level_note]
if i < lab_price_tags.size():
var tag: Control = lab_price_tags[i]
tag.visible = not maxed
if maxed:
tag.show_price({})
else:
tag.show_price(option.costs if bool(option.unlock) \
else GameState.research_costs(option.id),
GameState.research_point_cost(String(option.id)))
lab_upgrade_buttons[i].icon = _upgrade_icon(option)
lab_upgrade_buttons[i].glow_color = _vessel_colour(lab_vessel)
lab_upgrade_buttons[i].highlighted = i == lab_choice
@ -1262,6 +1682,15 @@ func _hangar_types() -> Array:
# each cell room, and the second row costs nothing: the lower half of the
# menu was empty.
const HANGAR_COLUMNS: int = 3
# #120: room for a two-line caption under each icon, and air around the rule
# that separates the two halves.
const HANGAR_LABEL_GAP: float = 2.0
const HANGAR_DIVIDER_GAP: float = 10.0
# Where the rule between "available" and "in the fleet" is drawn. Computed by
# the layout and read by the menu's own _draw, so the line cannot drift away
# from the sections it divides.
var hangar_divider_y: float = 0.0
# Arranges both hangar sections as grids sized from the space actually
@ -1286,10 +1715,18 @@ func _layout_hangar() -> void:
var cell_width: float = width / float(columns)
var cell_height: float = section_height / float(rows)
# Squarer than the authored 75x120: a grid cell is wider than it is tall
# here, so height is what constrains the icon.
var slot_size: float = minf(cell_height * 0.58, cell_width * 0.42)
var label_height: float = maxf(cell_height - slot_size - 6.0, 1.0)
# #120: the icon gives up height so the caption under it has somewhere to
# go. Vessel names are two and three words long and were wrapping into a
# box barely tall enough for one line, so the second line was clipped and
# a "Gas Bomber" read as "Gas".
var slot_size: float = minf(cell_height * 0.46, cell_width * 0.40)
# #143: TWO gaps, not one - above the caption and below it. With a single
# gap the box ended exactly HANGAR_LABEL_GAP past the bottom of its own
# cell, which is inside the next row's icon. Two pixels of overhang is
# not much on its own, but it is the difference between "the caption
# cannot reach the row below" and "the caption starts in it".
var label_height: float = maxf(
cell_height - slot_size - HANGAR_LABEL_GAP * 2.0, 1.0)
_layout_hangar_section(hangar_available_slots, hangar_available_labels,
top, columns, cell_width, cell_height, slot_size, label_height, width)
@ -1297,10 +1734,15 @@ func _layout_hangar() -> void:
# by subtracting from the second section's top put it back inside the
# first one, straight over its bottom row.
var available_bottom: float = top + section_height
hangar_fleet_title.position.y = available_bottom + 4.0
var fleet_top: float = available_bottom + title_height + 8.0
# #120: a rule between the two halves. They are different questions -
# what is parked here, and what is flying - and with only a heading
# between them the grids read as one long list.
hangar_divider_y = available_bottom + HANGAR_DIVIDER_GAP
hangar_fleet_title.position.y = hangar_divider_y + HANGAR_DIVIDER_GAP
var fleet_top: float = hangar_fleet_title.position.y + title_height + 8.0
_layout_hangar_section(hangar_fleet_slots, hangar_fleet_labels,
fleet_top, columns, cell_width, cell_height, slot_size, label_height, width)
hangar_menu.queue_redraw()
func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int,
@ -1324,11 +1766,40 @@ func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: in
slot.position = Vector2(cell_x + (cell_width - slot_size) * 0.5, cell_y)
slot.size = Vector2(slot_size, slot_size)
var label: Label = labels[i]
label.position = Vector2(cell_x, cell_y + slot_size + 4.0)
label.position = Vector2(cell_x, cell_y + slot_size + HANGAR_LABEL_GAP)
label.size = Vector2(cell_width, label_height)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
# #143: a caption is "Gas Bomber\n2 available" - two lines before
# autowrap, three after it in a narrow column - at a font size fixed
# in the scene. A Label does not clip by default, so the third line
# was drawn straight over the icon of the row below it.
#
# Two things, and both are wanted. The size is fitted to the box the
# layout above worked out, so the text has somewhere to go; and the
# box clips, so however the catalog grows - a longer vessel name, a
# fourth line - nothing can ever reach the next row again. The first
# is what keeps it legible, the second is what keeps it correct.
label.add_theme_font_size_override("font_size",
_hangar_caption_size(label_height))
label.clip_text = true
# The largest caption that still fits HANGAR_CAPTION_LINES lines into the
# height the grid could spare, within what is readable at arm's length.
const HANGAR_CAPTION_LINES: float = 3.0
const HANGAR_CAPTION_MIN: int = 13
const HANGAR_CAPTION_MAX: int = 22
# Godot lays a line out taller than its nominal font size; measured against
# the project font rather than guessed, this is the ratio that keeps three
# lines inside the box.
const HANGAR_LINE_RATIO: float = 1.25
func _hangar_caption_size(label_height: float) -> int:
return clampi(int(label_height / (HANGAR_CAPTION_LINES * HANGAR_LINE_RATIO)),
HANGAR_CAPTION_MIN, HANGAR_CAPTION_MAX)
func _refresh_hangar() -> void:

View file

@ -102,6 +102,10 @@ var keyboard_steering: bool = false
func _ready() -> void:
# #148: the main theme, which outlives this scene - see Music.ensure().
# This script is the root of BOTH the space scene and the planet
# surface, so one call here covers the whole game.
Music.ensure(get_tree())
if joystick != null:
joystick.direction_changed.connect(_on_joystick_direction)
@ -119,6 +123,30 @@ func _held_pivot_direction() -> float:
return clampf(dir, -1.0, 1.0)
# #153: "after a dialog popup the godot input system is lost on keypress. If
# I had a key held it keeps moving in the key direction."
#
# It was not lost - it was never delivered. A dialog stops the tree (#128),
# a paused node receives no input, and this controller is paused along with
# everything else. So a key RELEASED while the line was up never reached
# _handle_key, keys_down kept it, and the fleet flew on in that direction
# once the game restarted.
#
# NOTIFICATION_UNPAUSED arrives whatever a node's process mode is, which
# makes it the one moment where the question can be asked. The physical
# keyboard is the authority on what is actually held down; keys_down is only
# a record of what we were told, and it is the record that went stale.
func _notification(what: int) -> void:
if what == NOTIFICATION_UNPAUSED:
_forget_released_keys()
func _forget_released_keys() -> void:
for key in keys_down.keys():
if not Input.is_key_pressed(key):
keys_down.erase(key)
func keyboard_direction() -> Vector2:
var dir: Vector2 = Vector2.ZERO
for key in KEY_DIRECTIONS:
@ -250,15 +278,13 @@ func _process(delta: float) -> void:
# anchor in _process, so this only needs to report the direction. The
# keyboard takes precedence, and releasing the keys has to stop the fleet
# itself - unlike the stick, there is no centred-again signal to hook.
var keyed: Vector2 = keyboard_direction()
if keyed != Vector2.ZERO:
keyboard_steering = true
fleet.move_in_direction(keyed)
elif keyboard_steering:
# #124: the breach drives. One check here covers the stick, the keyboard
# and the released-again path together - three separate gates would be
# three chances for one of them to keep steering.
if GameState.intro_locks_movement:
keyboard_steering = false
fleet.stop()
elif joystick != null and joystick.direction != Vector2.ZERO:
fleet.move_in_direction(joystick.direction)
else:
_apply_steering()
var target: Vector2 = fleet.get_centroid()
if camera.global_position.distance_to(target) <= camera_snap_distance:
@ -272,6 +298,18 @@ func _process(delta: float) -> void:
camera.global_position = camera.global_position.lerp(target, t)
func _apply_steering() -> void:
var keyed: Vector2 = keyboard_direction()
if keyed != Vector2.ZERO:
keyboard_steering = true
fleet.move_in_direction(keyed)
elif keyboard_steering:
keyboard_steering = false
fleet.stop()
elif joystick != null and joystick.direction != Vector2.ZERO:
fleet.move_in_direction(joystick.direction)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
_handle_touch(event)
@ -283,8 +321,16 @@ func _unhandled_input(event: InputEvent) -> void:
_handle_key(event)
elif event is InputEventMouseMotion:
_note_keyboard()
elif event is InputEventMouseButton and event.pressed:
if speed_fader != null:
speed_fader.handle_mouse_motion(event)
elif event is InputEventMouseButton:
_note_keyboard()
# #118: the fader gets first refusal, so dragging the boost does not
# also pan or zoom the world behind it.
if speed_fader != null and speed_fader.handle_mouse_button(event):
return
if not event.pressed:
return
# Wheel zoom, the desktop equivalent of the pinch.
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
_set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP)

View file

@ -1,16 +1,26 @@
class_name GasBombController
extends "res://controllers/AimedFleetAbilityController.gd"
const BOMB_COLOR := Color(0.96, 0.22, 0.72)
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
const MAX_RANGE := 950.0
const AOE_RADIUS := 180.0
const DRAG_RADIUS := 260.0
const PROJECTILE_SPEED := 720.0
const DAMAGE := 3
const COOLDOWN := 5.0
const GAS_PER_SHOT: int = 5
# The player's end of the gas bomb: the button, the aim, the fleet's
# magazine. The SHOT itself is a GasBombEffect in the world (see that file),
# lifted out of here for the same reason the beam was lifted out of
# LaserController - a garrisoned bomber fires the same bomb from a different
# trigger, and none of this controller is involved in that.
const BOMB_EFFECT = preload("res://vfx/GasBombEffect.gd")
const BOMB_COLOR := BOMB_EFFECT.BOMB_COLOR
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
const MAX_RANGE := VesselGasBomberComponent.MAX_RANGE
const AOE_RADIUS := BOMB_EFFECT.AOE_RADIUS
const DRAG_RADIUS := 260.0
const DAMAGE := VesselGasBomberComponent.BLAST_DAMAGE
const COOLDOWN := VesselGasBomberComponent.SHOT_COOLDOWN
const GAS_PER_SHOT: int = VesselGasBomberComponent.GAS_PER_SHOT
# The bombs this controller has thrown that are still alive - in flight or
# still blooming. Split into the two lists the rest of the game already asks
# about rather than one, so "is a bomb still travelling" stays a question
# with a cheap answer.
var projectiles: Array = []
var explosions: Array = []
var last_hit_count: int = 0
@ -22,7 +32,8 @@ var explosion_time: float:
get:
var longest: float = 0.0
for explosion in explosions:
longest = maxf(longest, float(explosion.time))
if is_instance_valid(explosion):
longest = maxf(longest, float(explosion.explosion_time))
return longest
@ -48,78 +59,46 @@ func _consume_shot(component: Node) -> bool:
func _perform_shot(ship: Node2D, _component: Node) -> void:
projectiles.append({
"position": ship.global_position,
"target": aim_target,
"trail": PackedVector2Array([ship.global_position]),
})
# The cooldown was already spent by _consume_shot() above, so this
# launches the effect directly rather than going through fire_ability()
# and spending it twice.
var effect: Node2D = BOMB_EFFECT.launch(get_tree().root, ship.global_position,
aim_target, DAMAGE)
effect.struck.connect(_on_bomb_struck)
projectiles.append(effect)
func _on_bomb_struck(hit_count: int) -> void:
last_hit_count = hit_count
func _next_ready_bomber() -> Node2D:
return _next_ready_ship()
func _advance_effects(delta: float) -> void:
for i in range(projectiles.size() - 1, -1, -1):
var projectile: Dictionary = projectiles[i]
var distance: float = (projectile.position as Vector2).distance_to(projectile.target)
if distance <= PROJECTILE_SPEED * delta:
projectile.position = projectile.target
_explode(projectile.target)
projectiles.remove_at(i)
func _advance_effects(_delta: float) -> void:
# The effects fly, explode and free themselves. This only re-sorts them
# into "still travelling" and "still blooming", and forgets the gone.
var flying: Array = []
var blooming: Array = []
for effect in projectiles + explosions:
if not is_instance_valid(effect):
continue
if effect.in_flight():
flying.append(effect)
else:
projectile.position = (projectile.position as Vector2).move_toward(
projectile.target, PROJECTILE_SPEED * delta)
var trail: PackedVector2Array = projectile.trail
trail.append(projectile.position)
while trail.size() > 12:
trail.remove_at(0)
projectile.trail = trail
for i in range(explosions.size() - 1, -1, -1):
var explosion: Dictionary = explosions[i]
explosion.time = maxf(float(explosion.time) - delta, 0.0)
if float(explosion.time) <= 0.0:
explosions.remove_at(i)
func _explode(at: Vector2) -> void:
explosions.append({"position": at, "time": 0.8})
last_hit_count = 0
# Asteroids too - a blast that clears everything around it but leaves the
# rocks untouched would need explaining, and they take damage the same way.
for enemy in get_tree().get_nodes_in_group("enemies") \
+ get_tree().get_nodes_in_group("asteroids"):
if is_instance_valid(enemy) and enemy is Node2D \
and enemy.global_position.distance_to(at) <= AOE_RADIUS:
enemy.take_damage(DAMAGE)
last_hit_count += 1
blooming.append(effect)
projectiles = flying
explosions = blooming
func _draw() -> void:
if aiming:
# Only the aim guide. Each bomb draws itself, in its own node.
if not aiming:
return
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8)
_draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8)
_draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0)
for projectile in projectiles:
var trail: PackedVector2Array = projectile.trail
if trail.size() >= 2:
for i in range(1, trail.size()):
var alpha: float = float(i) / trail.size()
draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65),
4.0 + alpha * 4.0, true)
draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35))
NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0)
for explosion in explosions:
var progress: float = 1.0 - float(explosion.time) / 0.8
var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16))
_draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress)
for i in range(7):
var angle: float = TAU * float(i) / 7.0 + progress
var cloud_at: Vector2 = explosion.position \
+ Vector2(cos(angle), sin(angle)) * radius * 0.55
draw_circle(cloud_at, 18.0 + 14.0 * progress,
Color(BOMB_COLOR, (1.0 - progress) * 0.22))
func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void:

View file

@ -1,18 +1,26 @@
class_name LaserController
extends "res://controllers/AimedFleetAbilityController.gd"
const BEAM_COLOR := Color(0.3, 0.9, 1.0)
const TARGET_COLOR := Color(0.6, 0.95, 1.0)
const BEAM_HALF_WIDTH := 38.0
# The DRAWN beam is scaled from the hit width rather than tuned separately.
# The two were independent numbers and had drifted apart: the beam struck
# anything within 22 units of the line while its outer glow spanned about 11,
# so it killed things it visibly missed. NeonDraw's widest layer is 18px at
# scale 1, so this makes the glow span exactly what the beam hits.
const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0
const FLASH_DURATION := 0.22
const ENERGY_PER_SHOT: int = 8
# The player's end of the laser: the button, the aim, the fleet's magazine.
# The SHOT itself is a LaserBeamEffect in the world (see that file) - it was
# lifted out of here so a garrisoned laser vessel could fire the same beam
# from a different trigger without this controller being involved at all.
#
# What is left here is everything that is genuinely about the player firing:
# where they aimed, what it costs the fleet hold, and the guide line drawn
# while they aim it.
const BEAM_EFFECT = preload("res://vfx/LaserBeamEffect.gd")
const BEAM_COLOR := BEAM_EFFECT.BEAM_COLOR
const TARGET_COLOR := Color(0.6, 0.95, 1.0)
const BEAM_HALF_WIDTH := BEAM_EFFECT.BEAM_HALF_WIDTH
const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION
const ENERGY_PER_SHOT: int = VesselLaserComponent.ENERGY_PER_SHOT
# The beams this controller has fired and that are still alive. Kept so the
# fleet's own shots can be reasoned about - "is anything still on screen" for
# the redraw, and the tests' view of what a shot did - without this becoming
# the owner of them again.
var beams: Array = []
var last_hit_count: int = 0
@ -51,51 +59,37 @@ func _consume_shot(component: Node) -> bool:
func _perform_shot(ship: Node2D, component: Node) -> void:
# The cooldown was already spent by _consume_shot() above, so this fires
# the effect directly rather than going through fire_ability() and
# spending it twice.
var direction: Vector2 = aim_target - _aim_origin()
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
var beam_to: Vector2 = ship.global_position + direction * component.beam_range
_strike(ship.global_position, beam_to, direction, int(component.damage))
var effect: Node2D = BEAM_EFFECT.fire(get_tree().root, ship.global_position,
direction, component.beam_range, int(component.damage))
beams.append(effect)
last_hit_count = int(effect.hit_count)
func _next_ready_laser_ship() -> Node2D:
return _next_ready_ship()
func _advance_effects(delta: float) -> void:
for i in range(beams.size() - 1, -1, -1):
var beam: Dictionary = beams[i]
beam.time = maxf(float(beam.time) - delta, 0.0)
if float(beam.time) <= 0.0:
beams.remove_at(i)
func _advance_effects(_delta: float) -> void:
# The effects animate and free themselves; this only forgets the ones that
# have gone.
beams = beams.filter(func(beam): return is_instance_valid(beam))
func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void:
beams.append({"from": from, "to": to, "time": FLASH_DURATION})
last_hit_count = 0
var range_along: float = from.distance_to(to)
# Asteroids share the enemies' take_damage() contract, so the beam sweeps
# both groups in one pass. Shooting a path clear through a field is a
# perfectly good use of a laser, and the ore it leaves behind is the point.
for enemy in get_tree().get_nodes_in_group("enemies") \
+ get_tree().get_nodes_in_group("asteroids"):
if not is_instance_valid(enemy) or not (enemy is Node2D):
continue
var to_enemy: Vector2 = enemy.global_position - from
var along: float = to_enemy.dot(direction)
if along < 0.0 or along > range_along:
continue
var perpendicular: float = (to_enemy - direction * along).length()
if perpendicular <= BEAM_HALF_WIDTH:
enemy.take_damage(damage)
last_hit_count += 1
func beam_extension(beam: Node2D) -> float:
return beam.extension()
func beam_tip(beam: Node2D) -> Vector2:
return beam.tip()
func _draw() -> void:
# Only the aim guide. Each beam draws itself, in its own node.
if aiming:
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target,
TARGET_COLOR, 0.7, 0.6)
for beam in beams:
var progress: float = 1.0 - float(beam.time) / FLASH_DURATION
var alpha: float = 1.0 - progress
NeonDraw.draw_glowing_line(self, beam.from, beam.to,
BEAM_COLOR, alpha, BEAM_WIDTH_SCALE * (1.0 - progress * 0.45))

File diff suppressed because it is too large Load diff

235
core/Quests.gd Normal file
View file

@ -0,0 +1,235 @@
extends RefCounted
# Forgejo #129 / #133: the quest chain.
#
# One place, in order, and nothing but data. GameState answers each goal and
# QuestDirector reads each line out; neither of them knows what the quests
# ARE, so adding one here is the whole job of adding one.
#
# Two rules the chain obeys, both learned from the game rather than chosen:
#
# 1. Nothing here may ask for something the player cannot yet reach. The
# sharp edge is RESEARCH POINTS - they come only from conquering a
# planet (GameState.mark_point_captured), so every node with a
# research_points cost sits after `conquest` below. Ore-only nodes -
# Shipyards, Logistics - may come before it.
# 2. A quest names ONE goal, and the brief says what it will take. Asking
# for a gas burner is asking for the Power Systems research too; that
# belongs in the sentence, not in a second entry, or the chain turns
# into a list of errands.
#
# The whole story turns on `mothership`: the fleet builds a transmitter,
# calls the World Ship, and hears nothing - not because the array is weak
# but because the breach put them further out than anyone has been. Every
# quest after it is a search.
# There is nobody left to talk to, so the quests are the ship talking to
# itself - the same voice the prologue ends on (IntroDirector.SHIP_LOG).
const SPEAKER: String = "KESTREL - LOG"
# Goal kinds. Each is one line in GameState.quest_progress().
const GOAL_RESOURCE: String = "resource"
const GOAL_BUILDING: String = "building"
const GOAL_FLEET_VESSEL: String = "fleet_vessel"
const GOAL_VESSEL_UNLOCKED: String = "vessel_unlocked"
const GOAL_EXPLORE: String = "explore"
const GOAL_PLANET_VISITS: String = "planet_visits"
const GOAL_PLANET_KINDS: String = "planet_kinds"
const GOAL_CONQUERED: String = "conquered"
# #141: researching a doctrine, as a step of its own. The chain used to fold
# it into the brief of the thing it unlocks ("spend ore on Shipyards, then
# put a crafter down"), which asks the player to do two unrelated things -
# open a menu they have never opened, and place a structure - off one line.
const GOAL_RESEARCH: String = "research"
# #156: what is in the BANK, which is a different question from what the
# player can lay hands on. GOAL_RESOURCE counts the hold as well, and the
# hold is exactly what this quest is about being too small.
const GOAL_BANKED: String = "banked"
const CHAIN: Array = [
{
"id": "first_ore",
"title": "SALVAGE",
"objective": "Break asteroids for 5 ore",
"goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 5},
"brief": "Hull intact. Reactor holding. Everything else we had is on the other side of that hole. There is ore in the rocks out here - break some, and we can start again.",
"done": "Five ore in the hold. It is not much. It is a beginning.",
},
{
# #142: the gap this fills was a real one. "Collect 5 ore" was
# followed straight by "build the mothership", which costs twenty -
# so the first thing the chain ever asked for was four times what it
# had just taught, with nothing saying so.
"id": "stockpile",
"title": "THE PILE",
"objective": "Break asteroids for 20 ore",
"goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 20},
"brief": "Now do that four times over. Nothing permanent gets built out of five ore, and there is nothing else out here to spend it on yet.",
"done": "Twenty in the hold. That is a foundation.",
},
{
"id": "mothership",
"title": "A PLACE TO STAND",
"objective": "Build the mothership",
"goal": {"kind": GOAL_BUILDING, "building": "mothership"},
"brief": "A hull is not a base. Build the mothership - it is the only thing in the plans with a transmitter big enough to call the World Ship.",
# The hinge of the whole chain (#129): the answer to "we built the
# transmitter" is that the transmitter was never the problem.
"done": "Mothership online. Calling the World Ship. Calling. Nothing comes back - and the array is not at fault. Wherever the breach put us, it is further out than that signal will ever carry. We find their trail ourselves, or we do not find them.",
},
{
"id": "chart",
"title": "DEAD RECKONING",
"objective": "Chart 30 sectors",
"goal": {"kind": GOAL_EXPLORE, "sectors": 30},
"brief": "So we search. Fly, and let the map fill in behind us - whatever is left of their course is somewhere in it.",
"done": "Thirty sectors charted. No sign of the World Ship, but the sky behind us is no longer blank.",
},
{
# #141: its own step, and the first time the chain sends anyone into
# the research tree at all - so the brief says where the tree IS.
# Every later quest that needs a doctrine only mentions it in
# passing, which is enough once the player has been there once.
"id": "shipyards",
"title": "SHIPYARDS",
"objective": "Research the Shipyards doctrine",
"goal": {"kind": GOAL_RESEARCH, "node": "shipyards"},
"brief": "One hull will not chart a sky, and we cannot build what we have not remembered how to build. The mothership holds the archive - go aboard it and research Shipyards.",
"done": "Shipyard plans recovered. Everything else worth having is in that archive too.",
},
{
"id": "crafter",
"title": "A YARD OF OUR OWN",
"objective": "Build a ship crafter",
"goal": {"kind": GOAL_BUILDING, "building": "ship_crafter"},
"brief": "Now put a ship crafter down. Ore goes in, vessels come out - it is the only way this fleet grows past the one hull that came through the breach.",
"done": "The yard is live. We can build our own now.",
},
{
"id": "second_hull",
"title": "A SECOND HULL",
"objective": "Fly two vessels at once",
"goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "interceptor", "count": 2},
"brief": "Order an interceptor at the yard and crew it. Two guns is not a fleet, but it is twice what we flew out of the breach with.",
"done": "Two hulls in formation. It has been a while since this ship flew with company.",
},
{
"id": "planetfall",
"title": "PLANETFALL",
"objective": "Land on a planet",
"goal": {"kind": GOAL_PLANET_VISITS, "count": 1},
"brief": "There are worlds out here. Bring the fleet in close to one and take it down - there is more on a surface than there is in all this dark.",
"done": "Down on solid ground. Whatever else this place is, it is somewhere.",
},
{
"id": "conquest",
"title": "GROUND HELD",
"objective": "Take every capture point on one planet",
"goal": {"kind": GOAL_CONQUERED, "count": 1},
"brief": "Something got to this surface before us and it is still holding it. Take every point on the planet - what we pull out of their positions is worth more than the ore is.",
# Says out loud where research points come from, because nothing
# else in the game does.
"done": "Planet secured. Their equipment reads out as research data - the first we have had. Every doctrine worth having is going to cost some.",
},
{
# #156: the extractor is the first thing the chain asks for that
# cannot be paid for out of a single hold. An interceptor carries 20
# and the fleet is small; the mothership is where anything larger
# than one trip has to accumulate, and nothing had ever said so.
"id": "deposit",
"title": "THE HOLD IS SMALL",
"objective": "Bank 60 ore at the mothership",
"goal": {"kind": GOAL_BANKED, "resource": "ore", "amount": 60},
"brief": "Every hull out here carries twenty and no more, and we are going to need more than twenty. Fly what we have back to the mothership and unload it - what is stored there is not carried by anyone.",
"done": "Sixty banked. The hold empties, the vault does not - that is the difference, and everything from here on depends on it.",
},
{
"id": "extractor",
"title": "STANDING MINE",
"objective": "Build an extractor on a planet",
"goal": {"kind": GOAL_BUILDING, "building": "extractor"},
"brief": "Research Logistics and put an extractor down on a surface. It keeps digging whether we are standing over it or not.",
"done": "The extractor is cutting. Ore will be there when we come back for it.",
},
{
"id": "gas",
"title": "THE HEAVY AIR",
"objective": "Bring back 25 gas",
"goal": {"kind": GOAL_RESOURCE, "resource": "gas", "amount": 25},
"brief": "The banded worlds are full of gas, and gas is the only thing out here that burns. Take the fleet into one and fill the tanks.",
"done": "Twenty-five units of gas aboard. Now we need something that can burn it.",
},
{
"id": "hub",
"title": "FREIGHT",
"objective": "Build a transport hub",
"goal": {"kind": GOAL_BUILDING, "building": "transport_hub"},
"brief": "Carrying every load by hand does not scale. Research Freight Handling and build a hub - its transporters run the routes without us.",
"done": "The hub is running its own traffic. That is our hands back.",
},
{
"id": "burner",
"title": "POWER",
"objective": "Build a gas burner",
"goal": {"kind": GOAL_BUILDING, "building": "gas_burner"},
"brief": "Research Power Systems and build a gas burner. Gas in, energy out - and energy is what the better weapons run on.",
"done": "The burner is lit. There is power on this fleet for the first time since the breach.",
},
{
"id": "lab",
"title": "SHIP SCIENCE",
"objective": "Build a ship lab",
"goal": {"kind": GOAL_BUILDING, "building": "ship_lab"},
"brief": "Research Vessel Science and build a ship lab. Every hull we fly can be made better there, and there are hulls in the archive we have never built.",
"done": "The lab is open.",
},
{
"id": "shield",
"title": "A NEW HULL",
"objective": "Unlock the shield vessel",
"goal": {"kind": GOAL_VESSEL_UNLOCKED, "vessel": "shield"},
"brief": "The archive has a shield hull in it. Unlock it at the lab - the fleet has been taking every hit on the paint so far.",
"done": "Shield hull unlocked. There is more than one design in that archive.",
},
{
"id": "outpost",
"title": "FORWARD BASES",
"objective": "Build an outpost",
"goal": {"kind": GOAL_BUILDING, "building": "outpost"},
"brief": "We cannot be everywhere. Research Forward Bases and build an outpost - vessels stationed there hold ground we are not standing on.",
"done": "The outpost is crewed. That is one place we no longer have to fly back to.",
},
{
"id": "worlds",
"title": "THE SURVEY",
"objective": "Land on three different kinds of world",
"goal": {"kind": GOAL_PLANET_KINDS, "count": 3},
"brief": "Rock, gas, and whatever else is out here. Set down on three different kinds of world - if the World Ship passed this way, it passed something.",
"done": "Three kinds of world surveyed. Every one of them empty of anything that came through with us.",
},
{
"id": "trail",
"title": "THE TRAIL",
"objective": "Chart 150 sectors",
"goal": {"kind": GOAL_EXPLORE, "sectors": 150},
"brief": "Then we widen it. A hundred and fifty sectors, and every one of them looked at properly. They are out there.",
"done": "A hundred and fifty sectors charted, and no trail. The search does not stop because the list ran out.",
},
]
static func count() -> int:
return CHAIN.size()
static func at(index: int) -> Dictionary:
if index < 0 or index >= CHAIN.size():
return {}
return CHAIN[index]
static func by_id(id: String) -> Dictionary:
for quest in CHAIN:
if String(quest.id) == id:
return quest
return {}

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

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

View file

@ -8,11 +8,12 @@ script = ExtResource("1")
enemy_id = "artillery"
display_name = "Artillery"
max_hp = 8
speed = 90.0
acceleration = 180.0
rotation_speed = 3.0
hull_points = PackedVector2Array(0, -30, 14, -22, 16, -6, 26, 2, 22, 18, 10, 26, -10, 26, -22, 18, -26, 2, -16, -6, -14, -22)
speed = 215.0
acceleration = 390.0
rotation_speed = 4.2
hull_points = PackedVector2Array(0, -34, 7, -18, 34, -4, 40, 8, 20, 6, 12, 20, 4, 28, -4, 28, -12, 20, -20, 6, -40, 8, -34, -4, -7, -18)
glow_color = Color(1.0, 0.5, 0.2, 1)
detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6)
detail_rings = PackedVector2Array(0, -20, 8, 0)
detail_lines = PackedVector2Array(0, -30, 0, 22, -20, 4, 20, 4, -30, 0, -30, 6, 30, 0, 30, 6, -9, 12, 9, 12)
detail_rings = PackedVector2Array(0, 14, 9, 0, 0, -16, 5, 0)
component_scenes = Array[PackedScene]([ExtResource("2")])
contact_damage = 6

View file

@ -18,7 +18,7 @@ signal finished
# SEE coming and the cross marking where it will land. There used to be a
# 1.8s charge-up before anything left the barrel, which arrived long before
# the threat was legible and mostly read as the tower being stuck.
@export var shell_speed: float = 180.0
@export var shell_speed: float = 460.0
@export var aoe_radius: float = 220.0
@export var damage: int = 3
@export var color: Color = Color(1.0, 0.5, 0.2)
@ -30,6 +30,13 @@ const EXPLOSION_DURATION: float = 0.8
# already says that something is coming. A full blast-radius ring here read
# as an attack that had already happened.
const IMPACT_CROSS_RADIUS: float = 20.0
# #114: the blast throws real debris now. The ring alone read as a decal
# being stamped on the map; a burst gives the moment some weight, and it is
# the same LightBurstVFX every other explosion in the game is built from.
const BLAST_VFX = preload("res://vfx/BombBlastVFX.gd")
# Fins on the falling bomb, so it reads as an object with a direction rather
# than a travelling dot.
const FIN_LENGTH: float = 13.0
var phase: int = Phase.TRAVELING
var target: Vector2 = Vector2.ZERO
@ -78,9 +85,16 @@ func _explode() -> void:
phase = Phase.EXPLODING
explosion_time = EXPLOSION_DURATION
position = target
BLAST_VFX.spawn(get_tree().root, target, color)
for ship in get_tree().get_nodes_in_group("player_ships"):
if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius:
ship.take_damage(damage)
# #101: a bomb does not care whether what it landed on flies. The hitbox
# is a child Area2D of the Building, which is a plain Node2D and would
# otherwise be unhittable.
for hitbox in get_tree().get_nodes_in_group("player_buildings"):
if is_instance_valid(hitbox) and hitbox.global_position.distance_to(target) <= aoe_radius:
hitbox.take_damage(damage)
func _draw() -> void:
@ -91,14 +105,34 @@ func _draw() -> void:
_draw_impact_cross(target - shell_pos)
for i in range(1, trail.size()):
var alpha: float = float(i) / trail.size()
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true)
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos,
Color(color, alpha * 0.6), 5.0, true)
# A hot core down the middle of the smoke, brightest at the
# bomb itself.
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos,
Color(1.0, 0.94, 0.86, alpha * 0.5), 1.6, true)
draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35))
# Fins, laid across the direction of travel.
var heading: Vector2 = (target - shell_pos)
heading = heading.normalized() if heading.length() > 0.001 else Vector2.DOWN
var across: Vector2 = Vector2(-heading.y, heading.x) * FIN_LENGTH * 0.55
var tail: Vector2 = -heading * FIN_LENGTH
NeonDraw.draw_glowing_line(self, tail - across, tail + across, color, 0.9, 0.4)
NeonDraw.draw_glowing_line(self, tail, heading * 6.0, color, 0.9, 0.45)
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0)
Phase.EXPLODING:
var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION
var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0))
draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18))
_draw_ring(eradius, 1.0 - eprogress)
# A second ring chasing the first outwards, and a white flash
# that is gone almost at once - the two together are what make a
# blast read as a shock rather than a circle growing.
if eprogress < 0.55:
_draw_ring(eradius * 0.62, (0.55 - eprogress) * 1.6)
if eprogress < 0.18:
draw_circle(Vector2.ZERO, eradius * 0.7,
Color(1.0, 0.95, 0.88, (0.18 - eprogress) * 3.0))
# A small cross where the shell will land, plus a dot at the exact point.

View file

@ -15,3 +15,4 @@ hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0)
glow_color = Color(1, 0.15, 0.2, 1)
dies_on_contact = true
component_scenes = Array[PackedScene]([ExtResource("2")])
contact_damage = 16

View file

@ -5,6 +5,16 @@ signal destroyed
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
@export var enemy_id: String = "chaser"
# #113: this one came in with a WAVE, and a wave is an attack on what the
# player has built rather than a hazard sitting in space. It converges on the
# nearest structure from wherever it spawned, ignoring the detection range
# every component otherwise respects.
#
# Per instance rather than per definition: the same chaser is a wave when the
# director launches it and a garrison when a map author places it, and a
# planet garrison that chased the player across the system would be a very
# different thing from what the editor was used to place.
@export var relentless: bool = false
var definition: Resource
var hp: int
@ -57,10 +67,27 @@ func _physics_process(delta: float) -> void:
# explosion, same `destroyed` signal, same bookkeeping for whatever is
# listening.
func collide_with_player_vessel() -> void:
_resolve_ram()
# #101: a structure resolves an impact by the same rule - whether it costs
# the enemy its life is the definition's call, not the target's type.
func collide_with_player_building() -> void:
_resolve_ram()
func _resolve_ram() -> void:
if definition != null and bool(definition.dies_on_contact):
take_damage(hp)
# What one strike against a structure takes off it. Data on the definition,
# for the same reason dies_on_contact is: a lancer arriving at 950px/s is not
# a sniper drifting into a wall.
func contact_damage() -> int:
return 6 if definition == null else int(definition.contact_damage)
func take_damage(amount: int) -> void:
hp -= amount
if hp <= 0:

View file

@ -1,20 +1,32 @@
class_name EnemyArtilleryComponent
extends Node
# Stationary siege piece: drifts slowly toward the fleet (deliberately no
# pursuit component - closing distance is incidental, not its purpose) and
# fires a slow, clearly visible AOE shell on a long cooldown via a
# standalone ArtilleryShell, so an already-launched shot survives this
# enemy dying mid-flight, same as EnemyProjectile/Projectile already do.
# The bomber: it moves to where its bombs will land and drops them there.
#
# #114 turned it from a siege piece into an aircraft. It used to creep at
# 20px/s and lob a shell that took five seconds to arrive, which read as a
# thing that was stuck rather than a thing that was dangerous - by the time
# a shell landed the fleet had wandered off, and the tower was still
# approaching. It now flies at its own speed and its bombs travel at 460.
#
# The shell is still a standalone ArtilleryShell added to the root, so an
# already-dropped bomb survives this enemy dying mid-flight, same as
# EnemyProjectile/Projectile already do.
var capability_id: String = "artillery"
@export var fire_cooldown: float = 6.0
@export var fire_cooldown: float = 4.0
@export var fire_range: float = 900.0
@export var shell_speed: float = 180.0
# #114: fast enough that the bomb arrives while the target is still roughly
# where it was aimed, and slow enough that the impact cross is a warning
# rather than a formality.
@export var shell_speed: float = 460.0
@export var aoe_radius: float = 220.0
@export var damage: int = 3
@export var drift_speed: float = 20.0
# It flies now rather than creeping. Kept as its own number rather than the
# definition's `speed` so a run-in can be deliberately slower than a chaser's
# top speed without making the hull slow.
@export var drift_speed: float = 150.0
@export var color: Color = Color(1.0, 0.5, 0.2)
var cooldown_left: float = 0.0
@ -29,10 +41,12 @@ func tick(delta: float) -> void:
if cooldown_left > 0.0:
return
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
# #101/#113: it bombs what the player has BUILT as readily as what they
# are flying, and asks the shared targeting rather than the fleet alone.
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null:
return
var centroid: Vector2 = fleet.get_centroid()
var centroid: Vector2 = target.global_position
if enemy.global_position.distance_to(centroid) > fire_range:
return
@ -47,18 +61,22 @@ func tick(delta: float) -> void:
func _drift(enemy: Node2D, delta: float) -> void:
# Local position throughout, same convention as EnemyPursuitComponent/
# EnemyStandoffComponent: valid as long as the enemy's container and
# Fleet share a common origin, which every scene in this project does.
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null:
return
var to_fleet: Vector2 = fleet.get_centroid() - enemy.position
# #69: gated by the same fire_range it already shells within, rather
# than a second tunable that could drift out of sync - outside its own
# firing envelope it has nothing to be drifting toward yet.
if to_fleet.length() > fire_range:
var to_target: Vector2 = target.global_position - enemy.global_position
var distance: float = to_target.length()
if distance < 1.0:
return
if to_fleet.length() < 1.0:
# #69: gated by the same fire_range it already bombs within, rather than
# a second tunable that could drift out of sync - outside its own firing
# envelope it has nothing to be flying toward yet. #113: unless it came
# in with a wave, in which case it has somewhere to be.
if distance > fire_range and not EnemyTargets.ignores_range(enemy):
return
enemy.position += to_fleet.normalized() * drift_speed * delta
var heading: Vector2 = to_target / distance
enemy.global_position += heading * drift_speed * delta
# #114: it faces where it is going. A bomber that flew sideways was most
# of why the old one read as scenery being nudged along.
enemy.rotation = lerp_angle(enemy.rotation, heading.angle() + PI / 2.0,
enemy.rotation_speed * delta)

View file

@ -31,5 +31,9 @@ extends Resource
# check: the chaser rams because ramming is its whole behaviour, while a
# sniper that happens to drift into a ship has not spent itself doing it.
@export var dies_on_contact: bool = false
# #101: what one strike against a player structure takes off it. A rammer
# spends itself on the hit and so hits hardest; a stand-off enemy that drifts
# into a wall barely scratches it, its real threat being its weapon.
@export var contact_damage: int = 6
@export var core_color: Color = Color(0.06, 0.02, 0.03)
@export var component_scenes: Array[PackedScene] = []

View file

@ -2,9 +2,10 @@ class_name EnemyDefinitions
extends RefCounted
const DEFAULT_ID: String = "chaser"
const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
const ORDER: PackedStringArray = ["chaser", "lancer", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
const DEFINITIONS: Dictionary = {
"chaser": preload("res://enemies/ChaserEnemyDefinition.tres"),
"lancer": preload("res://enemies/LancerEnemyDefinition.tres"),
"sniper": preload("res://enemies/SniperEnemyDefinition.tres"),
"artillery": preload("res://enemies/ArtilleryEnemyDefinition.tres"),
"turret_spawner": preload("res://enemies/TurretSpawnerEnemyDefinition.tres"),

View file

@ -0,0 +1,198 @@
class_name EnemyLungeComponent
extends Node2D
# Forgejo #96. A ramming enemy that does not simply beeline the way
# EnemyPursuitComponent does: it drifts in from a distance, LOCKS a point,
# rocks back and forth on the spot while it winds up, shows the line it is
# about to fly down, and only then crosses the gap in one fast straight run.
#
# The lock is the whole point. Once WINDUP starts, nothing re-reads the
# fleet: the charge goes to where the target WAS, so the wind-up and the
# trajectory line are a real warning the player can act on, rather than
# decoration on an unavoidable hit. Same bargain as EnemyBigLaserBeam, which
# locks its aim in launch() for exactly this reason.
#
# A Node2D rather than a plain Node because the trajectory line has to be
# drawn somewhere, and top_level keeps that drawing in world space - the
# parent enemy spins to face its target, and a line that spun with it would
# not point where the charge is going.
#
# Never pair with a pursuit or standoff component: all three write the
# enemy's position every tick and would fight over it.
var capability_id: String = "lunge"
enum Phase { HUNT, WINDUP, CHARGE, RECOVER }
# Far enough that it is already on screen, and visibly waiting, before it
# commits - "starts a bit far from the target" is the behaviour, so a lancer
# that locked the instant it could see anything would not read as one.
@export var detection_range: float = 1500.0
@export var lock_range: float = 820.0
# The drift in HUNT is deliberately slower than the charge by an order of
# magnitude: the approach is a stalk, the charge is the attack.
@export var approach_speed_factor: float = 0.5
# Shortened with the cycle count (#115): one back-and-forth in the time two
# and a half used to take would be a slow-motion tell.
@export var windup_time: float = 0.85
# #96 to the letter: the trajectory line appears 300ms before it leaves.
@export var telegraph_time: float = 0.3
# The "small back and forth" - it recoils along the charge axis, so the
# motion reads as winding up to spring rather than as drifting.
@export var windup_sway: float = 34.0
# #115: exactly ONE back and forth, not two and a half. The tell is a recoil
# and a spring; repeating it turned a wind-up into a fidget, and the player
# stopped reading it as "about to happen".
@export var windup_cycles: float = 1.0
# #115: and it leaves hard. At 950 the charge read as a fast drift rather
# than as the thing the whole wind-up was promising.
@export var charge_speed: float = 1650.0
# It flies THROUGH the locked point rather than stopping on it. A charge
# that halted exactly where the ship used to be would look like it was
# aiming at empty space on purpose.
@export var charge_overshoot: float = 300.0
@export var recover_time: float = 1.3
@export var telegraph_color: Color = Color(1.0, 0.45, 0.15)
var phase: int = Phase.HUNT
var velocity: Vector2 = Vector2.ZERO
# Where the charge is going, and where it started - both frozen at lock.
var locked_point: Vector2 = Vector2.ZERO
var charge_direction: Vector2 = Vector2.RIGHT
var charge_anchor: Vector2 = Vector2.ZERO
var charge_distance: float = 0.0
var travelled: float = 0.0
var windup_left: float = 0.0
var recover_left: float = 0.0
func setup() -> void:
# World space, not the enemy's: see the class comment.
top_level = true
position = Vector2.ZERO
rotation = 0.0
# True while the trajectory line is up, i.e. the last telegraph_time of the
# wind-up. Public because it is the one piece of state a test can check that
# says "the warning is showing" without reaching into a _draw().
func is_telegraphing() -> bool:
return phase == Phase.WINDUP and windup_left <= telegraph_time
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
match phase:
Phase.HUNT:
_tick_hunt(enemy, delta)
Phase.WINDUP:
_tick_windup(enemy, delta)
Phase.CHARGE:
_tick_charge(enemy, delta)
Phase.RECOVER:
_tick_recover(enemy, delta)
queue_redraw()
func _tick_hunt(enemy: Node2D, delta: float) -> void:
var target: Node2D = _nearest_ship(enemy)
if target == null:
return
var to_target: Vector2 = target.global_position - enemy.global_position
var distance: float = to_target.length()
if distance < 0.001:
return
# #113: see EnemyPursuitComponent.
if not EnemyTargets.ignores_range(enemy) and distance > detection_range:
return
var direction: Vector2 = to_target / distance
if distance <= lock_range:
_lock(enemy, target)
return
var desired: Vector2 = direction * enemy.speed * approach_speed_factor
velocity = velocity.move_toward(desired, enemy.acceleration * delta)
enemy.global_position += velocity * delta
_face(enemy, direction, delta)
func _lock(enemy: Node2D, target: Node2D) -> void:
locked_point = target.global_position
charge_anchor = enemy.global_position
var to_target: Vector2 = locked_point - charge_anchor
charge_distance = to_target.length()
charge_direction = to_target.normalized() if charge_distance > 0.001 else Vector2.RIGHT
windup_left = windup_time
travelled = 0.0
velocity = Vector2.ZERO
phase = Phase.WINDUP
func _tick_windup(enemy: Node2D, delta: float) -> void:
windup_left -= delta
# Snap to the locked heading rather than easing into it: the nose is part
# of the warning, and a lancer still turning when the line appears would
# be lying about where it is going.
enemy.rotation = charge_direction.angle() + PI / 2.0
if windup_left <= 0.0:
phase = Phase.CHARGE
enemy.global_position = charge_anchor
return
# One full back-and-forth per cycle, starting and ending at the anchor,
# leaning BACKWARDS first (-sin) so the recoil comes before the spring.
var progress: float = 1.0 - windup_left / maxf(windup_time, 0.001)
var sway: float = -sin(progress * TAU * windup_cycles) * windup_sway
enemy.global_position = charge_anchor + charge_direction * sway
func _tick_charge(enemy: Node2D, delta: float) -> void:
var step: float = charge_speed * delta
travelled += step
enemy.global_position += charge_direction * step
enemy.rotation = charge_direction.angle() + PI / 2.0
if travelled >= charge_distance + charge_overshoot:
phase = Phase.RECOVER
recover_left = recover_time
velocity = charge_direction * charge_speed
func _tick_recover(enemy: Node2D, delta: float) -> void:
# Coasts to a stop instead of stopping dead, then hunts again. Without
# this it would re-lock the frame it finished and the wind-up would stop
# being a rhythm the player can read.
recover_left -= delta
velocity = velocity.move_toward(Vector2.ZERO, enemy.acceleration * delta)
enemy.global_position += velocity * delta
if recover_left <= 0.0:
phase = Phase.HUNT
velocity = Vector2.ZERO
func _face(enemy: Node2D, direction: Vector2, delta: float) -> void:
var target_angle: float = direction.angle() + PI / 2.0
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
# #101: a lancer will lock a structure exactly as it locks a vessel - the
# wind-up, the line and the charge are the same, and so is what happens when
# it arrives.
func _nearest_ship(enemy: Node2D) -> Node2D:
return EnemyTargets.nearest_target(self, enemy.global_position)
func _draw() -> void:
if not is_telegraphing():
return
var enemy: Node2D = get_parent()
if enemy == null:
return
# Thin, and brightening as the launch approaches, so it reads as a sight
# line being drawn rather than as a beam already firing.
var urgency: float = clampf(1.0 - windup_left / maxf(telegraph_time, 0.001), 0.0, 1.0)
var from: Vector2 = enemy.global_position
var to: Vector2 = locked_point + charge_direction * charge_overshoot
draw_line(from, to, Color(telegraph_color, 0.18 + 0.30 * urgency), 2.0, true)
draw_line(from, to, Color(1.0, 0.92, 0.82, 0.25 + 0.45 * urgency), 1.0, true)
# A tick across the locked point: the line says WHERE, this says how far.
var across: Vector2 = Vector2(-charge_direction.y, charge_direction.x) * 14.0
draw_line(locked_point - across, locked_point + across,
Color(telegraph_color, 0.35 + 0.5 * urgency), 2.0, true)

View file

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

View file

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

View file

@ -37,6 +37,12 @@ func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("player_ships"):
area.take_damage(damage)
queue_free()
return
# #101: structures are targets too. The hitbox is a child Area2D of the
# Building, which is a plain Node2D and could not be hit otherwise.
if area.is_in_group("player_buildings"):
area.take_damage(damage)
queue_free()
func _draw() -> void:

View file

@ -17,15 +17,23 @@ var velocity: Vector2 = Vector2.ZERO
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
var target: Node2D = _nearest_ship(enemy)
if target == null or enemy.position.distance_to(target.position) > detection_range:
# #101: a structure is a target now, not scenery. EnemyTargets keeps a
# ship in the enemy's face ahead of a building further off.
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null:
return
var to_target: Vector2 = target.position - enemy.position
# #113: a wave enemy closes on what the player built from wherever it
# arrived. Everything else keeps the radius that stops it reading as
# omniscient (#69).
if not EnemyTargets.ignores_range(enemy) \
and enemy.global_position.distance_to(target.global_position) > detection_range:
return
var to_target: Vector2 = target.global_position - enemy.global_position
if to_target.length() > 1.0:
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
enemy.position += velocity * delta
enemy.global_position += velocity * delta
if velocity.length() > 5.0:
var target_angle: float = velocity.angle() + PI / 2.0
@ -33,15 +41,4 @@ func tick(delta: float) -> void:
func _nearest_ship(enemy: Node2D) -> Node2D:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
return null
var nearest: Node2D = fleet.ships[0]
var best_dist: float = enemy.position.distance_to(nearest.position)
for ship in fleet.ships:
var d: float = enemy.position.distance_to(ship.position)
if d < best_dist:
best_dist = d
nearest = ship
return nearest
return EnemyTargets.nearest_ship(self, enemy.global_position)

View file

@ -38,17 +38,12 @@ func tick(delta: float) -> void:
fire_timer = fire_cooldown
# #101: shoots at whatever EnemyTargets says it is coming for, so a sniper
# shells a depot rather than sitting in range of one doing nothing.
func _nearest_ship_in_range(enemy: Node2D) -> Node2D:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null:
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null:
return null
var nearest: Node2D = null
var best_distance: float = fire_range
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var distance: float = enemy.global_position.distance_to(ship.global_position)
if distance <= best_distance:
best_distance = distance
nearest = ship
return nearest
if enemy.global_position.distance_to(target.global_position) > fire_range:
return null
return target

View file

@ -1,42 +1,66 @@
extends Node2D
@export var min_spawn_interval: float = 3.0
@export var max_spawn_interval: float = 6.0
# The wave director (#100 / #97). Keeps its old node name on purpose: it is
# still "the thing that puts enemies in the world", and every test that
# wanted a quiet world already says
# `main.get_node("EnemySpawner").set_process(false)`. Renaming it would have
# changed 88 test files to say the same thing differently.
#
# What it no longer does is trickle. It used to spawn one enemy every 3-6
# seconds for ever, so there was no rhythm to the danger and nothing to
# prepare for. Now the CLOCK lives in GameState (which survives the trip to
# a planet surface) and this node only reacts to it: when a wave turns
# active, it puts the wave in the world; when the wave is dead, it says so
# and the quiet starts again.
@export var spawn_margin: float = 150.0
# Waves arrive as a ring around the fleet rather than from one bearing: a
# wave that all came from the left would be dodged by flying right.
@export var wave_spread: float = 0.55
@onready var fleet: Node2D = get_node("../Fleet")
var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
var timer: float = 0.0
var next_spawn_time: float = 0.0
# The enemies this director launched, so "the wave is dead" is a question
# about THIS wave and not about every enemy on the map - a planet garrison
# or an enemy mothership's own spawns must not hold the next wave back.
var wave_enemies: Array = []
func _ready() -> void:
randomize()
_reset_timer()
func _process(delta: float) -> void:
timer += delta
if timer >= next_spawn_time:
timer = 0.0
_reset_timer()
_spawn_enemy()
func _reset_timer() -> void:
next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval)
func _spawn_enemy() -> void:
if fleet.ships.is_empty():
func _process(_delta: float) -> void:
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
if GameState.wave_pending:
_launch_wave()
return
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty():
GameState.wave_cleared()
# Public so a test can put a wave in the world without waiting out the peace.
func _launch_wave() -> void:
if fleet == null or fleet.ships.is_empty():
# Nothing to attack. Leave it pending rather than spending the wave
# on an empty map - the fleet-wipe safety net rebuilds within a frame
# and the wave arrives then.
return
GameState.wave_pending = false
var composition: PackedStringArray = GameState.wave_composition(fleet.ships.size())
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
var angle: float = randf() * TAU
var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius
var centre: Vector2 = fleet.get_centroid()
var bearing: float = randf() * TAU
for i in range(composition.size()):
# Spread around one bearing rather than scattered at random: a wave
# should read as an attack arriving, not as the map filling up.
var angle: float = bearing + randf_range(-wave_spread, wave_spread) \
+ TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread
var enemy: Node2D = enemy_scene.instantiate()
enemy.position = spawn_pos
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
enemy.enemy_id = composition[i]
# #113: a wave is an attack on what the player has built. It converges
# on the nearest structure from wherever it spawned rather than
# waiting to be flown near.
enemy.relentless = true
enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius
get_parent().add_child(enemy)
wave_enemies.append(enemy)

View file

@ -21,12 +21,16 @@ var velocity: Vector2 = Vector2.ZERO
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
var target: Node2D = _nearest_ship(enemy)
# #101: holds its preferred range off a building just as off a ship.
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null:
return
var to_target: Vector2 = target.position - enemy.position
var to_target: Vector2 = target.global_position - enemy.global_position
var distance: float = to_target.length()
if distance < 1.0 or distance > detection_range:
if distance < 1.0:
return
# #113: see EnemyPursuitComponent - a wave arrives with somewhere to be.
if not EnemyTargets.ignores_range(enemy) and distance > detection_range:
return
var direction: Vector2 = to_target / distance
var retreat_range: float = preferred_range * retreat_range_factor
@ -41,7 +45,7 @@ func tick(delta: float) -> void:
desired_velocity = lateral * enemy.speed * 0.5
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
enemy.position += velocity * delta
enemy.global_position += velocity * delta
if velocity.length() > 5.0:
var target_angle: float = velocity.angle() + PI / 2.0
@ -49,14 +53,4 @@ func tick(delta: float) -> void:
func _nearest_ship(enemy: Node2D) -> Node2D:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
return null
var nearest: Node2D = fleet.ships[0]
var best_dist: float = enemy.position.distance_to(nearest.position)
for ship in fleet.ships:
var d: float = enemy.position.distance_to(ship.position)
if d < best_dist:
best_dist = d
nearest = ship
return nearest
return EnemyTargets.nearest_ship(self, enemy.global_position)

73
enemies/EnemyTargets.gd Normal file
View file

@ -0,0 +1,73 @@
class_name EnemyTargets
extends RefCounted
# #101 (#97c): who a wave enemy is coming for.
#
# Before this, every enemy component asked the same question - "which player
# SHIP is nearest?" - and a structure was scenery they flew straight past.
# #97 makes waves go after what the player has built, so the question
# becomes "which player thing is nearest", asked in one place so pursuit,
# standoff, the lancer's lunge and the ranged weapon cannot drift into
# disagreeing about it.
#
# There is one thumb on the scale. A ship inside SHIP_PRIORITY_RANGE always
# wins, even if a building is nearer: an enemy that ignored the vessels
# shooting it in order to reach a depot two hundred pixels closer would read
# as broken, and would make the fleet unable to defend anything by
# interposing itself.
const SHIP_PRIORITY_RANGE: float = 520.0
# The GROUP, not Fleet.ships. #105 puts vessels at outposts that are not in
# the fleet at all, and an enemy that ignored the guns actually shooting at
# it - because they were not in the player's formation - would let a
# garrison hold a position without ever being fought back.
static func nearest_ship(node: Node, from: Vector2) -> Node2D:
var nearest: Node2D = null
var best: float = INF
for ship in node.get_tree().get_nodes_in_group("player_ships"):
if not is_instance_valid(ship) or ship.is_queued_for_deletion():
continue
var d: float = from.distance_to((ship as Node2D).global_position)
if d < best:
best = d
nearest = ship
return nearest
static func nearest_building(node: Node, from: Vector2) -> Node2D:
var nearest: Node2D = null
var best: float = INF
for building in node.get_tree().get_nodes_in_group("buildings"):
if not is_instance_valid(building) or building.is_queued_for_deletion():
continue
var d: float = from.distance_to(building.global_position)
if d < best:
best = d
nearest = building
return nearest
static func nearest_target(node: Node, from: Vector2) -> Node2D:
var ship: Node2D = nearest_ship(node, from)
if ship != null and from.distance_to(ship.global_position) <= SHIP_PRIORITY_RANGE:
return ship
var building: Node2D = nearest_building(node, from)
if building == null:
return ship
if ship == null:
return building
return ship if from.distance_to(ship.global_position) \
<= from.distance_to(building.global_position) else building
static func is_building(target: Node) -> bool:
return target != null and target.is_in_group("buildings")
# #113: whether this enemy may act on a target however far away it is.
# Asked here rather than read directly, so the three movement components
# cannot drift into disagreeing about what "relentless" means.
static func ignores_range(enemy: Node) -> bool:
return enemy != null and bool(enemy.get("relentless"))

View file

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

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://enemies/EnemyLungeComponent.tscn" id="2"]
[resource]
script = ExtResource("1")
enemy_id = "lancer"
display_name = "Lancer"
max_hp = 2
speed = 220.0
acceleration = 420.0
rotation_speed = 7.0
hull_points = PackedVector2Array(0, -32, 6, -8, 11, 14, 4, 10, 0, 20, -4, 10, -11, 14, -6, -8)
glow_color = Color(1, 0.45, 0.15, 1)
detail_lines = PackedVector2Array(0, -28, 0, 6, -7, -2, 7, -2)
detail_rings = PackedVector2Array(0, 8, 4, 0)
dies_on_contact = true
component_scenes = Array[PackedScene]([ExtResource("2")])
contact_damage = 22

View file

@ -19,3 +19,4 @@ glow_color = Color(0.55, 0.05, 0.85, 1)
detail_lines = PackedVector2Array(0, -44, 0, 40, -28, -18, 28, -18, -34, 4, 34, 4, -20, 28, 20, 28, -12, -40, 12, -40)
detail_rings = PackedVector2Array(0, -28, 9, 0, 0, 18, 6, 0)
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")])
contact_damage = 34

View file

@ -17,3 +17,4 @@ glow_color = Color(0.42, 0.72, 1.0, 1)
detail_lines = PackedVector2Array(0, -30, 0, 10, -6, 12, 6, 12)
detail_rings = PackedVector2Array(0, -2, 5, 0)
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
contact_damage = 4

View file

@ -16,3 +16,4 @@ glow_color = Color(0.68, 0.92, 0.18, 1)
detail_lines = PackedVector2Array(-16, -16, 16, 16, 16, -16, -16, 16)
detail_rings = PackedVector2Array(0, 0, 13, 0, 0, -22, 5, 0, 0, 22, 5, 0, -22, 0, 5, 0, 22, 0, 5, 0)
component_scenes = Array[PackedScene]([ExtResource("2")])
contact_damage = 5

40
logistics/RepairDrone.gd Normal file
View file

@ -0,0 +1,40 @@
extends Node2D
# #116: purely visual - a repair drone on its way out to a damaged structure
# or back to its workshop. Carries no state of its own; GameState owns the
# simulation and RepairDroneField creates, positions and frees this to match.
const HULL_COLOR: Color = Color(0.5, 0.95, 0.8)
var laden: bool = false
func _ready() -> void:
add_to_group("repair_drones")
# #91: behind the fleet, like every other piece of ground furniture.
z_index = -1
queue_redraw()
func set_laden(value: bool) -> void:
if laden == value:
return
laden = value
queue_redraw()
func _draw() -> void:
# Small and blunt, with a tool arm out front, in the workshop's own
# green-teal: it should read as a tender belonging to a building rather
# than as a fleet vessel or a hub's magenta hauler.
var hull: PackedVector2Array = PackedVector2Array([
Vector2(0, -11), Vector2(8, -3), Vector2(6, 10), Vector2(-6, 10), Vector2(-8, -3),
])
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0)
NeonDraw.draw_glowing_line(self, Vector2(0, -11), Vector2(0, -18), HULL_COLOR, 0.9, 0.35)
NeonDraw.draw_glowing_line(self, Vector2(-5, -18), Vector2(5, -18), HULL_COLOR, 0.9, 0.35)
if laden:
# The load it is carrying out, in ore's own colour so it reads as
# material rather than as a light on the hull.
NeonDraw.draw_glowing_circle(self, Vector2(0, 3), 5.0,
GameState.resource_color(GameState.RESOURCE_ORE), 1.0)

View file

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

View file

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

View file

@ -0,0 +1,59 @@
extends Node2D
# #116: draws the repair drones GameState is simulating, and only while they
# are somewhere the player could see them.
#
# Same arrangement as TransporterField, and for the same reason: the
# simulation is authoritative and the visuals are optional, so a repair run
# happening far away costs nothing but still puts the building back together.
# Position comes from the very same `progress` that decides when the repair
# actually lands.
@export var visible_radius: float = 2600.0
@onready var fleet: Node2D = get_node("../Fleet")
var drone_scene: PackedScene = preload("res://logistics/RepairDrone.tscn")
# index into GameState.repair_drones -> live node
var active: Dictionary = {}
func _process(_delta: float) -> void:
var centre: Vector2 = fleet.get_centroid()
for i in range(GameState.repair_drones.size()):
var drone: Dictionary = GameState.repair_drones[i]
# An idle drone is parked inside its workshop's own art; drawing it
# there adds a dot sitting on a building rather than a vessel doing
# something.
if String(drone.state) == GameState.DRONE_IDLE:
_despawn(i)
continue
var pos: Vector2 = GameState.repair_drone_position(drone)
if pos.distance_to(centre) > visible_radius:
_despawn(i)
continue
var node: Node2D = active.get(i)
if node == null or not is_instance_valid(node):
node = drone_scene.instantiate()
get_parent().add_child(node)
active[i] = node
var previous: Vector2 = node.position
node.position = pos
node.set_laden(int(drone.carrying) > 0)
var travel: Vector2 = pos - previous
if travel.length() > 0.5:
node.rotation = travel.angle() + PI / 2.0
for key in active.keys():
if key >= GameState.repair_drones.size():
_despawn(key)
func _despawn(index: int) -> void:
var node: Node2D = active.get(index)
if node != null and is_instance_valid(node):
node.queue_free()
active.erase(index)

View file

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

View file

@ -39,6 +39,12 @@ func _ready() -> void:
camera.position = GameState.return_world_position
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
GameState.has_return_position = false
elif GameState.intro_active:
# #108: the prologue opens with a convoy, and crews it itself. This
# node still runs first, so spawning the ordinary opening fleet here
# would be immediately thrown away - and worse, would be the fleet
# for the one frame before the director replaced it.
pass
else:
# A brand new game. STARTING_SHIP_COUNT rather than a literal: this
# said 5, which was the old BASE_FLEET_SIZE written out by hand, and
@ -123,6 +129,11 @@ func _on_enter_pressed() -> void:
GameState.planet_name = current_planet.planet_name
GameState.planet_id = current_planet.planet_id
GameState.planet_definition_id = current_planet.definition_id
# #133: quests ask about worlds LANDED ON, which is a different question
# from the ones flown past that explored_chunks answers. Recorded at the
# moment of committing to the descent - the same moment the three lines
# above are written - so the two can never disagree.
GameState.note_planet_visited(current_planet.planet_id, current_planet.definition_id)
var tween: Tween = create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, fade_duration)

72
shaders/breach.gdshader Normal file
View file

@ -0,0 +1,72 @@
shader_type canvas_item;
// #125: the dimensional breach, as a screen-space lens.
//
// The prologue's warp used to be a handful of lines turning around a point.
// This bends the ACTUAL SCENE around the breach instead - the ships, the
// rocks and the starfield all get dragged into it - which is most of what
// makes a hole in space read as a hole rather than as a decal drawn on top
// of one.
//
// Two effects, both radial and both fading to nothing at the edge of their
// reach, so the shader is invisible outside its own radius and costs a
// clamped texture fetch everywhere else:
//
// swirl angle offset that grows toward the centre, so the scene winds
// around the hole rather than merely sliding toward it
// pull inward displacement, strongest just outside the horizon, which
// is the lensing itself
//
// Inside the horizon nothing is sampled at all: the core is drawn as empty,
// the same decision BlackHoleVisual makes for the black-hole planet, and
// for the same reason - a "black hole" with something visible in the middle
// stops reading as one.
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
// Where the breach is, in SCREEN pixels, and how big it has grown.
uniform vec2 breach_center = vec2(360.0, 640.0);
uniform vec2 viewport_size = vec2(720.0, 1280.0);
// The event horizon: everything inside is void.
uniform float horizon_radius = 0.0;
// How far the lens reaches past the horizon. Beyond this the scene is
// untouched.
uniform float lens_radius = 1.0;
// 0 while the breach is opening, 1 at full strength. Scales both effects
// together so one knob fades the whole thing in.
uniform float strength = 0.0;
uniform float swirl_turns = 2.4;
uniform vec4 rim_color : source_color = vec4(0.72, 0.45, 1.0, 1.0);
void fragment() {
vec2 pixel = UV * viewport_size;
vec2 offset = pixel - breach_center;
float distance_to_centre = length(offset);
if (distance_to_centre >= lens_radius || strength <= 0.001) {
COLOR = texture(screen_texture, SCREEN_UV);
} else if (distance_to_centre <= horizon_radius) {
// The core. Not black-with-alpha: genuinely nothing, so whatever is
// behind the breach is gone rather than dimmed.
COLOR = vec4(0.0, 0.0, 0.0, 1.0);
} else {
// 1 at the horizon, 0 at the edge of the lens. Squared so the bend
// is concentrated near the hole instead of smeared evenly outward.
float falloff = 1.0 - clamp(
(distance_to_centre - horizon_radius) / max(lens_radius - horizon_radius, 1.0),
0.0, 1.0);
float bend = falloff * falloff * strength;
float angle = atan(offset.y, offset.x) + bend * swirl_turns;
float pulled = distance_to_centre - bend * (lens_radius - horizon_radius) * 0.55;
pulled = max(pulled, horizon_radius);
vec2 sampled = breach_center + vec2(cos(angle), sin(angle)) * pulled;
COLOR = texture(screen_texture, sampled / viewport_size);
// A hot rim where the lensing is fiercest, which is what gives the
// horizon an edge rather than a fade.
float rim = smoothstep(0.72, 1.0, falloff) * strength;
COLOR.rgb = mix(COLOR.rgb, rim_color.rgb, rim * 0.65);
}
}

View file

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

View file

@ -13,6 +13,14 @@ var vents: Array[Vector2] = []
var progress: float = 0.0
var emission_phase: float = 0.0
func _ready() -> void:
# #91: the vents are ground features and must never cover the fleet.
# SurfaceFeatures is added AFTER Fleet in PlanetSurface.tscn, so at the
# default z_index of 0 the later sibling simply wins and the player flies
# UNDER their own gas source. Same fix, and the same reason, as #50's
# capture rings and the rocks on a rocky surface (Rock.gd) - the
# difference is that this field draws in its own _draw() rather than
# spawning child nodes, so the depth belongs on the field itself.
z_index = -1
var rng := RandomNumberGenerator.new()
rng.seed = hash(GameState.planet_id if GameState.planet_id != "" else GameState.planet_name)
for i in range(6): vents.append(Vector2(rng.randf_range(-800,800), rng.randf_range(-650,650)))

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=16 format=3]
[gd_scene load_steps=19 format=3]
[ext_resource type="Script" path="res://controllers/GameController.gd" id="1"]
[ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"]
@ -13,6 +13,9 @@
[ext_resource type="Script" path="res://controllers/LaserController.gd" id="16"]
[ext_resource type="Script" path="res://ui/SpeedFader.gd" id="17"]
[ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"]
[ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="banner"]
[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"]
[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"]
[node name="PlanetSurface" type="Node2D"]
script = ExtResource("1")
@ -45,6 +48,9 @@ zoom = Vector2(1, 1)
[node name="SurfaceController" type="Node2D" parent="."]
script = ExtResource("5")
[node name="QuestDirector" type="Node" parent="."]
script = ExtResource("quest_dir")
[node name="UI" type="CanvasLayer" parent="."]
layer = 1
@ -123,6 +129,37 @@ icon = 4
glow_color = Color(0.06, 0.32, 0.5, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="QuestButton" type="Control" parent="UI"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172.0
offset_right = -24.0
offset_top = 160.0
offset_bottom = 308.0
script = ExtResource("6")
icon = 44
[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 24.0
offset_top = 24.0
offset_right = -24.0
offset_bottom = -24.0
script = ExtResource("quest_panel")
[node name="CloseButton" type="Control" parent="UI/QuestPanel"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -140.0
offset_right = -24.0
offset_top = 20.0
offset_bottom = 136.0
script = ExtResource("6")
icon = 1
[node name="Joystick" type="Control" parent="UI"]
mouse_filter = 2
script = ExtResource("7")
@ -273,6 +310,16 @@ offset_right = -252
offset_top = -130
offset_bottom = -26
[node name="NarrationBanner" type="Control" parent="UI"]
visible = false
mouse_filter = 2
anchor_right = 1.0
offset_left = 60.0
offset_right = -60.0
offset_top = 300.0
offset_bottom = 428.0
script = ExtResource("banner")
[node name="FadeOverlay" type="CanvasLayer" parent="."]
layer = 10

View file

@ -1,15 +1,19 @@
#!/bin/bash
# Runs INSIDE the proot-distro Ubuntu container - do not call this directly,
# call tests/run_tests.sh from Termux instead.
# The half of the runner that actually runs Godot - do not call this
# directly, call tests/run_tests.sh instead.
#
# It exists as its own file so the whole suite runs in ONE `proot-distro
# login`: entering the container costs ~0.5s, which used to be paid 38 times
# over. Everything below is the container-side half of the runner.
# On Termux it runs INSIDE the proot-distro Ubuntu container. It exists as
# its own file so the whole suite runs in ONE `proot-distro login`: entering
# the container costs ~0.5s, which used to be paid 38 times over. On WSL
# there is no container to enter and run_tests.sh calls it directly - the
# scheduling, timeouts and verdicts below are the same either way, which is
# why the Godot binary arrives as an argument rather than being hardcoded.
#
# Args: <project_dir> <jobs> <verbose> <test_file_name>...
# Args: <godot_bin> <project_dir> <jobs> <verbose> <test_file_name>...
set -uo pipefail
GODOT_BIN="$1"; shift
PROJECT_DIR="$1"; shift
JOBS="$1"; shift
VERBOSE="$1"; shift
@ -18,8 +22,35 @@ VERBOSE="$1"; shift
# complete in seconds, even on a busy phone, so make that failure explicit
# rather than making the suite look hung. Override for deliberate long runs.
TEST_TIMEOUT_SECONDS="${TEST_TIMEOUT_SECONDS:-120}"
# How many times to start a test whose process never got as far as saying
# anything. Under WSL the Godot binary is a Windows executable started across
# the interop boundary, and with six of them going at once that start
# occasionally just fails. It has been seen two ways: exit 127 with a
# completely empty log, and a non-zero exit after the engine banner and not
# one line more. Both moved from test to test between runs and both passed
# alone, so both are properties of the launch rather than of the test.
#
# The line that separates them from a real failure is OUTPUT, not the exit
# code and not the banner: every test says something before it stops - its
# own FAIL verdict, a SCRIPT ERROR, an engine complaint. A log holding
# nothing but the banner is a run that did not happen. This can only ever
# mask a fault that is both silent AND intermittent; a deterministic crash
# still fails all LAUNCH_ATTEMPTS and is reported.
LAUNCH_ATTEMPTS="${LAUNCH_ATTEMPTS:-3}"
# Did the process produce any line of its own? The engine banner and blank
# lines are what a bare launch prints by itself and do not count.
#
# Tests the OUTPUT rather than grep's exit status on purpose: `grep -q -v`
# does not agree across greps - ugrep, which is what `grep` resolves to on
# at least one machine this suite runs on, reports success for an empty file
# and failure for a file full of matching lines. A non-empty result is the
# same in every implementation.
_said_anything() {
[[ -n "$(grep -v -e '^[[:space:]]*$' -e '^Godot Engine' "$1")" ]]
}
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
cd "$PROJECT_DIR" || exit 2
tests=("$@")
@ -53,18 +84,37 @@ run_one() {
# 3 parallel runs. Pinned, a test simulates the same 60fps whatever else
# the phone is doing, and its result no longer depends on how busy the
# machine was.
local rc=0
local attempt=1
while true; do
timeout "$TEST_TIMEOUT_SECONDS" "$GODOT_BIN" --headless --fixed-fps 60 \
-s "res://tests/$name" >"$log" 2>&1
local rc=$?
rc=$?
# "Never started" is rc != 0 with a log that says nothing of its own.
# Anything that ran - passed, failed, crashed mid-way - said something.
if (( rc == 0 )) || _said_anything "$log" \
|| (( attempt >= LAUNCH_ATTEMPTS )); then
break
fi
attempt=$(( attempt + 1 ))
sleep 1
done
end=$(date +%s%N)
local ms=$(( (end - start) / 1000000 ))
# Retries are reported, never hidden: a test that needed one is telling
# you the machine is struggling, even where it went on to pass.
local retried=""
if (( attempt > 1 )); then
retried=" (started on attempt $attempt)"
fi
# Two independent criteria, because either one alone can lie: a test that
# crashes before reaching its verdict exits non-zero without ever printing
# FAIL, and a hypothetical `quit(0)` on a failing path would exit clean
# while the log says otherwise.
if (( rc == 0 )) && grep -q ": PASS" "$log"; then
printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status"
printf 'PASS %6dms %s%s\n' "$ms" "$name" "$retried" > "$outdir/$name.status"
elif (( rc == 124 )); then
printf 'TIMEOUT %4dms %s (limit %ss)\n' \
"$ms" "$name" "$TEST_TIMEOUT_SECONDS" > "$outdir/$name.status"
@ -75,8 +125,11 @@ run_one() {
# indistinguishable from a hang.
printf 'KILLED %4dms %s (signal %d - out of memory? try JOBS=1)\n' \
"$ms" "$name" "$(( rc - 128 ))" > "$outdir/$name.status"
elif (( rc != 0 )) && ! _said_anything "$log"; then
printf 'NOSTART %3dms %s (exit %d after %d attempts - the process never came up)\n' \
"$ms" "$name" "$rc" "$attempt" > "$outdir/$name.status"
else
printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status"
printf 'FAIL %6dms %s (exit %d)%s\n' "$ms" "$name" "$rc" "$retried" > "$outdir/$name.status"
fi
cat "$outdir/$name.status"
}

View file

@ -1,15 +1,27 @@
#!/data/data/com.termux/files/usr/bin/bash
# Runs the headless GDScript gameplay tests from Termux, via the Godot
# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container.
#!/usr/bin/env bash
# Runs the headless GDScript gameplay tests.
#
# bash tests/run_tests.sh # everything
# bash tests/run_tests.sh formation # only tests matching a substring
# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang
# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too
# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself
# GODOT_BIN=/path/to/godot bash tests/run_tests.sh # pick the binary
#
# Three things make this fast, in descending order of how much they matter
# (measured, not guessed - see tests/_profile_startup.gd):
# There are two ways to get a Godot to run them with, picked automatically:
#
# Termux the Godot 4.7 Linux arm64 build installed inside the
# proot-distro Ubuntu container, which is the only way to run
# a desktop Godot on the phone at all.
# WSL the Windows Godot build off /mnt/c. A Linux `godot` on PATH
# is used first if there is one.
#
# The platform decides which Godot runs and where the project is copied to.
# Everything else - the copy itself, the worker count, and
# tests/_run_inside.sh, which does the actual running - is shared.
#
# On Termux, three things make this fast, in descending order of how much
# they matter (measured, not guessed - see tests/_profile_startup.gd):
#
# 1. It runs from a COPY on Termux's own filesystem, not from the project
# directory. The project lives under ~/storage/shared, which is Android's
@ -17,7 +29,15 @@
# alone took 8.5s per test, against ~2s from a native path. Nearly every
# test loads Main.tscn, and none of that time is gameplay. The copy is
# ~1.4s for the whole suite and its .godot import cache is kept between
# runs.
# runs. WSL copies too, but to a native NTFS path rather than to ext4:
# a Windows Godot reads C: at full speed and would read \\wsl$ slowly, so
# the copy is for ISOLATION rather than for speed there. Six Godot
# processes churning the project directory is six processes writing
# .godot, creating and deleting files under PlanetSetups/, and doing it
# in a working tree that git, an open editor and the player are also
# using. Under that, single tests failed at random - a delete that did
# not take, a scene that would not instantiate - never the same one
# twice.
# 2. Tests run JOBS at a time. Each is its own Godot process - they call
# quit() and stamp on the GameState autoload, so they cannot share one -
# which makes the suite a pile of independent, single-core-bound jobs on
@ -32,6 +52,32 @@ set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PLATFORM="termux"
if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
PLATFORM="wsl"
fi
# Finding a Godot on WSL. A Linux build on PATH wins - no interop, no drive
# letters - but there usually is not one, and the Windows build the editor
# already runs from is right there on /mnt/c. Two things about picking that
# one up:
# - it must be the *_console.exe. The plain .exe is built as a Windows GUI
# subsystem binary: it detaches from the terminal, so every test would
# produce an empty log and the runner would call all of them failures.
# - sort -V so 4.7.2 beats 4.6.1 when several versions are downloaded.
_find_godot_wsl() {
local found
found="$(command -v godot 2>/dev/null)"
if [[ -n "$found" ]]; then
echo "$found"
return
fi
found="$(find /mnt/c/Users/*/Downloads /mnt/c/Users/*/AppData/Local/Programs \
"/mnt/c/Program Files" -maxdepth 2 -name 'Godot_v*_console.exe' \
2>/dev/null | sort -V | tail -1)"
echo "$found"
}
# Worker count comes from FREE MEMORY, not core count. Each Godot instance is
# ~400MB and this phone shares its RAM with everything else running on it.
# Getting it wrong is bad in both directions:
@ -64,7 +110,43 @@ _pick_jobs() {
JOBS="${JOBS:-$(_pick_jobs)}"
VERBOSE="${VERBOSE:-0}"
IN_PLACE="${IN_PLACE:-0}"
WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}"
# Where the copy lives. Termux wants its own native filesystem; WSL wants a
# WINDOWS one, because the thing reading it is a Windows binary and $HOME is
# ext4 behind the \\wsl$ share. %LOCALAPPDATA% is the natural home for it;
# if that cannot be read, fall back to a sibling of the project - still on
# the same native drive, still outside the repo.
_default_work_dir() {
if [[ "$PLATFORM" != "wsl" ]]; then
echo "$HOME/.cache/spaceship-tests"
return
fi
local win_local
win_local="$(cmd.exe /c 'echo %LOCALAPPDATA%' 2>/dev/null | tr -d '\r')"
if [[ -n "$win_local" && "$win_local" != *'%'* ]]; then
local unix_local
unix_local="$(wslpath -u "$win_local" 2>/dev/null)"
if [[ -n "$unix_local" ]]; then
echo "$unix_local/spaceship-tests"
return
fi
fi
echo "$(dirname "$PROJECT_DIR")/.spaceship-tests"
}
WORK_DIR="${WORK_DIR:-$(_default_work_dir)}"
if [[ -z "${GODOT_BIN:-}" ]]; then
if [[ "$PLATFORM" == "wsl" ]]; then
GODOT_BIN="$(_find_godot_wsl)"
if [[ -z "$GODOT_BIN" ]]; then
echo "no Godot found. Install one on PATH, or download a Windows" >&2
echo "build and point GODOT_BIN at its *_console.exe." >&2
exit 2
fi
else
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
fi
fi
filter="${1:-}"
tests=()
@ -91,14 +173,22 @@ if [[ "$IN_PLACE" != "1" ]]; then
# keep being run.
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
# android/ and builds/ are export artefacts no test touches, and the .apk
# is 30MB of nothing useful here.
# is 30MB of nothing useful here. audio/masters/ is the same argument:
# Godot skips it (.gdignore) so no test can reach it, and it is 59MB of
# source WAV that would be copied on every single run.
tar -C "$PROJECT_DIR" \
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
--exclude=./audio/masters \
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
fi
if [[ "$PLATFORM" == "wsl" ]]; then
bash "$run_dir/tests/_run_inside.sh" \
"$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
else
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
"$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
fi
status=$?
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"

View file

@ -10,16 +10,23 @@ extends SceneTree
# test_return_position_matches_planet fail intermittently.
#
# Verifies:
# 1. _safe_position never returns a point inside the bubble, for candidates
# all around and including the exact centre. This is checked directly
# rather than by looking at where asteroids happened to land: with ~54
# asteroids spread over 25 chunks, any single run only rarely produces a
# near miss, so an observational check passes just as happily with the
# guard removed and proves nothing.
# 2. candidates already outside the bubble are left exactly where they are,
# so the field is not distorted away from the fleet
# 3. the field still spawns a full complement, and a stationary fleet loses
# no ships
# #126 widens the rule from "not on the fleet" to "not IN VIEW": the old
# bubble was 420px, smaller than the screen, so a rock could and did
# materialise in plain sight several hundred pixels away.
#
# Verifies:
# 1. safe_position never returns a point inside the keep-out area, for
# candidates all around it and including its exact centre. Checked
# directly rather than by looking at where asteroids happened to land:
# with ~54 asteroids over 25 chunks, any single run only rarely
# produces a near miss, so an observational check passes just as
# happily with the guard removed and proves nothing.
# 2. candidates already outside are left exactly where they are, so the
# field is not distorted away from the fleet
# 3. the keep-out area really is the visible rect and not the old bubble
# 4. nothing the field actually placed is on screen
# 5. the field still spawns a full complement, and a stationary fleet
# loses no ships
var scene: Node
var fleet: Node
@ -53,32 +60,50 @@ func _process(delta: float) -> bool:
var centre: Vector2 = fleet.get_centroid()
var safe: float = field.safe_spawn_radius
var keep_out: Rect2 = field.keep_out_rect(centre)
# --- the guard itself, deterministically ----------------------
var worst_inside: float = 1e9
# --- 3. the keep-out area is the screen, not the old bubble ---
var on_screen: Rect2 = ViewUtils.get_visible_world_rect(field)
if not keep_out.encloses(on_screen):
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area does not cover the visible rect)")
quit(1)
return true
if keep_out.size.x < safe * 2.0 or keep_out.size.y < safe * 2.0:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area shrank below the old bubble)")
quit(1)
return true
print("keep-out %.0fx%.0f covers a %.0fx%.0f screen (old bubble was %.0f across)" % [
keep_out.size.x, keep_out.size.y, on_screen.size.x, on_screen.size.y, safe * 2.0
])
# --- 1/2. the guard itself, deterministically -----------------
var half: Vector2 = keep_out.size * 0.5
var reach: float = maxf(half.x, half.y)
var let_one_through: bool = false
var moved_an_outside_one: bool = false
for i in range(72):
var a: float = (float(i) / 72.0) * TAU
var dir: Vector2 = Vector2(cos(a), sin(a))
# Candidates well inside, just inside, and exactly on centre.
for d in [0.0, 1.0, safe * 0.25, safe * 0.99]:
var got: Vector2 = field._safe_position(centre + dir * d, centre)
worst_inside = minf(worst_inside, got.distance_to(centre))
for d in [0.0, 1.0, reach * 0.25, reach * 0.7]:
var candidate: Vector2 = keep_out.get_center() + dir * d
if not keep_out.has_point(candidate):
continue
if keep_out.has_point(field.safe_position(candidate, keep_out)):
let_one_through = true
# Candidates already outside must be returned untouched.
var outside: Vector2 = centre + dir * (safe * 1.5)
if field._safe_position(outside, centre).distance_to(outside) > 0.001:
var outside: Vector2 = keep_out.get_center() + dir * (reach * 3.0)
if field.safe_position(outside, keep_out).distance_to(outside) > 0.001:
moved_an_outside_one = true
print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [
worst_inside, safe
])
if worst_inside < safe - 0.001:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)")
if let_one_through:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the guard left a spawn inside the visible area)")
quit(1)
return true
if moved_an_outside_one:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)")
quit(1)
return true
print("every one of 288 in-view candidates was pushed out; none outside was touched")
var asteroids: Array = get_nodes_in_group("asteroids")
var closest: float = 1e9

View file

@ -3,9 +3,15 @@ extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_asteroid_ship_collision.gd
# Verifies that a ship touching an asteroid destroys BOTH the ship (removed
# from the fleet) and the asteroid itself (mutual destruction), each
# spawning its own coherent explosion VFX: light-burst blue for the ship,
# debris brown for the asteroid.
# from the fleet) and the asteroid itself (mutual destruction), each leaving
# its own coherent VFX behind.
#
# #130 changed what the ship leaves. Its death is a sequence now - the hull
# breaks away from the fleet, drifts clear, and explodes out there - so what
# exists on the frame of the collision is the WRECK, and the explosion comes
# later, somewhere else. That the wreck really does drift and really does go
# up is test_ship_death's job; what matters here is that the collision still
# ends the ship, ends the rock, and leaves each of them something to look at.
const CHECK_DELAY: float = 0.05
@ -55,7 +61,7 @@ func _check_results() -> void:
var ship_vfx_ok: bool = false
var asteroid_vfx_ok: bool = false
for child in root.get_children():
if child is ShipExplosionVFX:
if child is ShipWreckVFX:
ship_vfx_ok = true
elif child is AsteroidExplosionVFX:
asteroid_vfx_ok = true

View file

@ -20,7 +20,9 @@ var controller: Node
var popup: Control
var title: Label
var message: Label
var cost_number: NeonNumber
# Control, not PriceTag: naming a class whose body reads the GameState
# autoload does not compile inside an `extends SceneTree` script.
var cost_number: Control
var build_button: IconButton
var stage: int = 0
var elapsed: float = 0.0
@ -37,6 +39,10 @@ const OPTIONS: Array = [
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 500
# #138: some structures want gas as well as rocks, and the popup
# only offers a build button for something the player can pay for.
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500)
game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 500)
game_state.buildings.clear()
game_state.extractors.clear()
# Structures are gated behind the mothership's research tree. That gate
@ -89,11 +95,12 @@ func _process(delta: float) -> bool:
var informed: bool = popup.visible \
and title.text == option.title \
and message.text.length() > 40 \
and cost_number.amount == expected_cost \
and cost_number.priced(GameState.RESOURCE_ORE) == expected_cost \
and build_button.visible
var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before
print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [
type, popup.visible, title.text, cost_number.amount,
type, popup.visible, title.text,
cost_number.priced(GameState.RESOURCE_ORE),
build_button.visible, untouched
])
if not (informed and untouched):

View file

@ -17,6 +17,13 @@ extends SceneTree
# 4. an entry alone on its row is centred, not hanging in the left column
# 5. researched-but-not-usable is still shown greyed (an extractor with no
# planet under the fleet is "not now", not "not yet")
# 6. #136 - with every structure researched, no two entries overlap. The
# grid used to be a fixed list of row positions read with a clamp, so
# the first row that would not fit was drawn on top of another one
# 7. #144 - and that holds at any window shape. The header is anchored to
# the top of the panel and the grid to its centre, so the gap between
# them is half the panel height - it was a constant measured at 1280,
# and at 1080 the grid started above the status line and drew through it
var game_state: Node
var scene: Node
@ -45,6 +52,12 @@ func _shown() -> Array:
return out
func _rect(node: Control) -> Rect2:
return Rect2(Vector2(node.offset_left, node.offset_top),
Vector2(node.offset_right - node.offset_left,
node.offset_bottom - node.offset_top))
func _slots() -> Array:
# (row, x) of every visible button, in menu order.
var out: Array = []
@ -100,21 +113,94 @@ func _process(delta: float) -> bool:
# --- 3. consecutive slots, no holes -------------------------
var slots: Array = _slots()
var rows: Array = controller.MENU_ROW_TOPS
var columns: int = controller.menu_columns(slots.size())
var rows: int = controller.menu_rows(slots.size())
if columns != 2:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (4 entries laid out in %d columns)" % columns)
quit(1)
return true
for i in range(slots.size()):
var want_row: float = rows[i / 2]
var want_row: float = controller.menu_row_top(i / columns, rows, columns)
if absf(slots[i][0] - want_row) > 0.01:
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / 2, want_row])
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / columns, want_row])
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)")
quit(1)
return true
var want_x: float = -controller.MENU_COL_X if i % 2 == 0 else controller.MENU_COL_X
var spacing: float = controller.menu_column_spacing(columns)
var want_x: float = -spacing * 0.5 if i % 2 == 0 else spacing * 0.5
if absf(slots[i][1] - want_x) > 0.01:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x])
quit(1)
return true
print("4 entries packed into consecutive slots, no gaps")
# --- 6. #136: nothing overlaps, with EVERY structure on offer -
# The bug the ticket reported: the row list ran out and the last
# entry was placed on top of one already there.
for node in GameState.SKILL_TREE:
game_state.unlocked.append(String(node.id))
controller._refresh_build_status()
var all_shown: Array = _shown()
if all_shown.size() < 9:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (only %d structures unlocked - not the crowded case)" % all_shown.size())
quit(1)
return true
for i in range(all_shown.size()):
for j in range(i + 1, all_shown.size()):
for part in [1, 2, 3]:
var a: Control = all_shown[i][part]
var b: Control = all_shown[j][part]
if _rect(a).intersects(_rect(b)):
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%s and %s overlap at %s / %s)" \
% [all_shown[i][0], all_shown[j][0], _rect(a), _rect(b)])
quit(1)
return true
print("%d structures on offer, in %d columns, nothing overlapping" \
% [all_shown.size(), controller.menu_columns(all_shown.size())])
# --- 7. #144: and at any window shape ------------------------
# The grid is anchored to the panel's CENTRE and the status line
# above it to the panel's TOP, so the gap between them is half
# the panel height. It used to be a written-down constant, right
# only at 1280 - at 1080 the grid started above the status line
# and drew through it, which is what the ticket reported.
var menu: Control = scene.get_node("UI/BuildMenu")
var status: Control = scene.get_node("UI/BuildMenu/StatusLabel")
var restore: float = menu.offset_bottom
for panel_height in [900.0, 1080.0, 1280.0, 1600.0]:
menu.anchor_bottom = 0.0
menu.offset_top = 0.0
menu.offset_bottom = panel_height
controller._refresh_build_status()
var half: float = menu.size.y * 0.5
# The status line's own bottom, in the grid's centre-anchored
# frame - which is the comparison that was never made.
var header_bottom: float = status.offset_bottom - half
var placed: Array = _shown()
for a in range(placed.size()):
for part in [1, 2, 3]:
var box: Rect2 = _rect(placed[a][part])
if box.position.y < header_bottom:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s is drawn at y=%.0f, above the status line at %.0f)" \
% [panel_height, placed[a][0], box.position.y, header_bottom])
quit(1)
return true
for b in range(a + 1, placed.size()):
for part2 in [1, 2, 3]:
if _rect(placed[a][part2]).intersects(_rect(placed[b][part2])):
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s and %s overlap)" \
% [panel_height, placed[a][0], placed[b][0]])
quit(1)
return true
print(" %.0fpx tall: %d columns, top row at %.0f, clear of the status line at %.0f" \
% [panel_height, controller.menu_columns(placed.size()),
_rect(placed[0][1]).position.y, header_bottom])
menu.anchor_bottom = 1.0
menu.offset_top = 0.0
menu.offset_bottom = restore
controller._refresh_build_status()
print("the grid clears the header at every window shape")
# --- 5. researched but not usable is shown, greyed ----------
var extractor_shown: bool = false
for e in shown2:

View file

@ -0,0 +1,110 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_build_popup_fits_window.gd
# Forgejo #92: the confirm-this-building popup ran off a landscape window.
#
# The card is authored at a fixed 704x1200 around a portrait phone. On a
# window that is wider than it is tall the stretched canvas is ~1080 units
# high, so 1200 units of card hung over both edges - and since the confirm
# and cancel buttons are anchored to the card's own bottom edge, they went
# with it. A player on a desktop could open the popup and then neither buy
# nor dismiss the thing.
#
# Covers:
# A. on every window shape the card lies entirely inside the viewport
# B. so do the two buttons on it, which is the part that made the popup a
# dead end rather than merely an ugly one
# C. it is not blown up on a big screen - the layout inside is tuned at
# the authored size and only ever shrinks
# D. it re-fits when the window changes, rather than only at startup
# Portrait phone, 16:9 desktop, an ultrawide, and a small laptop.
const SHAPES: Array = [
Vector2i(720, 1280), Vector2i(1920, 1080), Vector2i(2560, 900), Vector2i(1280, 720),
]
var game_state: Node
var scene: Node
var controller: Node
var panel: Control
var shapes: Array = []
var frames: int = 0
var opened: bool = false
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
shapes = SHAPES.duplicate()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
panel = scene.get_node("UI/BuildMenu/InfoPopup/Panel")
func _inside(rect: Rect2, outer: Rect2) -> bool:
# Rect2.encloses() is exclusive at the edges for zero-area cases; this is
# the plain reading - all four sides within, touching allowed.
return rect.position.x >= outer.position.x - 0.5 \
and rect.position.y >= outer.position.y - 0.5 \
and rect.end.x <= outer.end.x + 0.5 \
and rect.end.y <= outer.end.y + 0.5
func _process(_delta: float) -> bool:
frames += 1
if not opened:
if frames < 12:
return false
controller._on_build_pressed()
controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
if not scene.get_node("UI/BuildMenu/InfoPopup").visible:
return _fail("the build popup did not open")
opened = true
frames = 0
root.size = SHAPES[0]
return false
# Give the resize a few frames to reach the Control layout before reading
# any rect off it.
if frames < 6:
return false
frames = 0
var shape: Vector2i = shapes.pop_front()
var screen: Rect2 = Rect2(Vector2.ZERO, panel.get_viewport_rect().size)
var card: Rect2 = Rect2(panel.global_position, panel.size)
print("%s -> canvas %.0fx%.0f, card %.0fx%.0f at %.0f,%.0f" % [
shape, screen.size.x, screen.size.y, card.size.x, card.size.y,
card.position.x, card.position.y])
# A. the card itself
if not _inside(card, screen):
return _fail("at %s the card %s is not inside the screen %s" % [shape, card, screen])
# B. and what the player has to press on it
for button_name in ["BuildButton", "CloseButton"]:
var button: Control = panel.get_node(button_name)
var button_rect: Rect2 = Rect2(button.global_position, button.size)
if not _inside(button_rect, screen):
return _fail("at %s the %s at %s is off screen" % [shape, button_name, button_rect])
# C. never bigger than authored
if card.size.x > panel.design_size.x + 0.5 or card.size.y > panel.design_size.y + 0.5:
return _fail("at %s the card grew past its authored %s" % [shape, panel.design_size])
if shapes.is_empty():
# D. the last shape was reached by resizing an already-open popup, so
# every check above already ran against a re-fit rather than against
# a card that happened to be built at the right size.
print("TEST_BUILD_POPUP_FITS_WINDOW: PASS (%d window shapes)" % SHAPES.size())
quit(0)
return true
root.size = shapes[0]
return false
func _fail(message: String) -> bool:
print("TEST_BUILD_POPUP_FITS_WINDOW: FAIL (%s)" % message)
quit(1)
return true

View file

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

View file

@ -0,0 +1,195 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_building_health.gd
# Forgejo #99 (#97a): structures can be hurt, and can come down.
#
# Health lives on the RECORD in GameState rather than on the Building node,
# because Main.tscn is destroyed and rebuilt on every trip to a planet
# surface - damage kept on a node would heal itself the moment the player
# landed. Most of this test therefore talks to GameState directly, and only
# the last case looks at what the scene does with it.
#
# Covers:
# A. every building definition declares a positive max_hp, and a fresh
# record starts at full health
# B. damage subtracts, reports survival, and enough of it destroys the
# record outright
# C. a record written before this existed reads as undamaged, not as
# rubble - an existing save must not open on a field of wrecks
# D. the mothership cannot be destroyed. The bank, the hangar and research
# all live there and a wiped fleet rebuilds there, so losing it would
# be a soft-lock; it bottoms out at 1hp instead
# E. destroying a transport hub takes its transporters with it, and
# nothing else keyed on the id is left pointing at a hole
# F. the Building node follows its record out of existence, and shows
# damage while it is still standing
var game_state: Node
var scene: Node
var stage: int = 0
var elapsed: float = 0.0
var destroyed_events: Array = []
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.rocks = 5000
func _fail(message: String) -> bool:
print("TEST_BUILDING_HEALTH: FAIL (%s)" % message)
quit(1)
return true
func _run_state_cases() -> bool:
var definitions: Script = load("res://buildings/BuildingDefinitions.gd")
# --- A. every structure declares what it can take -------------------
for definition in definitions.all():
if int(definition.max_hp) <= 0:
return _fail("'%s' has max_hp %d - it would be rubble the moment it was placed" \
% [definition.definition_id, definition.max_hp])
var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(300.0, 0.0))
var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER)
if game_state.building_hp(crafter_id) != full:
return _fail("a new crafter opened at %d of %d hp" \
% [game_state.building_hp(crafter_id), full])
if not is_equal_approx(game_state.building_health_fraction(crafter_id), 1.0):
return _fail("a new crafter is not at full health")
print("%d definitions all declare max_hp; a new crafter starts at %d" \
% [definitions.all().size(), full])
# --- B. damage, then death ------------------------------------------
game_state.building_destroyed.connect(func(id: int, type: String):
destroyed_events.append({"id": id, "type": type}))
if game_state.damage_building(crafter_id, full - 1):
return _fail("one hit point short of dead reported the crafter destroyed")
if game_state.building_hp(crafter_id) != 1:
return _fail("hp is %d after taking max_hp - 1" % game_state.building_hp(crafter_id))
if game_state.building_health_fraction(crafter_id) >= 0.5:
return _fail("a crafter on its last hit point still reads as healthy")
if not game_state.damage_building(crafter_id, 1):
return _fail("the last hit point did not destroy the crafter")
if not game_state.find_building(crafter_id).is_empty():
return _fail("the destroyed crafter is still in GameState.buildings")
if destroyed_events.size() != 1 or int(destroyed_events[0].id) != crafter_id:
return _fail("building_destroyed did not fire once for the crafter")
# Fired AFTER the record went, so a listener sees the world without it.
print("a crafter takes %d damage and comes down, announcing itself" % full)
# --- C. a record from before this existed ---------------------------
var legacy_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-300.0, 0.0))
game_state.find_building(legacy_id).erase("hp")
var lab_full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_LAB)
if game_state.building_hp(legacy_id) != lab_full:
return _fail("a record with no hp field reads as %d, not the %d it should" \
% [game_state.building_hp(legacy_id), lab_full])
if game_state.damage_building(legacy_id, 1):
return _fail("one point of damage levelled a building carried over from an old save")
print("a pre-#99 record reads as undamaged (%d hp), not as rubble" % lab_full)
# --- D. the mothership survives anything ----------------------------
var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
if not game_state.building_is_indestructible(game_state.find_building(mothership_id)):
return _fail("the mothership is not marked indestructible")
if game_state.damage_building(mothership_id, 999999):
return _fail("the mothership was destroyed - the bank, hangar and research went with it")
if game_state.building_hp(mothership_id) != 1:
return _fail("an overwhelmed mothership sits at %d hp, expected to floor at 1" \
% game_state.building_hp(mothership_id))
if game_state.destroy_building(mothership_id):
return _fail("destroy_building demolished the mothership outright")
if game_state.repair_building(mothership_id, 999999) <= 0:
return _fail("a battered mothership could not be repaired")
if game_state.building_hp(mothership_id) != game_state.building_max_hp(GameState.BUILDING_MOTHERSHIP):
return _fail("repair did not take the mothership back to full")
print("the mothership floors at 1hp and repairs to full")
# --- E. a hub takes its transporters with it ------------------------
var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(800.0, 0.0))
var hub_transporters: int = 0
for transporter in game_state.transporters:
if int(transporter.get("hub_id", 0)) == hub_id:
hub_transporters += 1
if hub_transporters <= 0:
return _fail("the hub provisioned no transporters, so this case proves nothing")
var others: int = game_state.transporters.size() - hub_transporters
if not game_state.destroy_building(hub_id):
return _fail("the hub could not be destroyed")
for transporter in game_state.transporters:
if int(transporter.get("hub_id", 0)) == hub_id:
return _fail("a transporter is still flying for a hub that no longer exists")
if game_state.transporters.size() != others:
return _fail("destroying the hub took %d unrelated transporters with it" \
% (others - game_state.transporters.size()))
print("destroying a hub retires its %d transporters and no others" % hub_transporters)
return false
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if _run_state_cases():
return true
# --- F. what the scene does with it -------------------------
game_state.buildings.clear()
game_state.transporters.clear()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
var field: Node = scene.get_node("BuildingField")
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0))
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
var field: Node = scene.get_node("BuildingField")
var node: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
node = b
if node == null:
return _fail("no crafter node was spawned")
if node.is_damaged():
return _fail("a freshly placed crafter already shows damage")
game_state.damage_building(node.building_id, game_state.building_max_hp(
GameState.BUILDING_SHIP_CRAFTER) / 2)
node._process(0.0)
if not node.is_damaged():
return _fail("a crafter at half health does not show it")
print("a half-wrecked crafter reads %.0f%%" % (node.health_fraction() * 100.0))
game_state.damage_building(node.building_id, 999999)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.2:
return false
var field: Node = scene.get_node("BuildingField")
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
return _fail("the crafter node outlived the record it was built from")
print("the node follows its record out of existence")
print("TEST_BUILDING_HEALTH: PASS")
quit(0)
return true
return false

View file

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

View file

@ -30,10 +30,11 @@ var stage: int = 0
var elapsed: float = 0.0
var crane_x_start: float = 0.0
var satellite_rotation_start: float = 0.0
var game_state: Node
func _initialize() -> void:
var game_state: Node = root.get_node("GameState")
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://world/Main.tscn").instantiate()
@ -71,6 +72,10 @@ func _process(delta: float) -> bool:
quit(1)
return true
# #117: an idle crafter holds its clock, so there would be no
# animation to measure the cost of. The point of this test is
# that animating is CHEAP, not that it happens unprompted.
game_state.queue_order(int(_crafter_id(field)), GameState.VESSEL_INTERCEPTOR)
crane_x_start = crafter._crane.position.x
satellite_rotation_start = mothership._satellites.rotation
elapsed = 0.0
@ -108,3 +113,10 @@ func _process(delta: float) -> bool:
quit(0)
return true
return false
func _crafter_id(building_field: Node) -> int:
for b in building_field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
return int(b.building_id)
return 0

View file

@ -12,7 +12,11 @@ extends SceneTree
# that never changes were a measurable framerate cost earlier on
# 3. the hull assembles progressively over the cycle rather than popping
# in whole, and every piece is on before the assembly phase ends
# 4. the cycle loops without anim_time growing without bound
# 4. the cycle loops without anim_time growing without bound, WHILE THERE
# IS SOMETHING ON THE LINE - #117 stops the clock on an idle crafter,
# because a factory whose cranes swing over an empty cradle reads as
# working on nothing
# 5. and an idle crafter really is still
var scene: Node
var game_state: Node
@ -99,11 +103,35 @@ func _process(delta: float) -> bool:
quit(1)
return true
# --- #117: idle means idle --------------------------------
if crafter.is_working():
print("TEST_CRAFTER_ANIMATION: FAIL (a crafter with an empty queue calls itself working)")
quit(1)
return true
time_at_start = crafter.anim_time
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
if crafter.anim_time != time_at_start:
print("TEST_CRAFTER_ANIMATION: FAIL (an idle crafter kept animating)")
quit(1)
return true
print("idle crafter held its clock at %.3f" % crafter.anim_time)
# Put something on the line, and it should run again.
game_state.queue_order(int(_find(GameState.BUILDING_SHIP_CRAFTER).building_id),
GameState.VESSEL_INTERCEPTOR)
if not crafter.is_working():
print("TEST_CRAFTER_ANIMATION: FAIL (an order was queued and the crafter is still idle)")
quit(1)
return true
time_at_start = crafter.anim_time
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.4:
return false
var advanced: bool = crafter.anim_time != time_at_start

View file

@ -0,0 +1,130 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_definitions_intact.gd
#
# A guard against a failure mode that has now cost this project twice, and
# both times silently.
#
# Opening the project in the Godot editor re-saves .tres files, and its
# writer DROPS properties it considers default-valued. An empty-looking
# PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost
# `capabilities` and `collected_outputs` once (which took the gas burner's
# whole conversion with it), and InterceptorVesselDefinition later lost
# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` -
# which cost the starting vessel its cargo hold, so nothing could be mined.
#
# Neither showed up as an error. The resource still loaded; it was just
# quietly less than it had been, and the failure surfaced somewhere else
# entirely - a full suite reporting ten unrelated tests broken.
#
# So this asserts the SHAPE of the catalog rather than any one field: every
# definition must carry the things that make it what it is, and a capability
# a definition claims must have a component behind it. Anything the editor
# eats fails here first, next to the thing that was eaten.
#
# Covers:
# A. every vessel is whole - name, cost, icon, hull, and stats
# B. a vessel with cargo declares it and has a capacity to go with it
# C. every building is whole, and its menu wiring is complete
# D. a converter building keeps the fields its conversion needs
# E. no definition has lost its component list
var failures: Array = []
func _fail(message: String) -> String:
failures.append(message)
return message
# get() answers null for a property a definition's script never declared,
# and null has no is_empty(). These keep a missing field from looking like
# an empty one - and from taking the rest of the sweep down with it.
func _text(definition: Resource, field: String) -> String:
var value: Variant = definition.get(field)
return "" if value == null else str(value)
func _list(definition: Resource, field: String) -> Array:
var value: Variant = definition.get(field)
if value == null:
return []
return Array(value.keys() if value is Dictionary else value)
func _check_vessels() -> void:
for definition in VesselDefinitions.all():
var id: String = String(definition.definition_id)
# --- A. whole ---------------------------------------------------
if String(definition.display_name).strip_edges() == "":
_fail("vessel %s has no display name" % id)
if definition.base_stats.is_empty():
_fail("vessel %s has no stats at all" % id)
# Only the hulls that actually join the fleet: a repair drone is a
# logistics unit driven by GameState, with nothing bolted to it.
if definition.fleet_eligible and definition.component_scenes.is_empty():
_fail("vessel %s flies in the fleet with no components - an empty hull" % id)
if definition.hull_points.is_empty():
_fail("vessel %s has no hull points - it would draw as the fallback triangle" % id)
# --- B. cargo --------------------------------------------------
var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0
var claims: bool = "cargo" in definition.capabilities
if carries != claims:
_fail("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \
% [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities])
if "weapon" in definition.capabilities \
and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0:
_fail("vessel %s claims a weapon and has no range" % id)
func _check_buildings() -> void:
for definition in BuildingDefinitions.all():
var id: String = String(definition.definition_id)
# --- C. whole --------------------------------------------------
if String(definition.display_name).strip_edges() == "":
_fail("building %s has no display name" % id)
if String(definition.description).strip_edges() == "":
_fail("building %s has no description - its info popup would be blank" % id)
if int(definition.cost) <= 0:
_fail("building %s is free" % id)
if int(definition.max_hp) <= 0:
_fail("building %s has no hit points" % id)
# Space-placed structures only: an extractor is a marker on a planet
# rather than a thing standing in the world, and has no scene.
if definition.placement_mode == "space" and definition.visual_scene == null:
_fail("building %s stands in space with nothing to draw" % id)
for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]:
if str(definition.get(field)).strip_edges() == "":
_fail("building %s has no %s - the build menu could not place it" % [id, field])
if definition.capabilities.is_empty():
_fail("building %s declares no capabilities - it would do nothing at all" % id)
# --- D. converters ---------------------------------------------
# These live on the LOGISTICS profile, a sub-resource of the
# definition, not on the definition itself. Asking the definition
# for them answers null and the whole check quietly never fires -
# which is how the first draft of this test passed while the gas
# burner sat there with its collected_outputs missing.
var logistics: Resource = definition.logistics
if logistics != null and _text(logistics, "conversion_input") != "":
if _text(logistics, "conversion_output") == "":
_fail("building %s converts something into nothing" % id)
if _list(logistics, "collected_outputs").is_empty():
_fail("building %s converts but lists no collected outputs - what it makes would never be fetched" % id)
if _list(logistics, "input_buffers").is_empty():
_fail("building %s converts with no input buffer" % id)
func _initialize() -> void:
_check_vessels()
_check_buildings()
if not failures.is_empty():
for message in failures:
print(" - %s" % message)
print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" % failures.size())
quit(1)
return
print("%d vessels and %d buildings, all intact" \
% [VesselDefinitions.all().size(), BuildingDefinitions.all().size()])
print("TEST_DEFINITIONS_INTACT: PASS")
quit(0)

View file

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

View file

@ -0,0 +1,184 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_desktop_controls.gd
# Forgejo #118 and #121: two raw-touch controls that a mouse could not
# reach.
#
# Both read InputEventScreenTouch/ScreenDrag, and the project no longer turns
# clicks into them - the same routing gap #93 found in the research tree.
# The boost was unreachable on a desktop and the formation could not be
# rearranged at all.
#
# Driven through the REAL routing in each case (GameController for the
# fader, BuildController for the editor) rather than by calling the controls
# directly, because the routing is what was missing - a test that called
# handle_mouse_button() itself would have passed against both reports.
#
# Covers:
# A. #118 - a click on the fader takes hold of it, dragging moves the
# boost, and releasing lets go
# B. the fader claims the press, so dragging the boost does not also zoom
# the world behind it
# C. #121 - a click grabs a formation slot, moving drags it, and the
# formation really changes
# D. releasing drops the slot, and moving afterwards does not keep dragging
# E. the wheel zooms the formation panel, which is what replaces the pinch
var game_state: Node
var scene: Node
var controller: Node
var fleet: Node
var fader: Control
var editor: Control
var formation_menu: Control
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_waves()
game_state.reset_formation()
game_state.intro_active = false
game_state.vessel_levels["unlock_distorder"] = 1
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
controller = scene.get_node("BuildController")
fader = scene.get_node("UI/SpeedFader")
editor = scene.get_node("UI/FormationMenu/Editor")
formation_menu = scene.get_node("UI/FormationMenu")
func _fail(message: String) -> bool:
print("TEST_DESKTOP_CONTROLS: FAIL (%s)" % message)
quit(1)
return true
func _click(at: Vector2, pressed: bool, target: Node) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.pressed = pressed
event.position = at
target._unhandled_input(event)
func _move(to: Vector2, target: Node) -> void:
var event: InputEventMouseMotion = InputEventMouseMotion.new()
event.position = to
target._unhandled_input(event)
func _wheel(button: int, at: Vector2) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = button
event.pressed = true
event.position = at
controller._unhandled_input(event)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.3:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# The fader only exists where the fleet can actually boost.
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER]))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
if not fader.visible:
return _fail("the fleet has an Energy Propulser and the fader is still hidden")
if game_state.max_fleet_speed_multiplier <= 1.0:
return _fail("the boost has no range to move through")
# --- A. click, drag, release -----------------------------
var top: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5,
fader.track_margin + 2.0)
var bottom: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5,
fader.size.y - fader.track_margin - 2.0)
_click(bottom, true, scene)
if not fader.is_active():
return _fail("a click on the fader did not take hold of it")
var at_bottom: float = game_state.fleet_speed_multiplier
_move(top, scene)
var at_top: float = game_state.fleet_speed_multiplier
if at_top <= at_bottom:
return _fail("dragging the fader from bottom to top left the boost at %.2f" % at_top)
_click(top, false, scene)
if fader.is_active():
return _fail("the fader is still held after the button came up")
_move(bottom, scene)
if not is_equal_approx(game_state.fleet_speed_multiplier, at_top):
return _fail("the fader kept following the pointer after release")
print("fader: %.2fx at the bottom, %.2fx at the top, released cleanly" \
% [at_bottom, at_top])
# --- B. it claims the press ------------------------------
var zoom_before: float = scene.get_node("Camera2D").zoom.x
_click(bottom, true, scene)
_click(bottom, false, scene)
if not is_equal_approx(scene.get_node("Camera2D").zoom.x, zoom_before):
return _fail("pressing the fader also moved the camera behind it")
print("the press stops at the fader; the world behind it is untouched")
# --- C/D/E. the formation editor -------------------------
formation_menu.visible = true
editor.queue_redraw()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
var slot_before: Vector2 = game_state.formation[0]
var grab: Vector2 = editor.global_position + editor._slot_screen_pos(0)
_click(grab, true, controller)
if editor.dragging_index != 0:
return _fail("a click on the first formation slot grabbed %d" % editor.dragging_index)
_move(grab + Vector2(70.0, 40.0), controller)
if game_state.formation[0] == slot_before:
return _fail("dragging the slot did not move it")
var dragged: Vector2 = game_state.formation[0]
print("slot 0 moved from %s to %s under a mouse" % [str(slot_before), str(dragged)])
# --- D. release lets go ----------------------------------
_click(grab + Vector2(70.0, 40.0), false, controller)
if editor.dragging_index != -1:
return _fail("the slot is still held after the button came up")
_move(grab + Vector2(200.0, 200.0), controller)
if game_state.formation[0] != dragged:
return _fail("the slot kept following the pointer after release")
print("released, and the slot stopped following")
# --- E. the wheel zooms ----------------------------------
var scale_before: float = editor.scale_factor
_wheel(MOUSE_BUTTON_WHEEL_UP, editor.global_position + editor.size * 0.5)
if editor.scale_factor <= scale_before:
return _fail("the wheel did not zoom the formation panel in")
_wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5)
_wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5)
if editor.scale_factor >= scale_before:
return _fail("the wheel did not zoom back out")
print("the wheel zooms the panel both ways (%.2f -> %.2f)" \
% [scale_before, editor.scale_factor])
print("TEST_DESKTOP_CONTROLS: PASS")
quit(0)
return true
return false

Some files were not shown because too many files have changed in this diff Show more