Remove the one-way ore API superseded by the transfer menu

The loose end from #24. withdraw_to_fleet, unload_into_building and
deliver_cargo were the old one-way moves behind "Load ore" and "Store
ore here"; every one of them is a special case of transfer_to_building /
transfer_to_fleet, and none had a caller left outside tests. The
cargo_delivered signal had no listeners at all.

Not just tidying: unload_into_building routed through
deposit_to_building, which does NOT special-case the mothership - it
writes b.stock, while building_holdings() returns `rocks` for a
mothership and ignores stock entirely. So unload_into_building(
mothership_id) would have parked ore in a field nothing reads, with no
error. Nothing called it that way, but it is a plausibly-named function
sitting there to be reached for later. transfer_to_building handles it.

CARGO_LOAD_BATCH stays - the transfer menu's batch buttons use it; only
its role as withdraw_to_fleet's default argument is gone.

The #24 note expected folding these in to be costly for
test_cargo_readout, which places no buildings. It is not: the transfer
primitives work off GameState records rather than spawned nodes, so a
single add_building() in setup is enough and it stays a display test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-12 13:07:52 +02:00
commit b6427b080d
27 changed files with 1085 additions and 275 deletions

View file

@ -74,12 +74,34 @@ var lab_choice: int = 0
@onready var craft_menu: Control = get_node("../UI/CraftMenu")
@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton")
@onready var craft_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton")
@onready var craft_store_button: IconButton = get_node("../UI/CraftMenu/StoreOreButton")
@onready var craft_store_label: Label = get_node("../UI/CraftMenu/StoreOreLabel")
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
@onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton")
@onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel")
# --- the transfer panel (#24) ---------------------------------------------
# Ore used to be moved from inside whichever panel happened to be open: "load
# ore" lived in the research screen, "store ore here" in the ship crafter.
# Both are now one top-level button that works at every building and in both
# directions, so moving ore is never buried under an unrelated menu.
@onready var transfer_button: IconButton = get_node("../UI/TransferButton")
@onready var transfer_menu: Control = get_node("../UI/TransferMenu")
@onready var transfer_status: Label = get_node("../UI/TransferMenu/StatusLabel")
@onready var transfer_close_button: IconButton = get_node("../UI/TransferMenu/CloseButton")
@onready var to_building_label: Label = get_node("../UI/TransferMenu/ToBuildingLabel")
@onready var to_building_batch_button: IconButton = get_node("../UI/TransferMenu/ToBuildingBatchButton")
@onready var to_building_batch_label: Label = get_node("../UI/TransferMenu/ToBuildingBatchLabel")
@onready var to_building_all_button: IconButton = get_node("../UI/TransferMenu/ToBuildingAllButton")
@onready var to_fleet_label: Label = get_node("../UI/TransferMenu/ToFleetLabel")
@onready var to_fleet_batch_button: IconButton = get_node("../UI/TransferMenu/ToFleetBatchButton")
@onready var to_fleet_batch_label: Label = get_node("../UI/TransferMenu/ToFleetBatchLabel")
@onready var to_fleet_all_button: IconButton = get_node("../UI/TransferMenu/ToFleetAllButton")
# Every building menu carries the same header (#24). Held as a list because
# the only thing any of them needs is to be told which building it is
# reporting on when its panel opens.
@onready var resource_bars: Array = [
get_node("../UI/CraftMenu/Bar"), get_node("../UI/SkillMenu/Bar"),
get_node("../UI/LabMenu/Bar"), get_node("../UI/TransferMenu/Bar"),
]
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
@ -141,6 +163,12 @@ var craft_building_id: int = 0
var craft_message: String = ""
var craft_message_until: int = 0
# Which building the transfer panel is moving ore in and out of. Remembered
# for the same reason craft_building_id is: _process() clears
# current_building while any menu is open, so by the next frame the panel
# would not know which building it belongs to.
var transfer_building_id: int = 0
var current_building: Node2D = null
var pending_building: Node2D = null
var pending_time: float = 0.0
@ -167,11 +195,16 @@ func _ready() -> void:
action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor)
load_ore_button.pressed.connect(_on_load_ore)
craft_transporter_button.pressed.connect(_on_craft_transporter)
craft_store_button.pressed.connect(_on_store_ore)
craft_close_button.pressed.connect(func(): craft_menu.visible = false)
transfer_button.pressed.connect(_on_transfer_pressed)
transfer_close_button.pressed.connect(func(): transfer_menu.visible = false)
to_building_batch_button.pressed.connect(_on_transfer.bind(true, GameState.CARGO_LOAD_BATCH))
to_building_all_button.pressed.connect(_on_transfer.bind(true, ALL))
to_fleet_batch_button.pressed.connect(_on_transfer.bind(false, GameState.CARGO_LOAD_BATCH))
to_fleet_all_button.pressed.connect(_on_transfer.bind(false, ALL))
formation_close_button.pressed.connect(func(): formation_menu.visible = false)
formation_reset_button.pressed.connect(_on_reset_formation)
formation_editor.formation_changed.connect(_on_formation_changed)
@ -186,14 +219,16 @@ func _ready() -> void:
formation_menu.visible = false
skill_menu.visible = false
lab_menu.visible = false
transfer_menu.visible = false
action_button.visible = false
transfer_button.visible = false
# True while a building prompt or panel owns the shared action slot, so the
# enter-atmosphere prompt knows to stay out of the way.
func is_showing_building_prompt() -> bool:
return current_building != null or build_menu.visible or craft_menu.visible \
or formation_menu.visible or skill_menu.visible
or formation_menu.visible or skill_menu.visible or transfer_menu.visible
func _unhandled_input(event: InputEvent) -> void:
@ -218,7 +253,8 @@ func _unhandled_input(event: InputEvent) -> void:
formation_editor.handle_drag(event)
get_viewport().set_input_as_handled()
return
if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible:
if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible \
or transfer_menu.visible:
# These panels have no raw-input handler of their own, but the world
# behind them must still not react.
get_viewport().set_input_as_handled()
@ -239,11 +275,18 @@ func _process(delta: float) -> void:
# the queue down instead of looking stuck.
_refresh_craft_status()
if transfer_menu.visible:
# Also live: a hauler banking ore while the panel is open should make
# the "to fleet" side become available without a reopen.
_refresh_transfer()
if world_map.visible or build_menu.visible or craft_menu.visible \
or formation_menu.visible or skill_menu.visible or lab_menu.visible:
or formation_menu.visible or skill_menu.visible or lab_menu.visible \
or transfer_menu.visible:
current_building = null
pending_building = null
action_button.visible = false
transfer_button.visible = false
return
# Debounced exactly like the enter-atmosphere prompt, so the action
@ -261,6 +304,10 @@ func _process(delta: float) -> void:
current_building = pending_building
action_button.visible = current_building != null
# Every building can hold ore, so the transfer button rides alongside the
# action button rather than being one of the things the action button
# turns into - the two are independent choices at the same structure.
transfer_button.visible = current_building != null
if current_building != null:
action_button.icon = _action_icon_for(current_building.building_type)
action_button.queue_redraw()
@ -292,9 +339,14 @@ func _check_fleet_wipe() -> void:
func _on_action_pressed() -> void:
if current_building == null:
return
# Whichever panel opens, its header reports on the building it was opened
# from. Set once here rather than in each branch, so a new building menu
# cannot forget to do it and silently show a blank HERE column.
for bar in resource_bars:
bar.building_id = current_building.building_id
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
skill_menu.visible = true
_refresh_load_ore()
_refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
# Remembered explicitly: _process() clears current_building while any
@ -591,14 +643,14 @@ func _say(message: String) -> void:
func _refresh_craft_status() -> void:
# "Here" is the ore delivered to THIS crafter, not the bank: since #16 a
# building builds out of its own stock, and showing the bank would say
# you can afford something the crafter cannot actually make.
# The bank/hold/here figures moved out to the shared header (#24), so this
# line is now only what is specific to a crafter: prices, the queue, and
# berth pressure. It used to restate "Here %d" itself, which is exactly
# the per-panel improvisation the header replaces.
var queued: int = GameState.pending_orders(craft_building_id)
var stock: int = GameState.building_stock(craft_building_id)
var queue_text: String = "" if queued == 0 else " Queued %d" % queued
craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [
GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text,
craft_status.text = "Interceptor %d Transporter %d%s Transporters %d/%d" % [
GameState.SHIP_COST, GameState.TRANSPORTER_COST, queue_text,
GameState.transporters_claimed(), GameState.transport_capacity()
]
# Nowhere to station one means the order would be refused, so say so
@ -609,12 +661,6 @@ func _refresh_craft_status() -> void:
craft_shield_button.enabled = GameState.shield_vessel_unlocked()
craft_shield_button.queue_redraw()
craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo
# Deliberately left ENABLED with an empty hold: a greyed-out button that
# cannot say why is exactly the dead-button problem this panel already
# had. Pressing it explains instead.
craft_store_button.enabled = true
craft_store_button.queue_redraw()
# A full fleet cannot take another vessel, so the order would be refused.
# Showing that on the button is the difference between "not available yet"
@ -663,17 +709,6 @@ func _on_craft_interceptor() -> void:
_report_order(GameState.PRODUCT_SHIP)
func _refresh_load_ore() -> void:
load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo]
load_ore_button.enabled = GameState.rocks > 0
load_ore_button.queue_redraw()
func _on_load_ore() -> void:
GameState.withdraw_to_fleet()
_refresh_load_ore()
func _report_order(product: String) -> void:
var short: int = GameState.outstanding_cost(craft_building_id)
if short > 0:
@ -684,17 +719,6 @@ func _report_order(product: String) -> void:
_say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product))
func _on_store_ore() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
if GameState.fleet_cargo <= 0:
_say("The fleet is carrying nothing - load ore at the mothership first")
return
var moved: int = GameState.unload_into_building(craft_building_id)
_say("Stored %d ore here" % moved)
func _on_craft_transporter() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
@ -823,6 +847,86 @@ func _on_craft_shield() -> void:
_report_order(GameState.VESSEL_SHIELD)
# --- moving ore, in both directions, at any building (#24) ----------------
# Sentinel for the "All" buttons. The transfer primitives cap what they move
# by what is actually there, so asking for more than could possibly exist is
# how you say "everything" without the UI having to work out how much that
# is first - and the answer cannot go stale between the read and the press.
const ALL: int = 1 << 30
func _on_transfer_pressed() -> void:
if current_building == null:
return
transfer_building_id = current_building.building_id
transfer_menu.visible = true
transfer_status.text = ""
_refresh_transfer()
func _on_transfer(to_building: bool, amount: int) -> void:
if transfer_building_id == 0:
transfer_status.text = "No building selected - reopen this panel from the building"
return
var moved: int = 0
if to_building:
moved = GameState.transfer_to_building(transfer_building_id, amount)
else:
moved = GameState.transfer_to_fleet(transfer_building_id, amount)
# Nothing moving is the common case at an empty end, and a silent no-op
# is the dead-button problem this panel exists to remove. Say which side
# was empty rather than just refusing.
if moved <= 0:
if to_building:
transfer_status.text = "The fleet is carrying nothing to unload"
else:
transfer_status.text = "%s has no ore to load" % _transfer_place_name()
elif to_building:
transfer_status.text = "Moved %d ore into %s" % [moved, _transfer_place_name()]
else:
transfer_status.text = "Loaded %d ore into the fleet" % moved
_refresh_transfer()
func _transfer_place_name() -> String:
var b: Dictionary = GameState.find_building(transfer_building_id)
if b.is_empty():
return "this building"
if b.type == GameState.BUILDING_MOTHERSHIP:
# Worth naming as the bank rather than as a structure: this is the
# one building where unloading makes the ore spendable everywhere.
return "the bank"
return "this building"
func _refresh_transfer() -> void:
for bar in resource_bars:
bar.building_id = transfer_building_id
var hold: int = GameState.fleet_cargo
var here: int = GameState.building_holdings(transfer_building_id)
var batch: int = GameState.CARGO_LOAD_BATCH
to_building_label.text = "Unload into %s" % _transfer_place_name()
to_fleet_label.text = "Load from %s" % _transfer_place_name()
# The batch buttons say what they would actually move, not the nominal
# batch size: offering "25" when the hold has 4 in it is a promise the
# press cannot keep.
to_building_batch_label.text = "%d" % mini(batch, hold)
to_fleet_batch_label.text = "%d" % mini(batch, here)
to_building_batch_button.enabled = hold > 0
to_building_all_button.enabled = hold > 0
to_fleet_batch_button.enabled = here > 0
to_fleet_all_button.enabled = here > 0
for button in [to_building_batch_button, to_building_all_button,
to_fleet_batch_button, to_fleet_all_button]:
button.queue_redraw()
func _on_formation_changed() -> void:
fleet.apply_formation()

