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_menu: Control = get_node("../UI/CraftMenu")
@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") @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_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_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @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_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") @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: String = ""
var craft_message_until: int = 0 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 current_building: Node2D = null
var pending_building: Node2D = null var pending_building: Node2D = null
var pending_time: float = 0.0 var pending_time: float = 0.0
@ -167,11 +195,16 @@ func _ready() -> void:
action_button.pressed.connect(_on_action_pressed) action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor) 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_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) 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_close_button.pressed.connect(func(): formation_menu.visible = false)
formation_reset_button.pressed.connect(_on_reset_formation) formation_reset_button.pressed.connect(_on_reset_formation)
formation_editor.formation_changed.connect(_on_formation_changed) formation_editor.formation_changed.connect(_on_formation_changed)
@ -186,14 +219,16 @@ func _ready() -> void:
formation_menu.visible = false formation_menu.visible = false
skill_menu.visible = false skill_menu.visible = false
lab_menu.visible = false lab_menu.visible = false
transfer_menu.visible = false
action_button.visible = false action_button.visible = false
transfer_button.visible = false
# True while a building prompt or panel owns the shared action slot, so the # True while a building prompt or panel owns the shared action slot, so the
# enter-atmosphere prompt knows to stay out of the way. # enter-atmosphere prompt knows to stay out of the way.
func is_showing_building_prompt() -> bool: func is_showing_building_prompt() -> bool:
return current_building != null or build_menu.visible or craft_menu.visible \ 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: func _unhandled_input(event: InputEvent) -> void:
@ -218,7 +253,8 @@ func _unhandled_input(event: InputEvent) -> void:
formation_editor.handle_drag(event) formation_editor.handle_drag(event)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return 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 # These panels have no raw-input handler of their own, but the world
# behind them must still not react. # behind them must still not react.
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@ -239,11 +275,18 @@ func _process(delta: float) -> void:
# the queue down instead of looking stuck. # the queue down instead of looking stuck.
_refresh_craft_status() _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 \ 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 current_building = null
pending_building = null pending_building = null
action_button.visible = false action_button.visible = false
transfer_button.visible = false
return return
# Debounced exactly like the enter-atmosphere prompt, so the action # Debounced exactly like the enter-atmosphere prompt, so the action
@ -261,6 +304,10 @@ func _process(delta: float) -> void:
current_building = pending_building current_building = pending_building
action_button.visible = current_building != null 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: if current_building != null:
action_button.icon = _action_icon_for(current_building.building_type) action_button.icon = _action_icon_for(current_building.building_type)
action_button.queue_redraw() action_button.queue_redraw()
@ -292,9 +339,14 @@ func _check_fleet_wipe() -> void:
func _on_action_pressed() -> void: func _on_action_pressed() -> void:
if current_building == null: if current_building == null:
return 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: if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
skill_menu.visible = true skill_menu.visible = true
_refresh_load_ore()
_refresh_skill_menu() _refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
# Remembered explicitly: _process() clears current_building while any # Remembered explicitly: _process() clears current_building while any
@ -591,14 +643,14 @@ func _say(message: String) -> void:
func _refresh_craft_status() -> void: func _refresh_craft_status() -> void:
# "Here" is the ore delivered to THIS crafter, not the bank: since #16 a # The bank/hold/here figures moved out to the shared header (#24), so this
# building builds out of its own stock, and showing the bank would say # line is now only what is specific to a crafter: prices, the queue, and
# you can afford something the crafter cannot actually make. # 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 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 var queue_text: String = "" if queued == 0 else " Queued %d" % queued
craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [ craft_status.text = "Interceptor %d Transporter %d%s Transporters %d/%d" % [
GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text, GameState.SHIP_COST, GameState.TRANSPORTER_COST, queue_text,
GameState.transporters_claimed(), GameState.transport_capacity() GameState.transporters_claimed(), GameState.transport_capacity()
] ]
# Nowhere to station one means the order would be refused, so say so # 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.enabled = GameState.shield_vessel_unlocked()
craft_shield_button.queue_redraw() 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. # 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" # 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) _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: func _report_order(product: String) -> void:
var short: int = GameState.outstanding_cost(craft_building_id) var short: int = GameState.outstanding_cost(craft_building_id)
if short > 0: if short > 0:
@ -684,17 +719,6 @@ func _report_order(product: String) -> void:
_say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product)) _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: func _on_craft_transporter() -> void:
if craft_building_id == 0: if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building") _say("No crafter selected - reopen this panel from the building")
@ -823,6 +847,86 @@ func _on_craft_shield() -> void:
_report_order(GameState.VESSEL_SHIELD) _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: func _on_formation_changed() -> void:
fleet.apply_formation() fleet.apply_formation()

