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