Forgejo #14, part 1 of #12. Rock.gd credited GameState.rocks on pickup, so ore was banked instantly from anywhere on the map and a mining run had nothing at stake. Ore now goes into GameState.fleet_cargo and only becomes spendable when the fleet reaches the mothership. Delivery lives in BuildingField because that node already knows where the buildings are and already answers "is the fleet standing on one?", using the fleet CENTROID - the same stable single-number test Planet and Building use, rather than per-ship collision that flickers as ships jostle in formation. Cargo lives in the GameState autoload so it survives Main.tscn being torn down on every trip to a planet's surface - cargo mined ON a surface would otherwise be dropped the moment the player took off. test_resources_and_buildings asserted the old behaviour (gathering a rock raises `rocks`) and now asserts the new one. That is a spec change, not a weakened test - the gathering assertion itself is intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
369 lines
12 KiB
GDScript
369 lines
12 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
|
|
|
|
|
|
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"
|
|
|
|
const COMMAND_CENTER_COST: int = 15
|
|
const EXTRACTOR_COST: int = 25
|
|
|
|
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": "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()
|
|
|
|
|
|
func add_building(type: String, at_position: Vector2) -> void:
|
|
buildings.append({"type": type, "position": at_position})
|
|
|
|
|
|
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 while the player is
|
|
# on a planet's surface or nowhere near the route.
|
|
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
|