spaceship/ui/menus/FleetUpkeepMenu.gd
Claude 34e252037e #231, #230: a key per ability, and an upkeep sheet that adds up
#231 - the abilities were hard to use on a keyboard.

"The currently selected weapons" names a real mechanism: MouseCombatController
(#80) has number keys that SELECT which weapon a click fires. That is two
acts for one shot, and the selection is invisible unless you look at the
buttons. Q, E and space now fire the gas bomb, the laser and the dash
outright, wherever the mouse already points.

It goes through fire_at(), the same public entry the click path uses, so
every guard comes with it - no hull ready, cannot afford the shot, a panel
over the button. The number keys stay: they are not what this ticket is
about, and removing them would take the click path's abilities with them.

Q and E were already taken - they pivot the formation - so the pivot pair
moved to Z and C, one row under the movement keys and still left-hand. That
is a change to a control the ticket did not mention, and the one thing here
worth arguing with.

test_keyboard_input spelled Q and E out and so reported a broken pivot when
the pivot was fine. It reads KEY_PIVOT_CW/CCW off the controller now, and
additionally asserts the pair is not bound to an ability key - the failure
that would actually matter.

#230 - the upkeep menu never added anything up.

Two faults, and the second is invisible from the code. There was no TOTAL,
so the bill had to be done in the player's head. And the rows were one Label
padded with %-16s, which aligns only in a monospace font - the theme's is
CarroisGothicSC, which is proportional - with autowrap on top, so a long row
folded and the table lost its shape.

A real GridContainer now: header, a row per paying berth, and a TOTAL. The
headline carries both figures, because they answer different questions - the
per-charge bill is what the player watches happen, the per-minute rate is
what they plan against.

Amounts are listed in the game's resource order rather than the dictionary's.
The screenshot showed "2 ore 1 energy" on one row above "2 gas 2 ore" on the
next, which defeats the point of a column.

The sheet is rebuilt only when the numbers change. BuildController refreshes
this menu every frame while it is open, and rebuilding a dozen Labels per
frame for figures that move every 25 seconds is what #6 and #10 are about.
The test refreshes twice and asserts the same cell objects come back.

Every figure still comes from fleet_upkeep_breakdown(), the calculation the
charging loop itself runs - the new total sums those same per-berth costs
rather than recomputing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-15 23:44:17 -04:00

148 lines
5.5 KiB
GDScript

extends "res://ui/menus/ModalMenu.gd"
# Forgejo #220: what the fleet costs to keep, and WHY it costs that.
#
# A Fleet Office charges for the vessel in its berth every 25 seconds, and
# each berth costs more than the one before it. Neither of those is visible
# from the resource bar - the player watches a number fall and cannot tell
# which of their own decisions is draining it - so this states the bill and
# shows the arithmetic that produced it, line by line.
#
# Every number here comes from GameState.fleet_upkeep_breakdown(), which is
# the same calculation the charging loop runs. A readout that did its own sum
# could disagree with what is actually being taken, which is exactly the kind
# of "the game is lying to me" the ticket is about.
@onready var total_label: Label = $TotalLabel
@onready var sheet: GridContainer = $Sheet
@onready var empty_label: Label = $EmptyLabel
@onready var formula_label: Label = $FormulaLabel
const HEADER_COLOR: Color = Color(0.55, 0.78, 0.86)
const CELL_COLOR: Color = Color(0.72, 0.88, 0.96)
const TOTAL_COLOR: Color = Color(1.0, 0.85, 0.35)
const UNPAID_COLOR: Color = Color(1.0, 0.45, 0.4)
const COLUMNS: Array = ["BERTH", "VESSEL", "PER CHARGE", "RATE", ""]
var building_id: int = 0
# What the sheet was last built from. The menu is refreshed every frame while
# it is open (see BuildController), and rebuilding a dozen Labels per frame to
# show numbers that change every 25 seconds is the thing issues #6 and #10
# are about.
var _built_from: String = ""
func open_for(next_building_id: int) -> void:
building_id = next_building_id
open_menu()
refresh()
func close_menu() -> void:
super.close_menu()
building_id = 0
func refresh() -> void:
if not visible:
return
var breakdown: Dictionary = GameState.fleet_upkeep_breakdown()
var berths: Array = breakdown.berths
var per_minute: Dictionary = breakdown.per_minute
# The bill, both ways round: what leaves the stores at each charge, and
# what that adds up to over a minute. The per-charge figure is the one
# the player watches happen; the rate is the one they plan against.
var totals: Dictionary = {}
for berth in berths:
for resource_type in berth.costs:
totals[resource_type] = int(totals.get(resource_type, 0)) \
+ int(berth.costs[resource_type])
if per_minute.is_empty():
total_label.text = "NOTHING TO KEEP"
else:
total_label.text = "%s EVERY %dS · %s PER MINUTE" % [
_amounts(totals), int(breakdown.charge_seconds), _rates(per_minute)]
var signature: String = "%s|%s" % [str(berths), str(totals)]
if signature != _built_from:
_built_from = signature
_build_sheet(berths, totals)
empty_label.visible = berths.is_empty()
sheet.visible = not berths.is_empty()
empty_label.text = "No berth is being charged. Build a Fleet Office and put a vessel in it."
formula_label.text = ("Each berth is charged every %ds: the vessel\'s build price "
+ "divided by %d, then multiplied by %.2f for every berth past the first "
+ "paid one. The first vessel is free.") % [
int(breakdown.charge_seconds), int(breakdown.divisor),
float(breakdown.growth)]
# One row per paying berth, plus a totals row - a berth standing empty is
# charged nothing and is left out rather than listed at zero.
func _build_sheet(berths: Array, totals: Dictionary) -> void:
for child in sheet.get_children():
child.free()
if berths.is_empty():
return
for column in COLUMNS:
_cell(String(column), HEADER_COLOR)
for berth in berths:
var starved: bool = bool(berth.starved)
var row_color: Color = UNPAID_COLOR if starved else CELL_COLOR
_cell(str(int(berth.slot)), row_color)
_cell(GameState.vessel_name(String(berth.vessel_type)), row_color)
_cell(_amounts(berth.costs), row_color)
_cell("x%.2f" % float(berth.scale), row_color)
_cell("UNPAID" if starved else "", UNPAID_COLOR)
# The totals row is what #230 asked for: the per-berth lines never added
# up to anything on screen, so the bill had to be done in the head.
_cell("TOTAL", TOTAL_COLOR)
_cell("", TOTAL_COLOR)
_cell(_amounts(totals), TOTAL_COLOR)
_cell("", TOTAL_COLOR)
_cell("", TOTAL_COLOR)
func _cell(text: String, colour: Color) -> void:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 28)
label.add_theme_color_override("font_color", colour)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
sheet.add_child(label)
# In the game's own resource order, not the dictionary's. Iterating the costs
# directly put "2 ore 1 energy" on one row and "2 gas 2 ore" on the next,
# which is most of what a column is for - you cannot read down a table whose
# entries are in a different order on every line.
func _ordered(costs: Dictionary) -> Array:
var out: Array = []
for resource_type in GameState.resource_types():
if costs.has(resource_type):
out.append(resource_type)
# Anything the catalog does not know about still gets shown rather than
# silently dropped.
for resource_type in costs:
if not out.has(resource_type):
out.append(resource_type)
return out
func _amounts(costs: Dictionary) -> String:
var parts: PackedStringArray = PackedStringArray()
for resource_type in _ordered(costs):
parts.append("%d %s" % [int(costs[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
return " ".join(parts) if not parts.is_empty() else "-"
func _rates(per_minute: Dictionary) -> String:
var parts: PackedStringArray = PackedStringArray()
for resource_type in _ordered(per_minute):
parts.append("%.1f %s" % [float(per_minute[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
return " ".join(parts)