diff --git a/buildings/BuildingDefinitions.gd b/buildings/BuildingDefinitions.gd index 972cd6a..cfa4b25 100644 --- a/buildings/BuildingDefinitions.gd +++ b/buildings/BuildingDefinitions.gd @@ -1,11 +1,12 @@ class_name BuildingDefinitions extends RefCounted -const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab", "gas_burner"] +const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "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"), "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 1c0949e..e0f8d51 100644 --- a/buildings/BuildingVisualBase.gd +++ b/buildings/BuildingVisualBase.gd @@ -26,6 +26,22 @@ const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85) const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9) const GAS_BURNER_COLOR: Color = Color(0.95, 0.35, 0.15) 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) + +# 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 +# here - it makes nothing and refines nothing, it just holds a position - +# and deliberately not radial, so it cannot be mistaken for a small hub. +const OUTPOST_MAST_TOP: float = -84.0 +const OUTPOST_MAST_FOOT: float = 46.0 +const OUTPOST_HEAD_RADIUS: float = 20.0 +const OUTPOST_FOOT_SPAN: float = 62.0 +const OUTPOST_FOOT_DROP: float = 26.0 +const OUTPOST_BERTH_X: float = 74.0 +const OUTPOST_BERTH_RADIUS: float = 22.0 +const OUTPOST_VANE_COUNT: int = 3 # Ship lab: a test frame with a vessel held in it and instruments sweeping # over it. Reads as a workshop rather than a factory - the crafter already @@ -359,6 +375,47 @@ func _draw_command_center() -> void: NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) +func _draw_outpost() -> void: + var c: Color = OUTPOST_COLOR + var lit: Color = Color(0.94, 0.99, 0.82) + + # Tripod footing. + var foot: Vector2 = Vector2(0.0, OUTPOST_MAST_FOOT) + for side in [-1.0, 0.0, 1.0]: + var toe: Vector2 = Vector2(OUTPOST_FOOT_SPAN * side, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP) + NeonDraw.draw_glowing_line(self, foot, toe, c, 0.7, 0.42) + NeonDraw.draw_glowing_line(self, + Vector2(-OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), + Vector2(OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), c, 0.55, 0.34) + + # The mast, with rungs so it reads as climbable structure rather than a + # drawn line. + var top: Vector2 = Vector2(0.0, OUTPOST_MAST_TOP) + NeonDraw.draw_glowing_line(self, foot, top, c, 0.9, 0.5) + for i in range(4): + var y: float = lerpf(OUTPOST_MAST_FOOT, OUTPOST_MAST_TOP + OUTPOST_HEAD_RADIUS, + float(i + 1) / 5.0) + NeonDraw.draw_glowing_line(self, Vector2(-11.0, y), Vector2(11.0, y), c, 0.45, 0.28) + + # Sensor head: a lit diamond inside a cage, with vanes looking outward. + NeonDraw.draw_glowing_polygon(self, _ring(top, OUTPOST_HEAD_RADIUS, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _diamond(top, OUTPOST_HEAD_RADIUS * 0.5), lit, 0.95) + for i in range(OUTPOST_VANE_COUNT): + var a: float = PI + (float(i) / float(OUTPOST_VANE_COUNT - 1)) * PI + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(self, top + dir * OUTPOST_HEAD_RADIUS, + top + dir * (OUTPOST_HEAD_RADIUS + 16.0), c, 0.7, 0.3) + + # Two open berths. Open, not enclosed: what sits in them belongs to the + # player and flies away, so they are cradles rather than hangars. + for side in [-1.0, 1.0]: + var berth: Vector2 = Vector2(OUTPOST_BERTH_X * side, 4.0) + NeonDraw.draw_glowing_path(self, _arc(berth, OUTPOST_BERTH_RADIUS, + -PI * 0.5, PI * 0.5, 10), c, 0.75, 0.36) + NeonDraw.draw_glowing_line(self, Vector2(12.0 * side, 4.0), + berth - Vector2(OUTPOST_BERTH_RADIUS * side, 0.0), c, 0.6, 0.34) + + # --- mothership: a radial flagship ---------------------------------------- # Not a big fighter. A mothership is the thing everything else orbits, so it # is built radially around a reactor core rather than pointed in a direction: diff --git a/buildings/OutpostBuildingDefinition.tres b/buildings/OutpostBuildingDefinition.tres new file mode 100644 index 0000000..18d29e1 --- /dev/null +++ b/buildings/OutpostBuildingDefinition.tres @@ -0,0 +1,20 @@ +[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/OutpostBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "outpost" +display_name = "Outpost" +description = "A forward position for the fleet to garrison. Vessels stationed here hold station and fight what comes at them, without you flying back." +cost = 30 +action_icon = 40 +menu_icon = 40 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("garrison") +map_color = Color(0.72, 0.86, 0.45, 1) +map_sides = 3 +map_radius = 7.0 +menu_button_name = "OutpostButton" +menu_label_name = "OutpostLabel" +menu_cost_name = "OutpostCost" +max_hp = 120 diff --git a/buildings/OutpostBuildingVisual.gd b/buildings/OutpostBuildingVisual.gd new file mode 100644 index 0000000..37beb3f --- /dev/null +++ b/buildings/OutpostBuildingVisual.gd @@ -0,0 +1,13 @@ +extends "res://buildings/BuildingVisualBase.gd" + +# Drawn once and never redrawn, like the command centre: nothing about a +# watch mast animates, 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_outpost() diff --git a/buildings/OutpostBuildingVisual.tscn b/buildings/OutpostBuildingVisual.tscn new file mode 100644 index 0000000..812c7dd --- /dev/null +++ b/buildings/OutpostBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://buildings/OutpostBuildingVisual.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("garrison", "transfer") diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 7282508..8c2da46 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -156,6 +156,16 @@ var lab_building_id: int = 0 get_node("../UI/LabMenu/Bar"), ] +# #103: the outpost's own panel. garrison_building_id is remembered for the +# same reason craft_building_id is - _process() clears current_building while +# a menu is open, so by the next frame the panel would not know which outpost +# it belongs to and every button in it would bail out at its `== 0` guard. +@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu") +@onready var garrison_panel: Control = get_node("../UI/GarrisonMenu/Panel") +@onready var garrison_status: Label = get_node("../UI/GarrisonMenu/StatusLabel") +@onready var garrison_close_button: IconButton = get_node("../UI/GarrisonMenu/CloseButton") +var garrison_building_id: int = 0 + @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") @@ -251,6 +261,8 @@ func _ready() -> void: _layout_hangar() build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) + garrison_close_button.pressed.connect(_close_garrison_menu) + garrison_panel.changed.connect(_refresh_garrison) _configure_build_menu_entries() lab_close_button.pressed.connect(func(): lab_menu.visible = false) lab_buy_button.pressed.connect(_on_lab_buy) @@ -586,6 +598,11 @@ func _on_action_pressed() -> void: elif action == "formation": formation_menu.visible = true formation_editor.queue_redraw() + elif action == "garrison": + garrison_building_id = current_building.building_id + garrison_panel.building_id = garrison_building_id + garrison_menu.visible = true + _refresh_garrison() func _on_build_pressed() -> void: @@ -691,6 +708,29 @@ func _close_build_menu() -> void: build_menu.visible = false +# --- the outpost's garrison panel (#103) ----------------------------------- + +func _close_garrison_menu() -> void: + garrison_menu.visible = false + garrison_building_id = 0 + + +func _refresh_garrison() -> void: + if garrison_building_id == 0: + return + garrison_panel.building_id = garrison_building_id + garrison_panel.refresh() + var ordered: int = GameState.garrison_desired_total(garrison_building_id) + var crewed: int = GameState.garrison_crew(garrison_building_id).size() + if ordered == 0: + garrison_status.text = "No vessels ordered. Only armed vessels can hold a position." + elif crewed >= ordered: + garrison_status.text = "Garrison at full strength" + else: + garrison_status.text = "%d of %d berths crewed - vessels are sent from the mothership as they become available" \ + % [crewed, ordered] + + 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 2f05648..72d0407 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -306,6 +306,9 @@ const SHIP_COST: int = 6 const BUILDING_MOTHERSHIP: String = "mothership" const BUILDING_SHIP_CRAFTER: String = "ship_crafter" 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" # 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" @@ -362,6 +365,23 @@ const SKILL_TREE: Array = [ "x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1, "desc": "Build command centers, where the fleet formation is arranged.", }, + { + # Early on purpose (#97: "pretty early in the tech tree"). It hangs off + # coordination rather than off a deep branch, so the answer to waves + # attacking your structures is available at about the time the waves + # start being able to hurt them. + "id": "forward_bases", "name": "Forward Bases", "cost": 30, "requires": "coordination", + "x": 0.66, "unlocks": BUILDING_OUTPOST, "research_points": 1, + "desc": "Build outposts: forward positions vessels can be stationed at, to hold ground you are not standing on.", + }, + { + # The same shape as freight_yards for hubs: a permanent bonus that + # applies to outposts already standing, not only to new ones, so + # researching it never means demolishing and rebuilding. + "id": "garrison_bays", "name": "Garrison Bays", "cost": 110, "requires": "forward_bases", + "x": 0.66, "outpost_bonus": 1, "research_points": 2, + "desc": "Permanent: +1 berth at every outpost, including ones already built.", + }, { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1, @@ -728,6 +748,138 @@ func destroy_building(id: int) -> bool: return true +# --- outpost garrisons (#103) ---------------------------------------------- +# +# Two separate things live on an outpost's record, and conflating them was +# the first thing to get wrong here: +# +# garrison what the player ASKED for - {vessel_type: count}. Set in +# the outpost's own menu and otherwise untouched. +# garrison_crew who is actually THERE - a list of vessel types. #104 +# fills it from the mothership; nothing else writes it. +# +# Keeping the order as a wish rather than as an inventory is what lets an +# outpost ask for a vessel nobody has yet and simply wait, instead of the +# request evaporating. + +const GARRISON_BASE_SLOTS: int = 2 +# Which vessels can hold a position. #97: "Only ships that attack can be +# assigned" - so a shield vessel, a hauler or an engine block cannot, however +# much the player might want the shield out there. Written as capabilities +# rather than as a list of types, so a new armed vessel is eligible the day +# it is added and an unarmed one never is by accident. +const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser"] + + +func outpost_slot_capacity() -> int: + var n: int = GARRISON_BASE_SLOTS + for id in unlocked: + n += int(skill_node(id).get("outpost_bonus", 0)) + return n + + +func vessel_can_garrison(vessel_type: String) -> bool: + var definition: Resource = vessel_definition(vessel_type) + if definition == null or not definition.fleet_eligible: + return false + for capability in GARRISON_CAPABILITIES: + if capability in definition.capabilities: + return true + return false + + +func garrison_vessel_types() -> PackedStringArray: + var out: PackedStringArray = PackedStringArray() + for definition in VESSEL_DEFINITIONS.all(): + if vessel_can_garrison(String(definition.definition_id)): + out.append(String(definition.definition_id)) + return out + + +func is_outpost(building: Dictionary) -> bool: + return building_has_capability(building, "garrison") + + +func garrison_desired(id: int) -> Dictionary: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b): + return {} + if not b.has("garrison"): + b.garrison = {} + return b.garrison + + +func garrison_desired_total(id: int) -> int: + var n: int = 0 + for count in garrison_desired(id).values(): + n += int(count) + return n + + +func garrison_desired_of(id: int, vessel_type: String) -> int: + return int(garrison_desired(id).get(vessel_type, 0)) + + +# Returns the count actually set, so the caller can show what happened when +# the request was clamped rather than silently doing something else. +func set_garrison_desired(id: int, vessel_type: String, count: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b) or not vessel_can_garrison(vessel_type): + return 0 + var wanted: Dictionary = garrison_desired(id) + var others: int = garrison_desired_total(id) - int(wanted.get(vessel_type, 0)) + var room: int = maxi(outpost_slot_capacity() - others, 0) + var final_count: int = clampi(count, 0, room) + if final_count <= 0: + wanted.erase(vessel_type) + else: + wanted[vessel_type] = final_count + return final_count + + +func adjust_garrison_desired(id: int, vessel_type: String, delta: int) -> int: + return set_garrison_desired(id, vessel_type, + garrison_desired_of(id, vessel_type) + delta) + + +func garrison_crew(id: int) -> Array: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b): + return [] + if not b.has("garrison_crew"): + b.garrison_crew = [] + return b.garrison_crew + + +func garrison_crew_of(id: int, vessel_type: String) -> int: + var n: int = 0 + for crewed in garrison_crew(id): + if String(crewed) == vessel_type: + n += 1 + return n + + +# What this outpost is still short of, as {vessel_type: count}. The one +# question #104 has to answer every tick, so it is asked here rather than +# recomputed by whoever needs it. +func garrison_shortfall(id: int) -> Dictionary: + var out: Dictionary = {} + for vessel_type in garrison_desired(id): + var missing: int = int(garrison_desired(id)[vessel_type]) \ + - garrison_crew_of(id, String(vessel_type)) + if missing > 0: + out[vessel_type] = missing + return out + + +func outpost_ids() -> Array: + var out: Array = [] + for b in buildings: + if is_outpost(b): + out.append(int(b.id)) + return out + + func building_has_capability(building: Dictionary, capability: String) -> bool: if building.is_empty(): return false diff --git a/tests/test_outpost_building.gd b/tests/test_outpost_building.gd new file mode 100644 index 0000000..4bcda0b --- /dev/null +++ b/tests/test_outpost_building.gd @@ -0,0 +1,146 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_outpost_building.gd +# Forgejo #102 (#97d): "A new building should be available, pretty early in +# the tech tree, to create outposts." +# +# Covers: +# A. the outpost is in the building catalog, with its own visual, its own +# icon and its own map colour - not a reused one +# B. a skill node unlocks it, and it is EARLY: shallower than the deep +# branches, and reachable for less than the mid-game structures cost +# C. it is hidden from the build menu until that node is researched, and +# appears afterwards, like every other gated structure +# D. it can be placed, it stands up in the world, and it has enough health +# to be worth attacking - it exists to be shot at + +const MAX_EARLY_DEPTH: int = 2 + +var game_state: Node +var scene: Node +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.reset_progression() + game_state.reset_waves() + game_state.rocks = 5000 + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_BUILDING: FAIL (%s)" % message) + quit(1) + return true + + +func _catalog_cases() -> bool: + var definitions: Script = load("res://buildings/BuildingDefinitions.gd") + var outpost: Resource = definitions.get_definition(GameState.BUILDING_OUTPOST) + if outpost == null: + return _fail("no \"outpost\" definition in the catalog") + if outpost.visual_scene == null: + return _fail("the outpost has no visual - it would be an invisible structure") + if outpost.menu_button_name == "": + return _fail("the outpost declares no build-menu entry, so it could never be built") + # --- A. its own look ------------------------------------------------- + for other in definitions.all(): + if other.definition_id == GameState.BUILDING_OUTPOST: + continue + if other.menu_icon == outpost.menu_icon: + return _fail("the outpost shares its icon with the %s" % other.definition_id) + if other.visual_scene == outpost.visual_scene: + return _fail("the outpost shares its visual with the %s" % other.definition_id) + if other.map_color.is_equal_approx(outpost.map_color): + return _fail("the outpost shares its map colour with the %s" % other.definition_id) + print("the outpost has its own icon, visual and map colour") + + # --- B. researched, and early ---------------------------------------- + var node_name: String = game_state.research_for_building(GameState.BUILDING_OUTPOST) + if node_name == "": + return _fail("nothing in the skill tree unlocks the outpost") + var node_id: String = "" + for node in GameState.SKILL_TREE: + if node.get("unlocks", "") == GameState.BUILDING_OUTPOST: + node_id = String(node.id) + var depth: int = game_state.skill_depth(node_id) + if depth > MAX_EARLY_DEPTH: + return _fail("\"%s\" sits %d prerequisites deep - that is not early" % [node_name, depth]) + var cost: int = int(game_state.skill_node(node_id).cost) + for node in GameState.SKILL_TREE: + if String(node.get("unlocks", "")) in [GameState.BUILDING_TRANSPORT_HUB, + GameState.BUILDING_SHIP_LAB, GameState.BUILDING_GAS_BURNER]: + if int(node.cost) <= cost: + return _fail("\"%s\" costs %d, no less than the mid-game %s at %d" \ + % [node_name, cost, node.name, node.cost]) + print("\"%s\" is %d deep and costs %d ore - earlier and cheaper than the mid-game structures" \ + % [node_name, depth, cost]) + + # --- C. gated until researched --------------------------------------- + if game_state.is_building_unlocked(GameState.BUILDING_OUTPOST): + return _fail("the outpost is buildable before its node is researched") + game_state.unlocked.append(node_id) + if not game_state.is_building_unlocked(GameState.BUILDING_OUTPOST): + return _fail("researching \"%s\" did not unlock the outpost" % node_name) + print("hidden until \"%s\" is researched, then offered" % node_name) + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _catalog_cases(): + return true + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + 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) + # --- C (continued). the menu shows it ------------------------ + var controller: Node = scene.get_node("BuildController") + var button: Control = scene.get_node("UI/BuildMenu/OutpostButton") + controller._layout_build_menu() + if not button.visible: + return _fail("the outpost is researched but its build-menu button is hidden") + scene.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- D. it stands up, and can be shot at --------------------- + var node: Node2D = null + for b in scene.get_node("BuildingField").spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_OUTPOST: + node = b + if node == null: + return _fail("placing an outpost produced no node") + if node.get_node_or_null("VisualHost").get_child_count() == 0: + return _fail("the outpost node has no visual under it") + if node.get_node_or_null("Hitbox") == null: + return _fail("the outpost has no hitbox - a wave could not touch it") + var hp: int = game_state.building_hp(node.building_id) + var crafter_hp: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER) + if hp < crafter_hp: + return _fail("the outpost has %d hp, less than a crafter's %d - it is meant to take hits" \ + % [hp, crafter_hp]) + print("an outpost stands at %d hp, against a crafter's %d" % [hp, crafter_hp]) + + print("TEST_OUTPOST_BUILDING: PASS") + quit(0) + return true + return false diff --git a/tests/test_outpost_garrison_menu.gd b/tests/test_outpost_garrison_menu.gd new file mode 100644 index 0000000..dfc6858 --- /dev/null +++ b/tests/test_outpost_garrison_menu.gd @@ -0,0 +1,206 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_outpost_garrison_menu.gd +# Forgejo #103 (#97e): "Outposts can be occupied by ships. Only ships that +# attack can be assigned. A dedicated menu on each outpost should let you +# compose how you want the outpost to be filled. There's a default of 2 +# vessel per outpost, upgrades can increase this number." +# +# Two things live on an outpost's record and conflating them is the first +# thing to get wrong: the ORDER (what the player asked for) and the CREW +# (who is actually there). This menu edits the order only; #104 fills the +# crew. Keeping the order as a wish is what lets an outpost ask for a hull +# nobody has built yet and simply wait. +# +# Covers: +# A. only armed vessels can be assigned - a shield, a hauler or an engine +# block cannot hold a position however much the player wants it there +# B. two berths by default, and the order cannot exceed them +# C. researching the berth upgrade raises the ceiling at outposts ALREADY +# standing, not only at new ones +# D. the menu opens from the outpost, drives the real order through the +# real buttons, and reads back "here / ordered" rather than one number +# E. an outpost with an order nobody can fill reports it as waiting, and +# the order survives - it is not silently dropped + +var game_state: Node +var scene: Node +var controller: Node +var menu: Control +var panel: Control +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.reset_progression() + game_state.reset_waves() + game_state.rocks = 5000 + game_state.unlocked.append("forward_bases") + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_GARRISON_MENU: FAIL (%s)" % message) + quit(1) + return true + + +func _state_cases() -> bool: + # --- A. only armed vessels ------------------------------------------ + var eligible: PackedStringArray = game_state.garrison_vessel_types() + if eligible.is_empty(): + return _fail("no vessel at all may be garrisoned") + if not (GameState.VESSEL_INTERCEPTOR in eligible): + return _fail("the interceptor, which carries the fleet's gun, cannot be garrisoned") + for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER, + GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER]: + if game_state.vessel_can_garrison(unarmed): + return _fail("%s can be garrisoned - it does not attack" % unarmed) + print("garrisonable: %s" % str(eligible)) + + var id: int = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0)) + outpost_id = id + if not game_state.is_outpost(game_state.find_building(id)): + return _fail("the outpost does not carry the garrison capability") + if game_state.set_garrison_desired(id, GameState.VESSEL_SHIELD, 1) != 0: + return _fail("a shield vessel was accepted into a garrison") + + # --- B. two berths, and the ceiling bites --------------------------- + if GameState.GARRISON_BASE_SLOTS != 2: + return _fail("the default is %d berths, not the 2 the ticket asks for" \ + % GameState.GARRISON_BASE_SLOTS) + if game_state.outpost_slot_capacity() != 2: + return _fail("an unresearched outpost offers %d berths" % game_state.outpost_slot_capacity()) + if game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 5) != 2: + return _fail("an order of 5 was not clamped to the 2 berths available") + if game_state.garrison_desired_total(id) != 2: + return _fail("the outpost holds an order of %d against 2 berths" \ + % game_state.garrison_desired_total(id)) + print("2 berths by default; an order of 5 clamps to 2") + + # --- C. the upgrade reaches existing outposts ----------------------- + var upgrade_id: String = "" + for node in GameState.SKILL_TREE: + if int(node.get("outpost_bonus", 0)) > 0: + upgrade_id = String(node.id) + if upgrade_id == "": + return _fail("no skill node raises the berth count") + game_state.unlocked.append(upgrade_id) + if game_state.outpost_slot_capacity() != 3: + return _fail("researching \"%s\" left the capacity at %d" \ + % [upgrade_id, game_state.outpost_slot_capacity()]) + # The outpost was placed BEFORE the research, which is the case that + # matters: a bonus that only reached new buildings would mean demolishing + # and rebuilding to collect it. + if game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 3) != 3: + return _fail("an outpost built before the upgrade did not gain the berth") + print("\"%s\" raises an already-standing outpost to 3 berths" % upgrade_id) + game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 0) + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _state_cases(): + return true + game_state.buildings.clear() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + menu = scene.get_node("UI/GarrisonMenu") + panel = scene.get_node("UI/GarrisonMenu/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_OUTPOST, Vector2(500.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- D. the real menu, through the real buttons -------------- + var outpost: Node2D = null + for b in scene.get_node("BuildingField").spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_OUTPOST: + outpost = b + if outpost == null: + return _fail("no outpost node was placed") + outpost_id = int(outpost.building_id) + if outpost.primary_action() != "garrison": + return _fail("the outpost's primary action is \"%s\", not \"garrison\"" \ + % outpost.primary_action()) + controller.current_building = outpost + controller._on_action_pressed() + if not menu.visible: + return _fail("the outpost did not open the garrison menu") + if panel.building_id != outpost_id: + return _fail("the panel opened without knowing which outpost it belongs to") + + 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") + for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER]: + for candidate in panel._rows: + if String(candidate.type) == unarmed: + return _fail("the panel offers a row for the unarmed %s" % unarmed) + + row.plus.pressed.emit() + row.plus.pressed.emit() + if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 2: + return _fail("two presses of + ordered %d vessels" \ + % game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR)) + if row.count.text != "0/2": + return _fail("the row reads \"%s\" - it should show crew over order" % row.count.text) + row.minus.pressed.emit() + if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1: + return _fail("- did not take the order back down") + print("the menu drives the real order: + + - leaves 1, shown as \"%s\"" % row.count.text) + + # The ceiling is enforced through the buttons too, not only in + # GameState - a + that silently did nothing would be worse than a + # disabled one. + row.plus.pressed.emit() + row.plus.pressed.emit() + row.plus.pressed.emit() + var capacity: int = game_state.outpost_slot_capacity() + if game_state.garrison_desired_total(outpost_id) != capacity: + return _fail("pressing + past the ceiling ordered %d against %d berths" \ + % [game_state.garrison_desired_total(outpost_id), capacity]) + if row.plus.enabled: + return _fail("+ is still offered with every berth already spoken for") + print("+ stops at %d berths and greys itself out" % capacity) + + # --- E. an unfillable order waits --------------------------- + if game_state.garrison_crew(outpost_id).size() != 0: + return _fail("the outpost is crewed already, so waiting proves nothing") + controller._refresh_garrison() + var status: String = scene.get_node("UI/GarrisonMenu/StatusLabel").text + if status.strip_edges() == "": + return _fail("an outpost waiting on vessels says nothing at all") + if game_state.garrison_desired_total(outpost_id) != capacity: + return _fail("the order was dropped because it could not be filled") + print("waiting, and saying so: \"%s\"" % status) + + print("TEST_OUTPOST_GARRISON_MENU: PASS") + quit(0) + return true + return false diff --git a/ui/GarrisonPanel.gd b/ui/GarrisonPanel.gd new file mode 100644 index 0000000..b7e12d7 --- /dev/null +++ b/ui/GarrisonPanel.gd @@ -0,0 +1,134 @@ +extends Control + +# #103 (#97e): "A dedicated menu on each outpost should let you compose how +# you want the outpost to be filled." +# +# Builds its own rows from GameState.garrison_vessel_types() rather than +# from nodes placed in Main.tscn. Which vessels can hold a position is +# decided by capability, not by a list of types, so the day an armed vessel +# is added it gets a row here and an unarmed one never does by accident - +# and neither change touches the scene. +# +# The panel edits the ORDER, not the crew. What the player sets here is what +# they want stationed; #104 is what actually gets vessels there. The two are +# shown side by side ("1/2") so an outpost that is waiting on a hull it does +# not have yet reads as waiting rather than as broken. + +# Emitted when the player changes the order, so whatever hosts the panel can +# update its own readout rather than polling. +signal changed + +const ROW_HEIGHT: float = 96.0 +const ROW_GAP: float = 12.0 +const BUTTON_SIZE: float = 76.0 +const NAME_FONT_SIZE: int = 30 +const COUNT_FONT_SIZE: int = 34 +const HEADER_FONT_SIZE: int = 22 + +var building_id: int = 0 + +var _rows: Array = [] +var _capacity_label: Label + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + # _make_label already parents what it builds. + _capacity_label = _make_label("", HEADER_FONT_SIZE, Color(0.62, 0.78, 0.86)) + _capacity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + for vessel_type in GameState.garrison_vessel_types(): + var row: Dictionary = _make_row(vessel_type) + _rows.append(row) + 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 count_label: Label = _make_label("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, "count": count_label, + "minus": minus, "plus": plus} + + +func _on_adjust(vessel_type: String, delta: int) -> void: + if building_id == 0: + return + GameState.adjust_garrison_desired(building_id, vessel_type, delta) + refresh() + changed.emit() + + +func _layout() -> void: + if _rows.is_empty(): + return + _capacity_label.position = Vector2.ZERO + _capacity_label.size = Vector2(size.x, 44.0) + var y: float = 52.0 + for row in _rows: + row.name.position = Vector2(0.0, y) + row.name.size = Vector2(size.x * 0.46, ROW_HEIGHT) + 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.0, y) + row.count.size = Vector2(BUTTON_SIZE * 2.0, ROW_HEIGHT) + row.minus.position = Vector2(right - BUTTON_SIZE * 4.0, + 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 52.0 + float(_rows.size()) * (ROW_HEIGHT + ROW_GAP) + + +func refresh() -> void: + if _rows.is_empty(): + return + var capacity: int = GameState.outpost_slot_capacity() + var ordered: int = GameState.garrison_desired_total(building_id) + _capacity_label.text = "BERTHS %d / %d" % [ordered, capacity] + for row in _rows: + var vessel_type: String = String(row.type) + var want: int = GameState.garrison_desired_of(building_id, vessel_type) + var here: int = GameState.garrison_crew_of(building_id, vessel_type) + # "here / ordered", so an outpost waiting on a hull nobody has built + # yet reads as waiting rather than as having lost one. + row.count.text = "%d/%d" % [here, want] + 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 and ordered < capacity + row.minus.queue_redraw() + row.plus.queue_redraw() diff --git a/ui/IconButton.gd b/ui/IconButton.gd index 836dd35..83e4991 100644 --- a/ui/IconButton.gd +++ b/ui/IconButton.gd @@ -50,6 +50,9 @@ enum Icon { # inserting anywhere earlier renumbers every icon int already stored in a # scene file. PAUSE, RESUME, + # #102: appended, like everything above - inserting anywhere earlier + # renumbers every icon int already stored in every scene. + OUTPOST, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -291,6 +294,8 @@ func _draw() -> void: _draw_laser_shot(center, pulse, s) Icon.DISTORDER_VESSEL: _draw_distorder_vessel(center, pulse, s) + Icon.OUTPOST: + _draw_outpost_icon(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -659,6 +664,30 @@ func _draw_ship_lab_icon(center: Vector2, pulse: float, s: float) -> void: _active_color(), pulse, 0.35 * 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 + # here"; this has to mean "things stay here and watch", so it is a + # narrow mast with a sensor head rather than a ring, and the two darts + # beside it are the garrison. + var mast_top: Vector2 = center + Vector2(0.0, -11.0) * s + var mast_foot: Vector2 = center + Vector2(0.0, 9.0) * s + NeonDraw.draw_glowing_line(self, mast_foot, mast_top, _active_color(), pulse, 0.5 * s, core_color) + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + mast_top + Vector2(0.0, -4.0) * s, mast_top + Vector2(4.0, 0.0) * s, + mast_top + Vector2(0.0, 4.0) * s, mast_top + Vector2(-4.0, 0.0) * s, + ]), _active_color(), pulse, core_color) + # Footing, so the mast stands on something rather than floating. + NeonDraw.draw_glowing_line(self, mast_foot + Vector2(-6.0, 0.0) * s, + mast_foot + Vector2(6.0, 0.0) * s, _active_color(), pulse, 0.4 * s, core_color) + for side in [-1.0, 1.0]: + var at: Vector2 = center + Vector2(11.0 * side, -1.0) * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + at + Vector2(0.0, -4.0) * s, at + Vector2(2.6, 3.0) * s, + at + Vector2(-2.6, 3.0) * s, + ]), _active_color(), pulse, core_color) + + func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void: # A ring of docking cradles with a freight box in the middle: reads as # "things are stationed here and go out from here", which is what the hub diff --git a/world/Main.tscn b/world/Main.tscn index 03e5c60..adc866b 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -21,6 +21,7 @@ [ext_resource type="Script" path="res://ui/NeonPanel.gd" id="20"] [ext_resource type="Script" path="res://ui/SkillTreeView.gd" id="21"] [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://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"] @@ -719,6 +720,48 @@ script = ExtResource("19") amount = 30 digit_height = 40.0 +[node name="OutpostButton" 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 = 830 +offset_bottom = 958 +script = ExtResource("10") +icon = 40 +glow_color = Color(0.72, 0.86, 0.45, 1) + +[node name="OutpostLabel" 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 = 964 +offset_bottom = 1030 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Outpost" +horizontal_alignment = 1 + +[node name="OutpostCost" 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 = 1034 +offset_bottom = 1086 +script = ExtResource("19") +amount = 30 +digit_height = 40.0 + [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 anchor_right = 1.0 @@ -2060,6 +2103,62 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) +[node name="GarrisonMenu" 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/GarrisonMenu"] +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/GarrisonMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 20.0 +offset_right = -200.0 +offset_bottom = 82.0 +text = "Outpost Garrison" +horizontal_alignment = 1 + +[node name="Panel" type="Control" parent="UI/GarrisonMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 40.0 +offset_top = 120.0 +offset_right = -40.0 +offset_bottom = 620.0 +script = ExtResource("33") + +[node name="StatusLabel" type="Label" parent="UI/GarrisonMenu"] +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 = 30 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="CloseButton" type="Control" parent="UI/GarrisonMenu"] +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.72, 0.86, 0.45, 1) + [node name="SkillMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2