View file

@ -11,33 +11,20 @@ extends Node2D
var building_scene: PackedScene = preload("res://Building.tscn")
var spawned: Array = []
# Whether the fleet was already standing on the mothership last frame - see
# the edge-triggered delivery in _process().
var _was_on_mothership: bool = false
# Nothing happens here per frame any more. Arriving at the mothership used to
# bank the fleet's hold automatically (#14), edge-triggered so that ore
# withdrawn while parked was not instantly taken back. #24 removes it
# entirely: unloading is now an explicit move in the transfer menu, in both
# directions and at every building, so an automatic one-way exception at the
# mothership was the odd case out - and it silently overrode a player who had
# just loaded the hold in order to carry ore somewhere else.
func _ready() -> void:
rebuild_all()
func _process(_delta: float) -> void:
# Unloading the fleet's hold (#14). Done here because this is the node
# that already knows where the buildings are and already answers "is the
# fleet standing on one?" - and it uses the fleet CENTROID, the same
# stable single-number test Planet and Building already use, rather than
# per-ship collision that flickers as ships jostle in formation.
# EDGE-triggered on arrival, not continuous. Delivering every frame the
# fleet is parked on the mothership would make withdrawing impossible:
# ore loaded into the hold at the mothership (#17) would be banked again
# the very next frame, and the button would look broken.
var under: Node2D = get_building_fleet_is_on()
var on_mothership: bool = under != null \
and under.building_type == GameState.BUILDING_MOTHERSHIP
if on_mothership and not _was_on_mothership and GameState.fleet_cargo > 0:
GameState.deliver_cargo()
_was_on_mothership = on_mothership
func rebuild_all() -> void:
for b in spawned:
if is_instance_valid(b):

