diff --git a/buildings/BuildingDefinitions.gd b/buildings/BuildingDefinitions.gd index cfa4b25..727c905 100644 --- a/buildings/BuildingDefinitions.gd +++ b/buildings/BuildingDefinitions.gd @@ -1,12 +1,13 @@ class_name BuildingDefinitions extends RefCounted -const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "extractor", "transport_hub", "ship_lab", "gas_burner"] +const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "extractor", "transport_hub", "ship_lab", "gas_burner"] const DEFINITIONS: Dictionary = { "mothership": preload("res://buildings/MothershipBuildingDefinition.tres"), "ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"), "command_center": preload("res://buildings/CommandBuildingDefinition.tres"), "outpost": preload("res://buildings/OutpostBuildingDefinition.tres"), + "planning_center": preload("res://buildings/PlanningBuildingDefinition.tres"), "extractor": preload("res://buildings/ExtractorBuildingDefinition.tres"), "transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"), "ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"), diff --git a/buildings/BuildingVisualBase.gd b/buildings/BuildingVisualBase.gd index e0f8d51..b808707 100644 --- a/buildings/BuildingVisualBase.gd +++ b/buildings/BuildingVisualBase.gd @@ -29,6 +29,18 @@ const GAS_BURNER_ENERGY_COLOR: Color = Color(0.85, 0.95, 0.15) # #102. Olive rather than another blue or amber: every existing structure is # a place you go to DO something, and this is the one you leave people at. const OUTPOST_COLOR: Color = Color(0.72, 0.86, 0.45) +# #106. A cool grey-blue: this is the one structure that does no work at all, +# it only decides what work the others do. +const PLANNING_COLOR: Color = Color(0.62, 0.74, 0.92) + +# Planning centre: a standing board with rows of tallies on it, flanked by +# two slim pylons. Rectilinear on purpose - every other structure here is +# radial or trussed, and this one is an office. +const PLAN_BOARD_W: float = 86.0 +const PLAN_BOARD_H: float = 62.0 +const PLAN_ROW_COUNT: int = 4 +const PLAN_PYLON_X: float = 104.0 +const PLAN_PYLON_H: float = 54.0 # Outpost: a watch mast on a tripod footing, with two open berths beside it # where a garrison sits. Deliberately the least machinery of any structure @@ -375,6 +387,41 @@ func _draw_command_center() -> void: NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) +func _draw_planning_center() -> void: + var c: Color = PLANNING_COLOR + var lit: Color = Color(0.88, 0.94, 1.0) + + # The board. + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(-PLAN_BOARD_W, -PLAN_BOARD_H), Vector2(PLAN_BOARD_W, -PLAN_BOARD_H), + Vector2(PLAN_BOARD_W, PLAN_BOARD_H), Vector2(-PLAN_BOARD_W, PLAN_BOARD_H), + ]), c, 1.0) + + # Rows of tallies at differing lengths - a plan being met unevenly, which + # is what the building is for. + var fractions: Array = [0.86, 0.42, 0.68, 0.24] + for i in range(PLAN_ROW_COUNT): + var y: float = lerpf(-PLAN_BOARD_H + 16.0, PLAN_BOARD_H - 16.0, + float(i) / float(PLAN_ROW_COUNT - 1)) + var left: float = -PLAN_BOARD_W + 14.0 + var span: float = (PLAN_BOARD_W - 14.0) * 2.0 + NeonDraw.draw_glowing_line(self, Vector2(left, y), + Vector2(left + span * float(fractions[i]), y), lit, 0.8, 0.34) + NeonDraw.draw_glowing_line(self, Vector2(left + span * float(fractions[i]), y), + Vector2(left + span, y), c, 0.35, 0.22) + + # Pylons holding it up, and a footing bar under the whole thing. + for side in [-1.0, 1.0]: + var x: float = PLAN_PYLON_X * side + NeonDraw.draw_glowing_line(self, Vector2(x, -PLAN_PYLON_H), + Vector2(x, PLAN_PYLON_H), c, 0.75, 0.4) + NeonDraw.draw_glowing_line(self, Vector2(PLAN_BOARD_W * side, 0.0), + Vector2(x, 0.0), c, 0.55, 0.3) + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2(x, -PLAN_PYLON_H), 9.0), c, 0.9) + NeonDraw.draw_glowing_line(self, Vector2(-PLAN_PYLON_X, PLAN_PYLON_H), + Vector2(PLAN_PYLON_X, PLAN_PYLON_H), c, 0.5, 0.3) + + func _draw_outpost() -> void: var c: Color = OUTPOST_COLOR var lit: Color = Color(0.94, 0.99, 0.82) diff --git a/buildings/PlanningBuildingDefinition.tres b/buildings/PlanningBuildingDefinition.tres new file mode 100644 index 0000000..77552af --- /dev/null +++ b/buildings/PlanningBuildingDefinition.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://buildings/PlanningBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "planning_center" +display_name = "Planning Center" +description = "Holds standing orders for the whole game: say how many of each vessel should always exist, and the ship crafters are kept working until they do." +cost = 70 +action_icon = 41 +menu_icon = 41 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("planning") +map_color = Color(0.62, 0.74, 0.92, 1) +map_sides = 4 +map_radius = 7.5 +map_phase = 0.785398 +menu_button_name = "PlanningButton" +menu_label_name = "PlanningLabel" +menu_cost_name = "PlanningCost" +unique = true +max_hp = 80 diff --git a/buildings/PlanningBuildingVisual.gd b/buildings/PlanningBuildingVisual.gd new file mode 100644 index 0000000..20e11fd --- /dev/null +++ b/buildings/PlanningBuildingVisual.gd @@ -0,0 +1,13 @@ +extends "res://buildings/BuildingVisualBase.gd" + +# Drawn once, like the command centre and the outpost: a board of numbers +# does not animate, and per-frame redraws of art that never changes were a +# real framerate cost earlier on. + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_planning_center() diff --git a/buildings/PlanningBuildingVisual.tscn b/buildings/PlanningBuildingVisual.tscn new file mode 100644 index 0000000..b8c5ab8 --- /dev/null +++ b/buildings/PlanningBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://buildings/PlanningBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("planning", "transfer") diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 8c2da46..273de3d 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -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 diff --git a/core/GameState.gd b/core/GameState.gd index 038cbf8..5582079 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -20,6 +20,10 @@ const WORLD_GEN = preload("res://world/WorldGen.gd") var has_return_position: bool = false var surviving_ship_count: int = STARTING_SHIP_COUNT var surviving_vessel_types: PackedStringArray = PackedStringArray() +# #106: a mirror of what is flying, pushed by Fleet._sync_composition_state() +# whenever the composition changes. GameState cannot see scene nodes, and the +# census the Planning Center works from has to include the fleet. +var fleet_vessel_types: PackedStringArray = PackedStringArray() var return_world_position: Vector2 = Vector2.ZERO var return_camera_zoom: float = 1.0 @@ -309,6 +313,8 @@ const BUILDING_COMMAND_CENTER: String = "command_center" # #102: a forward position the fleet can garrison, researched early because # it is the answer to waves going after what you have built. const BUILDING_OUTPOST: String = "outpost" +# #106: where standing orders for the whole fleet are kept. +const BUILDING_PLANNING_CENTER: String = "planning_center" # Extractors are not placed in space like the others - they are built ON a # planet's surface and tracked separately (see `extractors`). const BUILDING_EXTRACTOR: String = "extractor" @@ -382,6 +388,16 @@ const SKILL_TREE: Array = [ "x": 0.66, "outpost_bonus": 1, "research_points": 2, "desc": "Permanent: +1 berth at every outpost, including ones already built.", }, + { + # Deep on purpose, unlike forward_bases: standing orders are a thing + # you want once the fleet is large enough that keeping track of it by + # hand has become work, and it has shipyards behind it because it + # manages what shipyards do. + "id": "logistics_command", "name": "Logistics Command", "cost": 140, + "requires": "vessel_science", "x": 0.94, + "unlocks": BUILDING_PLANNING_CENTER, "research_points": 3, + "desc": "Build a planning center: set how many of each vessel should always exist, and the crafters are kept working until they do.", + }, { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1, @@ -556,6 +572,7 @@ func reset_new_game() -> void: has_return_position = false surviving_ship_count = STARTING_SHIP_COUNT surviving_vessel_types = PackedStringArray() + fleet_vessel_types = PackedStringArray() return_world_position = Vector2.ZERO return_camera_zoom = 1.0 planet_name = "" @@ -576,6 +593,7 @@ func reset_new_game() -> void: extractors.clear() transporters.clear() planet_capture.clear() + vessel_targets.clear() reset_waves() reset_progression() reset_formation() @@ -1397,6 +1415,179 @@ func _return_surplus_garrison(b: Dictionary) -> void: }) +# --- standing orders, and the census behind them (#106) -------------------- +# +# "how many ships of a give type should always be available over the whole +# game (fleet + storage + outposts)". +# +# The targets are GLOBAL, not per building. There is one plan for the whole +# game; a second Planning Center is a second place to read and edit the same +# plan, not a competing one. Storing them per building would mean two +# centres could hold contradictory orders and the crafters would be told to +# satisfy both. +# +# The census is the harder half, and the rule is that every vessel is +# counted exactly once, wherever it happens to be at that instant - 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. + +var vessel_targets: Dictionary = {} + + +func has_planning_center() -> bool: + for b in buildings: + if building_has_capability(b, "planning"): + return true + return false + + +func vessel_target(vessel_type: String) -> int: + return int(vessel_targets.get(vessel_type, 0)) + + +func set_vessel_target(vessel_type: String, count: int) -> int: + if not vessel_is_fleet_eligible(vessel_type): + return 0 + var final_count: int = maxi(count, 0) + if final_count == 0: + vessel_targets.erase(vessel_type) + else: + vessel_targets[vessel_type] = final_count + return final_count + + +func adjust_vessel_target(vessel_type: String, delta: int) -> int: + return set_vessel_target(vessel_type, vessel_target(vessel_type) + delta) + + +func planned_vessel_types() -> PackedStringArray: + var out: PackedStringArray = PackedStringArray() + for definition in VESSEL_DEFINITIONS.all(): + if bool(definition.fleet_eligible): + out.append(String(definition.definition_id)) + return out + + +# How many of this type exist anywhere. Broken out per place rather than +# summed inline so the census can be read - and tested - one location at a +# time. +func vessel_census(vessel_type: String) -> int: + return vessel_count_in_fleet(vessel_type) \ + + vessel_count_in_hangar(vessel_type) \ + + vessel_count_in_garrisons(vessel_type) \ + + vessel_count_in_transit(vessel_type) + + +func vessel_count_in_fleet(vessel_type: String) -> int: + var n: int = 0 + for flying in fleet_vessel_types: + if String(flying) == vessel_type: + n += 1 + return n + + +func vessel_count_in_hangar(vessel_type: String) -> int: + var n: int = 0 + for stored in stored_vessels: + if String(stored) == vessel_type: + n += 1 + return n + + +func vessel_count_in_garrisons(vessel_type: String) -> int: + var n: int = 0 + for id in outpost_ids(): + n += garrison_crew_of(id, vessel_type) + return n + + +# Both directions of flight: a finished hull on its way to the mothership, +# and a vessel being moved to or from an outpost. All of them are vessels +# that exist and will land somewhere. +func vessel_count_in_transit(vessel_type: String) -> int: + var n: int = 0 + for delivery in vessel_deliveries: + if String(delivery.type) == vessel_type: + n += 1 + return n + + +# Ordered but not yet built. Kept separate from the census on purpose: these +# do not exist yet, so a readout has to be able to say "4 of 6, 2 building" +# rather than claiming six. +func vessel_count_on_order(vessel_type: String) -> int: + var n: int = 0 + for b in buildings: + if not can_produce(String(b.type)): + continue + for order in b.get("orders", []): + if String(order.product) == vessel_type: + n += 1 + return n + + +func vessel_shortfall(vessel_type: String) -> int: + return maxi(vessel_target(vessel_type) + - vessel_census(vessel_type) - vessel_count_on_order(vessel_type), 0) + + +# --- the planner working the crafters (#107) ------------------------------- +# +# "the planning center automatically manage the ship craft building queues to +# ensure that the needed vessels are produced to fullfill the constraints." +# +# Three rules keep this from fighting the player: +# +# It TOPS UP, never rewrites. Orders placed by hand are left exactly where +# they are; the planner only ever appends, and only for a type that is +# genuinely short. Cancelling one of the player's orders to make room for +# one of its own would be the game arguing with them. +# +# Everything counts. A vessel already queued, already flying to the +# mothership, or already stationed at an outpost is a vessel that exists or +# shortly will - see vessel_shortfall(). Without that it re-queues the same +# shortfall every tick and buries the player in hulls. +# +# Nothing happens without a planning centre. Destroy it and the standing +# orders stop being acted on, though they are remembered, so rebuilding +# picks up where it left off rather than starting from a blank plan. + +func _crafter_for_planning() -> Dictionary: + # The shortest queue, so orders spread across the crafters that exist + # rather than piling onto whichever happens to be first in the list. + var best: Dictionary = {} + var best_queue: int = 0 + for b in buildings: + if not can_produce(String(b.type)): + continue + var queued: int = (b.get("orders", []) as Array).size() + if best.is_empty() or queued < best_queue: + best = b + best_queue = queued + return best + + +func _advance_planning() -> void: + if vessel_targets.is_empty() or not has_planning_center(): + return + for vessel_type in vessel_targets: + var type_id: String = String(vessel_type) + if not vessel_is_unlocked(type_id): + continue + var missing: int = vessel_shortfall(type_id) + if missing <= 0: + continue + var crafter: Dictionary = _crafter_for_planning() + if crafter.is_empty(): + return + # ONE per pass, not the whole shortfall. The queue is spread across + # crafters by _crafter_for_planning(), and asking for the whole + # shortfall at once would put it all on the shortest queue before + # that had a chance to change. + queue_order(int(crafter.id), type_id) + + func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: var lab: Dictionary = find_building(lab_id) if not building_has_capability(lab, "vessel_research") or upgrade_id == "": @@ -1991,6 +2182,7 @@ func _process(delta: float) -> void: _advance_lab_research(delta) _advance_vessel_deliveries(delta) _advance_garrison_dispatch() + _advance_planning() _advance_resource_converters(delta) _advance_distorder_drain(delta) for e in extractors: diff --git a/tests/test_planning_center.gd b/tests/test_planning_center.gd new file mode 100644 index 0000000..36e81bf --- /dev/null +++ b/tests/test_planning_center.gd @@ -0,0 +1,246 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_planning_center.gd +# Forgejo #106 and #107 (#98a, #98b): the Planning Center. +# +# "let the player tell the system how many ships of a give type should +# always be available over the whole game (fleet + storage + outposts) ... +# the planning center automatically manage the ship craft building queues +# to ensure that the needed vessels are produced to fullfill the +# constraints." +# +# Covers: +# A. the census counts a vessel exactly once wherever it is - flying, +# stored, stationed at an outpost, or in the air between any two +# B. a shortfall is queued at a crafter, and queued vessels count, so it +# does not re-order the same shortfall every tick +# C. it tops up rather than rewriting: an order placed by hand survives, +# and the planner appends beside it +# D. it stops when the target is met, and starts again when one is lost +# E. nothing happens without a planning centre, but the plan is remembered +# across losing and rebuilding one +# F. the panel drives the real targets through the real buttons + +var game_state: Node +var scene: Node +var controller: Node +var menu: Control +var panel: Control +var crafter_id: int = 0 +var outpost_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.vessel_targets.clear() + game_state.fleet_vessel_types = PackedStringArray() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append_array(["shipyards", "coordination", "forward_bases", + "vessel_science", "logistics_command"]) + + +func _fail(message: String) -> bool: + print("TEST_PLANNING_CENTER: FAIL (%s)" % message) + quit(1) + return true + + +func _queued_at_crafter() -> int: + return game_state.pending_orders_of(crafter_id, GameState.VESSEL_INTERCEPTOR) + + +func _state_cases() -> bool: + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + crafter_id = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0)) + outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(-900.0, 0.0)) + var interceptor: String = GameState.VESSEL_INTERCEPTOR + + # --- A. one vessel, one count, wherever it is ------------------------ + if game_state.vessel_census(interceptor) != 0: + return _fail("an empty game counts %d vessels" % game_state.vessel_census(interceptor)) + game_state.fleet_vessel_types = PackedStringArray([interceptor, interceptor]) + game_state.stored_vessels.append(interceptor) + game_state.set_garrison_desired(outpost_id, interceptor, 1) + game_state.garrison_crew(outpost_id).append(interceptor) + game_state.vessel_deliveries.append({"type": interceptor, "from": Vector2.ZERO, + "to": Vector2(400.0, 0.0), "progress": 0.0, "to_outpost": 0}) + if game_state.vessel_count_in_fleet(interceptor) != 2: + return _fail("the fleet reads %d" % game_state.vessel_count_in_fleet(interceptor)) + if game_state.vessel_count_in_hangar(interceptor) != 1: + return _fail("the hangar reads %d" % game_state.vessel_count_in_hangar(interceptor)) + if game_state.vessel_count_in_garrisons(interceptor) != 1: + return _fail("the garrisons read %d" % game_state.vessel_count_in_garrisons(interceptor)) + if game_state.vessel_count_in_transit(interceptor) != 1: + return _fail("in transit reads %d" % game_state.vessel_count_in_transit(interceptor)) + if game_state.vessel_census(interceptor) != 5: + return _fail("2 flying + 1 stored + 1 stationed + 1 moving counted as %d" \ + % game_state.vessel_census(interceptor)) + print("census: 2 flying, 1 stored, 1 stationed, 1 moving = %d" \ + % game_state.vessel_census(interceptor)) + + # --- E (first half). no centre, no action --------------------------- + game_state.set_vessel_target(interceptor, 8) + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != 0: + return _fail("orders were placed with no planning centre built") + print("with no planning centre, a standing order does nothing") + + game_state.add_building(GameState.BUILDING_PLANNING_CENTER, Vector2(900.0, 0.0)) + if not game_state.has_planning_center(): + return _fail("the planning centre was built but is not recognised") + + # --- B. the shortfall is queued, once ------------------------------ + if game_state.vessel_shortfall(interceptor) != 3: + return _fail("a target of 8 against 5 reads a shortfall of %d" \ + % game_state.vessel_shortfall(interceptor)) + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != 3: + return _fail("20 passes at a shortfall of 3 queued %d orders" % _queued_at_crafter()) + if game_state.vessel_shortfall(interceptor) != 0: + return _fail("the shortfall is still %d with 3 on the slipway" \ + % game_state.vessel_shortfall(interceptor)) + print("a shortfall of 3 queued exactly 3, and 20 more passes added none") + + # --- C. hand-placed orders are left alone --------------------------- + game_state.queue_order(crafter_id, GameState.VESSEL_SHIELD) + var by_hand: int = game_state.pending_orders_of(crafter_id, GameState.VESSEL_SHIELD) + game_state.set_vessel_target(interceptor, 10) + for i in range(20): + game_state._advance_planning() + if game_state.pending_orders_of(crafter_id, GameState.VESSEL_SHIELD) != by_hand: + return _fail("the planner cancelled a shield the player ordered by hand") + if _queued_at_crafter() != 5: + return _fail("raising the target to 10 left %d interceptors queued" % _queued_at_crafter()) + print("a hand-placed shield survives; the planner appended beside it") + + # --- D. it stops, and it reacts to a loss --------------------------- + game_state.set_vessel_target(interceptor, 5) + var queued_before: int = _queued_at_crafter() + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != queued_before: + return _fail("lowering the target changed the queue by %d" \ + % (_queued_at_crafter() - queued_before)) + # Losing one from the fleet is a real shortfall again. + game_state.set_vessel_target(interceptor, 12) + var before_loss: int = _queued_at_crafter() + for i in range(20): + game_state._advance_planning() + var after_top_up: int = _queued_at_crafter() + game_state.fleet_vessel_types = PackedStringArray([interceptor]) + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() <= after_top_up: + return _fail("losing a vessel did not put a replacement on the slipway") + print("target met -> nothing queued; a vessel lost -> %d queued (was %d)" \ + % [_queued_at_crafter() - after_top_up, before_loss]) + + # --- E (second half). the plan outlives the building ---------------- + for b in game_state.buildings: + if game_state.building_has_capability(b, "planning"): + game_state.destroy_building(int(b.id)) + break + if game_state.has_planning_center(): + return _fail("the planning centre survived being destroyed") + if game_state.vessel_target(interceptor) != 12: + return _fail("losing the building erased the plan") + var frozen: int = _queued_at_crafter() + game_state.fleet_vessel_types = PackedStringArray() + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != frozen: + return _fail("the planner kept working after its building was destroyed") + print("destroyed: the plan is remembered, and nothing acts on it") + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _state_cases(): + return true + game_state.buildings.clear() + game_state.vessel_targets.clear() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + menu = scene.get_node("UI/PlanningMenu") + panel = scene.get_node("UI/PlanningMenu/Panel") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + scene.get_node("BuildingField").place(GameState.BUILDING_PLANNING_CENTER, + Vector2(900.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- F. the real panel, through the real buttons ------------- + var node: Node2D = null + for b in scene.get_node("BuildingField").spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_PLANNING_CENTER: + node = b + if node == null: + return _fail("no planning centre node was placed") + if node.primary_action() != "planning": + return _fail("its primary action is \"%s\"" % node.primary_action()) + controller.current_building = node + controller._on_action_pressed() + if not menu.visible: + return _fail("the planning centre did not open its menu") + + var row: Dictionary = {} + for candidate in panel._rows: + if String(candidate.type) == GameState.VESSEL_INTERCEPTOR: + row = candidate + if row.is_empty(): + return _fail("the panel has no row for the interceptor") + row.plus.pressed.emit() + row.plus.pressed.emit() + row.plus.pressed.emit() + if game_state.vessel_target(GameState.VESSEL_INTERCEPTOR) != 3: + return _fail("three presses of + set a target of %d" \ + % game_state.vessel_target(GameState.VESSEL_INTERCEPTOR)) + row.minus.pressed.emit() + if game_state.vessel_target(GameState.VESSEL_INTERCEPTOR) != 2: + return _fail("- did not lower the target") + if not row.count.text.ends_with("/2"): + return _fail("the row reads \"%s\" - it should show have over want" % row.count.text) + # The note is what makes an unmet plan legible: it says where the + # vessels actually are. + if row.note.text.strip_edges() == "" and game_state.vessel_census( + GameState.VESSEL_INTERCEPTOR) > 0: + return _fail("vessels exist but the row says nothing about where they are") + print("the panel drives the real plan: + + + - leaves \"%s\" (%s)" \ + % [row.count.text, row.note.text]) + + var status: String = scene.get_node("UI/PlanningMenu/StatusLabel").text + if status.strip_edges() == "": + return _fail("the planning menu reports nothing at all") + print("status: \"%s\"" % status) + + print("TEST_PLANNING_CENTER: PASS") + quit(0) + return true + return false diff --git a/ui/IconButton.gd b/ui/IconButton.gd index 83e4991..0cf165f 100644 --- a/ui/IconButton.gd +++ b/ui/IconButton.gd @@ -53,6 +53,8 @@ enum Icon { # #102: appended, like everything above - inserting anywhere earlier # renumbers every icon int already stored in every scene. OUTPOST, + # #106: appended, same rule. + PLANNING_CENTER, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -296,6 +298,8 @@ func _draw() -> void: _draw_distorder_vessel(center, pulse, s) Icon.OUTPOST: _draw_outpost_icon(center, pulse, s) + Icon.PLANNING_CENTER: + _draw_planning_icon(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -664,6 +668,26 @@ func _draw_ship_lab_icon(center: Vector2, pulse: float, s: float) -> void: _active_color(), pulse, 0.35 * s, core_color) +func _draw_planning_icon(center: Vector2, pulse: float, s: float) -> void: + # A tally board: a frame with three bars of different lengths inside it. + # Nothing else in the set is rectilinear and horizontal, and the meaning + # wanted is "how many of each", not "a place that builds things" - the + # crafter's cradle already owns that. + var w: float = 12.0 * s + var h: float = 11.0 * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + center + Vector2(-w, -h), center + Vector2(w, -h), + center + Vector2(w, h), center + Vector2(-w, h), + ]), _active_color(), pulse, core_color) + var lengths: Array = [0.82, 0.5, 0.66] + for i in range(lengths.size()): + var y: float = center.y + (float(i) - 1.0) * 6.0 * s + var left: float = center.x - w + 3.0 * s + NeonDraw.draw_glowing_line(self, Vector2(left, y), + Vector2(left + (2.0 * w - 6.0 * s) * float(lengths[i]), y), + _active_color(), pulse, 0.4 * s, core_color) + + func _draw_outpost_icon(center: Vector2, pulse: float, s: float) -> void: # A watchtower with two vessels holding station off it. The hub's # octagon already means "things are stationed here and go out from diff --git a/ui/PlanningPanel.gd b/ui/PlanningPanel.gd new file mode 100644 index 0000000..935367b --- /dev/null +++ b/ui/PlanningPanel.gd @@ -0,0 +1,150 @@ +extends Control + +# #106 (#98a): where the player says how many of each vessel should always +# exist. +# +# Like GarrisonPanel it builds its own rows from the catalog, so a new +# fleet-eligible vessel gets a row the day it is added without touching the +# scene. Unlike GarrisonPanel the targets are GLOBAL - there is one plan for +# the whole game, and a second planning centre is another place to read the +# same plan rather than a competing one. +# +# Each row reads "have / want" with what is being built alongside it, so a +# plan that is merely waiting on a production queue is distinguishable from +# one nothing is being done about. That distinction is the whole readout: +# without it a player looking at "2 / 5" cannot tell whether the system is +# working. + +signal changed + +const ROW_HEIGHT: float = 84.0 +const ROW_GAP: float = 8.0 +const BUTTON_SIZE: float = 68.0 +const NAME_FONT_SIZE: int = 28 +const COUNT_FONT_SIZE: int = 30 +const NOTE_FONT_SIZE: int = 20 +const HEADER_FONT_SIZE: int = 22 + +var _rows: Array = [] +var _header: Label + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + _header = _make_label("", HEADER_FONT_SIZE, Color(0.62, 0.78, 0.86)) + _header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + for vessel_type in GameState.planned_vessel_types(): + _rows.append(_make_row(String(vessel_type))) + resized.connect(_layout) + _layout() + + +func _make_label(text_value: String, font_size: int, colour: Color) -> Label: + var label := Label.new() + label.text = text_value + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", colour) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.01, 0.035, 0.95)) + label.add_theme_constant_override("outline_size", 2) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(label) + return label + + +func _make_row(vessel_type: String) -> Dictionary: + var definition: Resource = GameState.vessel_definition(vessel_type) + var colour: Color = definition.glow_color if definition != null else Color.WHITE + + var name_label: Label = _make_label(GameState.vessel_name(vessel_type), + NAME_FONT_SIZE, colour) + var note_label: Label = _make_label("", NOTE_FONT_SIZE, Color(0.62, 0.78, 0.86)) + var count_label: Label = _make_label("0/0", COUNT_FONT_SIZE, Color(0.94, 0.98, 1.0)) + count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + var minus := IconButton.new() + minus.icon = IconButton.Icon.MINUS + minus.glow_color = colour + minus.pressed.connect(_on_adjust.bind(vessel_type, -1)) + add_child(minus) + + var plus := IconButton.new() + plus.icon = IconButton.Icon.PLUS + plus.glow_color = colour + plus.pressed.connect(_on_adjust.bind(vessel_type, 1)) + add_child(plus) + + return {"type": vessel_type, "name": name_label, "note": note_label, + "count": count_label, "minus": minus, "plus": plus} + + +func _on_adjust(vessel_type: String, delta: int) -> void: + GameState.adjust_vessel_target(vessel_type, delta) + refresh() + changed.emit() + + +func _layout() -> void: + if _rows.is_empty(): + return + _header.position = Vector2.ZERO + _header.size = Vector2(size.x, 40.0) + var y: float = 46.0 + for row in _rows: + row.name.position = Vector2(0.0, y) + row.name.size = Vector2(size.x * 0.44, ROW_HEIGHT * 0.58) + row.note.position = Vector2(0.0, y + ROW_HEIGHT * 0.54) + row.note.size = Vector2(size.x * 0.52, ROW_HEIGHT * 0.42) + var right: float = size.x + row.plus.position = Vector2(right - BUTTON_SIZE, y + (ROW_HEIGHT - BUTTON_SIZE) * 0.5) + row.plus.size = Vector2(BUTTON_SIZE, BUTTON_SIZE) + row.count.position = Vector2(right - BUTTON_SIZE * 3.2, y) + row.count.size = Vector2(BUTTON_SIZE * 2.2, ROW_HEIGHT) + row.minus.position = Vector2(right - BUTTON_SIZE * 4.2, + y + (ROW_HEIGHT - BUTTON_SIZE) * 0.5) + row.minus.size = Vector2(BUTTON_SIZE, BUTTON_SIZE) + y += ROW_HEIGHT + ROW_GAP + + +func natural_height() -> float: + return 46.0 + float(_rows.size()) * (ROW_HEIGHT + ROW_GAP) + + +func refresh() -> void: + if _rows.is_empty(): + return + var planned: int = 0 + for vessel_type in GameState.vessel_targets: + planned += int(GameState.vessel_targets[vessel_type]) + _header.text = "STANDING ORDERS %d VESSELS" % planned + for row in _rows: + var vessel_type: String = String(row.type) + var want: int = GameState.vessel_target(vessel_type) + var have: int = GameState.vessel_census(vessel_type) + var building: int = GameState.vessel_count_on_order(vessel_type) + row.count.text = "%d/%d" % [have, want] + # Where they are, not merely how many: a plan can look unmet while + # every vessel it asked for is sitting at an outpost. + var parts: PackedStringArray = PackedStringArray() + var flying: int = GameState.vessel_count_in_fleet(vessel_type) + var stored: int = GameState.vessel_count_in_hangar(vessel_type) + var stationed: int = GameState.vessel_count_in_garrisons(vessel_type) + var moving: int = GameState.vessel_count_in_transit(vessel_type) + if flying > 0: + parts.append("%d flying" % flying) + if stored > 0: + parts.append("%d stored" % stored) + if stationed > 0: + parts.append("%d stationed" % stationed) + if moving > 0: + parts.append("%d moving" % moving) + if building > 0: + parts.append("%d building" % building) + row.note.text = ", ".join(parts) + var unlocked: bool = GameState.vessel_is_unlocked(vessel_type) + row.name.modulate.a = 1.0 if unlocked else 0.45 + row.minus.enabled = unlocked and want > 0 + row.plus.enabled = unlocked + row.minus.queue_redraw() + row.plus.queue_redraw() diff --git a/vessels/Fleet.gd b/vessels/Fleet.gd index 0ba61f4..dd45d00 100644 --- a/vessels/Fleet.gd +++ b/vessels/Fleet.gd @@ -96,6 +96,13 @@ func vessel_types() -> PackedStringArray: func _sync_composition_state() -> void: + # #106: the Planning Center counts vessels across the whole game, and + # the fleet is the one place it cannot see - the ships are scene nodes + # and GameState outlives the scene. Pushed here rather than polled, + # alongside the cargo capacity and distorder count that already are, + # and for the same reason: composition changes are authoritative, and + # this is the one function every one of them goes through. + GameState.fleet_vessel_types = vessel_types() GameState.sync_fleet_cargo_capacity(vessel_types()) # Composition changes are authoritative; scene teardown is not. Keeping # this out of SpeedFader prevents an outgoing scene's empty fleet from diff --git a/world/Main.tscn b/world/Main.tscn index 31b6244..9f30978 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -23,6 +23,7 @@ [ext_resource type="Script" path="res://ui/WaveAlert.gd" id="32"] [ext_resource type="Script" path="res://ui/GarrisonPanel.gd" id="33"] [ext_resource type="Script" path="res://vessels/GarrisonField.gd" id="34"] +[ext_resource type="Script" path="res://ui/PlanningPanel.gd" id="35"] [ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"] @@ -766,6 +767,48 @@ script = ExtResource("19") amount = 30 digit_height = 40.0 +[node name="PlanningButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 1090 +offset_bottom = 1218 +script = ExtResource("10") +icon = 41 +glow_color = Color(0.62, 0.74, 0.92, 1) + +[node name="PlanningLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -150 +offset_right = 150 +offset_top = 1224 +offset_bottom = 1290 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Planning Center" +horizontal_alignment = 1 + +[node name="PlanningCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 1294 +offset_bottom = 1346 +script = ExtResource("19") +amount = 70 +digit_height = 40.0 + [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 anchor_right = 1.0 @@ -2107,6 +2150,63 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) +[node name="PlanningMenu" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/PlanningMenu"] +mouse_filter = 2 +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/PlanningMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 20.0 +offset_right = -200.0 +offset_bottom = 82.0 +text = "Planning Center" +horizontal_alignment = 1 + +[node name="Panel" type="Control" parent="UI/PlanningMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 40.0 +offset_top = 120.0 +offset_right = -40.0 +offset_bottom = -180.0 +anchor_bottom = 1.0 +script = ExtResource("35") + +[node name="StatusLabel" type="Label" parent="UI/PlanningMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = -160.0 +offset_right = -32.0 +offset_bottom = -24.0 +theme_override_font_sizes/font_size = 28 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="CloseButton" type="Control" parent="UI/PlanningMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_top = 20.0 +offset_right = -24.0 +offset_bottom = 136.0 +script = ExtResource("10") +icon = 1 +glow_color = Color(0.62, 0.74, 0.92, 1) + [node name="GarrisonMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2