View file

@ -11,33 +11,20 @@ extends Node2D
var building_scene: PackedScene = preload("res://Building.tscn") var building_scene: PackedScene = preload("res://Building.tscn")
var spawned: Array = [] var spawned: Array = []
# Whether the fleet was already standing on the mothership last frame - see
# the edge-triggered delivery in _process(). # Nothing happens here per frame any more. Arriving at the mothership used to
var _was_on_mothership: bool = false # 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: func _ready() -> void:
rebuild_all() 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: func rebuild_all() -> void:
for b in spawned: for b in spawned:
if is_instance_valid(b): 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. # surface would otherwise be dropped the moment the player took off.
var fleet_cargo: int = 0 var fleet_cargo: int = 0
signal cargo_delivered(amount: int)
func collect_cargo(amount: int = 1) -> void: func collect_cargo(amount: int = 1) -> void:
fleet_cargo += amount fleet_cargo += amount
# One press of "load ore" at the mothership. A batch rather than the whole # The batch size the transfer menu offers, next to "All". A batch rather
# bank: the hold is the part that can be lost, and emptying the bank in one # than only all-or-nothing: the hold is the part that can be lost, and
# tap would also strand research, which still spends from it. # emptying the bank in one tap would strand research, which spends from it.
const CARGO_LOAD_BATCH: int = 25 const CARGO_LOAD_BATCH: int = 25
func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int: # --- moving ore by hand, in either direction (#24) --------------------------
# Banked ore back into the hold, so the fleet can carry it to a building #
# that is waiting on it (#17). # One pair of primitives the transfer menu drives, rather than the two
var taken: int = mini(amount, rocks) # one-way special cases that used to live inside the skill and craft panels
if taken <= 0: # ("load ore" at the mothership, "store ore here" at a crafter).
return 0 #
rocks -= taken # The mothership is deliberately not a special case in the UI: it is simply
fleet_cargo += taken # the building whose holdings ARE the bank. Depositing at one banks the ore,
return taken # 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: func building_holdings(id: int) -> int:
# Everything the fleet is carrying, into this building's stock. Returns var b: Dictionary = find_building(id)
# how much moved so a caller can tell the player something happened. if b.is_empty():
if fleet_cargo <= 0:
return 0 return 0
var moved: int = fleet_cargo if b.type == BUILDING_MOTHERSHIP:
if deposit_to_building(id, moved) <= 0: 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 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 return moved
func deliver_cargo() -> int: func transfer_to_fleet(id: int, amount: int) -> int:
# Everything the fleet is carrying, banked at once. Returns how much moved # This building -> hold. Capped by what the building actually holds, so
# so a caller can react to a delivery actually having happened rather than # there is no overdraft in either direction.
# to merely being in range. var b: Dictionary = find_building(id)
if fleet_cargo <= 0: if b.is_empty():
return 0 return 0
var delivered: int = fleet_cargo var moved: int = mini(amount, building_holdings(id))
rocks += delivered if moved <= 0:
fleet_cargo = 0 return 0
cargo_delivered.emit(delivered) if b.type == BUILDING_MOTHERSHIP:
return delivered rocks -= moved
else:
b.stock = int(b.get("stock", 0)) - moved
fleet_cargo += moved
return moved
const MOTHERSHIP_COST: int = 20 const MOTHERSHIP_COST: int = 20
const SHIP_CRAFTER_COST: int = 12 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 # and the command centre sharing a folded-map glyph and neither of them
# looking anything like the structure they build. # looking anything like the structure they build.
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB, SHIP_LAB, 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 # 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) _draw_transport_hub_icon(center, pulse, s)
Icon.SHIP_LAB: Icon.SHIP_LAB:
_draw_ship_lab_icon(center, pulse, s) _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: 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) 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: func _draw_build(center: Vector2, pulse: float, s: float) -> void:
# A plus inside a frame - "add a structure". # A plus inside a frame - "add a structure".
var r: float = 11.0 * s 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://Starfield.gd" id="1"]
[ext_resource type="Script" path="res://Fleet.gd" id="2"] [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://SkillTreeView.gd" id="21"]
[ext_resource type="Script" path="res://CargoCounter.gd" id="22"] [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://TransporterField.gd" id="23"]
[ext_resource type="Script" path="res://ResourceBar.gd" id="24"]
[sub_resource type="ShaderMaterial" id="1"] [sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5") shader = ExtResource("5")
@ -144,6 +145,18 @@ offset_bottom = -572
script = ExtResource("10") script = ExtResource("10")
icon = 2 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"] [node name="MapButton" type="Control" parent="UI"]
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
@ -633,19 +646,27 @@ color = Color(0.03, 0.03, 0.08, 0.94)
mouse_filter = 2 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
offset_left = 24 offset_left = 24
offset_top = 60 offset_top = 36
offset_right = -24 offset_right = -200
offset_bottom = 170 offset_bottom = 166
text = "Ship Lab" text = "Ship Lab"
horizontal_alignment = 1 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"] [node name="VesselA" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5 anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
offset_left = -290 offset_left = -290
offset_right = -170 offset_right = -170
offset_top = 190 offset_top = 274
offset_bottom = 310 offset_bottom = 394
script = ExtResource("10") script = ExtResource("10")
icon = 2 icon = 2
glow_color = Color(0.25, 0.85, 1, 1) glow_color = Color(0.25, 0.85, 1, 1)
@ -655,8 +676,8 @@ anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
offset_left = -60 offset_left = -60
offset_right = 60 offset_right = 60
offset_top = 190 offset_top = 274
offset_bottom = 310 offset_bottom = 394
script = ExtResource("10") script = ExtResource("10")
icon = 2 icon = 2
glow_color = Color(0.45, 0.8, 1, 1) glow_color = Color(0.45, 0.8, 1, 1)
@ -666,8 +687,8 @@ anchor_left = 0.5
anchor_right = 0.5 anchor_right = 0.5
offset_left = 170 offset_left = 170
offset_right = 290 offset_right = 290
offset_top = 190 offset_top = 274
offset_bottom = 310 offset_bottom = 394
script = ExtResource("10") script = ExtResource("10")
icon = 12 icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1) 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 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
offset_left = 24 offset_left = 24
offset_top = 318 offset_top = 402
offset_right = -24 offset_right = -24
offset_bottom = 380 offset_bottom = 460
theme_override_font_sizes/font_size = 34 theme_override_font_sizes/font_size = 34
horizontal_alignment = 1 horizontal_alignment = 1
text = "" text = ""
@ -688,7 +709,7 @@ mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 32 offset_left = 32
offset_top = 396 offset_top = 470
offset_right = -32 offset_right = -32
offset_bottom = -470 offset_bottom = -470
theme_override_font_sizes/font_size = 30 theme_override_font_sizes/font_size = 30
@ -788,20 +809,28 @@ color = Color(0.03, 0.03, 0.08, 0.94)
mouse_filter = 2 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
offset_left = 24 offset_left = 24
offset_top = 70 offset_top = 36
offset_right = -24 offset_right = -200
offset_bottom = 196 offset_bottom = 166
text = "Build a ship" text = "Build a ship"
horizontal_alignment = 1 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"] [node name="StatusLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
offset_left = 24 offset_left = 24
offset_top = 206 offset_top = 274
offset_right = -24 offset_right = -24
offset_bottom = 360 offset_bottom = 420
theme_override_font_sizes/font_size = 40 theme_override_font_sizes/font_size = 36
autowrap_mode = 2 autowrap_mode = 2
text = "" text = ""
horizontal_alignment = 1 horizontal_alignment = 1
@ -813,8 +842,8 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -234 offset_left = -234
offset_right = -86 offset_right = -86
offset_top = -74 offset_top = 60
offset_bottom = 74 offset_bottom = 208
script = ExtResource("10") script = ExtResource("10")
icon = 2 icon = 2
@ -826,8 +855,8 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -300 offset_left = -300
offset_right = -20 offset_right = -20
offset_top = 100 offset_top = 216
offset_bottom = 200 offset_bottom = 296
theme_override_font_sizes/font_size = 32 theme_override_font_sizes/font_size = 32
text = "Interceptor" text = "Interceptor"
horizontal_alignment = 1 horizontal_alignment = 1
@ -839,8 +868,8 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = 86 offset_left = 86
offset_right = 234 offset_right = 234
offset_top = -74 offset_top = 60
offset_bottom = 74 offset_bottom = 208
script = ExtResource("10") script = ExtResource("10")
icon = 12 icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1) glow_color = Color(0.95, 0.55, 0.85, 1)
@ -853,8 +882,8 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = 20 offset_left = 20
offset_right = 300 offset_right = 300
offset_top = 96 offset_top = 216
offset_bottom = 176 offset_bottom = 296
theme_override_font_sizes/font_size = 32 theme_override_font_sizes/font_size = 32
autowrap_mode = 2 autowrap_mode = 2
text = "Transporter" text = "Transporter"
@ -867,8 +896,8 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -74 offset_left = -74
offset_right = 74 offset_right = 74
offset_top = -260 offset_top = -200
offset_bottom = -112 offset_bottom = -52
script = ExtResource("10") script = ExtResource("10")
icon = 2 icon = 2
glow_color = Color(0.45, 0.8, 1, 1) glow_color = Color(0.45, 0.8, 1, 1)
@ -881,41 +910,13 @@ anchor_top = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -160 offset_left = -160
offset_right = 160 offset_right = 160
offset_top = -104 offset_top = -44
offset_bottom = -44 offset_bottom = 16
theme_override_font_sizes/font_size = 32 theme_override_font_sizes/font_size = 32
autowrap_mode = 2 autowrap_mode = 2
text = "Shield Vessel" text = "Shield Vessel"
horizontal_alignment = 1 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"] [node name="CloseButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
@ -926,6 +927,171 @@ offset_bottom = 172
script = ExtResource("10") script = ExtResource("10")
icon = 1 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"]] [node name="FormationMenu" type="Control" parent="UI" groups=["modal_menus"]]
visible = false visible = false
mouse_filter = 2 mouse_filter = 2
@ -1026,18 +1192,26 @@ color = Color(0.03, 0.03, 0.08, 0.94)
mouse_filter = 2 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
offset_left = 24.0 offset_left = 24.0
offset_top = 70.0 offset_top = 36.0
offset_right = -24.0 offset_right = -200.0
offset_bottom = 196.0 offset_bottom = 166.0
text = "Research" text = "Research"
horizontal_alignment = 1 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"] [node name="Tree" type="Control" parent="UI/SkillMenu"]
mouse_filter = 2 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 24.0 offset_left = 24.0
offset_top = 210.0 offset_top = 274.0
offset_right = -24.0 offset_right = -24.0
offset_bottom = -620.0 offset_bottom = -620.0
script = ExtResource("21") script = ExtResource("21")
@ -1096,34 +1270,6 @@ script = ExtResource("10")
icon = 5 icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1) 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"] [node name="CloseButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 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 extends Node2D
# Preview, not a test. Opens the CraftMenu so its layout can be checked on a # 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 # real viewport - label collisions and things falling off the bottom of
# of shorter screens, which no headless test can see. # shorter screens are what no headless test can see.
# #
# bash tests/screenshot.sh res://tests/_craft_menu_preview.tscn /tmp/m.png 20 720x1280 # 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. # ... and again at 720x960 to check a shorter screen format.
@ -17,11 +17,14 @@ func _ready() -> void:
GameState.unlocked = ["shipyards", "logistics", "freight"] GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate() var main: Node = load("res://Main.tscn").instantiate()
add_child(main) 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") 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 main.get_node("UI/CraftMenu").visible = true
if "Craft" == "Craft":
bc._refresh_craft_status() bc._refresh_craft_status()
else:
bc._refresh_load_ore()

