spaceship/controllers/BuildController.gd

905 lines
32 KiB
GDScript3
Raw Permalink Normal View History

extends Node2D
#84: move 215 root files into a folder hierarchy Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
const ALL: int = 1 << 30
const HUD_COLUMN_MARGIN := 18.0
const HUD_ROW_GAP := 16.0
@export var prompt_debounce_time: float = 0.15
@onready var fleet: Node2D = get_node("../Fleet")
@onready var building_field: Node2D = get_node("../BuildingField")
@onready var planet_field: Node2D = get_node("../PlanetField")
@onready var landing_controller: Node2D = get_node("../LandingController")
@onready var world_map: Control = get_node("../UI/WorldMap")
@onready var build_button: IconButton = get_node("../UI/BuildButton")
@onready var action_button: IconButton = get_node("../UI/BuildActionButton")
2026-08-13 20:49:15 +02:00
@onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton")
@onready var landing_button: IconButton = get_node("../UI/EnterAtmosphereButton")
@onready var transfer_button: IconButton = get_node("../UI/TransferButton")
2026-08-12 22:47:46 +02:00
@onready var hangar_button: IconButton = get_node("../UI/HangarButton")
# Menu roots are scene APIs. BuildController deliberately never reaches into
# their child trees; each scene owns its controls, layout and input routing.
@onready var build_menu: Control = get_node("../UI/BuildMenu")
@onready var craft_menu: Control = get_node("../UI/CraftMenu")
@onready var lab_menu: Control = get_node("../UI/LabMenu")
@onready var transfer_menu: Control = get_node("../UI/TransferMenu")
@onready var hangar_menu: Control = get_node("../UI/HangarMenu")
@onready var formation_menu: Control = get_node("../UI/FormationMenu")
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu")
# #216: where a hub or a workshop is told how many craft to keep.
@onready var station_menu: Control = get_node("../UI/StationMenu")
# #220: what the fleet costs to keep, and the arithmetic behind it.
@onready var upkeep_menu: Control = get_node("../UI/FleetUpkeepMenu")
# Where research data comes from, shown when a player finds out they have
# none - the tree used to refuse in silence and the lab used to blame the
# queue.
@onready var research_points_menu: Control = get_node("../UI/ResearchPointsMenu")
2026-09-04 15:56:07 +02:00
@onready var scanner_menu: Control = get_node("../UI/ScannerMenu")
var pending_build_type: String = ""
var craft_building_id: int = 0
var lab_building_id: int = 0
var lab_vessel: String = GameState.VESSEL_INTERCEPTOR
var lab_choice: int = 0
var lab_can_buy: bool = false
var transfer_building_id: int = 0
var transfer_extractor_index: int = -1
var garrison_building_id: int = 0
2026-09-04 15:56:07 +02:00
var scanner_building_id: int = 0
var current_building: Node2D = null
var pending_building: Node2D = null
var current_extractor_index: int = -1
var pending_extractor_index: int = -1
var current_landing_planet: Node2D = null
var pending_time: float = 0.0
2026-08-13 20:49:15 +02:00
var context_actions_expanded: bool = false
func _ready() -> void:
#228: shrink the HUD on a screen the canvas is short on The layout is authored in CANVAS units. With stretch "canvas_items" and aspect "expand" over a 1920x1080 base, a 720x1280 phone scales by 0.375 and gets 1920x3413 canvas units - while ANY 16:9-or-wider screen lands on 1920x1080 units however many pixels it really has. The canvas is three times shorter, so a 148-unit button went from 4.3% of the screen's height to 13.7%. Measured at 3.16x, and confirmed twice: once by hand off the scene file, once by the test failing with the rule disabled. Nothing collided. That was checked pair by pair on both canvases and the same six pairs overlap on each, all of them same-slot alternatives. The fault was only that everything was enormous. HudScale captures each HUD control's authored offsets once, then moves it toward the edge it hangs off and scales the NODE. Scaling the rect alone was tried first and was wrong: half the HUD draws its own contents at fixed sizes - ResourceReadout lays its rows out in code, Labels carry their own font size - so shrunken boxes kept full-size content and the build button slid up underneath the resource list. Scaling the node takes content and children with it. The cost is that position (parent space) and size (local space) now differ by that scale, and a lot of code here assumed they did not. Five places: ResourceReadout.content_bottom() added a scaled position to an unscaled height, overstating itself by more than a button - so _seat_hud_columns pushed the whole column down to clear space nothing occupied. A live bug, not just a bad measurement. HUD_COLUMN_MARGIN would have been the one gap still phone-sized. _layout_context_buttons wrote its stride and pitch unscaled, landing the action row on the building-action row - which is the second of the two regressions test_hud_layout was written for, in a new disguise. test_hud_layout and test_hangar_management compared authored rects. And HudScale reapplies on resize, which wiped the two layouts BuildController writes at runtime - so dragging a desktop window left the context row where it was. Both re-run on size_changed now, deferred, because which of two siblings connected first is not a thing to depend on. test_hud_layout ran at one window shape, which happened to be the desktop canvas. It now runs every check at both, so this cannot hide at whichever shape was not being exercised. Rejected: scaling the UI CanvasLayer does not work at all - Control anchors resolve against the viewport rect, so bottom-anchored elements land mid-screen. A single scaled wrapper Control with the HUD reparented under it is the genuinely clean shape and would need no scale-aware arithmetic anywhere, but it changes every node path and would break every get_node in the controllers and tests. MIN_SCALE floors the shrink at 0.45 rather than the proportional 0.316: a landscape phone gets the same short canvas as a desktop, and there the sticks are still thumb targets. That leaves the HUD at 1.42x its phone share on a PC rather than 1.0x. 177/177 at JOBS=3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-12 18:04:00 +02:00
get_viewport().size_changed.connect(_on_viewport_resized)
_seat_hud_columns()
build_button.pressed.connect(_on_build_pressed)
action_button.pressed.connect(_on_context_action_pressed)
building_action_button.pressed.connect(_on_action_pressed)
transfer_button.pressed.connect(_on_transfer_pressed)
hangar_button.pressed.connect(_on_hangar_pressed)
build_menu.building_selected.connect(_show_building_info)
build_menu.build_confirmed.connect(_confirm_build)
build_menu.closed.connect(_on_build_menu_closed)
craft_menu.craft_requested.connect(_on_craft_vessel)
craft_menu.queue_cancel_requested.connect(_on_queue_slot)
craft_menu.closed.connect(func(): craft_building_id = 0)
lab_menu.vessel_selected.connect(_on_lab_vessel)
lab_menu.upgrade_selected.connect(_on_lab_upgrade)
lab_menu.closed.connect(func(): lab_building_id = 0)
transfer_menu.transfer_requested.connect(_on_resource_transfer)
transfer_menu.closed.connect(_clear_transfer_context)
hangar_menu.crew_requested.connect(_on_hangar_crew_type)
hangar_menu.store_requested.connect(_on_hangar_store_type)
formation_menu.formation_changed.connect(_on_formation_changed)
formation_menu.reset_requested.connect(_on_reset_formation)
skill_menu.research_requested.connect(_on_unlock_requested)
2026-09-04 15:56:07 +02:00
scanner_menu.scan_requested.connect(_on_scan_requested)
scanner_menu.closed.connect(func(): scanner_building_id = 0)
for control in [action_button, building_action_button, landing_button,
transfer_button, hangar_button]:
control.visible = false
if GameState.intro_active:
_hide_all_actions()
_layout_context_buttons()
#228: shrink the HUD on a screen the canvas is short on The layout is authored in CANVAS units. With stretch "canvas_items" and aspect "expand" over a 1920x1080 base, a 720x1280 phone scales by 0.375 and gets 1920x3413 canvas units - while ANY 16:9-or-wider screen lands on 1920x1080 units however many pixels it really has. The canvas is three times shorter, so a 148-unit button went from 4.3% of the screen's height to 13.7%. Measured at 3.16x, and confirmed twice: once by hand off the scene file, once by the test failing with the rule disabled. Nothing collided. That was checked pair by pair on both canvases and the same six pairs overlap on each, all of them same-slot alternatives. The fault was only that everything was enormous. HudScale captures each HUD control's authored offsets once, then moves it toward the edge it hangs off and scales the NODE. Scaling the rect alone was tried first and was wrong: half the HUD draws its own contents at fixed sizes - ResourceReadout lays its rows out in code, Labels carry their own font size - so shrunken boxes kept full-size content and the build button slid up underneath the resource list. Scaling the node takes content and children with it. The cost is that position (parent space) and size (local space) now differ by that scale, and a lot of code here assumed they did not. Five places: ResourceReadout.content_bottom() added a scaled position to an unscaled height, overstating itself by more than a button - so _seat_hud_columns pushed the whole column down to clear space nothing occupied. A live bug, not just a bad measurement. HUD_COLUMN_MARGIN would have been the one gap still phone-sized. _layout_context_buttons wrote its stride and pitch unscaled, landing the action row on the building-action row - which is the second of the two regressions test_hud_layout was written for, in a new disguise. test_hud_layout and test_hangar_management compared authored rects. And HudScale reapplies on resize, which wiped the two layouts BuildController writes at runtime - so dragging a desktop window left the context row where it was. Both re-run on size_changed now, deferred, because which of two siblings connected first is not a thing to depend on. test_hud_layout ran at one window shape, which happened to be the desktop canvas. It now runs every check at both, so this cannot hide at whichever shape was not being exercised. Rejected: scaling the UI CanvasLayer does not work at all - Control anchors resolve against the viewport rect, so bottom-anchored elements land mid-screen. A single scaled wrapper Control with the HUD reparented under it is the genuinely clean shape and would need no scale-aware arithmetic anywhere, but it changes every node path and would break every get_node in the controllers and tests. MIN_SCALE floors the shrink at 0.45 rather than the proportional 0.316: a landscape phone gets the same short canvas as a desktop, and there the sticks are still thumb targets. That leaves the HUD at 1.42x its phone share on a PC rather than 1.0x. 177/177 at JOBS=3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-12 18:04:00 +02:00
# HudScale reapplies each control's authored offsets whenever the window
# changes, which wipes the two layouts this controller writes at runtime.
# Re-running them after a resize is what keeps a desktop window that gets
# dragged to another size from leaving the context row where it was.
func _on_viewport_resized() -> void:
# Deferred: HudScale reapplies on the same signal, and which of two
# siblings connected first is not a thing to depend on. By the end of the
# frame its offsets are in, and these two can seat against them.
_relayout_hud.call_deferred()
func _relayout_hud() -> void:
_seat_hud_columns()
_layout_context_buttons()
Seat the HUD button columns below the resource readout, and move the planet name The build button overlapped the resource list. ResourceReadout builds one row per resource type AT RUNTIME (58px each), so how far down it reaches is not knowable when the scene is authored: PlanetSurface/Main declared offset_bottom 154, four resources actually reach 270, and BuildButton sat at a hardcoded 160. A 110px collision that grew with every resource type added. Because the readout spans the full width - the fleet hold column is at x 356 - it hit both button columns, TransferButton and EnterAtmosphereButton as well as the left one. Rather than nudge the offsets, the layout now derives from the readout: ResourceReadout.rows_height()/content_bottom() report the real extent and _ready() stops the node understating its own size, and BuildController._seat_hud_columns() seats both columns below that at load. Adding a fifth resource type pushes the buttons down on its own, so the overlap cannot come back. Two details worth keeping: the height is derived from the live resource list rather than cached in _ready(), because BuildController readies before UI's children in scene-tree order and a cached value would answer zero. And it translates the block rather than re-flowing it, since BuildingActionButton and HangarButton deliberately share a row as mutually exclusive options. The planet name moved from the centre - where it sat across resource rows 3 and 4 - to the top-right corner, right-aligned. Right alignment is what keeps it clear: short names sit against the right edge, well clear of the fleet hold column, and long ones wrap downward into empty space rather than growing left into it. That scene edit rode along in the #68 commit, both being changes to PlanetSurface.tscn. Also adds three .uid files orphaned by earlier commits this session; the repo tracks them for every other script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:51 +02:00
func _seat_hud_columns() -> void:
var readout: ResourceReadout = get_node_or_null("../UI/ResourceReadout")
if readout == null:
return
var buttons: Array = [build_button, transfer_button, action_button,
landing_button, building_action_button, hangar_button]
Seat the HUD button columns below the resource readout, and move the planet name The build button overlapped the resource list. ResourceReadout builds one row per resource type AT RUNTIME (58px each), so how far down it reaches is not knowable when the scene is authored: PlanetSurface/Main declared offset_bottom 154, four resources actually reach 270, and BuildButton sat at a hardcoded 160. A 110px collision that grew with every resource type added. Because the readout spans the full width - the fleet hold column is at x 356 - it hit both button columns, TransferButton and EnterAtmosphereButton as well as the left one. Rather than nudge the offsets, the layout now derives from the readout: ResourceReadout.rows_height()/content_bottom() report the real extent and _ready() stops the node understating its own size, and BuildController._seat_hud_columns() seats both columns below that at load. Adding a fifth resource type pushes the buttons down on its own, so the overlap cannot come back. Two details worth keeping: the height is derived from the live resource list rather than cached in _ready(), because BuildController readies before UI's children in scene-tree order and a cached value would answer zero. And it translates the block rather than re-flowing it, since BuildingActionButton and HangarButton deliberately share a row as mutually exclusive options. The planet name moved from the centre - where it sat across resource rows 3 and 4 - to the top-right corner, right-aligned. Right alignment is what keeps it clear: short names sit against the right edge, well clear of the fleet hold column, and long ones wrap downward into empty space rather than growing left into it. That scene edit rode along in the #68 commit, both being changes to PlanetSurface.tscn. Also adds three .uid files orphaned by earlier commits this session; the repo tracks them for every other script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:51 +02:00
var highest: float = INF
for button in buttons:
if button != null:
highest = minf(highest, button.position.y)
if is_inf(highest):
return
#228: shrink the HUD on a screen the canvas is short on The layout is authored in CANVAS units. With stretch "canvas_items" and aspect "expand" over a 1920x1080 base, a 720x1280 phone scales by 0.375 and gets 1920x3413 canvas units - while ANY 16:9-or-wider screen lands on 1920x1080 units however many pixels it really has. The canvas is three times shorter, so a 148-unit button went from 4.3% of the screen's height to 13.7%. Measured at 3.16x, and confirmed twice: once by hand off the scene file, once by the test failing with the rule disabled. Nothing collided. That was checked pair by pair on both canvases and the same six pairs overlap on each, all of them same-slot alternatives. The fault was only that everything was enormous. HudScale captures each HUD control's authored offsets once, then moves it toward the edge it hangs off and scales the NODE. Scaling the rect alone was tried first and was wrong: half the HUD draws its own contents at fixed sizes - ResourceReadout lays its rows out in code, Labels carry their own font size - so shrunken boxes kept full-size content and the build button slid up underneath the resource list. Scaling the node takes content and children with it. The cost is that position (parent space) and size (local space) now differ by that scale, and a lot of code here assumed they did not. Five places: ResourceReadout.content_bottom() added a scaled position to an unscaled height, overstating itself by more than a button - so _seat_hud_columns pushed the whole column down to clear space nothing occupied. A live bug, not just a bad measurement. HUD_COLUMN_MARGIN would have been the one gap still phone-sized. _layout_context_buttons wrote its stride and pitch unscaled, landing the action row on the building-action row - which is the second of the two regressions test_hud_layout was written for, in a new disguise. test_hud_layout and test_hangar_management compared authored rects. And HudScale reapplies on resize, which wiped the two layouts BuildController writes at runtime - so dragging a desktop window left the context row where it was. Both re-run on size_changed now, deferred, because which of two siblings connected first is not a thing to depend on. test_hud_layout ran at one window shape, which happened to be the desktop canvas. It now runs every check at both, so this cannot hide at whichever shape was not being exercised. Rejected: scaling the UI CanvasLayer does not work at all - Control anchors resolve against the viewport rect, so bottom-anchored elements land mid-screen. A single scaled wrapper Control with the HUD reparented under it is the genuinely clean shape and would need no scale-aware arithmetic anywhere, but it changes every node path and would break every get_node in the controllers and tests. MIN_SCALE floors the shrink at 0.45 rather than the proportional 0.316: a landscape phone gets the same short canvas as a desktop, and there the sticks are still thumb targets. That leaves the HUD at 1.42x its phone share on a PC rather than 1.0x. 177/177 at JOBS=3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-12 18:04:00 +02:00
# The margin is authored in the same canvas units as everything else, so
# it scales with the HUD (#228) or it would be the one gap that stayed
# phone-sized on a desktop.
var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN * readout.scale.y - highest
Seat the HUD button columns below the resource readout, and move the planet name The build button overlapped the resource list. ResourceReadout builds one row per resource type AT RUNTIME (58px each), so how far down it reaches is not knowable when the scene is authored: PlanetSurface/Main declared offset_bottom 154, four resources actually reach 270, and BuildButton sat at a hardcoded 160. A 110px collision that grew with every resource type added. Because the readout spans the full width - the fleet hold column is at x 356 - it hit both button columns, TransferButton and EnterAtmosphereButton as well as the left one. Rather than nudge the offsets, the layout now derives from the readout: ResourceReadout.rows_height()/content_bottom() report the real extent and _ready() stops the node understating its own size, and BuildController._seat_hud_columns() seats both columns below that at load. Adding a fifth resource type pushes the buttons down on its own, so the overlap cannot come back. Two details worth keeping: the height is derived from the live resource list rather than cached in _ready(), because BuildController readies before UI's children in scene-tree order and a cached value would answer zero. And it translates the block rather than re-flowing it, since BuildingActionButton and HangarButton deliberately share a row as mutually exclusive options. The planet name moved from the centre - where it sat across resource rows 3 and 4 - to the top-right corner, right-aligned. Right alignment is what keeps it clear: short names sit against the right edge, well clear of the fleet hold column, and long ones wrap downward into empty space rather than growing left into it. That scene edit rode along in the #68 commit, both being changes to PlanetSurface.tscn. Also adds three .uid files orphaned by earlier commits this session; the repo tracks them for every other script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:51 +02:00
if shift <= 0.0:
return
for button in buttons:
if button != null:
button.position.y += shift
func _menu_is_open() -> bool:
for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_menu,
formation_menu, skill_menu, planning_menu, garrison_menu, scanner_menu,
station_menu, upkeep_menu, research_points_menu]:
if menu.visible:
return true
return false
func is_showing_building_prompt() -> bool:
return _menu_is_open()
func _unhandled_input(event: InputEvent) -> void:
2026-08-13 20:49:15 +02:00
if context_actions_expanded and (event is InputEventScreenTouch and event.pressed \
or event is InputEventMouseButton and event.pressed \
and event.button_index == MOUSE_BUTTON_LEFT):
2026-08-13 20:49:15 +02:00
_collapse_context_actions()
if skill_menu.handle_unhandled_input(event):
Block input behind open panels; keep craft-menu messages on screen Three play-testing reports. 1. Buttons behind an open panel were still clickable. There are two input paths and both leaked. GUI clicks: the research and formation panels cannot block with mouse_filter = STOP, because their contents are driven from raw _unhandled_input and a Control consuming touches through the GUI system swallows them first - so the panels stay transparent and IconButton now checks whether it is behind one, via a "modal_menus" group. Raw input: a touch landing on the research panel but missing a node was left unhandled, so it fell through to GameController - which sits on the ROOT node and sees whatever its children ignore - and moved the fleet or pinch-zoomed the world. 2. "Store ore here" looked dead. It was working: the confirmation was overwritten one frame later by the per-frame status refresh added in #16, so it flashed for a single frame. Craft-panel messages now outlive a refresh (3s), and the button stays ENABLED with an empty hold so pressing it can say why - a greyed button that cannot explain itself is the same dead-button problem. 3. The mothership's description could overlap the button below it. The description block was positioned from the TOP while the buttons were anchored to the BOTTOM, so the gap between them was whatever the screen height left over - overlapping at 1280 and separating at 960. It is one bottom-anchored stack now, checked at both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:58 +02:00
get_viewport().set_input_as_handled()
return
if formation_menu.handle_unhandled_input(event):
get_viewport().set_input_as_handled()
Block input behind open panels; keep craft-menu messages on screen Three play-testing reports. 1. Buttons behind an open panel were still clickable. There are two input paths and both leaked. GUI clicks: the research and formation panels cannot block with mouse_filter = STOP, because their contents are driven from raw _unhandled_input and a Control consuming touches through the GUI system swallows them first - so the panels stay transparent and IconButton now checks whether it is behind one, via a "modal_menus" group. Raw input: a touch landing on the research panel but missing a node was left unhandled, so it fell through to GameController - which sits on the ROOT node and sees whatever its children ignore - and moved the fleet or pinch-zoomed the world. 2. "Store ore here" looked dead. It was working: the confirmation was overwritten one frame later by the per-frame status refresh added in #16, so it flashed for a single frame. Craft-panel messages now outlive a refresh (3s), and the button stays ENABLED with an empty hold so pressing it can say why - a greyed button that cannot explain itself is the same dead-button problem. 3. The mothership's description could overlap the button below it. The description block was positioned from the TOP while the buttons were anchored to the BOTTOM, so the gap between them was whatever the screen height left over - overlapping at 1280 and separating at 960. It is one bottom-anchored stack now, checked at both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:58 +02:00
return
if world_map.visible or _menu_is_open():
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
_check_fleet_wipe()
#108: the prologue - the convoy, the ambush, and the breach Landed as one piece across #122, #123 and #124: the frame is useless without a script, and the script has nowhere to run without the frame. The director drives the REAL scene rather than being a scene of its own. It wants the real fleet, the real enemies and the real asteroid field, and it makes "then the game starts" cost nothing - the director stops steering, clears the flag, and what is left running is the ordinary game. The script is a list of beats, each with a deadline as well as its own completion test, so a beat whose condition is never met - a player who refuses to fly at the ambush - runs out rather than wedging the prologue. skip() ends the whole thing at any point and lands in exactly the state playing it through does, so the game has one way to start rather than two that have to be kept in step. The test pins that equivalence. "No action button available, only movements" is enforced where the buttons are decided, not by the director reaching in: the ability controllers set their own visibility every frame and would have put them straight back, and the map button belongs to the map. The joystick, the pivot and the fader stay. The convoy is ten hulls where a new game's fleet is one, so it needs an explicit fleet-size override - a plain number rather than a fake doctrine, because nothing about the player's own progression should be bent to fit a cutscene. It is carriers rather than transporters: transporters belong to hubs and are not fleet-eligible, so a convoy of them would be silently filtered down to its escorts. The ambush is chasers and only chasers. "Easy enough" is the requirement, and a sniper against a convoy the player cannot yet fight properly is neither easy nor a good first impression. The breach takes the wheel through one check in GameController, covering the stick, the keyboard and the released-again path together - three separate gates would be three chances for one of them to keep steering. The shake is written to camera.offset, never to its position, because GameController owns where the camera is and would smooth a shake away. The vessel that survives is chosen as the interceptor rather than whichever happened to be first in the list, and the prologue ends on exactly a new game's opening position. Also: PlanetAtmosphereController no longer spawns the opening fleet while the prologue is arming, and the tests that are about what the GAME starts from now say so by turning the prologue off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:59:12 +02:00
if GameState.intro_active:
_hide_all_actions()
return
if not build_button.visible:
build_button.visible = true
if build_menu.visible:
build_menu.refresh_option_states(_planet_under_fleet())
if craft_menu.visible:
craft_menu.refresh()
if lab_menu.visible:
_refresh_lab()
if transfer_menu.visible:
transfer_menu.refresh()
Stop redrawing what only needs moving Reviewed against the rule issues #6 and #10 set for the buildings: geometry is rasterised once and thereafter only moved, scaled or faded, because every NeonDraw call expands into four antialiased polylines. The planet visuals never got that refactor. The black hole rebuilt ten glowing paths of twenty-five points every frame - forty polylines - to spin five rings; each ring is a NeonPiece that rotates now. The research lab spent nine draw calls a frame, eight of them fixed geometry, to turn one dish and blink six lights; the dish is a child that rotates and the lights a child whose modulate breathes. The gas giant's bands genuinely deform, so they still draw, but its constant rim moved out to a child. NeonPiece gained add_path, which it needed for open arcs. And I had broken the rule myself. ScannerGuidance re-queued a redraw of its arrow every frame for a colour that changes only with the destination - and it runs with PROCESS_MODE_ALWAYS, so it was re-tessellating through every dialog that stops the rest of the game. Guarded, and pinned by a test that counts CanvasItem's own draw signal: 36 rasterisations in 0.6s before, one after. Two smaller things from the same pass: the station menu drew its condition bar by hand where the ship lab already uses a ProgressBar node, so it uses one now and its _draw is gone; and station/upkeep refreshes were wired into _refresh_garrison(), which nothing but a test calls, so both menus showed whatever was true when they opened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-08 11:35:45 +02:00
# Both of these move on their own while they are open - a transporter
# wears down, an office starves, the fleet changes under the bill - so
# they follow the same rule as the menus above: refreshed while visible,
# untouched when not.
if station_menu.visible:
station_menu.refresh()
if upkeep_menu.visible:
upkeep_menu.refresh()
if world_map.visible or _menu_is_open():
_clear_detected_context()
return
var detected: Node2D = building_field.get_building_fleet_is_on()
var detected_extractor: int = -1
if detected == null:
var planet: Node2D = _planet_under_fleet()
if planet != null:
detected_extractor = GameState.extractor_index_at(planet.planet_id)
if detected != pending_building or detected_extractor != pending_extractor_index:
pending_building = detected
pending_extractor_index = detected_extractor
pending_time = 0.0
else:
pending_time += delta
var detected_planet: Node2D = landing_controller.current_planet
if pending_time < prompt_debounce_time or (pending_building == current_building \
and pending_extractor_index == current_extractor_index \
and detected_planet == current_landing_planet):
return
current_building = pending_building
current_extractor_index = pending_extractor_index
current_landing_planet = detected_planet
2026-08-13 20:49:15 +02:00
context_actions_expanded = false
hangar_button.visible = false
2026-08-13 20:49:15 +02:00
building_action_button.visible = false
landing_button.visible = false
transfer_button.visible = _has_transfer_context()
var actions: Array = _context_action_buttons()
action_button.visible = not actions.is_empty()
if current_building != null:
Implement #77, #78 and #79 #77 - the Distorder becomes the Energy Propulser. Renamed everywhere the player reads it, reskinned to match: a symmetric hull flaring into an engine skirt instead of the lopsided warped lump, an engine bell with a thrust plume instead of the wobbling ring glyph, and energy's own yellow instead of purple, since energy is what it burns. The internal id stays "distorder" - it is the capability string, the unlock key and the component scene name, and renaming the wiring would be a large mechanical change with no player-visible effect. Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used to add a flat 1.0, so a fleet that could afford six simply drove at six times cruise and the vessel stopped being a choice. Base-2 makes the rule one sentence - every doubling adds another 1.0 - and leaves two propulsers worth exactly what they were worth before, so nothing already built is devalued. #78 - a gas burner can be paused. It converts gas the player may want for bombs into energy they may not need yet, and the only way to stop it was to demolish it. The action is data-driven like every other: the visual's ActionProvider lists "pause", and any converter can adopt it. Pause is the burner's PRIMARY action, so it is one tap rather than a menu; the button shows what it will DO, bars while running and a play triangle while stopped; and the building itself goes visibly cold, because a paused building that looked identical to a running one would be a setting to remember rather than see. Conversion progress is left where it was rather than reset, so switching off and on again does not discard a part-finished unit. #79 - picking something up says so. A "+N" with the resource's own mark rises and fades where it happened. Built from the SAME glyph the resource tables and transfer rows use, so a material added to the catalog later cannot end up with one mark in the HUD and a different one in the world. A pickup a full hold REFUSES raises nothing. Two Godot traps cost real time here and are now written down where the next person will hit them: - a `#` comment inside a .tres [resource] block is not a comment. It parses as part of the preceding value and silently blanks the property after it. One in DistorderVesselDefinition.tres left definition_id empty, which surfaced as "<blank> did not resolve its definition" in a test far from the edit. The note lives in VesselDefinition.gd now. - a global class_name is not reliably resolvable, INCLUDING a script's own inside its own static function. ResourcePickupVFX.new() failed to compile when reached through a preview scene, taking Rock and the gas vents with it. It has no class_name now and constructs itself by path. Suite 114/114. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
building_action_button.icon = _building_action_icon(current_building)
2026-08-13 20:49:15 +02:00
building_action_button.queue_redraw()
if not actions.is_empty():
action_button.icon = int(actions[0].icon) if actions.size() == 1 \
else IconButton.Icon.ACTION_MENU
action_button.queue_redraw()
_layout_context_buttons()
func _clear_detected_context() -> void:
current_building = null
pending_building = null
current_extractor_index = -1
pending_extractor_index = -1
current_landing_planet = null
for control in [action_button, building_action_button, landing_button,
transfer_button, hangar_button]:
control.visible = false
context_actions_expanded = false
func _layout_context_buttons() -> void:
#228: shrink the HUD on a screen the canvas is short on The layout is authored in CANVAS units. With stretch "canvas_items" and aspect "expand" over a 1920x1080 base, a 720x1280 phone scales by 0.375 and gets 1920x3413 canvas units - while ANY 16:9-or-wider screen lands on 1920x1080 units however many pixels it really has. The canvas is three times shorter, so a 148-unit button went from 4.3% of the screen's height to 13.7%. Measured at 3.16x, and confirmed twice: once by hand off the scene file, once by the test failing with the rule disabled. Nothing collided. That was checked pair by pair on both canvases and the same six pairs overlap on each, all of them same-slot alternatives. The fault was only that everything was enormous. HudScale captures each HUD control's authored offsets once, then moves it toward the edge it hangs off and scales the NODE. Scaling the rect alone was tried first and was wrong: half the HUD draws its own contents at fixed sizes - ResourceReadout lays its rows out in code, Labels carry their own font size - so shrunken boxes kept full-size content and the build button slid up underneath the resource list. Scaling the node takes content and children with it. The cost is that position (parent space) and size (local space) now differ by that scale, and a lot of code here assumed they did not. Five places: ResourceReadout.content_bottom() added a scaled position to an unscaled height, overstating itself by more than a button - so _seat_hud_columns pushed the whole column down to clear space nothing occupied. A live bug, not just a bad measurement. HUD_COLUMN_MARGIN would have been the one gap still phone-sized. _layout_context_buttons wrote its stride and pitch unscaled, landing the action row on the building-action row - which is the second of the two regressions test_hud_layout was written for, in a new disguise. test_hud_layout and test_hangar_management compared authored rects. And HudScale reapplies on resize, which wiped the two layouts BuildController writes at runtime - so dragging a desktop window left the context row where it was. Both re-run on size_changed now, deferred, because which of two siblings connected first is not a thing to depend on. test_hud_layout ran at one window shape, which happened to be the desktop canvas. It now runs every check at both, so this cannot hide at whichever shape was not being exercised. Rejected: scaling the UI CanvasLayer does not work at all - Control anchors resolve against the viewport rect, so bottom-anchored elements land mid-screen. A single scaled wrapper Control with the HUD reparented under it is the genuinely clean shape and would need no scale-aware arithmetic anywhere, but it changes every node path and would break every get_node in the controllers and tests. MIN_SCALE floors the shrink at 0.45 rather than the proportional 0.316: a landscape phone gets the same short canvas as a desktop, and there the sticks are still thumb targets. That leaves the HUD at 1.42x its phone share on a PC rather than 1.0x. 177/177 at JOBS=3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-12 18:04:00 +02:00
# position is in the UI layer's space and size is in the button's own,
# and since #228 those differ by the HUD scale on a short canvas. Every
# gap and stride below is therefore scaled, while the RECTS keep their
# authored size - HudScale renders them down rather than shrinking them.
# Mixing the two is what this function got wrong twice before (see
# test_hud_layout), and a desktop canvas makes it wrong again.
var k: float = build_button.scale.y
var row_top: float = build_button.position.y + build_button.size.y * k + HUD_ROW_GAP * k
var row_height: float = build_button.size.y
action_button.offset_top = row_top
action_button.offset_bottom = row_top + row_height
2026-08-13 20:49:15 +02:00
var buttons: Array = _context_action_buttons()
if not context_actions_expanded:
for button in buttons:
button.visible = false
return
for i in range(buttons.size()):
var button: Control = buttons[i]
2026-08-13 20:49:15 +02:00
button.visible = true
#228: shrink the HUD on a screen the canvas is short on The layout is authored in CANVAS units. With stretch "canvas_items" and aspect "expand" over a 1920x1080 base, a 720x1280 phone scales by 0.375 and gets 1920x3413 canvas units - while ANY 16:9-or-wider screen lands on 1920x1080 units however many pixels it really has. The canvas is three times shorter, so a 148-unit button went from 4.3% of the screen's height to 13.7%. Measured at 3.16x, and confirmed twice: once by hand off the scene file, once by the test failing with the rule disabled. Nothing collided. That was checked pair by pair on both canvases and the same six pairs overlap on each, all of them same-slot alternatives. The fault was only that everything was enormous. HudScale captures each HUD control's authored offsets once, then moves it toward the edge it hangs off and scales the NODE. Scaling the rect alone was tried first and was wrong: half the HUD draws its own contents at fixed sizes - ResourceReadout lays its rows out in code, Labels carry their own font size - so shrunken boxes kept full-size content and the build button slid up underneath the resource list. Scaling the node takes content and children with it. The cost is that position (parent space) and size (local space) now differ by that scale, and a lot of code here assumed they did not. Five places: ResourceReadout.content_bottom() added a scaled position to an unscaled height, overstating itself by more than a button - so _seat_hud_columns pushed the whole column down to clear space nothing occupied. A live bug, not just a bad measurement. HUD_COLUMN_MARGIN would have been the one gap still phone-sized. _layout_context_buttons wrote its stride and pitch unscaled, landing the action row on the building-action row - which is the second of the two regressions test_hud_layout was written for, in a new disguise. test_hud_layout and test_hangar_management compared authored rects. And HudScale reapplies on resize, which wiped the two layouts BuildController writes at runtime - so dragging a desktop window left the context row where it was. Both re-run on size_changed now, deferred, because which of two siblings connected first is not a thing to depend on. test_hud_layout ran at one window shape, which happened to be the desktop canvas. It now runs every check at both, so this cannot hide at whichever shape was not being exercised. Rejected: scaling the UI CanvasLayer does not work at all - Control anchors resolve against the viewport rect, so bottom-anchored elements land mid-screen. A single scaled wrapper Control with the HUD reparented under it is the genuinely clean shape and would need no scale-aware arithmetic anywhere, but it changes every node path and would break every get_node in the controllers and tests. MIN_SCALE floors the shrink at 0.45 rather than the proportional 0.316: a landscape phone gets the same short canvas as a desktop, and there the sticks are still thumb targets. That leaves the HUD at 1.42x its phone share on a PC rather than 1.0x. 177/177 at JOBS=3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-12 18:04:00 +02:00
button.offset_left = (188.0 + float(i) * 164.0) * k
button.offset_right = button.offset_left + 148.0
button.offset_top = row_top
button.offset_bottom = row_top + row_height
2026-08-13 20:49:15 +02:00
func _context_action_buttons() -> Array:
var buttons: Array = []
if current_building != null:
for available_action in current_building.available_actions():
var button: IconButton = _button_for_building_action(available_action)
if button != null and button not in buttons:
buttons.append(button)
if current_landing_planet != null:
buttons.append(landing_button)
return buttons
func _has_transfer_context() -> bool:
return current_extractor_index >= 0 or current_building != null \
and current_building.available_actions().has("transfer")
Implement #77, #78 and #79 #77 - the Distorder becomes the Energy Propulser. Renamed everywhere the player reads it, reskinned to match: a symmetric hull flaring into an engine skirt instead of the lopsided warped lump, an engine bell with a thrust plume instead of the wobbling ring glyph, and energy's own yellow instead of purple, since energy is what it burns. The internal id stays "distorder" - it is the capability string, the unlock key and the component scene name, and renaming the wiring would be a large mechanical change with no player-visible effect. Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used to add a flat 1.0, so a fleet that could afford six simply drove at six times cruise and the vessel stopped being a choice. Base-2 makes the rule one sentence - every doubling adds another 1.0 - and leaves two propulsers worth exactly what they were worth before, so nothing already built is devalued. #78 - a gas burner can be paused. It converts gas the player may want for bombs into energy they may not need yet, and the only way to stop it was to demolish it. The action is data-driven like every other: the visual's ActionProvider lists "pause", and any converter can adopt it. Pause is the burner's PRIMARY action, so it is one tap rather than a menu; the button shows what it will DO, bars while running and a play triangle while stopped; and the building itself goes visibly cold, because a paused building that looked identical to a running one would be a setting to remember rather than see. Conversion progress is left where it was rather than reset, so switching off and on again does not discard a part-finished unit. #79 - picking something up says so. A "+N" with the resource's own mark rises and fades where it happened. Built from the SAME glyph the resource tables and transfer rows use, so a material added to the catalog later cannot end up with one mark in the HUD and a different one in the world. A pickup a full hold REFUSES raises nothing. Two Godot traps cost real time here and are now written down where the next person will hit them: - a `#` comment inside a .tres [resource] block is not a comment. It parses as part of the preceding value and silently blanks the property after it. One in DistorderVesselDefinition.tres left definition_id empty, which surfaced as "<blank> did not resolve its definition" in a test far from the edit. The note lives in VesselDefinition.gd now. - a global class_name is not reliably resolvable, INCLUDING a script's own inside its own static function. ResourcePickupVFX.new() failed to compile when reached through a preview scene, taking Rock and the gas vents with it. It has no class_name now and constructs itself by path. Suite 114/114. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
func _building_action_icon(building: Node) -> int:
if building.primary_action() == "pause":
return IconButton.Icon.RESUME if GameState.is_building_paused(building.building_id) \
else IconButton.Icon.PAUSE
return int(building.definition.action_icon)
func _button_for_building_action(available_action: String) -> IconButton:
if available_action == "transfer":
return null
if available_action == "hangar":
return hangar_button
return building_action_button
2026-08-13 20:49:15 +02:00
func _on_context_action_pressed() -> void:
if current_building == null and current_extractor_index < 0 \
and current_landing_planet == null:
2026-08-13 20:49:15 +02:00
return
var actions: Array = _context_action_buttons()
if actions.size() == 1:
if actions[0] == landing_button:
landing_controller._on_enter_pressed()
elif actions[0] == hangar_button:
_on_hangar_pressed()
2026-08-13 20:49:15 +02:00
else:
_on_action_pressed()
return
context_actions_expanded = not context_actions_expanded
_layout_context_buttons()
func _collapse_context_actions() -> void:
if context_actions_expanded:
context_actions_expanded = false
_layout_context_buttons()
2026-08-13 20:49:15 +02:00
func _check_fleet_wipe() -> void:
if fleet.ships.is_empty():
var spawn_pos: Variant = GameState.get_mothership_position()
fleet.spawn_ships(1, spawn_pos if spawn_pos != null else Vector2.ZERO)
func _on_action_pressed() -> void:
if current_building == null:
return
var building_id: int = current_building.building_id
var building_action: String = current_building.primary_action()
if building_action == "pause":
var paused: bool = GameState.toggle_building_paused(building_id)
build_menu.set_status("%s %s" % [current_building.definition.display_name,
"paused" if paused else "running"])
Implement #77, #78 and #79 #77 - the Distorder becomes the Energy Propulser. Renamed everywhere the player reads it, reskinned to match: a symmetric hull flaring into an engine skirt instead of the lopsided warped lump, an engine bell with a thrust plume instead of the wobbling ring glyph, and energy's own yellow instead of purple, since energy is what it burns. The internal id stays "distorder" - it is the capability string, the unlock key and the component scene name, and renaming the wiring would be a large mechanical change with no player-visible effect. Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used to add a flat 1.0, so a fleet that could afford six simply drove at six times cruise and the vessel stopped being a choice. Base-2 makes the rule one sentence - every doubling adds another 1.0 - and leaves two propulsers worth exactly what they were worth before, so nothing already built is devalued. #78 - a gas burner can be paused. It converts gas the player may want for bombs into energy they may not need yet, and the only way to stop it was to demolish it. The action is data-driven like every other: the visual's ActionProvider lists "pause", and any converter can adopt it. Pause is the burner's PRIMARY action, so it is one tap rather than a menu; the button shows what it will DO, bars while running and a play triangle while stopped; and the building itself goes visibly cold, because a paused building that looked identical to a running one would be a setting to remember rather than see. Conversion progress is left where it was rather than reset, so switching off and on again does not discard a part-finished unit. #79 - picking something up says so. A "+N" with the resource's own mark rises and fades where it happened. Built from the SAME glyph the resource tables and transfer rows use, so a material added to the catalog later cannot end up with one mark in the HUD and a different one in the world. A pickup a full hold REFUSES raises nothing. Two Godot traps cost real time here and are now written down where the next person will hit them: - a `#` comment inside a .tres [resource] block is not a comment. It parses as part of the preceding value and silently blanks the property after it. One in DistorderVesselDefinition.tres left definition_id empty, which surfaced as "<blank> did not resolve its definition" in a test far from the edit. The note lives in VesselDefinition.gd now. - a global class_name is not reliably resolvable, INCLUDING a script's own inside its own static function. ResourcePickupVFX.new() failed to compile when reached through a preview scene, taking Rock and the gas vents with it. It has no class_name now and constructs itself by path. Suite 114/114. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
building_action_button.icon = _building_action_icon(current_building)
building_action_button.queue_redraw()
elif building_action == "research":
skill_menu.open_for(building_id)
elif building_action == "craft":
craft_building_id = building_id
craft_menu.open_for(building_id)
elif building_action == "lab":
lab_building_id = building_id
lab_menu.open_for(building_id)
_refresh_lab()
elif building_action == "formation":
formation_menu.open_menu()
elif building_action == "planning":
planning_menu.open_menu()
elif building_action == "garrison":
garrison_building_id = building_id
garrison_menu.open_for(building_id)
2026-09-04 15:56:07 +02:00
elif building_action == "scanner":
scanner_building_id = building_id
scanner_menu.open_menu()
elif building_action == "staff":
station_menu.open_for(building_id)
elif building_action == "upkeep":
upkeep_menu.open_for(building_id)
2026-09-04 15:56:07 +02:00
func _on_scan_requested(definition_id: String) -> void:
var scanner: Dictionary = GameState.find_building(scanner_building_id)
if scanner.is_empty():
scanner_menu.show_failure(definition_id)
return
var result: Dictionary = GameState.scan_for_planet(definition_id,
Vector2(scanner.position))
if result.is_empty():
scanner_menu.show_failure(definition_id)
return
scanner_menu.close_menu()
world_map.open_map()
world_map.focus_world_position(Vector2(result.world_position))
func _on_build_pressed() -> void:
pending_build_type = ""
build_menu.open_menu()
_refresh_build_status()
func _on_build_menu_closed() -> void:
pending_build_type = ""
#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
func _planet_under_fleet() -> Node2D:
return planet_field.get_planet_fleet_is_on()
#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
func cost_of(type: String) -> int:
return int(costs_of(type).get(GameState.RESOURCE_ORE, 0))
func costs_of(type: String) -> Dictionary:
return GameState.building_costs(type)
func _refresh_build_status() -> void:
build_menu.refresh(_planet_under_fleet())
func _refresh_build_option_states() -> void:
build_menu.refresh_option_states(_planet_under_fleet())
#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
#102/#103 (#97d/#97e): the outpost, and the garrison you compose for it Landed together because an outpost you cannot crew is decoration, and a garrison menu with no building to hang it off is nothing at all. #102, the building. Researched at "Forward Bases", two prerequisites deep and cheaper than any mid-game structure - the ticket asks for early, and the answer to waves attacking your buildings should arrive at about the time the waves can hurt them. It is drawn as a watch mast on a tripod with two open berths, deliberately the least machinery of any structure here: it makes nothing and refines nothing, it holds a position. 120hp, more than a crafter, because being shot at is its job. #103, the garrison. Two separate things live on its record, and conflating them was the first thing to get wrong: the ORDER, what the player asked for, and the CREW, who is actually there. This menu edits the order only; #104 fills the crew. Keeping the order as a wish is what lets an outpost ask for a hull nobody has built yet and wait for it, rather than the request evaporating - and it is why the panel shows "here / ordered" instead of one number. "Only ships that attack can be assigned" is expressed as capabilities rather than a list of types, so a new armed vessel is eligible the day it is added and an unarmed one never is by accident. Today that means the interceptor and the laser vessel; a shield, a hauler and an engine block cannot hold a position however much one might want them to. Two berths by default, and Garrison Bays raises it at outposts ALREADY standing - the same shape as freight_yards for hubs, because a bonus that only reached new buildings would mean demolishing and rebuilding to collect it. The panel builds its own rows from the catalog rather than from nodes placed in the scene, so adding an armed vessel later needs no scene surgery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
func _layout_build_menu() -> void:
build_menu.refresh(_planet_under_fleet())
#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
#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
func menu_columns(count: int) -> int:
return build_menu.menu_columns(count)
#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
func menu_rows(count: int) -> int:
return build_menu.menu_rows(count)
#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
func menu_row_top(row: int, rows: int, columns: int) -> float:
return build_menu.menu_row_top(row, rows, columns)
#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
func menu_column_spacing(columns: int) -> float:
return build_menu.menu_column_spacing(columns)
func _show_building_info(type: String) -> void:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
if definition == null:
return
#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
var costs: Dictionary = GameState.building_costs(type)
var lines: Array = [definition.description]
var can_build: bool = true
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
if not GameState.is_building_unlocked(type):
lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type))
can_build = false
if can_build and definition.placement_mode == "planet":
var planet: Node2D = _planet_under_fleet()
if planet == null:
lines.append("No planet in reach - fly over one.")
can_build = false
elif not GameState.is_planet_conquered(planet.planet_id):
lines.append("%s is not captured yet - take all its capture points." % planet.planet_name)
can_build = false
elif GameState.has_extractor_at(planet.planet_id):
lines.append("%s already has an extractor." % planet.planet_name)
can_build = false
else:
lines.append("Will mine %s from %s." % [
GameState.resource_name(planet.resource_type), planet.planet_name])
#224: refuse to build a structure on top of another BuildingField.place() dropped a structure at the fleet centroid with no overlap check of any kind, so they could be stacked. It reads worse than it looks. BuildingField decides which structure the fleet is standing on by testing the centroid against interact_radius and returning the FIRST match, so two structures inside one another make that a question of list order rather than of where the player flew - you could fly to your workshop and open the crafter. The separation is interact_radius itself (150) rather than a new number, because that is exactly the circle the ambiguity comes from. Buildings draw to a radius of roughly 60, so this leaves a visible gap rather than letting them touch. Gated in three places: the info popup (the button greys out and says which structure is in the way), _confirm_build, and _try_place - the preview goes stale as the fleet keeps moving. Ordering matters in one case. A unique structure replaces itself by demolishing the old one BEFORE placing the new one, so a placement refused afterwards would leave the player with neither. The check runs before the demolition and excludes the structure being replaced. Case D pins it. Two existing tests encoded the old permissive behaviour and now fly clear first; neither is about placement: test_build_info_popup builds every menu option from one position, so each structure blocked the next test_payments_use_fleet_hold builds a mothership on the ship lab that an earlier case injects at the origin Also fixes a bug from 37e2e89: Sfx.warm() called add_child() from _ready(), which Godot refuses outright - it printed an error and never pre-built the pool, defeating the point of warming it. Deferred, like Music.ensure(). 173/173. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-09 12:57:28 +02:00
elif can_build:
if definition.unique and GameState.has_building_of_type(type):
lines.append("WARNING: only one may exist - this destroys your current one.")
# A unique structure replaces itself, so the one being demolished is
# not in the way.
var blocker: Dictionary = GameState.building_blocking(fleet.get_centroid(),
type if definition.unique else "")
if not blocker.is_empty():
lines.append("Too close to your %s - fly clear and try again." \
% _building_name(String(blocker.get("type", ""))))
can_build = false
2026-09-02 13:43:56 +02:00
if can_build and not GameState.can_afford_resources(costs, fleet.get_centroid()):
can_build = false
pending_build_type = type if can_build else ""
build_menu.show_info(definition.display_name, "\n\n".join(lines), costs, can_build)
func _close_info() -> void:
pending_build_type = ""
build_menu.close_info()
func _confirm_build() -> void:
var type: String = pending_build_type
if type == "":
return
pending_build_type = ""
build_menu.close_info()
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
if not GameState.is_building_unlocked(type):
return
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
if definition == null:
return
if definition.placement_mode == "planet":
var planet: Node2D = _planet_under_fleet()
if planet == null or GameState.has_extractor_at(planet.planet_id):
build_menu.set_status("No planet in reach any more")
return
if not GameState.is_planet_conquered(planet.planet_id):
build_menu.set_status("%s is not captured yet" % planet.planet_name)
return
if String(planet.resource_type) == "":
build_menu.set_status("%s has nothing to extract" % planet.planet_name)
return
2026-09-02 13:43:56 +02:00
if not GameState.spend_resources(GameState.building_costs(type), fleet.get_centroid()):
build_menu.set_status(_cannot_afford(type))
return
GameState.add_extractor(planet.planet_name, planet.position,
planet.resource_type, planet.planet_id)
build_menu.close_menu()
return
#224: refuse to build a structure on top of another BuildingField.place() dropped a structure at the fleet centroid with no overlap check of any kind, so they could be stacked. It reads worse than it looks. BuildingField decides which structure the fleet is standing on by testing the centroid against interact_radius and returning the FIRST match, so two structures inside one another make that a question of list order rather than of where the player flew - you could fly to your workshop and open the crafter. The separation is interact_radius itself (150) rather than a new number, because that is exactly the circle the ambiguity comes from. Buildings draw to a radius of roughly 60, so this leaves a visible gap rather than letting them touch. Gated in three places: the info popup (the button greys out and says which structure is in the way), _confirm_build, and _try_place - the preview goes stale as the fleet keeps moving. Ordering matters in one case. A unique structure replaces itself by demolishing the old one BEFORE placing the new one, so a placement refused afterwards would leave the player with neither. The check runs before the demolition and excludes the structure being replaced. Case D pins it. Two existing tests encoded the old permissive behaviour and now fly clear first; neither is about placement: test_build_info_popup builds every menu option from one position, so each structure blocked the next test_payments_use_fleet_hold builds a mothership on the ship lab that an earlier case injects at the origin Also fixes a bug from 37e2e89: Sfx.warm() called add_child() from _ready(), which Godot refuses outright - it printed an error and never pre-built the pool, defeating the point of warming it. Deferred, like Music.ensure(). 173/173. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-09 12:57:28 +02:00
# BEFORE the demolition below, not after: a unique structure replaces
# itself by removing the old one first, so a blocked placement discovered
# afterwards would leave the player with neither. The old one is excluded
# because it is the thing being replaced.
var blocker: Dictionary = GameState.building_blocking(fleet.get_centroid(),
type if definition.unique else "")
if not blocker.is_empty():
build_menu.set_status("Too close to your %s" \
% _building_name(String(blocker.get("type", ""))))
return
if definition.unique and GameState.has_building_of_type(type):
2026-09-02 13:43:56 +02:00
if not GameState.can_afford_resources(GameState.building_costs(type), fleet.get_centroid()):
build_menu.set_status(_cannot_afford(type))
return
GameState.remove_buildings_of_type(type)
building_field.rebuild_all()
#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
_try_place(type)
#224: refuse to build a structure on top of another BuildingField.place() dropped a structure at the fleet centroid with no overlap check of any kind, so they could be stacked. It reads worse than it looks. BuildingField decides which structure the fleet is standing on by testing the centroid against interact_radius and returning the FIRST match, so two structures inside one another make that a question of list order rather than of where the player flew - you could fly to your workshop and open the crafter. The separation is interact_radius itself (150) rather than a new number, because that is exactly the circle the ambiguity comes from. Buildings draw to a radius of roughly 60, so this leaves a visible gap rather than letting them touch. Gated in three places: the info popup (the button greys out and says which structure is in the way), _confirm_build, and _try_place - the preview goes stale as the fleet keeps moving. Ordering matters in one case. A unique structure replaces itself by demolishing the old one BEFORE placing the new one, so a placement refused afterwards would leave the player with neither. The check runs before the demolition and excludes the structure being replaced. Case D pins it. Two existing tests encoded the old permissive behaviour and now fly clear first; neither is about placement: test_build_info_popup builds every menu option from one position, so each structure blocked the next test_payments_use_fleet_hold builds a mothership on the ship lab that an earlier case injects at the origin Also fixes a bug from 37e2e89: Sfx.warm() called add_child() from _ready(), which Godot refuses outright - it printed an error and never pre-built the pool, defeating the point of warming it. Deferred, like Music.ensure(). 173/173. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-09 12:57:28 +02:00
func _building_name(type: String) -> String:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return String(definition.display_name) if definition != null else type.capitalize()
#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
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])
2026-09-02 13:43:56 +02:00
var have: int = GameState.available_resource_at(id, fleet.get_centroid())
#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
if have < want:
missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have])
return "" if missing.is_empty() else "Not enough %s" % " and ".join(missing)
#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
func _try_place(type: String) -> void:
#224: refuse to build a structure on top of another BuildingField.place() dropped a structure at the fleet centroid with no overlap check of any kind, so they could be stacked. It reads worse than it looks. BuildingField decides which structure the fleet is standing on by testing the centroid against interact_radius and returning the FIRST match, so two structures inside one another make that a question of list order rather than of where the player flew - you could fly to your workshop and open the crafter. The separation is interact_radius itself (150) rather than a new number, because that is exactly the circle the ambiguity comes from. Buildings draw to a radius of roughly 60, so this leaves a visible gap rather than letting them touch. Gated in three places: the info popup (the button greys out and says which structure is in the way), _confirm_build, and _try_place - the preview goes stale as the fleet keeps moving. Ordering matters in one case. A unique structure replaces itself by demolishing the old one BEFORE placing the new one, so a placement refused afterwards would leave the player with neither. The check runs before the demolition and excludes the structure being replaced. Case D pins it. Two existing tests encoded the old permissive behaviour and now fly clear first; neither is about placement: test_build_info_popup builds every menu option from one position, so each structure blocked the next test_payments_use_fleet_hold builds a mothership on the ship lab that an earlier case injects at the origin Also fixes a bug from 37e2e89: Sfx.warm() called add_child() from _ready(), which Godot refuses outright - it printed an error and never pre-built the pool, defeating the point of warming it. Deferred, like Music.ensure(). 173/173. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-09 12:57:28 +02:00
# Re-checked here and not only in the preview: the fleet keeps moving
# between opening the menu and confirming, and this is the one place
# every space structure is actually placed.
var blocker: Dictionary = GameState.building_blocking(fleet.get_centroid())
if not blocker.is_empty():
build_menu.set_status("Too close to your %s" \
% _building_name(String(blocker.get("type", ""))))
return
2026-09-02 13:43:56 +02:00
if not GameState.spend_resources(GameState.building_costs(type), fleet.get_centroid()):
build_menu.set_status(_cannot_afford(type))
return
building_field.place(type, fleet.get_centroid())
build_menu.close_menu()
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
func _on_unlock_requested(id: String) -> void:
2026-09-02 13:43:56 +02:00
var research_building: Dictionary = GameState.find_building(skill_menu.building_id)
if research_building.is_empty():
skill_menu.set_status("The research building is no longer available")
return
var payment_position: Variant = research_building.get("position", null)
if not GameState.unlock(id, payment_position):
# A doctrine can be refused for several reasons, but only one of them
# is invisible on the node itself: the price in ORE is written there,
# the prerequisite is drawn as an unlit link, and research data is
# just a number the player has never been told how to get.
var points_short: int = GameState.skill_research_point_cost(id)
if GameState.research_points < points_short:
research_points_menu.open_for_shortfall(points_short,
String(GameState.skill_node(id).get("name", "This doctrine")))
skill_menu.refresh(id)
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
return
if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0:
fleet.apply_formation()
skill_menu.research_completed(id)
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
func _on_unlock_pressed() -> void:
var id: String = skill_menu.selected_id()
if id != "":
_on_unlock_requested(id)
func _refresh_skill_menu(id: String = "") -> void:
skill_menu.refresh(id)
Block input behind open panels; keep craft-menu messages on screen Three play-testing reports. 1. Buttons behind an open panel were still clickable. There are two input paths and both leaked. GUI clicks: the research and formation panels cannot block with mouse_filter = STOP, because their contents are driven from raw _unhandled_input and a Control consuming touches through the GUI system swallows them first - so the panels stay transparent and IconButton now checks whether it is behind one, via a "modal_menus" group. Raw input: a touch landing on the research panel but missing a node was left unhandled, so it fell through to GameController - which sits on the ROOT node and sees whatever its children ignore - and moved the fleet or pinch-zoomed the world. 2. "Store ore here" looked dead. It was working: the confirmation was overwritten one frame later by the per-frame status refresh added in #16, so it flashed for a single frame. Craft-panel messages now outlive a refresh (3s), and the button stays ENABLED with an empty hold so pressing it can say why - a greyed button that cannot explain itself is the same dead-button problem. 3. The mothership's description could overlap the button below it. The description block was positioned from the TOP while the buttons were anchored to the BOTTOM, so the gap between them was whatever the screen height left over - overlapping at 1280 and separating at 960. It is one bottom-anchored stack now, checked at both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:58 +02:00
2026-08-15 04:45:15 +02:00
func _format_costs(costs: Dictionary) -> String:
var parts := PackedStringArray()
2026-08-15 04:45:15 +02:00
for resource_type in GameState.resource_types():
var amount: int = int(costs.get(resource_type, 0))
if amount > 0:
parts.append("%d %s" % [amount,
GameState.resource_name(resource_type).to_lower()])
2026-08-15 04:45:15 +02:00
return " · ".join(parts) if not parts.is_empty() else "free"
func _format_missing_costs(costs: Dictionary) -> String:
var parts := PackedStringArray()
2026-08-15 04:45:15 +02:00
for resource_type in GameState.resource_types():
var amount: int = int(costs.get(resource_type, 0))
if amount > 0:
parts.append("%d more %s" % [amount,
GameState.resource_name(resource_type).to_lower()])
return " · ".join(parts)
func _on_craft_interceptor() -> void:
_on_craft_vessel(GameState.VESSEL_INTERCEPTOR)
func _on_craft_shield() -> void:
_on_craft_vessel(GameState.VESSEL_SHIELD)
func _on_craft_vessel(vessel: String) -> void:
if craft_building_id == 0:
craft_menu.say("No crafter selected - reopen this panel from the building")
return
var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel)
if definition == null:
return
if not GameState.queue_order(craft_building_id, vessel):
if not GameState.vessel_is_unlocked(vessel):
craft_menu.say("%s must be unlocked at a ship lab first" % definition.display_name)
return
2026-08-15 04:45:15 +02:00
var missing: Dictionary = GameState.outstanding_costs(craft_building_id)
if missing.is_empty():
craft_menu.say("%s ordered - building now" % GameState.vessel_name(vessel))
else:
craft_menu.say("%s queued - deliver %s here" % [GameState.vessel_name(vessel),
_format_missing_costs(missing)])
func _on_queue_slot(index: int) -> void:
var orders: Array = GameState.order_list(craft_building_id)
if index >= orders.size():
return
var vessel: String = GameState.vessel_name(String(orders[index].product))
var refunded: bool = bool(orders[index].started)
if GameState.cancel_order(craft_building_id, index):
craft_menu.say("%s cancelled%s" % [vessel,
" - resources refunded here" if refunded else ""])
func _refresh_craft_status() -> void:
if craft_building_id != 0 and craft_menu.building_id != craft_building_id:
craft_menu.building_id = craft_building_id
craft_menu.resource_bar.building_id = craft_building_id
craft_menu.refresh()
func _lab_options() -> Array:
var options: Array = []
var definition: Resource = VESSEL_DEFINITIONS.get_definition(lab_vessel)
if definition == null:
return options
if not GameState.vessel_is_unlocked(lab_vessel):
options.append({"unlock": true, "id": definition.unlock_id,
"name": definition.unlock_name, "desc": definition.unlock_description,
2026-08-15 04:45:15 +02:00
"costs": GameState.research_costs(definition.unlock_id),
"icon": definition.unlock_icon if definition.unlock_icon >= 0 else definition.icon})
2026-08-13 16:45:08 +02:00
return options
for upgrade in GameState.upgrades_for(lab_vessel):
options.append({"unlock": false, "id": upgrade.id,
"name": upgrade.name, "desc": upgrade.desc})
return options
func _on_lab_vessel(index: int) -> void:
var definitions: Array = VESSEL_DEFINITIONS.all()
if index >= 0 and index < definitions.size():
lab_vessel = definitions[index].definition_id
lab_choice = 0
_refresh_lab()
2026-08-13 16:45:08 +02:00
func _on_lab_upgrade(index: int) -> void:
if index < 0 or index >= _lab_options().size():
return
Implement #160, #161, #162, #163, #164 and #165 #162 - a single tap bought a research node outright. One physical tap arrives TWICE: Godot emulates a mouse button from every touch AND a touch from every mouse button, both project settings are on, and the menu's dispatcher routes InputEventScreenTouch and InputEventMouseButton alike to the tree. The second arrival found the node already selected and took itself for the confirming tap. Measured, not reasoned about - one tap produced two picks and two confirms. What is ignored is specifically the TWIN: same frame, same place, arriving through the other input kind. Deduping on frame and position alone also swallowed two genuine taps in one frame, which the test caught. #164 - the build menu predicted a caption's height from the COLUMN WIDTH: wide column, assume one line. Right for most names and wrong for the long ones, so "Spacial Workshop" wrapped onto the price beneath it. Captions are now measured against the font that draws them, and prices sized from PriceTag.wanted_height(), which existed for exactly this and was not being asked. Prices are set before anything is measured, because the old order laid out first and priced afterwards. The ship lab: its buy button is gone and a second tap on an option buys it, the same rule as the research tree; the explanation drops into the room that freed; research points move up into the resource table, where everything else being spent already is; the second row of options is placed from the first row's measured bottom rather than a constant; and the vessel strip is spaced from the panel's own width, so the eighth vessel is no longer half off the right edge. #160 - the quest panel was drawn BEFORE the sticks and the pivot pair, so they sat on top of it. Moved after them, under the narration banner. Its title rule ran straight through the close button; the header band is now as tall as that button. #161 - the prologue widens the formation into a freight convoy by writing over GameState.formation, and nothing put it back. The player flew the convoy's 600-unit spread for the rest of the game - a shape nobody chose, which reads as the fleet having drifted apart. #163 - a quest to station a vessel at the outpost, after the one that asks for the outpost. Building one and crewing it are different acts, and an empty outpost holds nothing. #165 - the gas bomb's blast and a vessel arriving fast both damage a structure. The blast used to stop politely at anything the player owned, which made a bomb dropped on a besieged mothership a free way to sweep the attackers off it. Collisions are caught on ENTRY, not on the 0.7s strike tick: a vessel is only moving fast for the instant it arrives, and a ship sent in at 900 did no damage at all until this moved. Docking is free - the fleet sits on its own mothership constantly to bank ore - and garrison vessels are exempt outright, because they launch from their outpost and re-dock with it at speed. The suite caught that one: the ram rule was destroying the garrison as it launched. Suite 153/153. Four tests changed because what they pinned moved: the lab's buy button, the intro's formation, the research tree's tap delivery, and the build menu's own preview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:27:44 +02:00
var confirming: bool = index == lab_choice
2026-08-13 16:45:08 +02:00
lab_choice = index
lab_menu.set_status("")
Implement #160, #161, #162, #163, #164 and #165 #162 - a single tap bought a research node outright. One physical tap arrives TWICE: Godot emulates a mouse button from every touch AND a touch from every mouse button, both project settings are on, and the menu's dispatcher routes InputEventScreenTouch and InputEventMouseButton alike to the tree. The second arrival found the node already selected and took itself for the confirming tap. Measured, not reasoned about - one tap produced two picks and two confirms. What is ignored is specifically the TWIN: same frame, same place, arriving through the other input kind. Deduping on frame and position alone also swallowed two genuine taps in one frame, which the test caught. #164 - the build menu predicted a caption's height from the COLUMN WIDTH: wide column, assume one line. Right for most names and wrong for the long ones, so "Spacial Workshop" wrapped onto the price beneath it. Captions are now measured against the font that draws them, and prices sized from PriceTag.wanted_height(), which existed for exactly this and was not being asked. Prices are set before anything is measured, because the old order laid out first and priced afterwards. The ship lab: its buy button is gone and a second tap on an option buys it, the same rule as the research tree; the explanation drops into the room that freed; research points move up into the resource table, where everything else being spent already is; the second row of options is placed from the first row's measured bottom rather than a constant; and the vessel strip is spaced from the panel's own width, so the eighth vessel is no longer half off the right edge. #160 - the quest panel was drawn BEFORE the sticks and the pivot pair, so they sat on top of it. Moved after them, under the narration banner. Its title rule ran straight through the close button; the header band is now as tall as that button. #161 - the prologue widens the formation into a freight convoy by writing over GameState.formation, and nothing put it back. The player flew the convoy's 600-unit spread for the rest of the game - a shape nobody chose, which reads as the fleet having drifted apart. #163 - a quest to station a vessel at the outpost, after the one that asks for the outpost. Building one and crewing it are different acts, and an empty outpost holds nothing. #165 - the gas bomb's blast and a vessel arriving fast both damage a structure. The blast used to stop politely at anything the player owned, which made a bomb dropped on a besieged mothership a free way to sweep the attackers off it. Collisions are caught on ENTRY, not on the 0.7s strike tick: a vessel is only moving fast for the instant it arrives, and a ship sent in at 900 did no damage at all until this moved. Docking is free - the fleet sits on its own mothership constantly to bank ore - and garrison vessels are exempt outright, because they launch from their outpost and re-dock with it at speed. The suite caught that one: the ram rule was destroying the garrison as it launched. Suite 153/153. Four tests changed because what they pinned moved: the lab's buy button, the intro's formation, the research tree's tap delivery, and the build menu's own preview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:27:44 +02:00
if confirming and lab_can_buy:
_on_lab_buy()
else:
_refresh_lab()
func _on_lab_buy() -> void:
var options: Array = _lab_options()
if options.is_empty():
return
var selected: Dictionary = options[lab_choice]
var id: String = String(selected.id)
var lab_points: int = GameState.research_point_cost(id)
if GameState.research_points < lab_points:
# Checked BEFORE the queue call, which would otherwise refuse this
# and report it as "already queued or complete" - which it is not.
research_points_menu.open_for_shortfall(lab_points, String(selected.name))
return
2026-08-13 22:04:21 +02:00
if not GameState.queue_lab_research(lab_building_id, id):
lab_menu.set_status("Research is already queued or complete")
return
lab_menu.set_status("%s queued - deliver %s to this lab" % [
selected.name, _format_costs(GameState.research_costs(id))])
_refresh_lab()
func _refresh_lab() -> void:
lab_menu.set_status("")
lab_menu.hide_research_progress()
var definitions: Array = VESSEL_DEFINITIONS.all()
lab_menu.render_vessels(definitions, lab_vessel)
lab_menu.vessel_heading.text = "%s research · %d RP" % [
GameState.vessel_name(lab_vessel), GameState.research_points]
var options: Array = _lab_options()
lab_choice = clampi(lab_choice, 0, maxi(options.size() - 1, 0))
var rendered: Array = []
for option in options:
#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
var maxed: bool = not bool(option.unlock) and GameState.upgrade_maxed(option.id)
2026-08-13 16:45:08 +02:00
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)]
rendered.append({"label": "%s%s" % [option.name, level_note],
"costs": option.costs if bool(option.unlock) else GameState.research_costs(option.id),
"points": GameState.research_point_cost(String(option.id)),
"icon": int(option.get("icon", IconButton.Icon.SHIP_LAB)), "maxed": maxed})
var definition: Resource = VESSEL_DEFINITIONS.get_definition(lab_vessel)
lab_menu.render_options(rendered, lab_choice,
definition.glow_color if definition != null else Color(0.25, 0.85, 1.0))
if options.is_empty():
lab_menu.clear_detail()
Implement #160, #161, #162, #163, #164 and #165 #162 - a single tap bought a research node outright. One physical tap arrives TWICE: Godot emulates a mouse button from every touch AND a touch from every mouse button, both project settings are on, and the menu's dispatcher routes InputEventScreenTouch and InputEventMouseButton alike to the tree. The second arrival found the node already selected and took itself for the confirming tap. Measured, not reasoned about - one tap produced two picks and two confirms. What is ignored is specifically the TWIN: same frame, same place, arriving through the other input kind. Deduping on frame and position alone also swallowed two genuine taps in one frame, which the test caught. #164 - the build menu predicted a caption's height from the COLUMN WIDTH: wide column, assume one line. Right for most names and wrong for the long ones, so "Spacial Workshop" wrapped onto the price beneath it. Captions are now measured against the font that draws them, and prices sized from PriceTag.wanted_height(), which existed for exactly this and was not being asked. Prices are set before anything is measured, because the old order laid out first and priced afterwards. The ship lab: its buy button is gone and a second tap on an option buys it, the same rule as the research tree; the explanation drops into the room that freed; research points move up into the resource table, where everything else being spent already is; the second row of options is placed from the first row's measured bottom rather than a constant; and the vessel strip is spaced from the panel's own width, so the eighth vessel is no longer half off the right edge. #160 - the quest panel was drawn BEFORE the sticks and the pivot pair, so they sat on top of it. Moved after them, under the narration banner. Its title rule ran straight through the close button; the header band is now as tall as that button. #161 - the prologue widens the formation into a freight convoy by writing over GameState.formation, and nothing put it back. The player flew the convoy's 600-unit spread for the rest of the game - a shape nobody chose, which reads as the fleet having drifted apart. #163 - a quest to station a vessel at the outpost, after the one that asks for the outpost. Building one and crewing it are different acts, and an empty outpost holds nothing. #165 - the gas bomb's blast and a vessel arriving fast both damage a structure. The blast used to stop politely at anything the player owned, which made a bomb dropped on a besieged mothership a free way to sweep the attackers off it. Collisions are caught on ENTRY, not on the 0.7s strike tick: a vessel is only moving fast for the instant it arrives, and a ship sent in at 900 did no damage at all until this moved. Docking is free - the fleet sits on its own mothership constantly to bank ore - and garrison vessels are exempt outright, because they launch from their outpost and re-dock with it at speed. The suite caught that one: the ram rule was destroying the garrison as it launched. Suite 153/153. Four tests changed because what they pinned moved: the lab's buy button, the intro's formation, the research tree's tap delivery, and the build menu's own preview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:27:44 +02:00
lab_can_buy = false
return
var selected: Dictionary = options[lab_choice]
lab_menu.show_detail(String(selected.name), String(selected.desc))
2026-08-13 22:04:21 +02:00
var queued: Dictionary = GameState.lab_research_order(lab_building_id)
lab_can_buy = queued.is_empty() and (selected.unlock \
or not GameState.upgrade_maxed(selected.id))
2026-08-13 22:04:21 +02:00
if not queued.is_empty():
var queued_points: int = GameState.research_point_cost(String(queued.id))
var progress: float = float(queued.elapsed) \
/ maxf(GameState.lab_research_time(), 0.001)
if not bool(queued.started) and GameState.research_points < queued_points:
lab_menu.show_research_progress(
"Need %d research point%s (have %d)" % [queued_points,
"" if queued_points == 1 else "s", GameState.research_points], progress)
else:
lab_menu.show_research_progress(
"Researching" if bool(queued.started) else "Waiting for resources",
progress)
func _on_transfer_pressed() -> void:
if current_building == null and current_extractor_index < 0:
return
transfer_building_id = current_building.building_id if current_building != null else 0
transfer_extractor_index = current_extractor_index
transfer_menu.open_destination(transfer_building_id, transfer_extractor_index)
func _clear_transfer_context() -> void:
transfer_building_id = 0
transfer_extractor_index = -1
func _on_transfer(to_building: bool, amount: int) -> void:
_on_resource_transfer(GameState.RESOURCE_ORE, to_building, amount)
func _on_gas_transfer(to_building: bool) -> void:
_on_resource_transfer(GameState.RESOURCE_GAS, to_building)
func _on_resource_transfer(resource_type: String, to_building: bool,
amount: int = ALL) -> void:
if transfer_building_id == 0 and transfer_extractor_index < 0:
transfer_menu.show_status("No resource store selected - reopen this panel from its action")
return
var moved: int
if transfer_extractor_index >= 0:
moved = GameState.transfer_resource_to_extractor(transfer_extractor_index,
resource_type, amount) if to_building else \
GameState.transfer_resource_from_extractor(transfer_extractor_index,
resource_type, amount)
else:
moved = GameState.transfer_resource_to_building(transfer_building_id,
resource_type, amount) if to_building else \
GameState.transfer_resource_to_fleet(transfer_building_id,
resource_type, amount)
var resource_name: String = GameState.resource_name(resource_type).to_lower()
if moved <= 0:
transfer_menu.show_status("The fleet is carrying nothing to unload (no %s)" \
% resource_name if to_building else "%s has no %s to load" \
% [_transfer_place_name().capitalize(), resource_name])
elif to_building:
transfer_menu.show_status("Moved %d %s into %s" % [
moved, resource_name, _transfer_place_name()])
else:
transfer_menu.show_status("Loaded %d %s into the fleet" % [moved, resource_name])
transfer_menu.refresh()
2026-08-13 22:17:24 +02:00
func _transfer_place_name() -> String:
return transfer_menu.place_name()
func _refresh_transfer() -> void:
if transfer_menu.building_id != transfer_building_id \
or transfer_menu.extractor_index != transfer_extractor_index:
transfer_menu.building_id = transfer_building_id
transfer_menu.extractor_index = transfer_extractor_index
transfer_menu.refresh()
2026-08-12 22:47:46 +02:00
func _on_hangar_pressed() -> void:
hangar_menu.open_for(fleet)
2026-08-12 22:47:46 +02:00
func _hangar_types() -> Array:
var types: Array = []
for definition in VESSEL_DEFINITIONS.fleet():
types.append(String(definition.definition_id))
return types
2026-08-12 22:47:46 +02:00
func _resolve_hangar_type(selection: Variant) -> String:
if selection is String:
return String(selection)
var index: int = int(selection)
var types: Array = _hangar_types()
return String(types[index]) if index >= 0 and index < types.size() else ""
2026-08-12 22:47:46 +02:00
func _on_hangar_crew_type(selection: Variant) -> void:
var vessel_type: String = _resolve_hangar_type(selection)
if vessel_type == "":
return
2026-08-12 22:47:46 +02:00
if fleet.ships.size() >= GameState.max_fleet_size():
hangar_menu.set_status("Fleet is full (%d/%d) - each further berth is a supplied Fleet Office" \
% [fleet.ships.size(), GameState.max_fleet_size()])
2026-08-12 22:47:46 +02:00
return
var stored_index: int = GameState.stored_vessels.find(vessel_type)
if stored_index < 0:
hangar_menu.set_status("No %s available" % GameState.vessel_name(vessel_type))
return
if not fleet.add_ship(GameState.take_stored_vessel(stored_index)):
hangar_menu.set_status("Could not crew that vessel")
2026-08-12 22:47:46 +02:00
return
hangar_menu.set_status("%s joined the fleet" % GameState.vessel_name(vessel_type))
hangar_menu.refresh(fleet)
func _on_hangar_store_type(selection: Variant) -> void:
var vessel_type: String = _resolve_hangar_type(selection)
if vessel_type == "":
return
var ship_index: int = -1
for i in range(fleet.ships.size()):
if String(fleet.ships[i].vessel_type) == vessel_type:
ship_index = i
break
if ship_index < 0:
hangar_menu.set_status("No %s in the fleet" % GameState.vessel_name(vessel_type))
return
var stored: String = fleet.store_ship(ship_index)
if stored == "":
hangar_menu.set_status("Keep one ship deployed")
return
GameState.stored_vessels.append(stored)
hangar_menu.set_status("%s stored in the hangar" % GameState.vessel_name(stored))
hangar_menu.refresh(fleet)
func _refresh_hangar() -> void:
hangar_menu.refresh(fleet)
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
func _layout_hangar() -> void:
hangar_menu._layout_sections()
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
func _refresh_garrison() -> void:
garrison_menu.refresh()
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
func _refresh_planning() -> void:
planning_menu.refresh()
2026-08-12 22:47:46 +02:00
func _on_formation_changed() -> void:
fleet.apply_formation()
func _on_reset_formation() -> void:
GameState.reset_formation()
fleet.apply_formation()
formation_menu.redraw()
func _hide_all_actions() -> void:
_clear_detected_context()
build_button.visible = false