Forgejo #18, part 5 of #12. The building only - the vessels that do the hauling are #19. Placeable, costed at 30, gated behind a new "Freight Handling" research node hanging off Logistics, with its own info popup, build-menu glyph and map marker. Drawn as an octagonal dock ring with berth arms and a squared-off freight core: deliberately radial-but-flat so it does not read as a small mothership, which is the only structure with swept arms. Its stationed transports are NeonPiece children of one rotating holder, per #10 - the geometry is rasterised once and only the holder's rotation changes, so the traffic costs no redraws. Building._draw()'s match has a catch-all that renders anything unrecognised as a ship crafter, so a missing case is silent and looks almost right. The test checks for the hub's own _hub_traffic holder rather than trusting that something plausible appeared - which caught exactly that: a _ready() patch had silently not applied and the hub was rendering through the crafter branch. The build menu grew a third row. Every button, label and price moved: the old row occupied 332px against a 372px pitch, and three of those do not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px price digits) at a 290px pitch, ending at y=1176. Rendered and looked at - the first attempt tightened the pitch alone and left every price overlapping the row beneath it, which no headless test would have seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
497 lines
16 KiB
GDScript
497 lines
16 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": "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, "queued": 0,
|
|
})
|
|
return id
|
|
|
|
|
|
func find_building(id: int) -> Dictionary:
|
|
for b in buildings:
|
|
if b.get("id", 0) == id:
|
|
return b
|
|
return {}
|
|
|
|
|
|
func production_cost(type: String) -> int:
|
|
# What one order out of this building costs it. 0 means it produces
|
|
# nothing and cannot be ordered from.
|
|
if type == BUILDING_SHIP_CRAFTER:
|
|
return SHIP_COST
|
|
return 0
|
|
|
|
|
|
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 int(b.get("queued", 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) -> bool:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty() or production_cost(b.type) <= 0:
|
|
return false
|
|
b.queued = int(b.get("queued", 0)) + 1
|
|
return true
|
|
|
|
|
|
func _advance_production() -> void:
|
|
for b in buildings:
|
|
var cost: int = production_cost(b.type)
|
|
if cost <= 0:
|
|
continue
|
|
# `while`, not `if`: a delivery big enough to cover several queued
|
|
# orders should clear them all rather than dribbling one out per
|
|
# frame.
|
|
while int(b.get("queued", 0)) > 0 and int(b.get("stock", 0)) >= cost:
|
|
b.stock = int(b.stock) - cost
|
|
b.queued = int(b.queued) - 1
|
|
if b.type == BUILDING_SHIP_CRAFTER:
|
|
ships_owed += 1
|
|
order_completed.emit(int(b.id), String(b.type))
|
|
|
|
|
|
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()
|
|
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
|
|
|
|
|
|
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
|