View file

@ -16,10 +16,17 @@ func _ready() -> void:
var main: Node = load("res://Main.tscn").instantiate() var main: Node = load("res://Main.tscn").instantiate()
add_child(main) add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_LAB, Vector2(400.0, 0.0)) 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 # A level already bought, so the list shows both a researched and an
# unresearched line rather than a uniform page of zeroes. # unresearched line rather than a uniform page of zeroes.
GameState.vessel_levels["int_damage"] = 1 GameState.vessel_levels["int_damage"] = 1
var bc: Node = main.get_node("BuildController") 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 main.get_node("UI/LabMenu").visible = true
bc.lab_vessel = GameState.VESSEL_INTERCEPTOR bc.lab_vessel = GameState.VESSEL_INTERCEPTOR
bc._refresh_lab() bc._refresh_lab()

View file

@ -1,8 +1,8 @@
extends Node2D extends Node2D
# Preview, not a test. Opens the SkillMenu so its layout can be checked on a # 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 # real viewport - the tree lost vertical room to the shared resource header
# of shorter screens, which no headless test can see. # (#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 # 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. # ... and again at 720x960 to check a shorter screen format.
@ -17,15 +17,13 @@ func _ready() -> void:
GameState.unlocked = ["shipyards", "logistics", "freight"] GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate() var main: Node = load("res://Main.tscn").instantiate()
add_child(main) 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") 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 main.get_node("UI/SkillMenu").visible = true
if "Skill" == "Craft": # Select a node so the description, price and status all have text - an
bc._refresh_craft_status() # empty description cannot collide with anything.
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.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("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 banked: Control
var cargo: Control var cargo: Control
var stage: int = 0 var stage: int = 0
var mothership_id: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
@ -36,6 +37,11 @@ func _initialize() -> void:
game_state.extractors.clear() game_state.extractors.clear()
game_state.rocks = 42 game_state.rocks = 42
game_state.fleet_cargo = 0 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() scene = load("res://Main.tscn").instantiate()
root.add_child(scene) root.add_child(scene)
banked = scene.get_node("UI/ResourceCounter") banked = scene.get_node("UI/ResourceCounter")
@ -95,7 +101,7 @@ func _process(delta: float) -> bool:
]) ])
# --- 4. delivery moves it across ---------------------------- # --- 4. delivery moves it across ----------------------------
game_state.deliver_cargo() game_state.transfer_to_building(mothership_id, game_state.fleet_cargo)
elapsed = 0.0 elapsed = 0.0
stage = 2 stage = 2
return false return false

