From f9ed46746d698dc0600c1ca32ae2f17480ff5f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 23 Aug 2026 21:58:58 +0200 Subject: [PATCH] #110: a dev-mode button that puts a wave on the board now Waves are on a fifty-second clock, which makes anything that happens during one - the alert, the pathing, buildings taking damage, an outpost garrison earning its keep - slow to look at even once. It skips the warning as well as the peace: the point of the button is to see the wave, and waiting out a nine-second alert every time would make it useless as a tool. Gated on dev mode, the same gate the main menu already uses for the map editor, so it does not exist in a shipped build. Co-Authored-By: Claude Opus 5 --- core/GameState.gd | 269 ++++++++++++++++++++++++++++++++++++++++++++ ui/DevWaveButton.gd | 28 +++++ world/Main.tscn | 91 +++++++++++++++ 3 files changed, 388 insertions(+) create mode 100644 ui/DevWaveButton.gd diff --git a/core/GameState.gd b/core/GameState.gd index a70f41e..872f933 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -346,6 +346,8 @@ const BUILDING_COMMAND_CENTER: String = "command_center" const BUILDING_OUTPOST: String = "outpost" # #106: where standing orders for the whole fleet are kept. const BUILDING_PLANNING_CENTER: String = "planning_center" +# #116: repairs what the waves knock down. +const BUILDING_WORKSHOP: String = "spacial_workshop" # 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" @@ -417,6 +419,7 @@ const SKILL_TREE: Array = [ # 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, + "icon": 40, "desc": "Permanent: +1 berth at every outpost, including ones already built.", }, { @@ -429,6 +432,23 @@ const SKILL_TREE: Array = [ "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.", }, + { + # Behind power_systems rather than off the fleet branch: a workshop + # is infrastructure that looks after infrastructure, and it should + # arrive after the player has some worth looking after. + "id": "field_engineering", "name": "Field Engineering", "cost": 95, + "requires": "power_systems", "x": 0.34, + "unlocks": BUILDING_WORKSHOP, "research_points": 2, + "desc": "Build spacial workshops. Their drones fly out and repair damaged structures, spending ore delivered to the workshop.", + }, + { + # Same shape as freight_yards and garrison_bays: a permanent bonus + # that reaches workshops already standing. + "id": "drone_bays", "name": "Drone Bays", "cost": 150, + "requires": "field_engineering", "x": 0.34, + "workshop_bonus": 1, "research_points": 2, "icon": 43, + "desc": "Permanent: +1 repair drone at every spacial workshop, including ones already built.", + }, { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1, @@ -442,6 +462,7 @@ const SKILL_TREE: Array = [ { "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", "x": 0.18, "hub_bonus": 1, "research_points": 2, + "icon": 12, "desc": "Permanent: +1 transporter stationed at every hub, including ones already built.", }, { @@ -456,6 +477,7 @@ const SKILL_TREE: Array = [ # Pricing them as a luxury made the early game feel stuck. "id": "wing", "name": "Wing Doctrine", "cost": 15, "requires": "coordination", "x": 0.82, "fleet_bonus": 1, "research_points": 1, + "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { @@ -468,11 +490,13 @@ const SKILL_TREE: Array = [ # Also cut (70 ore / 2 points), for the same reason as wing. "id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing", "x": 0.82, "fleet_bonus": 1, "research_points": 1, + "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { "id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron", "x": 0.82, "fleet_bonus": 1, "research_points": 2, + "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { @@ -482,11 +506,13 @@ const SKILL_TREE: Array = [ # ceiling from 6 to 10 while keeping the branch a readable length. "id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada", "x": 0.82, "fleet_bonus": 2, "research_points": 4, + "icon": 2, "desc": "Permanent: +2 vessels in the fleet.", }, { "id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup", "x": 0.82, "fleet_bonus": 2, "research_points": 6, + "icon": 2, "desc": "Permanent: +2 vessels in the fleet.", }, ] @@ -566,9 +592,27 @@ func unlock(id: String) -> bool: unlocked.append(id) if int(node.get("hub_bonus", 0)) > 0: _sync_hub_transporters() + if int(node.get("workshop_bonus", 0)) > 0: + _sync_workshop_drones() return true +# #112: the mark drawn inside a skill node. A node that unlocks a structure +# borrows that structure's own menu icon, so the tree and the build menu +# cannot disagree about what a shipyard looks like; the rest - doctrines and +# permanent bonuses, which have no building to point at - carry an explicit +# one. -1 means "no mark", and the node is drawn hollow as it always was. +func skill_node_icon(node: Dictionary) -> int: + if node.is_empty(): + return -1 + var unlocks: String = String(node.get("unlocks", "")) + if unlocks != "": + var definition: Resource = building_definition(unlocks) + if definition != null: + return int(definition.menu_icon) + return int(node.get("icon", -1)) + + func is_building_unlocked(type: String) -> bool: for node in SKILL_TREE: if node.get("unlocks", "") == type: @@ -628,6 +672,7 @@ func reset_new_game() -> void: vessel_deliveries.clear() extractors.clear() transporters.clear() + repair_drones.clear() planet_capture.clear() vessel_targets.clear() reset_waves() @@ -704,6 +749,8 @@ func add_building(type: String, at_position: Vector2) -> int: var building: Dictionary = buildings[-1] if building_has_capability(building, "transport_hub"): _provision_hub_transporters(building) + if building_has_capability(building, "repair"): + _provision_workshop_drones(building) return id @@ -1675,6 +1722,213 @@ func _advance_planning() -> void: queue_order(int(crafter.id), type_id) +# --- the spacial workshop and its repair drones (#116) --------------------- +# +# Modelled on the transport hub, deliberately and closely: drones belong to a +# workshop rather than to the player, are provisioned when it is built, are +# never a production order, and can be upgraded at a ship lab like any other +# vessel. Anything that already knows how hub transporters behave knows how +# these do. +# +# What a repair COSTS is the one new idea: a share of the building's own +# price, in proportion to how much of its health is gone. Levelling a crafter +# and rebuilding it costs its full price; patching one up after a wave costs +# a fraction, and the fraction is the damage. + +const BASE_WORKSHOP_DRONES: int = 2 +const REPAIR_DRONE: String = "repair_drone" +# What a drone puts back per trip, before upgrades. +const BASE_REPAIR_AMOUNT: int = 25 +const DRONE_IDLE: String = "idle" +const DRONE_OUTBOUND: String = "outbound" +const DRONE_RETURNING: String = "returning" + +var repair_drones: Array = [] + + +func workshop_drone_capacity() -> int: + var n: int = BASE_WORKSHOP_DRONES + for id in unlocked: + n += int(skill_node(id).get("workshop_bonus", 0)) + return n + + +func has_workshop() -> bool: + for b in buildings: + if building_has_capability(b, "repair"): + return true + return false + + +func workshop_ids() -> Array: + var out: Array = [] + for b in buildings: + if building_has_capability(b, "repair"): + out.append(int(b.id)) + return out + + +# The ore a full repair of this building would take: its own price, scaled by +# the share of its health that is missing. A structure at full health costs +# nothing, one levelled to a sliver costs nearly what it did to build. +func repair_cost(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var definition: Resource = building_definition(String(b.type)) + if definition == null: + return 0 + var missing: float = 1.0 - building_health_fraction(id) + return int(ceil(float(definition.cost) * missing)) + + +func repair_amount() -> int: + return int(vessel_stat(REPAIR_DRONE, "repair_amount")) + + +# What one drone-load of repair costs, for a building that needs one. +func repair_trip_cost(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var definition: Resource = building_definition(String(b.type)) + if definition == null: + return 0 + var per_point: float = float(definition.cost) / float(maxi(building_max_hp(String(b.type)), 1)) + var putting_back: int = mini(repair_amount(), + building_max_hp(String(b.type)) - building_hp(id)) + return maxi(int(ceil(per_point * float(putting_back))), 1) if putting_back > 0 else 0 + + +func _damaged_buildings() -> Array: + var out: Array = [] + for b in buildings: + if building_health_fraction(int(b.id)) < 0.999: + out.append(b) + return out + + +# #116: "Required resources are sent to the spacial workshop". The workshop +# asks hub logistics for the ore its outstanding repairs will need, exactly +# as a gas burner asks for gas - see building_logistics_deficit. +func workshop_ore_demand(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty() or not building_has_capability(b, "repair"): + return 0 + var needed: int = 0 + for damaged in _damaged_buildings(): + needed += repair_cost(int(damaged.id)) + return mini(needed, WORKSHOP_ORE_BUFFER) + + +# A ceiling, so one badly-mauled base does not pull the entire bank into a +# single workshop and starve everything else. +const WORKSHOP_ORE_BUFFER: int = 120 + + +func _provision_workshop_drones(workshop: Dictionary) -> void: + var stationed: int = 0 + for drone in repair_drones: + if int(drone.get("workshop_id", 0)) == int(workshop.id): + stationed += 1 + while stationed < workshop_drone_capacity(): + repair_drones.append({ + "workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0, + "from": workshop.position, "to": workshop.position, "progress": 1.0, + "carrying": 0, + }) + stationed += 1 + + +func _sync_workshop_drones() -> void: + for workshop in buildings: + if building_has_capability(workshop, "repair"): + _provision_workshop_drones(workshop) + + +func repair_drone_position(drone: Dictionary) -> Vector2: + return (drone.from as Vector2).lerp(drone.to as Vector2, + clampf(float(drone.progress), 0.0, 1.0)) + + +func _drone_inbound_to(building_id: int) -> bool: + for drone in repair_drones: + if String(drone.state) == DRONE_OUTBOUND and int(drone.target_id) == building_id: + return true + return false + + +func _advance_repairs(delta: float) -> void: + if repair_drones.is_empty(): + return + var speed: float = vessel_stat(REPAIR_DRONE, "transport_speed") + for drone in repair_drones: + var workshop: Dictionary = find_building(int(drone.workshop_id)) + if workshop.is_empty(): + continue + if String(drone.state) == DRONE_IDLE: + _dispatch_drone(drone, workshop) + continue + var distance: float = (drone.from as Vector2).distance_to(drone.to as Vector2) + drone.progress = float(drone.progress) + delta / maxf(distance / maxf(speed, 1.0), 0.001) + if float(drone.progress) < 1.0: + continue + drone.progress = 1.0 + if String(drone.state) == DRONE_OUTBOUND: + # The ore was taken when it left, so a building demolished in + # transit does not silently consume it - it goes home with the + # load and the deposit below puts it back. + var target: Dictionary = find_building(int(drone.target_id)) + if not target.is_empty(): + repair_building(int(drone.target_id), repair_amount()) + else: + deposit_resource_to_building(int(drone.workshop_id), RESOURCE_ORE, + int(drone.carrying)) + drone.carrying = 0 + drone.target_id = 0 + _send_drone(drone, workshop.position, DRONE_RETURNING) + else: + drone.state = DRONE_IDLE + + +func _dispatch_drone(drone: Dictionary, workshop: Dictionary) -> void: + var inventory: RefCounted = building_inventory(int(workshop.id)) + if inventory == null: + return + var best: Dictionary = {} + var best_health: float = 1.0 + for damaged in _damaged_buildings(): + var id: int = int(damaged.id) + # One drone per building at a time: two arriving at the same wall + # would spend twice the ore to put back one drone-load of health. + if _drone_inbound_to(id): + continue + var cost: int = repair_trip_cost(id) + if cost <= 0 or inventory.amount(RESOURCE_ORE) < cost: + continue + var health: float = building_health_fraction(id) + if best.is_empty() or health < best_health: + best = damaged + best_health = health + if best.is_empty(): + return + # Worst first: the structure closest to coming down is the one a repair + # is worth most on. + var spend: int = repair_trip_cost(int(best.id)) + inventory.withdraw(RESOURCE_ORE, spend) + drone.carrying = spend + drone.target_id = int(best.id) + drone.from = workshop.position + _send_drone(drone, best.position, DRONE_OUTBOUND) + + +func _send_drone(drone: Dictionary, to: Vector2, state: String) -> void: + drone.from = repair_drone_position(drone) + drone.to = to + drone.progress = 0.0 + drone.state = state + + 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 == "": @@ -1957,6 +2211,10 @@ func building_logistics_deficit(id: int, resource_type: String) -> int: # on who is stationed there, which is not something a definition can # state. Taking the larger of the two keeps the static case untouched. target = maxi(target, garrison_magazine_capacity(id, resource_type)) + # #116: and a workshop asks for the ore its outstanding repairs will + # take, which likewise depends on the world rather than on a definition. + if resource_type == RESOURCE_ORE: + target = maxi(target, workshop_ore_demand(id)) return maxi(0, target - building_resource(id, resource_type)) @@ -2259,6 +2517,16 @@ func advance_waves(delta: float) -> void: wave_cleared() +# #110: put a wave on the board now, for testing. Skips the peace AND the +# warning: the point of the button is to see the wave, and waiting out a +# nine-second alert every time would make it useless as a tool. +func force_wave_now() -> void: + wave_phase = WAVE_PHASE_ACTIVE + wave_timer = 0.0 + wave_number += 1 + wave_pending = true + + func wave_cleared() -> void: wave_phase = WAVE_PHASE_PEACE wave_timer = 0.0 @@ -2274,6 +2542,7 @@ func _process(delta: float) -> void: _advance_vessel_deliveries(delta) _advance_garrison_dispatch() _advance_planning() + _advance_repairs(delta) _advance_resource_converters(delta) _advance_distorder_drain(delta) for e in extractors: diff --git a/ui/DevWaveButton.gd b/ui/DevWaveButton.gd new file mode 100644 index 0000000..bcf34cf --- /dev/null +++ b/ui/DevWaveButton.gd @@ -0,0 +1,28 @@ +extends Control + +# #110: a dev-only button that puts a wave on the board now. +# +# Waves are otherwise on a fifty-second clock, which makes anything that +# happens during one - the alert, the pathing, buildings taking damage, an +# outpost garrison actually earning its keep - slow to look at even once. +# +# Dev mode is the same gate the main menu already uses for the map editor: +# these are tools for building the game, and a player who installed a build +# has no use for them. The button does not exist outside it. + +@onready var button: IconButton = get_node("Button") + + +func _ready() -> void: + button.pressed.connect(_on_pressed) + visible = false + + +func _process(_delta: float) -> void: + var show_it: bool = GameState.dev_mode and not GameState.intro_active + if show_it != visible: + visible = show_it + + +func _on_pressed() -> void: + GameState.force_wave_now() diff --git a/world/Main.tscn b/world/Main.tscn index a203216..707df7a 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -26,6 +26,9 @@ [ext_resource type="Script" path="res://ui/PlanningPanel.gd" id="35"] [ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="36"] [ext_resource type="Script" path="res://world/IntroDirector.gd" id="37"] +[ext_resource type="Script" path="res://ui/DevWaveButton.gd" id="38"] +[ext_resource type="Script" path="res://ui/HangarMenuPanel.gd" id="39"] +[ext_resource type="Script" path="res://logistics/RepairDroneField.gd" id="40"] [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"] @@ -103,6 +106,9 @@ script = ExtResource("34") [node name="TransporterField" type="Node2D" parent="."] script = ExtResource("23") +[node name="RepairDroneField" type="Node2D" parent="."] +script = ExtResource("40") + [node name="VesselDeliveryField" type="Node2D" parent="."] script = ExtResource("26") @@ -219,6 +225,24 @@ offset_bottom = 172 script = ExtResource("10") icon = 0 +[node name="DevWaveButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_right = -24.0 +offset_top = 268.0 +offset_bottom = 384.0 +script = ExtResource("38") + +[node name="Button" type="Control" parent="UI/DevWaveButton"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("10") +icon = 5 +glow_color = Color(1, 0.45, 0.2, 1) + [node name="NarrationBanner" type="Control" parent="UI"] visible = false mouse_filter = 2 @@ -824,6 +848,48 @@ script = ExtResource("19") amount = 70 digit_height = 40.0 +[node name="WorkshopButton" 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 = 1350 +offset_bottom = 1478 +script = ExtResource("10") +icon = 42 +glow_color = Color(0.5, 0.95, 0.8, 1) + +[node name="WorkshopLabel" 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 = 1484 +offset_bottom = 1550 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Spacial Workshop" +horizontal_alignment = 1 + +[node name="WorkshopCost" 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 = 1554 +offset_bottom = 1606 +script = ExtResource("19") +amount = 55 +digit_height = 40.0 + [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 anchor_right = 1.0 @@ -926,6 +992,7 @@ visible = false mouse_filter = 2 anchor_right = 1.0 anchor_bottom = 1.0 +script = ExtResource("39") [node name="Backdrop" type="ColorRect" parent="UI/HangarMenu"] anchor_right = 1.0 @@ -1411,6 +1478,17 @@ script = ExtResource("10") icon = 37 glow_color = Color(0.7, 0.3, 1, 1) +[node name="VesselH" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 320 +offset_right = 380 +offset_top = 300 +offset_bottom = 420 +script = ExtResource("10") +icon = 43 +glow_color = Color(0.5, 0.95, 0.8, 1) + [node name="VesselLabel" type="Label" parent="UI/LabMenu"] visible = false mouse_filter = 2 @@ -1514,6 +1592,19 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "Distorder" +[node name="VesselHLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 310 +offset_right = 390 +offset_top = 424 +offset_bottom = 496 +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "Repair Drone" + [node name="VesselUpgradeSeparator" type="ColorRect" parent="UI/LabMenu"] mouse_filter = 2 anchor_right = 1.0