#106/#107 (#98a/#98b): the Planning Center

Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.

The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.

The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.

Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.

Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.

The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 14:21:52 +02:00
commit 0c595d775d
12 changed files with 883 additions and 16 deletions

View file

@ -166,6 +166,14 @@ var lab_building_id: int = 0
@onready var garrison_close_button: IconButton = get_node("../UI/GarrisonMenu/CloseButton")
var garrison_building_id: int = 0
# #106: the standing-order board. No building id is remembered because the
# plan is global - there is one plan for the whole game, and a second
# planning centre is another place to read it rather than a competing one.
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
@onready var planning_panel: Control = get_node("../UI/PlanningMenu/Panel")
@onready var planning_status: Label = get_node("../UI/PlanningMenu/StatusLabel")
@onready var planning_close_button: IconButton = get_node("../UI/PlanningMenu/CloseButton")
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
@ -263,6 +271,8 @@ func _ready() -> void:
build_close_button.pressed.connect(_close_build_menu)
garrison_close_button.pressed.connect(_close_garrison_menu)
garrison_panel.changed.connect(_refresh_garrison)
planning_close_button.pressed.connect(_close_planning_menu)
planning_panel.changed.connect(_refresh_planning)
_configure_build_menu_entries()
lab_close_button.pressed.connect(func(): lab_menu.visible = false)
lab_buy_button.pressed.connect(_on_lab_buy)
@ -598,6 +608,9 @@ func _on_action_pressed() -> void:
elif action == "formation":
formation_menu.visible = true
formation_editor.queue_redraw()
elif action == "planning":
planning_menu.visible = true
_refresh_planning()
elif action == "garrison":
garrison_building_id = current_building.building_id
garrison_panel.building_id = garrison_building_id
@ -617,9 +630,17 @@ func _planet_under_fleet() -> Node2D:
return planet_field.get_planet_fleet_is_on()
# Grid geometry, shared with the layout below. Rows of two, and the menu is
# rebuilt from whatever is currently researched (#21) rather than being a
# fixed arrangement with holes punched in it.
# Grid geometry, shared with the layout below. The menu is rebuilt from
# whatever is currently researched (#21) rather than being a fixed
# arrangement with holes punched in it.
#
# Two profiles, because the grid ran out of room. #54 already tightened the
# rows to 223px to fit a fourth, explicitly to avoid a scrollable menu, and a
# ninth structure (#106's planning centre) needs a FIFTH row that will not
# fit at any spacing a button, a label and a cost can share. So past eight
# entries the grid goes to three columns instead, which takes nine into three
# rows - fewer than it uses today, not more. Narrower columns mean narrower
# labels, so those get a taller box to wrap into.
const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0]
const MENU_COL_X: float = 140.0
const MENU_BUTTON_HALF: float = 64.0
@ -629,13 +650,17 @@ const MENU_LABEL_H: float = 52.0
const MENU_COST_HALF: float = 60.0
const MENU_COST_H: float = 44.0
# Gaps from each row's own top to where the label/cost start, sized to the
# shrunk MENU_*_H values above (#54: fitting a 4th row meant tightening
# MENU_ROW_TOPS' spacing to 223px, less than the original 256px a row's
# button+label+cost needed - shrinking those closed the gap without
# resorting to a scrollable menu).
# shrunk MENU_*_H values above.
const MENU_LABEL_OFFSET: float = 106.0
const MENU_COST_OFFSET: float = 162.0
const MENU_WIDE_THRESHOLD: int = 8
const MENU_ROW_TOPS_WIDE: Array = [-330.0, -85.0, 160.0]
const MENU_COL_X_WIDE: float = 224.0
const MENU_LABEL_HALF_WIDE: float = 108.0
const MENU_LABEL_H_WIDE: float = 68.0
const MENU_COST_OFFSET_WIDE: float = 178.0
func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void:
node.offset_left = cx - half
@ -659,16 +684,26 @@ func _layout_build_menu() -> void:
if visible:
shown.append(entry)
var wide: bool = shown.size() > MENU_WIDE_THRESHOLD
var columns: int = 3 if wide else 2
var tops: Array = MENU_ROW_TOPS_WIDE if wide else MENU_ROW_TOPS
var col_x: float = MENU_COL_X_WIDE if wide else MENU_COL_X
var label_half: float = MENU_LABEL_HALF_WIDE if wide else MENU_LABEL_HALF
var label_h: float = MENU_LABEL_H_WIDE if wide else MENU_LABEL_H
var cost_offset: float = MENU_COST_OFFSET_WIDE if wide else MENU_COST_OFFSET
for i in range(shown.size()):
var row: int = i / 2
# Centred when it is the only one on its row, rather than hanging in
# the left column looking like something is missing beside it.
var alone: bool = (i == shown.size() - 1) and (i % 2 == 0)
var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X)
var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)]
var row: int = i / columns
# A short last row is CENTRED rather than left-packed, so it does not
# read as a row with something missing beside it.
var in_row: int = mini(shown.size() - row * columns, columns)
var col: int = i % columns
var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * col_x * 2.0 \
/ maxf(float(columns) - 1.0, 1.0)
var top: float = tops[mini(row, tops.size() - 1)]
_place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H)
_place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, MENU_LABEL_HALF, MENU_LABEL_H)
_place_entry(shown[i][3], cx, top + MENU_COST_OFFSET, MENU_COST_HALF, MENU_COST_H)
_place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h)
_place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, MENU_COST_H)
func _refresh_build_status() -> void:
@ -731,6 +766,25 @@ func _refresh_garrison() -> void:
% [crewed, ordered]
# --- the planning centre's standing orders (#106) ---------------------------
func _close_planning_menu() -> void:
planning_menu.visible = false
func _refresh_planning() -> void:
planning_panel.refresh()
var short: PackedStringArray = PackedStringArray()
for vessel_type in GameState.planned_vessel_types():
var missing: int = GameState.vessel_shortfall(String(vessel_type))
if missing > 0:
short.append("%d %s" % [missing, GameState.vessel_name(String(vessel_type))])
if short.is_empty():
planning_status.text = "Every standing order is met or already being built"
else:
planning_status.text = "Short: %s" % ", ".join(short)
func cost_of(type: String) -> int:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return int(definition.cost) if definition != null else 0