View file

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

View file

@ -12,7 +12,10 @@ extends SceneTree
# #
# Verifies: # Verifies:
# 1. mining fills the hold and leaves the bank alone # 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 # 3. with no mothership there is nowhere to deliver, so cargo just
# accumulates - it is never quietly banked anyway # accumulates - it is never quietly banked anyway
# 4. sitting on a DIFFERENT building is not a delivery # 4. sitting on a DIFFERENT building is not a delivery
@ -27,6 +30,7 @@ var field: Node
var stage: int = 0 var stage: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
var banked_before: int = 0 var banked_before: int = 0
var mothership_id: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0) const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(-4000.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 -------- # --- 4. a different building is not a delivery point --------
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
_park_fleet_on(CRAFTER_AT) _park_fleet_on(CRAFTER_AT)
elapsed = 0.0 elapsed = 0.0
stage = 2 stage = 2
@ -139,6 +144,14 @@ func _process(delta: float) -> bool:
if elapsed < 0.4: if elapsed < 0.4:
return false return false
var total_before: int = banked_before + 3 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)" % [ print("delivered: hold=%d bank=%d (expected hold=0 bank=%d)" % [
game_state.fleet_cargo, game_state.rocks, total_before 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 # Not simulated by loading PlanetSurface.tscn here: the point is
# that cargo lives in the autoload rather than in Main.tscn, so # that cargo lives in the autoload rather than in Main.tscn, so
# tearing Main.tscn down must not touch it. # 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) game_state.collect_cargo(5)
scene.queue_free() scene.queue_free()
elapsed = 0.0 elapsed = 0.0

