#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 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 21:58:58 +02:00
commit f9ed46746d
3 changed files with 388 additions and 0 deletions

View file

@ -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:

28
ui/DevWaveButton.gd Normal file
View file

@ -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()

View file

@ -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