View file

@ -38,53 +38,70 @@ var rocks: int = 500
# surface would otherwise be dropped the moment the player took off.
var fleet_cargo: int = 0
signal cargo_delivered(amount: int)
func collect_cargo(amount: int = 1) -> void:
fleet_cargo += amount
# One press of "load ore" at the mothership. A batch rather than the whole
# bank: the hold is the part that can be lost, and emptying the bank in one
# tap would also strand research, which still spends from it.
# The batch size the transfer menu offers, next to "All". A batch rather
# than only all-or-nothing: the hold is the part that can be lost, and
# emptying the bank in one tap would strand research, which spends from it.
const CARGO_LOAD_BATCH: int = 25
func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int:
# Banked ore back into the hold, so the fleet can carry it to a building
# that is waiting on it (#17).
var taken: int = mini(amount, rocks)
if taken <= 0:
return 0
rocks -= taken
fleet_cargo += taken
return taken
# --- moving ore by hand, in either direction (#24) --------------------------
#
# One pair of primitives the transfer menu drives, rather than the two
# one-way special cases that used to live inside the skill and craft panels
# ("load ore" at the mothership, "store ore here" at a crafter).
#
# The mothership is deliberately not a special case in the UI: it is simply
# the building whose holdings ARE the bank. Depositing at one banks the ore,
# withdrawing from one draws on the bank, and every other building keeps its
# own stock (#16). That is what lets one menu serve every structure.
func unload_into_building(id: int) -> int:
# Everything the fleet is carrying, into this building's stock. Returns
# how much moved so a caller can tell the player something happened.
if fleet_cargo <= 0:
func building_holdings(id: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var moved: int = fleet_cargo
if deposit_to_building(id, moved) <= 0:
if b.type == BUILDING_MOTHERSHIP:
return rocks
return int(b.get("stock", 0))
func transfer_to_building(id: int, amount: int) -> int:
# Hold -> this building. Returns how much actually moved, which is capped
# by the hold: a caller asking for "all" passes a number too big on
# purpose and reads the result back.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
fleet_cargo = 0
var moved: int = mini(amount, fleet_cargo)
if moved <= 0:
return 0
fleet_cargo -= moved
if b.type == BUILDING_MOTHERSHIP:
rocks += moved
else:
b.stock = int(b.get("stock", 0)) + moved
return moved
func deliver_cargo() -> int:
# Everything the fleet is carrying, banked at once. Returns how much moved
# so a caller can react to a delivery actually having happened rather than
# to merely being in range.
if fleet_cargo <= 0:
func transfer_to_fleet(id: int, amount: int) -> int:
# This building -> hold. Capped by what the building actually holds, so
# there is no overdraft in either direction.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var delivered: int = fleet_cargo
rocks += delivered
fleet_cargo = 0
cargo_delivered.emit(delivered)
return delivered
var moved: int = mini(amount, building_holdings(id))
if moved <= 0:
return 0
if b.type == BUILDING_MOTHERSHIP:
rocks -= moved
else:
b.stock = int(b.get("stock", 0)) - moved
fleet_cargo += moved
return moved
const MOTHERSHIP_COST: int = 20
const SHIP_CRAFTER_COST: int = 12

View file

@ -16,6 +16,10 @@ enum Icon {
# and the command centre sharing a folded-map glyph and neither of them
# looking anything like the structure they build.
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB, SHIP_LAB,
# Moving ore between the fleet and a building (#24). Two arrows passing
# each other: the single up/down arrows already mean "load" and "store"
# one way each, and this button is the place where both live.
TRANSFER,
}
# Size the glyphs were originally drawn against. Every glyph dimension is
@ -147,6 +151,8 @@ func _draw() -> void:
_draw_transport_hub_icon(center, pulse, s)
Icon.SHIP_LAB:
_draw_ship_lab_icon(center, pulse, s)
Icon.TRANSFER:
_draw_transfer_icon(center, pulse, s)
func _draw_close(center: Vector2, pulse: float, s: float) -> void:
@ -172,6 +178,27 @@ func _draw_arrow(center: Vector2, pulse: float, direction: float, s: float) -> v
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(r * 0.7, -r * 0.8 * direction), _active_color(), pulse, 0.5 * s, core_color)
func _draw_transfer_icon(center: Vector2, pulse: float, s: float) -> void:
# Two shafts side by side pointing opposite ways. Drawn as a pair rather
# than as one double-headed arrow because the button's whole job is that
# ore moves BOTH ways here, and a double-headed arrow reads as one thing
# sliding back and forth.
var r: float = 10.0 * s
var gap: float = 7.0 * s
for side in [-1.0, 1.0]:
# Left shaft points up (into the fleet), right shaft points down.
var direction: float = side
var x: float = gap * side
var tip: Vector2 = center + Vector2(x, r * direction)
var tail: Vector2 = center + Vector2(x, -r * direction)
NeonDraw.draw_glowing_line(self, tail, tip, _active_color(), pulse, 0.42 * s, core_color)
var head: float = 5.0 * s
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-head, -head * direction),
_active_color(), pulse, 0.42 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(head, -head * direction),
_active_color(), pulse, 0.42 * s, core_color)
func _draw_build(center: Vector2, pulse: float, s: float) -> void:
# A plus inside a frame - "add a structure".
var r: float = 11.0 * s

328
Main.tscn
View file