View file

@ -14,9 +14,10 @@ extends SceneTree
# Verifies: # Verifies:
# 1. withdrawing moves ore bank -> hold, and conserves the total # 1. withdrawing moves ore bank -> hold, and conserves the total
# 2. withdrawing is capped by what is actually banked (no overdraft) # 2. withdrawing is capped by what is actually banked (no overdraft)
# 3. arriving at the mothership banks the hold, but doing so is EDGE # 3. arriving at the mothership does NOT bank the hold. It used to, edge
# triggered - ore withdrawn while parked there is not instantly taken # triggered on arrival (#14); #24 removed that entirely, because an
# back, which would make the button look broken # 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 # 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 # 5. end to end: a queued order the bank cannot reach is fulfilled using
# only the fleet, with no Transport Hub in existence # only the fleet, with no Transport Hub in existence
@ -28,6 +29,7 @@ var fleet: Node
var field: Node var field: Node
var controller: Node var controller: Node
var crafter_id: int = 0 var crafter_id: int = 0
var mothership_id: int = 0
var stage: int = 0 var stage: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
var ships_before: int = 0 var ships_before: int = 0
@ -74,6 +76,7 @@ func _process(delta: float) -> bool:
_clear_hazards() _clear_hazards()
fleet.spawn_ships(2, MOTHERSHIP_AT) fleet.spawn_ships(2, MOTHERSHIP_AT)
field.place(GameState.BUILDING_MOTHERSHIP, 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) field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id) crafter_id = int(game_state.buildings[-1].id)
total_before = _total() total_before = _total()
@ -107,7 +110,7 @@ func _process(delta: float) -> bool:
if elapsed < 0.3: if elapsed < 0.3:
return false return false
var bank_before: int = game_state.rocks 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: 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]) print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo])
quit(1) quit(1)
@ -123,14 +126,22 @@ func _process(delta: float) -> bool:
stage = 4 stage = 4
return false return false
4: 4:
# --- 3. still parked on the mothership ---------------------- # --- 3. parked on the mothership, and arriving again --------
if elapsed < 0.5: if elapsed < 0.5:
return false return false
if game_state.fleet_cargo != GameState.SHIP_COST: 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) quit(1)
return true 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 ----------------- # --- 4. carry it to the crafter and open it -----------------
_park_fleet_on(CRAFTER_AT) _park_fleet_on(CRAFTER_AT)
@ -140,7 +151,7 @@ func _process(delta: float) -> bool:
5: 5:
if elapsed < 0.6: if elapsed < 0.6:
return false 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: 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]) print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo])
quit(1) quit(1)
@ -180,7 +191,7 @@ func _process(delta: float) -> bool:
# --- 2. no overdraft ---------------------------------------- # --- 2. no overdraft ----------------------------------------
game_state.rocks = 3 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: 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]) print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks])
quit(1) 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