spaceship/GameState.gd
Loïc Wisniewski f19807618a Transporter capacity per hub, and research to raise it
Forgejo #20, part 7 of #12 and the last of it. A hub stations
BASE_HUB_CAPACITY (2) transporters. Total berths are hubs x per-hub
capacity, so building another hub or researching "Freight Yards" both
raise it - which is the choice #12 asks the player to make.

Capacity follows the same shape as max_fleet_size() (sum a bonus over
`unlocked`) rather than being a second mechanism doing the same job, so
research applies to hubs already built, not only to ones placed
afterwards.

A queued-but-unbuilt transporter holds its berth. Without that a player
could order ten against two berths and only discover the problem when
they refused to appear. Ordering past the cap is refused outright, and
the two refusals - no hub at all, versus every berth taken - say which,
because "nothing happened" is exactly the bug reported against the
craft button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +02:00

753 lines
24 KiB
GDScript

extends Node
# Autoload singleton - the one thing that survives change_scene_to_file()
# scene swaps between space (Main.tscn) and a planet's surface
# (PlanetSurface.tscn). Everything else in either scene gets torn down
# and rebuilt from scratch on each swap, so anything that needs to
# survive the trip has to live here instead.
var has_return_position: bool = false
var surviving_ship_count: int = 5
var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0
var planet_name: String = ""
var planet_type: String = ""
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on
# every scene swap) so exploration progress isn't lost every time the
# player lands on and leaves a planet.
var explored_chunks: Dictionary = {}
# --- Resources -------------------------------------------------------------
# Ore that has been BANKED at the mothership. This is the only pool anything
# can be bought with.
# Starts stocked for play-testing so buildings can be tried without mining
# first - drop this back to 0 for a real playthrough.
var rocks: int = 500
# Ore the fleet is physically carrying and has not delivered yet (#14). Mining
# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a
# long mining run is a real trip with something to lose rather than instant
# income from anywhere on the map.
#
# Lives here, like everything else that has to survive Main.tscn being torn
# down and rebuilt on every trip to a planet's surface - and cargo mined ON a
# surface would otherwise be dropped the moment the player took off.
var fleet_cargo: int = 0
signal cargo_delivered(amount: int)
func collect_cargo(amount: int = 1) -> void:
fleet_cargo += amount
# One press of "load ore" at the mothership. A batch rather than the whole
# bank: the hold is the part that can be lost, and emptying the bank in one
# tap would also strand research, which still spends from it.
const CARGO_LOAD_BATCH: int = 25
func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int:
# Banked ore back into the hold, so the fleet can carry it to a building
# that is waiting on it (#17).
var taken: int = mini(amount, rocks)
if taken <= 0:
return 0
rocks -= taken
fleet_cargo += taken
return taken
func unload_into_building(id: int) -> int:
# Everything the fleet is carrying, into this building's stock. Returns
# how much moved so a caller can tell the player something happened.
if fleet_cargo <= 0:
return 0
var moved: int = fleet_cargo
if deposit_to_building(id, moved) <= 0:
return 0
fleet_cargo = 0
return moved
func deliver_cargo() -> int:
# Everything the fleet is carrying, banked at once. Returns how much moved
# so a caller can react to a delivery actually having happened rather than
# to merely being in range.
if fleet_cargo <= 0:
return 0
var delivered: int = fleet_cargo
rocks += delivered
fleet_cargo = 0
cargo_delivered.emit(delivered)
return delivered
const MOTHERSHIP_COST: int = 20
const SHIP_CRAFTER_COST: int = 12
const SHIP_COST: int = 6
# --- Buildings -------------------------------------------------------------
# Player-placed structures, as [{"type": String, "position": Vector2}].
# Buildings exist only in space (Main.tscn) but are stored here so they
# survive the scene swap to a planet's surface and back.
const BUILDING_MOTHERSHIP: String = "mothership"
const BUILDING_SHIP_CRAFTER: String = "ship_crafter"
const BUILDING_COMMAND_CENTER: String = "command_center"
# Extractors are not placed in space like the others - they are built ON a
# planet's surface and tracked separately (see `extractors`).
const BUILDING_EXTRACTOR: String = "extractor"
# Hosts the transport vessels that supply other buildings (#18). Built in
# space like the rest; it is the transporters (#19) that do the moving.
const BUILDING_TRANSPORT_HUB: String = "transport_hub"
const COMMAND_CENTER_COST: int = 15
const EXTRACTOR_COST: int = 25
const TRANSPORT_HUB_COST: int = 30
var buildings: Array = []
# --- Skill tree ------------------------------------------------------------
# Researched at the mothership. Two things hang off it: which structures the
# player is allowed to build at all, and permanent upgrades that have no
# building of their own. The mothership itself is deliberately NOT in the
# tree - it is the thing you research AT, so gating it behind research would
# be a deadlock.
#
# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT
# stored, it is derived from how many prerequisites deep the node is, so the
# tree always reads root-at-the-bottom and cannot drift out of step with the
# dependencies when nodes are added. `requires` is a single prerequisite id
# ("" for a root).
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
"x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER,
"desc": "Build ship crafters, and buy extra vessels at them.",
},
{
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
"x": 0.18, "unlocks": BUILDING_EXTRACTOR,
"desc": "Build extractors on planets. They mine ore whether or not you are nearby.",
},
{
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
"x": 0.82, "unlocks": BUILDING_COMMAND_CENTER,
"desc": "Build command centers, where the fleet formation is arranged.",
},
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"desc": "Build transport hubs, which supply your other buildings with ore.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.05, "hub_bonus": 1,
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
},
{
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.65, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.3, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
]
const BASE_FLEET_SIZE: int = 5
var unlocked: Array = []
func skill_node(id: String) -> Dictionary:
for node in SKILL_TREE:
if node.id == id:
return node
return {}
func skill_depth(id: String) -> int:
# How many prerequisites deep this node sits. Drives its height in the
# tree view: roots at the bottom, everything that depends on something
# above it.
var depth: int = 0
var node: Dictionary = skill_node(id)
while not node.is_empty() and node.requires != "":
depth += 1
node = skill_node(node.requires)
if depth > SKILL_TREE.size():
break # a cycle in the data would otherwise hang here
return depth
func max_skill_depth() -> int:
var deepest: int = 0
for node in SKILL_TREE:
deepest = maxi(deepest, skill_depth(node.id))
return deepest
func is_unlocked(id: String) -> bool:
return id in unlocked
func prerequisite_met(id: String) -> bool:
var node: Dictionary = skill_node(id)
if node.is_empty():
return false
return node.requires == "" or is_unlocked(node.requires)
func can_unlock(id: String) -> bool:
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
func unlock(id: String) -> bool:
if not can_unlock(id):
return false
if not spend(int(skill_node(id).cost)):
return false
unlocked.append(id)
return true
func is_building_unlocked(type: String) -> bool:
# The mothership is always buildable: it is where research happens, so
# locking it would leave a player with no mothership unable to ever get
# one.
if type == BUILDING_MOTHERSHIP:
return true
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return is_unlocked(node.id)
return true
func research_for_building(type: String) -> String:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return node.name
return ""
func max_fleet_size() -> int:
var n: int = BASE_FLEET_SIZE
for id in unlocked:
n += int(skill_node(id).get("fleet_bonus", 0))
return mini(n, DEFAULT_FORMATION.size())
func reset_progression() -> void:
unlocked.clear()
# --- building stock and production queue (#16) -----------------------------
# Since #12, a building cannot just deduct from the bank at the moment of
# purchase: what it produces has to be paid for out of resources physically
# delivered to it. So each building carries its own `stock`, and an order it
# cannot afford yet is QUEUED rather than refused, and completes by itself
# once the stock turns up.
#
# `id` exists because a building has to be referable across the space <->
# planet-surface scene swap, when every Building NODE has been destroyed and
# rebuilt. Position would nearly work and then fail on two buildings placed
# on the same spot.
var next_building_id: int = 1
# Ships that have been paid for and built but not yet handed to the fleet.
# Production is simulated here, in the autoload, the same way extractor
# haulers are - so an order can complete while the player is down on a
# planet's surface, where there is no Fleet node to hand a ship to. The fleet
# claims them when it next can.
var ships_owed: int = 0
signal order_completed(building_id: int, type: String)
func add_building(type: String, at_position: Vector2) -> int:
var id: int = next_building_id
next_building_id += 1
buildings.append({
"id": id, "type": type, "position": at_position, "stock": 0, "orders": [],
})
return id
func find_building(id: int) -> Dictionary:
for b in buildings:
if b.get("id", 0) == id:
return b
return {}
const PRODUCT_SHIP: String = "interceptor"
const PRODUCT_TRANSPORTER: String = "transporter"
const TRANSPORTER_COST: int = 10
func product_cost(product: String) -> int:
if product == PRODUCT_TRANSPORTER:
return TRANSPORTER_COST
return SHIP_COST
func can_produce(type: String) -> bool:
# Only the ship crafter builds anything. The hub STATIONS transporters,
# it does not make them (#12).
return type == BUILDING_SHIP_CRAFTER
func building_stock(id: int) -> int:
var b: Dictionary = find_building(id)
return 0 if b.is_empty() else int(b.get("stock", 0))
func pending_orders(id: int) -> int:
var b: Dictionary = find_building(id)
return 0 if b.is_empty() else (b.get("orders", []) as Array).size()
func pending_orders_of(id: int, product: String) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var n: int = 0
for o in b.get("orders", []):
if o == product:
n += 1
return n
func outstanding_cost(id: int) -> int:
# What this building still needs delivered to clear everything queued.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var need: int = 0
for o in b.get("orders", []):
need += product_cost(o)
return maxi(need - int(b.get("stock", 0)), 0)
func deposit_to_building(id: int, amount: int) -> int:
if amount <= 0:
return 0
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
b.stock = int(b.get("stock", 0)) + amount
return amount
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or not can_produce(b.type):
return false
if product == PRODUCT_TRANSPORTER and not has_transport_hub():
# Nowhere to station it. Refused rather than queued, so the player is
# told now instead of watching an order sit forever.
return false
if product == PRODUCT_TRANSPORTER and not can_station_transporter():
# Every berth is taken or spoken for (#20).
return false
(b.orders as Array).append(product)
return true
func _advance_production() -> void:
for b in buildings:
if not can_produce(b.type):
continue
# `while`, not `if`: a delivery big enough to cover several queued
# orders should clear them all rather than dribbling one out per
# frame.
var orders: Array = b.get("orders", [])
while not orders.is_empty():
var product: String = String(orders[0])
var cost: int = product_cost(product)
if int(b.get("stock", 0)) < cost:
break
if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity():
# A hub was demolished after this was ordered, so the berth it
# was going to use is gone. Hold the order rather than
# overfilling what is left.
break
if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
# Every hub was demolished while this sat in the queue. Leave
# it queued and the stock untouched rather than producing a
# vessel with nowhere to live.
break
b.stock = int(b.stock) - cost
orders.pop_front()
if product == PRODUCT_TRANSPORTER:
_station_transporter(b.position)
else:
ships_owed += 1
order_completed.emit(int(b.id), product)
func has_mothership() -> bool:
return get_mothership_position() != null
func remove_buildings_of_type(type: String) -> int:
var kept: Array = []
var removed: int = 0
for b in buildings:
if b.type == type:
removed += 1
else:
kept.append(b)
buildings = kept
return removed
func get_mothership_position() -> Variant:
# Where a wiped-out fleet gets rebuilt. Returns null when no mothership
# has been built yet, so the caller can fall back to the world origin.
for b in buildings:
if b.type == BUILDING_MOTHERSHIP:
return b.position
return null
# --- Fleet formation -------------------------------------------------------
# Where each vessel sits relative to the fleet centre, editable at a command
# centre. Lives here (not as a const on Fleet) so a layout the player has
# arranged survives the trip to a planet's surface and back, and so a
# rebuilt fleet keeps their arrangement.
# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet
# capacity upgrade is researched, but they are defined up front so the wedge
# keeps a deliberate shape as it grows rather than having positions invented
# at unlock time.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
]
var formation: Array = DEFAULT_FORMATION.duplicate()
func reset_formation() -> void:
formation = DEFAULT_FORMATION.duplicate()
func rotate_formation(radians: float) -> void:
# Pivots every slot about the fleet centre, keeping the shape intact
# and only changing which way it faces.
for i in range(formation.size()):
formation[i] = formation[i].rotated(radians)
# --- Extractors and their haulers -----------------------------------------
# An extractor sits on a planet and runs a single automated hauler back and
# forth to the mothership, delivering ore on each arrival.
#
# The round trip is SIMULATED HERE, in the autoload, rather than by any
# node: it advances whether the player is in space, on a planet's surface,
# or far enough away that nothing about the route is loaded. `progress` is
# a fraction of one full there-and-back cycle, so a hauler's drawn position
# is derived from the same number that decides when ore is delivered - the
# visual arrival and the payout cannot drift apart, because they are the
# same event.
#
# Entries are {"position": Vector2 (the planet's position in SPACE),
# "planet_name": String, "progress": float}.
const HAULER_SPEED: float = 260.0
const HAULER_YIELD: int = 4
# Progress value at which the hauler reaches the mothership: exactly half
# way through the cycle, the outbound leg being the first half.
const HAULER_ARRIVAL: float = 0.5
var extractors: Array = []
func has_extractor_at(planet_name: String) -> bool:
for e in extractors:
if e.planet_name == planet_name:
return true
return false
signal extractor_added(planet_name: String)
func add_extractor(planet_name: String, at_position: Vector2) -> void:
extractors.append({
"position": at_position,
"planet_name": planet_name,
"progress": 0.0,
})
# An extractor is built from SPACE, with the fleet parked on the planet -
# so the Planet node is already in the tree with its _ready() long since
# run. Without this it kept showing no derrick until it happened to
# despawn and respawn, which reads as the build having done nothing.
extractor_added.emit(planet_name)
func get_hauler_route_time(extractor: Dictionary) -> float:
# Full cycle: out to the mothership and back again.
var mothership: Variant = get_mothership_position()
if mothership == null:
return 0.0
var distance: float = extractor.position.distance_to(mothership)
if distance < 1.0:
return 0.0
return (distance * 2.0) / HAULER_SPEED
func get_hauler_position(extractor: Dictionary) -> Variant:
# Where the hauler is right now, derived purely from `progress`. Returns
# null when there is no route (no mothership built yet).
var mothership: Variant = get_mothership_position()
if mothership == null:
return null
var t: float = extractor.progress
if t <= HAULER_ARRIVAL:
return extractor.position.lerp(mothership, t / HAULER_ARRIVAL)
return mothership.lerp(extractor.position, (t - HAULER_ARRIVAL) / (1.0 - HAULER_ARRIVAL))
func _process(delta: float) -> void:
# Runs in the autoload, so extraction keeps earning - and queued orders
# keep completing - while the player is on a planet's surface or nowhere
# near either.
_advance_production()
_advance_transporters(delta)
if extractors.is_empty():
return
for e in extractors:
var cycle: float = get_hauler_route_time(e)
if cycle <= 0.0:
continue # nothing to deliver to yet
var before: float = e.progress
e.progress += delta / cycle
# Ore lands the instant the hauler reaches the mothership - the same
# instant get_hauler_position() puts it there.
if before < HAULER_ARRIVAL and e.progress >= HAULER_ARRIVAL:
rocks += HAULER_YIELD
while e.progress >= 1.0:
e.progress -= 1.0
# --- transporters (#19) ---------------------------------------------------
# Vessels stationed at a transport hub that fetch ore from the mothership and
# deliver it to whichever building is waiting on it.
#
# Simulated HERE rather than by any node, exactly like the extractor haulers
# above and for the same reason: a route the player is nowhere near still has
# to run. `progress` drives both the drawn position and the moment the ore
# actually moves, so what you see and what happens cannot drift apart.
#
# Entries are {"hub_id", "state", "target_id", "carrying", "progress",
# "from", "to"}.
const TRANSPORTER_SPEED: float = 340.0
# How many transporters one hub can station. Raised for EVERY hub - built
# already or not - by research, which is the same shape as max_fleet_size()
# rather than a second mechanism doing the same job.
const BASE_HUB_CAPACITY: int = 2
const TRANSPORTER_IDLE: String = "idle"
const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership"
const TRANSPORTER_TO_BUILDING: String = "to_building"
const TRANSPORTER_RETURNING: String = "returning"
var transporters: Array = []
func hub_capacity() -> int:
# Per hub.
var n: int = BASE_HUB_CAPACITY
for id in unlocked:
n += int(skill_node(id).get("hub_bonus", 0))
return n
func hub_count() -> int:
var n: int = 0
for b in buildings:
if b.type == BUILDING_TRANSPORT_HUB:
n += 1
return n
func transport_capacity() -> int:
# Total berths: more hubs, or more capacity per hub. Either raises it,
# which is exactly the choice #12 asks the player to make.
return hub_count() * hub_capacity()
func pending_transporter_orders() -> int:
var n: int = 0
for b in buildings:
for o in b.get("orders", []):
if o == PRODUCT_TRANSPORTER:
n += 1
return n
func transporters_claimed() -> int:
# Built plus already ordered: an order in the queue has a berth reserved,
# or a player could queue ten of them against two berths and only find out
# when they refused to appear.
return transporters.size() + pending_transporter_orders()
func can_station_transporter() -> bool:
return transporters_claimed() < transport_capacity()
func has_transport_hub() -> bool:
return not nearest_hub_to(Vector2.ZERO).is_empty()
func nearest_hub_to(at: Vector2) -> Dictionary:
var best: Dictionary = {}
var best_d: float = INF
for b in buildings:
if b.type != BUILDING_TRANSPORT_HUB:
continue
var d: float = at.distance_squared_to(b.position)
if d < best_d:
best_d = d
best = b
return best
func _station_transporter(built_at: Vector2) -> void:
# Built at the crafter, then it flies itself to the nearest hub and waits
# there for orders (#12).
var hub: Dictionary = nearest_hub_to(built_at)
if hub.is_empty():
return
transporters.append({
"hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0,
"carrying": 0, "progress": 0.0, "from": built_at, "to": hub.position,
})
func transporter_position(t: Dictionary) -> Vector2:
return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0))
func _hub_position(hub_id: int) -> Variant:
var hub: Dictionary = find_building(hub_id)
return null if hub.is_empty() else hub.position
func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> void:
t.from = from
t.to = to
t.state = state
t.progress = 0.0
func _transporter_inbound_to(building_id: int) -> bool:
for t in transporters:
if t.state != TRANSPORTER_IDLE and int(t.target_id) == building_id:
return true
return false
func _dispatch_transporters() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return # nowhere to load from
for b in buildings:
if outstanding_cost(int(b.id)) <= 0:
continue
# One at a time per building: without this every idle transporter
# would be sent after the same shortfall and they would all arrive
# carrying ore nobody needs any more.
if _transporter_inbound_to(int(b.id)):
continue
var chosen: Dictionary = {}
var best_d: float = INF
for t in transporters:
if t.state != TRANSPORTER_IDLE:
continue
var here: Vector2 = transporter_position(t)
var d: float = here.distance_squared_to(b.position)
if d < best_d:
best_d = d
chosen = t
if chosen.is_empty():
return # every transporter is busy
chosen.target_id = int(b.id)
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
func _advance_transporters(delta: float) -> void:
if transporters.is_empty():
return
_dispatch_transporters()
for t in transporters:
if t.state == TRANSPORTER_IDLE:
continue
var distance: float = (t.from as Vector2).distance_to(t.to as Vector2)
var leg: float = maxf(distance / TRANSPORTER_SPEED, 0.0001)
t.progress += delta / leg
if t.progress < 1.0:
continue
t.progress = 1.0
_arrive(t)
func _arrive(t: Dictionary) -> void:
var here: Vector2 = t.to
var hub_pos: Variant = _hub_position(int(t.hub_id))
match t.state:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
var want: int = outstanding_cost(int(t.target_id))
var take: int = mini(want, rocks)
if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty.
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
rocks -= take
t.carrying = take
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
TRANSPORTER_TO_BUILDING:
# The ore moves at exactly the moment the vessel arrives.
if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0:
# Building demolished in transit - put the ore back rather
# than quietly destroying it.
rocks += int(t.carrying)
t.carrying = 0
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_RETURNING:
t.state = TRANSPORTER_IDLE
t.progress = 1.0
func can_afford(cost: int) -> bool:
return rocks >= cost
func spend(cost: int) -> bool:
if not can_afford(cost):
return false
rocks -= cost
return true