@ -1,4 +1,4 @@
[gd_scene load_steps=20 format=3]
[gd_scene load_steps=21 format=3]
[ext_resource type="Script" path="res://Starfield.gd" id="1"]
[ext_resource type="Script" path="res://Fleet.gd" id="2"]
@ -23,6 +23,7 @@
[ext_resource type="Script" path="res://SkillTreeView.gd" id="21"]
[ext_resource type="Script" path="res://CargoCounter.gd" id="22"]
[ext_resource type="Script" path="res://TransporterField.gd" id="23"]
[ext_resource type="Script" path="res://ResourceBar.gd" id="24"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5")
@ -144,6 +145,18 @@ offset_bottom = -572
script = ExtResource("10")
icon = 2
[node name="TransferButton" type="Control" parent="UI"]
visible = false
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 172
offset_top = -600
offset_bottom = -452
script = ExtResource("10")
icon = 14
glow_color = Color(1, 0.78, 0.25, 1)
[node name="MapButton" type="Control" parent="UI"]
anchor_left = 1.0
anchor_right = 1.0
@ -633,19 +646,27 @@ color = Color(0.03, 0.03, 0.08, 0.94)
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 60
offset_right = -24
offset_bottom = 170
offset_top = 36
offset_right = -200
offset_bottom = 166
text = "Ship Lab"
horizontal_alignment = 1
[node name="Bar" type="Control" parent="UI/LabMenu"]
mouse_filter = 2
offset_left = 24
offset_top = 180
offset_right = 520
offset_bottom = 260
script = ExtResource("24")
[node name="VesselA" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -290
offset_right = -170
offset_top = 190
offset_bottom = 310
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
icon = 2
glow_color = Color(0.25, 0.85, 1, 1)
@ -655,8 +676,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -60
offset_right = 60
offset_top = 190
offset_bottom = 310
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
icon = 2
glow_color = Color(0.45, 0.8, 1, 1)
@ -666,8 +687,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 170
offset_right = 290
offset_top = 190
offset_bottom = 310
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
@ -676,9 +697,9 @@ glow_color = Color(0.95, 0.55, 0.85, 1)
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 318
offset_top = 402
offset_right = -24
offset_bottom = 380
offset_bottom = 460
theme_override_font_sizes/font_size = 34
horizontal_alignment = 1
text = ""
@ -688,7 +709,7 @@ mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32
offset_top = 396
offset_top = 470
offset_right = -32
offset_bottom = -470
theme_override_font_sizes/font_size = 30
@ -788,20 +809,28 @@ color = Color(0.03, 0.03, 0.08, 0.94)
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 70
offset_right = -24
offset_bottom = 196
offset_top = 36
offset_right = -200
offset_bottom = 166
text = "Build a ship"
horizontal_alignment = 1
[node name="Bar" type="Control" parent="UI/CraftMenu"]
mouse_filter = 2
offset_left = 24
offset_top = 180
offset_right = 520
offset_bottom = 260
script = ExtResource("24")
[node name="StatusLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 206
offset_top = 274
offset_right = -24
offset_bottom = 360
theme_override_font_sizes/font_size = 40
offset_bottom = 420
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = ""
horizontal_alignment = 1
@ -813,8 +842,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -234
offset_right = -86
offset_top = -74
offset_bottom = 74
offset_top = 60
offset_bottom = 208
script = ExtResource("10")
icon = 2
@ -826,8 +855,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -300
offset_right = -20
offset_top = 100
offset_bottom = 200
offset_top = 216
offset_bottom = 296
theme_override_font_sizes/font_size = 32
text = "Interceptor"
horizontal_alignment = 1
@ -839,8 +868,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 86
offset_right = 234
offset_top = -74
offset_bottom = 74
offset_top = 60
offset_bottom = 208
script = ExtResource("10")
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
@ -853,8 +882,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 20
offset_right = 300
offset_top = 96
offset_bottom = 176
offset_top = 216
offset_bottom = 296
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Transporter"
@ -867,8 +896,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -74
offset_right = 74
offset_top = -260
offset_bottom = -112
offset_top = -200
offset_bottom = -52
script = ExtResource("10")
icon = 2
glow_color = Color(0.45, 0.8, 1, 1)
@ -881,41 +910,13 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -160
offset_right = 160
offset_top = -104
offset_bottom = -44
offset_top = -44
offset_bottom = 16
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Shield Vessel"
horizontal_alignment = 1
[node name="StoreOreButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -74
offset_top = -304
offset_right = 74
offset_bottom = -156
script = ExtResource("10")
icon = 3
glow_color = Color(1, 0.78, 0.25, 1)
[node name="StoreOreLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -260
offset_top = -148
offset_right = 260
offset_bottom = -24
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Store ore here"
horizontal_alignment = 1
[node name="CloseButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 1.0
anchor_right = 1.0
@ -926,6 +927,171 @@ offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="TransferMenu" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/TransferMenu"]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.94)
[node name="TitleLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 36
offset_right = -200
offset_bottom = 166
text = "Move ore"
horizontal_alignment = 1
[node name="Bar" type="Control" parent="UI/TransferMenu"]
mouse_filter = 2
offset_left = 24
offset_top = 180
offset_right = 520
offset_bottom = 260
script = ExtResource("24")
[node name="StatusLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 274
offset_right = -24
offset_bottom = 396
theme_override_font_sizes/font_size = 34
autowrap_mode = 2
text = ""
horizontal_alignment = 1
[node name="ToBuildingLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 400
offset_right = -24
offset_bottom = 452
theme_override_font_sizes/font_size = 34
text = ""
horizontal_alignment = 1
[node name="ToBuildingBatchButton" type="Control" parent="UI/TransferMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -184
offset_right = -36
offset_top = 462
offset_bottom = 610
script = ExtResource("10")
icon = 3
glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="ToBuildingBatchLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -230
offset_right = 10
offset_top = 616
offset_bottom = 668
theme_override_font_sizes/font_size = 30
text = ""
horizontal_alignment = 1
[node name="ToBuildingAllButton" type="Control" parent="UI/TransferMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 36
offset_right = 184
offset_top = 462
offset_bottom = 610
script = ExtResource("10")
icon = 3
glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="ToBuildingAllLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -10
offset_right = 230
offset_top = 616
offset_bottom = 668
theme_override_font_sizes/font_size = 30
text = "All"
horizontal_alignment = 1
[node name="ToFleetLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 694
offset_right = -24
offset_bottom = 746
theme_override_font_sizes/font_size = 34
text = ""
horizontal_alignment = 1
[node name="ToFleetBatchButton" type="Control" parent="UI/TransferMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -184
offset_right = -36
offset_top = 756
offset_bottom = 904
script = ExtResource("10")
icon = 4
glow_color = Color(1, 0.78, 0.25, 1)
[node name="ToFleetBatchLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -230
offset_right = 10
offset_top = 910
offset_bottom = 962
theme_override_font_sizes/font_size = 30
text = ""
horizontal_alignment = 1
[node name="ToFleetAllButton" type="Control" parent="UI/TransferMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 36
offset_right = 184
offset_top = 756
offset_bottom = 904
script = ExtResource("10")
icon = 4
glow_color = Color(1, 0.78, 0.25, 1)
[node name="ToFleetAllLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -10
offset_right = 230
offset_top = 910
offset_bottom = 962
theme_override_font_sizes/font_size = 30
text = "All"
horizontal_alignment = 1
[node name="CloseButton" type="Control" parent="UI/TransferMenu"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172
offset_right = -24
offset_top = 24
offset_bottom = 172
script = ExtResource("10")
icon = 1
glow_color = Color(1, 0.78, 0.25, 1)
[node name="FormationMenu" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
mouse_filter = 2
@ -1026,18 +1192,26 @@ color = Color(0.03, 0.03, 0.08, 0.94)
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 70.0
offset_right = -24.0
offset_bottom = 196.0
offset_top = 36.0
offset_right = -200.0
offset_bottom = 166.0
text = "Research"
horizontal_alignment = 1
[node name="Bar" type="Control" parent="UI/SkillMenu"]
mouse_filter = 2
offset_left = 24.0
offset_top = 180.0
offset_right = 520.0
offset_bottom = 260.0
script = ExtResource("24")
[node name="Tree" type="Control" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 24.0
offset_top = 210.0
offset_top = 274.0
offset_right = -24.0
offset_bottom = -620.0
script = ExtResource("21")
@ -1096,34 +1270,6 @@ script = ExtResource("10")
icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="LoadOreButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -256.0
offset_top = -304.0
offset_right = -108.0
offset_bottom = -156.0
script = ExtResource("10")
icon = 4
glow_color = Color(1, 0.78, 0.25, 1)
[node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -330.0
offset_top = -150.0
offset_right = -30.0
offset_bottom = -14.0
theme_override_font_sizes/font_size = 28
horizontal_alignment = 1
autowrap_mode = 2
text = "Load ore"
[node name="CloseButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 1.0
anchor_right = 1.0

111
ResourceBar.gd Normal file
View file

@ -0,0 +1,111 @@
class_name ResourceBar
extends Control
# The header every building menu now carries (#24): banked ore, what the
# fleet is holding, and what THIS building has. Before this, each panel
# improvised its own - the crafter spelled the same three figures into a
# run-on StatusLabel string, the research screen printed a bank total inside
# a button caption - so the same numbers looked different in every room.
#
# Three columns rather than a sentence, because the point of the bar is that
# a figure stays in the same place as the player moves between menus.
#
# Reads GameState directly every frame, like ResourceCounter and CargoCounter
# do, so it stays correct across the space <-> planet-surface scene swap with
# nobody having to push updates into it. Only `building_id` is pushed in,
# because "here" is the one thing the bar cannot work out for itself.
# Which building the HERE column is reporting. 0 means none, which greys the
# column rather than showing a confident zero.
var building_id: int = 0
# The caption box has to be a full line tall, not just the font size: a Label
# renders its line height, not its point size, and the first version sat the
# digits 26px down from the top straight through the middle of the caption.
const CAPTION_HEIGHT: float = 34.0
const CAPTION_GAP: float = 4.0
const CAPTION_FONT_SIZE: int = 24
const DIGIT_HEIGHT: float = 34.0
# Bank keeps NeonNumber's own ore-teal, and the hold keeps the amber
# CargoCounter established for ore in transit. Only HERE needed a new colour;
# it borrows the mothership's green, which already reads as "landed
# somewhere and staying there" everywhere else in the game.
const BANK_GLOW: Color = Color(0.0, 0.62, 0.7)
const BANK_CORE: Color = Color(0.0, 0.12, 0.15)
const HOLD_GLOW: Color = Color(1.0, 0.78, 0.25)
const HOLD_CORE: Color = Color(0.22, 0.12, 0.0)
const HERE_GLOW: Color = Color(0.45, 0.9, 0.55)
const HERE_CORE: Color = Color(0.05, 0.16, 0.08)
const RULE_COLOR: Color = Color(0.25, 0.55, 0.7)
var _numbers: Array[NeonNumber] = []
var _captions: Array[Label] = []
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
var columns: Array = [
["BANK", BANK_GLOW, BANK_CORE],
["HOLD", HOLD_GLOW, HOLD_CORE],
["HERE", HERE_GLOW, HERE_CORE],
]
for column in columns:
var caption: Label = Label.new()
caption.text = column[0]
caption.mouse_filter = Control.MOUSE_FILTER_IGNORE
caption.add_theme_font_size_override("font_size", CAPTION_FONT_SIZE)
caption.add_theme_color_override("font_color", column[1])
add_child(caption)
_captions.append(caption)
var number: NeonNumber = NeonNumber.new()
number.mouse_filter = Control.MOUSE_FILTER_IGNORE
number.glow_color = column[1]
number.core_color = column[2]
number.digit_height = DIGIT_HEIGHT
add_child(number)
_numbers.append(number)
_layout()
resized.connect(_layout)
func _layout() -> void:
var column_width: float = size.x / float(_numbers.size())
for i in _numbers.size():
var x: float = column_width * float(i)
_captions[i].position = Vector2(x, 0.0)
_captions[i].size = Vector2(column_width, CAPTION_HEIGHT)
# The hexagon and digits are drawn from the node's own origin, so the
# number is positioned rather than sized.
_numbers[i].position = Vector2(x, CAPTION_HEIGHT + CAPTION_GAP)
func _process(_delta: float) -> void:
_numbers[0].set_amount(GameState.rocks)
_numbers[1].set_amount(GameState.fleet_cargo)
# A building with no id is not a building: showing "0" there would claim
# the store is empty rather than that there is no store to look at.
var known: bool = building_id != 0
_numbers[2].visible = known
_captions[2].visible = known
if known:
_numbers[2].set_amount(GameState.building_holdings(building_id))
# The height the bar needs for a caption line and a row of digits. The scene
# sizes the node, so this is what those offsets have to add up to; anything
# less and the rule is drawn back through the digits.
static func natural_height() -> float:
return CAPTION_HEIGHT + CAPTION_GAP + DIGIT_HEIGHT + 8.0
func _draw() -> void:
# One rule under the whole bar, so the header reads as a band separating
# the standing figures from whatever the panel below them does.
var rule_y: float = maxf(size.y, natural_height())
NeonDraw.draw_glowing_line(
self, Vector2(0.0, rule_y), Vector2(size.x, rule_y), RULE_COLOR, 0.55, 1.0
)

1
ResourceBar.gd.uid Normal file
View file

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

BIN
shots/craft.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

40
shots/craft.png.import Normal file
View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dgs8khl5js3ip"
path="res://.godot/imported/craft.png-a8697b6db303e087da4d7b82087549c2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shots/craft.png"
dest_files=["res://.godot/imported/craft.png-a8697b6db303e087da4d7b82087549c2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
shots/lab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

40
shots/lab.png.import Normal file
View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://m1obuogb3oca"
path="res://.godot/imported/lab.png-3565548fdb7eb90f645dd6a0f6721400.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shots/lab.png"
dest_files=["res://.godot/imported/lab.png-3565548fdb7eb90f645dd6a0f6721400.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
shots/skill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

40
shots/skill.png.import Normal file
View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btv6h40i8dvcw"
path="res://.godot/imported/skill.png-7e1397f0de9b8dc691fffdc4231115e7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shots/skill.png"
dest_files=["res://.godot/imported/skill.png-7e1397f0de9b8dc691fffdc4231115e7.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
shots/transfer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

40
shots/transfer.png.import Normal file
View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b32illfmrcwlt"
path="res://.godot/imported/transfer.png-6949bd9dcafcd9892b4949e2a2071ddc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shots/transfer.png"
dest_files=["res://.godot/imported/transfer.png-6949bd9dcafcd9892b4949e2a2071ddc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -1,8 +1,8 @@
extends Node2D
# Preview, not a test. Opens the CraftMenu so its layout can be checked on a
# real viewport - the mothership's "Load ore" label was falling off the bottom
# of shorter screens, which no headless test can see.
# real viewport - label collisions and things falling off the bottom of
# shorter screens are what no headless test can see.
#
# bash tests/screenshot.sh res://tests/_craft_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
@ -17,11 +17,14 @@ func _ready() -> void:
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0))
var crafter_id: int = int(GameState.buildings[-1].id)
GameState.deposit_to_building(crafter_id, 42)
var bc: Node = main.get_node("BuildController")
bc.craft_building_id = 1
bc.craft_building_id = crafter_id
# The shared header (#24) is the thing worth eyeballing here, so give it
# a building to report on - otherwise its HERE column hides itself.
for bar in bc.resource_bars:
bar.building_id = crafter_id
main.get_node("UI/CraftMenu").visible = true
if "Craft" == "Craft":
bc._refresh_craft_status()
else:
bc._refresh_load_ore()
bc._refresh_craft_status()

View file

@ -16,10 +16,17 @@ func _ready() -> void:
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_LAB, Vector2(400.0, 0.0))
var lab_id: int = int(GameState.buildings[-1].id)
GameState.deposit_to_building(lab_id, 18)
GameState.fleet_cargo = 12
# A level already bought, so the list shows both a researched and an
# unresearched line rather than a uniform page of zeroes.
GameState.vessel_levels["int_damage"] = 1
var bc: Node = main.get_node("BuildController")
# The shared header (#24) hides its HERE column when it has no building,
# so bind it the way opening the panel from the building does.
for bar in bc.resource_bars:
bar.building_id = lab_id
main.get_node("UI/LabMenu").visible = true
bc.lab_vessel = GameState.VESSEL_INTERCEPTOR
bc._refresh_lab()

View file

@ -1,8 +1,8 @@
extends Node2D
# Preview, not a test. Opens the SkillMenu so its layout can be checked on a
# real viewport - the mothership's "Load ore" label was falling off the bottom
# of shorter screens, which no headless test can see.
# real viewport - the tree lost vertical room to the shared resource header
# (#24), and whether it still fits is a thing only pixels can answer.
#
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
@ -17,15 +17,13 @@ func _ready() -> void:
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
main.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
var mothership_id: int = int(GameState.buildings[-1].id)
var bc: Node = main.get_node("BuildController")
bc.craft_building_id = 1
for bar in bc.resource_bars:
bar.building_id = mothership_id
main.get_node("UI/SkillMenu").visible = true
if "Skill" == "Craft":
bc._refresh_craft_status()
else:
bc._refresh_load_ore()
# Select a node so the description, price and status all have text -
# an empty description cannot collide with anything.
bc.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("freight")
# Select a node so the description, price and status all have text - an
# empty description cannot collide with anything.
bc.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("freight")

View file

@ -0,0 +1,27 @@
extends Node2D
# Preview, not a test. Opens the TransferMenu (#24) so its layout can be
# checked on a real viewport - four buttons, two captions and the shared
# header have to coexist without collisions, which no headless test can see.
#
# bash tests/screenshot.sh res://tests/_transfer_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.rocks = 180
GameState.fleet_cargo = 24
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0))
var crafter_id: int = int(GameState.buildings[-1].id)
GameState.deposit_to_building(crafter_id, 42)
var bc: Node = main.get_node("BuildController")
bc.transfer_building_id = crafter_id
main.get_node("UI/TransferMenu").visible = true
bc._refresh_transfer()
bc.transfer_status.text = "Moved 25 ore into this building"

View file

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

View file

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

View file

@ -27,6 +27,7 @@ var scene: Node
var banked: Control
var cargo: Control
var stage: int = 0
var mothership_id: int = 0
var elapsed: float = 0.0
@ -36,6 +37,11 @@ func _initialize() -> void:
game_state.extractors.clear()
game_state.rocks = 42
game_state.fleet_cargo = 0
# Banking is a transfer TO the mothership now (#24), and a transfer needs
# a building to aim at. Only the record is required - the transfer
# primitives work off GameState, not off the spawned node - so this stays
# a display test rather than becoming one about the building system.
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
banked = scene.get_node("UI/ResourceCounter")
@ -95,7 +101,7 @@ func _process(delta: float) -> bool:
])
# --- 4. delivery moves it across ----------------------------
game_state.deliver_cargo()
game_state.transfer_to_building(mothership_id, game_state.fleet_cargo)
elapsed = 0.0
stage = 2
return false

View file

@ -5,29 +5,28 @@ extends SceneTree
# Every press in the ship crafter must say what happened. Reported twice from
# play-testing as "the buttons do nothing".
#
# There is no silent-failure path left to press: storing with an empty hold,
# ordering something the crafter cannot pay for yet, and storing successfully
# all report. The queue in particular is invisible by nature - an order that
# cannot be afforded is accepted and waits (#16), which looks exactly like a
# dead button unless it says so.
# The queue is invisible by nature - an order that cannot be afforded is
# accepted and waits (#16), which looks exactly like a dead button unless it
# says so.
#
# Verifies:
# 1. storing with an empty hold explains where ore comes from
# 2. ordering against an empty stock says it is queued AND how much more
# 1. ordering against an empty stock says it is queued AND how much more
# ore the building needs
# 3. a successful store reports the amount
# 4. the message survives the per-frame status refresh - it used to be
# 2. the message survives the per-frame status refresh - it used to be
# overwritten on the very next frame, which is what made a working
# button look dead
# 5. the message is the FIRST line, not buried under the standing state
# 6. the panel is opened the way the GAME opens it - park the fleet on the
# crafter and press the action button - and every button then works
# 3. the message is the FIRST line, not buried under the standing state
# 4. the panel is opened the way the GAME opens it - park the fleet on the
# crafter and press the action button - and its buttons then work
#
# Point 6 exists because this test used to set craft_building_id by hand.
# Point 4 exists because this test used to set craft_building_id by hand.
# That skipped the one step that was actually broken: opening the panel
# stopped recording WHICH crafter, so every handler bailed out at its `== 0`
# guard while the panel still opened. Reported four times as "the buttons do
# nothing", and invisible to every test that set the id itself.
#
# Storing ore used to be tested here too. It moved out of this panel entirely
# in #24 - see test_resource_transfer.gd.
var game_state: Node
var scene: Node
@ -44,7 +43,11 @@ func _initialize() -> void:
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.unlocked = ["shipyards", "coordination", "wing"]
# Every capacity node, so the interceptor button stays available for the
# real-tap checks below. A full fleet correctly DISABLES it, and a
# disabled button not reacting to a tap is not the bug those checks are
# looking for.
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.vessels_owed.clear()
@ -104,17 +107,7 @@ func _process(delta: float) -> bool:
return true
print("panel opened from the building, bound to crafter %d" % controller.craft_building_id)
# --- 1. store with an empty hold ----------------------------
controller._on_store_ore()
var empty_msg: String = _first_line()
if "carrying nothing" not in empty_msg:
print("got: %s" % empty_msg)
print("TEST_CRAFTER_FEEDBACK: FAIL (storing with an empty hold said nothing useful)")
quit(1)
return true
print("empty hold -> %s" % empty_msg)
# --- 2. order against an empty stock ------------------------
# --- 1. order against an empty stock ------------------------
controller._on_craft_interceptor()
var queued_msg: String = _first_line()
if "queued" not in queued_msg.to_lower() or "more ore" not in queued_msg:
@ -127,71 +120,47 @@ func _process(delta: float) -> bool:
quit(1)
return true
print("unaffordable order -> %s" % queued_msg)
# --- 3. a successful store ----------------------------------
game_state.withdraw_to_fleet(30)
controller._on_store_ore()
var stored_msg: String = _first_line()
if "Stored" not in stored_msg:
print("got: %s" % stored_msg)
print("TEST_CRAFTER_FEEDBACK: FAIL (a successful store did not report)")
quit(1)
return true
print("successful store -> %s" % stored_msg)
elapsed = 0.0
stage = 2
return false
2:
# --- 4 + 5. it survives the refresh, and stays first --------
# --- 2 + 3. it survives the refresh, and stays first --------
if elapsed < 0.5:
return false
controller._refresh_craft_status()
var still: String = _first_line()
if "Stored" not in still:
if "queued" not in still.to_lower():
print("first line half a second later: %s" % still)
print("TEST_CRAFTER_FEEDBACK: FAIL (the message was overwritten by the per-frame refresh)")
quit(1)
return true
print("half a second later, still first: %s" % still)
# --- 6. a REAL tap, through the input path ------------------
# --- 4. a REAL tap, through the input path ------------------
# Everything above calls the handlers directly, which is how a
# whole class of bug kept getting missed: the crafter logic was
# fine and the BUTTON was inert, blocked by a modal guard. This
# taps the control the way a touch does.
game_state.withdraw_to_fleet(20)
var stock_before: int = game_state.building_stock(crafter_id)
var store_button: Control = scene.get_node("UI/CraftMenu/StoreOreButton")
for pressed in [true, false]:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.pressed = pressed
ev.position = store_button.size / 2.0
store_button._gui_input(ev)
if game_state.building_stock(crafter_id) <= stock_before:
print("stock %d -> %d after tapping Store ore" % [
stock_before, game_state.building_stock(crafter_id)
var queued_before: int = game_state.pending_orders(crafter_id)
_tap(scene.get_node("UI/CraftMenu/InterceptorButton"))
if game_state.pending_orders(crafter_id) <= queued_before:
print("orders %d -> %d after tapping Interceptor" % [
queued_before, game_state.pending_orders(crafter_id)
])
print("TEST_CRAFTER_FEEDBACK: FAIL (tapping the store button did nothing)")
print("TEST_CRAFTER_FEEDBACK: FAIL (tapping the interceptor button did nothing)")
quit(1)
return true
print("real tap on Store ore moved %d ore into the crafter" % [
game_state.building_stock(crafter_id) - stock_before
])
print("real tap on Interceptor queued another order")
# And the same tap must still work after the build menu has been
# opened and closed, which used to block every button forever.
controller._on_build_pressed()
controller._show_building_info(GameState.BUILDING_SHIP_CRAFTER)
scene.get_node("UI/BuildMenu").visible = false
game_state.withdraw_to_fleet(10)
var stock_mid: int = game_state.building_stock(crafter_id)
for pressed2 in [true, false]:
var ev2: InputEventScreenTouch = InputEventScreenTouch.new()
ev2.pressed = pressed2
ev2.position = store_button.size / 2.0
store_button._gui_input(ev2)
if game_state.building_stock(crafter_id) <= stock_mid:
print("TEST_CRAFTER_FEEDBACK: FAIL (store button dead after the build menu was opened and closed)")
var queued_mid: int = game_state.pending_orders(crafter_id)
_tap(scene.get_node("UI/CraftMenu/InterceptorButton"))
if game_state.pending_orders(crafter_id) <= queued_mid:
print("TEST_CRAFTER_FEEDBACK: FAIL (craft button dead after the build menu was opened and closed)")
quit(1)
return true
print("still works after opening and closing the build menu")

View file

@ -12,7 +12,10 @@ extends SceneTree
#
# Verifies:
# 1. mining fills the hold and leaves the bank alone
# 2. reaching the mothership banks the lot, and empties the hold
# 2. unloading at the mothership banks the lot and empties the hold - but
# only when the player asks. Arriving used to do it by itself; #24
# removed that, so this now checks BOTH halves: parking changes
# nothing, and the explicit transfer moves everything
# 3. with no mothership there is nowhere to deliver, so cargo just
# accumulates - it is never quietly banked anyway
# 4. sitting on a DIFFERENT building is not a delivery
@ -27,6 +30,7 @@ var field: Node
var stage: int = 0
var elapsed: float = 0.0
var banked_before: int = 0
var mothership_id: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(-4000.0, 0.0)
@ -117,6 +121,7 @@ func _process(delta: float) -> bool:
# --- 4. a different building is not a delivery point --------
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 2
@ -139,6 +144,14 @@ func _process(delta: float) -> bool:
if elapsed < 0.4:
return false
var total_before: int = banked_before + 3
# Parking alone must do nothing now (#24).
if game_state.fleet_cargo != 3 or game_state.rocks != banked_before:
print("TEST_FLEET_CARGO: FAIL (arriving at the mothership banked the hold by itself)")
quit(1)
return true
print("parked on the mothership -> hold still %d (no auto-banking)" % game_state.fleet_cargo)
game_state.transfer_to_building(mothership_id, 999)
print("delivered: hold=%d bank=%d (expected hold=0 bank=%d)" % [
game_state.fleet_cargo, game_state.rocks, total_before
])
@ -156,10 +169,6 @@ func _process(delta: float) -> bool:
# Not simulated by loading PlanetSurface.tscn here: the point is
# that cargo lives in the autoload rather than in Main.tscn, so
# tearing Main.tscn down must not touch it.
# Fly off the mothership first, or this new cargo is (correctly)
# banked the moment BuildingField next runs, and the teardown
# check below would be measuring a delivery instead.
_park_fleet_on(CRAFTER_AT)
game_state.collect_cargo(5)
scene.queue_free()
elapsed = 0.0

View file

@ -14,9 +14,10 @@ extends SceneTree
# Verifies:
# 1. withdrawing moves ore bank -> hold, and conserves the total
# 2. withdrawing is capped by what is actually banked (no overdraft)
# 3. arriving at the mothership banks the hold, but doing so is EDGE
# triggered - ore withdrawn while parked there is not instantly taken
# back, which would make the button look broken
# 3. arriving at the mothership does NOT bank the hold. It used to, edge
# triggered on arrival (#14); #24 removed that entirely, because an
# automatic one-way move overrides a player who has just loaded the
# hold in order to carry ore somewhere else
# 4. a building takes the fleet's whole hold when opened
# 5. end to end: a queued order the bank cannot reach is fulfilled using
# only the fleet, with no Transport Hub in existence
@ -28,6 +29,7 @@ var fleet: Node
var field: Node
var controller: Node
var crafter_id: int = 0
var mothership_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var ships_before: int = 0
@ -74,6 +76,7 @@ func _process(delta: float) -> bool:
_clear_hazards()
fleet.spawn_ships(2, MOTHERSHIP_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
total_before = _total()
@ -107,7 +110,7 @@ func _process(delta: float) -> bool:
if elapsed < 0.3:
return false
var bank_before: int = game_state.rocks
var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST)
var took: int = game_state.transfer_to_fleet(mothership_id, GameState.SHIP_COST)
if took != GameState.SHIP_COST or game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo])
quit(1)
@ -123,14 +126,22 @@ func _process(delta: float) -> bool:
stage = 4
return false
4:
# --- 3. still parked on the mothership ----------------------
# --- 3. parked on the mothership, and arriving again --------
if elapsed < 0.5:
return false
if game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was instantly banked again - delivery is not edge triggered)")
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was banked again by itself)")
quit(1)
return true
print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo)
# Leaving and coming back is the case the old edge-triggered
# delivery fired on, so it is the one worth re-entering.
_park_fleet_on(CRAFTER_AT)
_park_fleet_on(MOTHERSHIP_AT)
if game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (arriving at the mothership auto-banked the hold - #24 removed that)")
quit(1)
return true
print("hold kept its %d across an arrival (no auto-banking)" % game_state.fleet_cargo)
# --- 4. carry it to the crafter and open it -----------------
_park_fleet_on(CRAFTER_AT)
@ -140,7 +151,7 @@ func _process(delta: float) -> bool:
5:
if elapsed < 0.6:
return false
var moved: int = game_state.unload_into_building(crafter_id)
var moved: int = game_state.transfer_to_building(crafter_id, game_state.fleet_cargo)
if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo])
quit(1)
@ -180,7 +191,7 @@ func _process(delta: float) -> bool:
# --- 2. no overdraft ----------------------------------------
game_state.rocks = 3
var over: int = game_state.withdraw_to_fleet(999)
var over: int = game_state.transfer_to_fleet(mothership_id, 999)
if over != 3 or game_state.rocks != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks])
quit(1)

View file

@ -0,0 +1,219 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_resource_transfer.gd
# Forgejo #24: moving ore between the fleet and a building is its own
# top-level action, not something buried inside the ship crafter or the
# research screen.
#
# Verifies:
# 1. the transfer button appears alongside the action button whenever the
# fleet is standing on a building, and opens the transfer panel bound to
# THAT building - the `== 0` guard failure that made every crafter
# button look dead is the same trap here
# 2. ore moves both ways at an ordinary building, capped by what is
# actually at each end (no overdraft in either direction)
# 3. at a mothership the building's holdings ARE the bank, so unloading
# there banks the ore and loading there draws on it
# 4. a REAL tap through the input path moves ore, not just the handler
# 5. pressing against an empty end reports why instead of doing nothing
# 6. the shared resource bar reports the building the panel was opened from
# 7. ore is conserved across the whole exercise
var game_state: Node
var scene: Node
var fleet: Node
var controller: Node
var crafter_id: int = 0
var mothership_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var total_before: int = 0
var failures: PackedStringArray = PackedStringArray()
const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0)
func _total() -> int:
var sum: int = game_state.rocks + game_state.fleet_cargo
for b in game_state.buildings:
if b.type != GameState.BUILDING_MOTHERSHIP:
sum += int(b.get("stock", 0))
return sum
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.vessels_owed.clear()
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
controller = scene.get_node("BuildController")
func _park_fleet_on(at: Vector2) -> void:
for ship in fleet.ships:
ship.position = at
fleet.move_to(at)
func _tap(button: Control) -> void:
for pressed in [true, false]:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.pressed = pressed
ev.position = button.size / 2.0
button._gui_input(ev)
func _expect(condition: bool, message: String) -> void:
if not condition:
failures.append("stage %d: %s" % [stage, message])
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
_clear_hazards()
fleet.spawn_ships(2, MOTHERSHIP_AT)
var field: Node = scene.get_node("BuildingField")
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
total_before = _total()
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 1
return false
1:
# --- 1. the button appears, and binds to this building ------
if elapsed < 1.2:
return false
_expect(controller.current_building != null,
"parking on the crafter never registered")
_expect(controller.transfer_button.visible,
"the transfer button did not appear at a building")
_tap(controller.transfer_button)
_expect(controller.transfer_menu.visible,
"tapping the transfer button did not open the panel")
_expect(controller.transfer_building_id == crafter_id,
"panel bound to building %d, crafter is %d"
% [controller.transfer_building_id, crafter_id])
# --- 6. the shared bar follows the panel --------------------
for bar in controller.resource_bars:
_expect(bar.building_id == crafter_id,
"a resource bar is reporting building %d, not the crafter %d"
% [bar.building_id, crafter_id])
# --- 5. an empty hold explains itself -----------------------
game_state.fleet_cargo = 0
controller._on_transfer(true, GameState.CARGO_LOAD_BATCH)
_expect("carrying nothing" in controller.transfer_status.text,
"unloading an empty hold said: %s" % controller.transfer_status.text)
# --- 2. both directions at an ordinary building -------------
# Baseline taken AFTER the last hand-set figure: the conservation
# check at the end is about the transfers, and ore conjured into
# the hold to set the scene would otherwise read as a leak.
game_state.fleet_cargo = 40
total_before = _total()
controller._on_transfer(true, GameState.CARGO_LOAD_BATCH)
_expect(game_state.building_stock(crafter_id) == GameState.CARGO_LOAD_BATCH,
"a batch into the crafter left stock at %d"
% game_state.building_stock(crafter_id))
_expect(game_state.fleet_cargo == 40 - GameState.CARGO_LOAD_BATCH,
"the hold paid %d, not the batch" % (40 - game_state.fleet_cargo))
controller._on_transfer(false, 999)
_expect(game_state.building_stock(crafter_id) == 0,
"loading ALL left %d in the crafter" % game_state.building_stock(crafter_id))
_expect(game_state.fleet_cargo == 40,
"the hold came back to %d, not 40" % game_state.fleet_cargo)
# Asking for more than exists moves only what is there.
var over: int = game_state.transfer_to_fleet(crafter_id, 999)
_expect(over == 0, "overdrew %d from an empty crafter" % over)
_park_fleet_on(MOTHERSHIP_AT)
controller.transfer_menu.visible = false
elapsed = 0.0
stage = 2
return false
2:
# --- 3. the mothership's holdings are the bank --------------
if elapsed < 1.2:
return false
_expect(controller.transfer_button.visible,
"the transfer button did not appear at the mothership")
_tap(controller.transfer_button)
_expect(controller.transfer_building_id == mothership_id,
"panel bound to %d, mothership is %d"
% [controller.transfer_building_id, mothership_id])
_expect(game_state.building_holdings(mothership_id) == game_state.rocks,
"mothership holdings %d != bank %d"
% [game_state.building_holdings(mothership_id), game_state.rocks])
var bank_before: int = game_state.rocks
var hold_before: int = game_state.fleet_cargo
controller._on_transfer(true, 999)
_expect(game_state.fleet_cargo == 0,
"unloading ALL at the mothership left %d in the hold" % game_state.fleet_cargo)
_expect(game_state.rocks == bank_before + hold_before,
"the bank went to %d, expected %d" % [game_state.rocks, bank_before + hold_before])
# --- 4. a REAL tap, through the input path ------------------
# The handler working while the BUTTON is inert is the exact bug
# class this project keeps hitting, so one press goes the long way.
var bank_at_tap: int = game_state.rocks
_tap(scene.get_node("UI/TransferMenu/ToFleetBatchButton"))
_expect(game_state.fleet_cargo == GameState.CARGO_LOAD_BATCH,
"a real tap loaded %d, expected a batch of %d"
% [game_state.fleet_cargo, GameState.CARGO_LOAD_BATCH])
_expect(game_state.rocks == bank_at_tap - GameState.CARGO_LOAD_BATCH,
"the bank did not pay for the tapped withdrawal")
# --- 7. conservation ----------------------------------------
_expect(_total() == total_before,
"ore created or destroyed: %d -> %d" % [total_before, _total()])
_finish()
return true
return false
func _finish() -> void:
if failures.is_empty():
print("TEST_RESOURCE_TRANSFER: PASS")
quit(0)
else:
print("bank=%d hold=%d crafter=%d" % [
game_state.rocks, game_state.fleet_cargo, game_state.building_stock(crafter_id)
])
for f in failures:
print("FAIL: %s" % f)
print("TEST_RESOURCE_TRANSFER: FAIL")
quit(1)
func _clear_hazards() -> void:
# Drifting rocks can destroy a parked ship and change the fleet size out
# from under a logistics test. Same reason as test_manual_supply_route.
for a in get_nodes_in_group("asteroids"):
if is_instance_valid(a):
a.queue_free()
for e in get_nodes_in_group("enemies"):
if is_instance_valid(e):
e.queue_free()

View file

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