#105 shipped with interceptors as the only garrisonable vessel, and the reason was a design fault rather than a decision: firing the laser and the gas bomb lived in the player's fleet controllers, so a stationed laser would have held its position and never shot. The shot is now a thing in the world rather than something a controller draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam and ArtilleryShell already had on the enemy side - and they outlive the vessel that fired them. Above them sits one seam, on VesselCooldownFillComponent: ability_range(), ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a bomb are now the same shape of thing to whoever pulls the trigger, so the garrison AI never names an ability - the test asserts that literally, by reading the AI's own source. A third armed vessel needs to implement the seam and nothing else. Ammunition is the CALLER's business, deliberately: the fleet spends from its hold, a garrison from its outpost's store, and neither concern belongs in the component. A garrison with an empty magazine holds fire rather than falling back on the fleet's hold. Outpost storage: 50 gas per stationed bomber and 50 energy per stationed laser, off the VESSEL definitions, so an outpost never learns which vessel needs what. It is a capacity, not a stock - hub transporters fill it exactly as they fill a gas burner's buffer, because building_logistics_deficit now takes the larger of the definition's static buffer and the garrison's magazine. Every other resource is capped at zero: an outpost is not a depot, and ore parked at one has nothing to do there and no transporter coming for it. Also restores two properties the Godot editor dropped from GasBurnerBuildingDefinition.tres when it re-saved the file: collected_outputs and capabilities. Without the first, nothing ever collects the burner's energy - test_gas_burner caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2648 lines
98 KiB
GDScript
2648 lines
98 KiB
GDScript
extends Node
|
|
|
|
const INVENTORY = preload("res://resources/Inventory.gd")
|
|
const RESOURCE_DEFINITIONS = preload("res://resources/ResourceDefinitions.gd")
|
|
const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd")
|
|
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
|
|
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
|
# By path, not by its `WorldGen` global name: an autoload compiles before the
|
|
# global class table is necessarily populated, the same hazard PlanetSetups.gd
|
|
# works around.
|
|
const WORLD_GEN = preload("res://world/WorldGen.gd")
|
|
|
|
|
|
# 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 = STARTING_SHIP_COUNT
|
|
var surviving_vessel_types: PackedStringArray = PackedStringArray()
|
|
# #106: a mirror of what is flying, pushed by Fleet._sync_composition_state()
|
|
# whenever the composition changes. GameState cannot see scene nodes, and the
|
|
# census the Planning Center works from has to include the fleet.
|
|
var fleet_vessel_types: PackedStringArray = PackedStringArray()
|
|
var return_world_position: Vector2 = Vector2.ZERO
|
|
var return_camera_zoom: float = 1.0
|
|
|
|
var planet_name: String = ""
|
|
var planet_id: String = ""
|
|
var planet_definition_id: String = "rocky"
|
|
|
|
# #59: 1.0 is today's behaviour. Applied at the point of use (Fleet's cruise
|
|
# anchor, Ship's own max_speed/acceleration) rather than baked into stored
|
|
# stats, so a speed upgrade researched while boosted can't corrupt the base
|
|
# value - see Fleet.gd/Ship.gd for the multiply sites.
|
|
# The ceiling ONE Energy Propulser reaches. Every DOUBLING of them raises it
|
|
# by another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel
|
|
# figure rather than an absolute cap so the fader's meaning at a single
|
|
# vessel is unchanged.
|
|
const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0
|
|
# How far the fader can actually go right now, given the fleet's current
|
|
# composition. Pushed by Fleet whenever that composition changes, the same
|
|
# direction as every other fleet-derived figure here - GameState does not
|
|
# reach into the scene tree to count ships.
|
|
var max_fleet_speed_multiplier: float = MAX_FLEET_SPEED_MULTIPLIER
|
|
var fleet_speed_multiplier: float = 1.0
|
|
|
|
# #62: energy cost of sustaining a boost, scaled by how far above 1.0 the
|
|
# multiplier is - a small boost costs proportionally less, full boost costs
|
|
# ENERGY_DRAIN_PER_SECOND. Sitting at exactly 1.0 costs nothing.
|
|
const ENERGY_DRAIN_PER_SECOND: float = 2.0
|
|
var _distorder_drain_progress: float = 0.0
|
|
# One-shot notice, consumed and cleared by SpeedFader.gd: true the instant
|
|
# a sustained boost is force-reset for running the fleet hold's energy
|
|
# to zero, so the control (which may not even be part of the current
|
|
# scene tree at that exact frame) can show why on its next _process().
|
|
var boost_depleted_notice: bool = false
|
|
|
|
|
|
func set_fleet_speed_multiplier(value: float) -> void:
|
|
fleet_speed_multiplier = clampf(value, 1.0, max_fleet_speed_multiplier)
|
|
|
|
|
|
# Every distorder vessel past the first widens the fleet's speed ceiling by
|
|
# 1.0, so stacking them is worth doing rather than the second one being dead
|
|
# weight. Zero vessels returns the ceiling to its one-vessel value; the
|
|
# multiplier itself is reset separately by Fleet when the last one is lost.
|
|
#
|
|
# Re-clamps the CURRENT multiplier, so losing a distorder vessel mid-boost
|
|
# pulls the fleet down to the new ceiling instead of leaving it travelling
|
|
# faster than its remaining crew can sustain.
|
|
# #77: the ceiling grows with the LOGARITHM of how many propulsers fly, not
|
|
# linearly. Each one used to add a flat 1.0, so a fleet that could afford six
|
|
# of them simply drove at six times cruise and the vessel stopped being a
|
|
# choice. Base-2, which makes the rule one sentence: every DOUBLING of
|
|
# propulsers buys another 1.0.
|
|
#
|
|
# 1 -> 3.0 2 -> 4.0 4 -> 5.0 8 -> 6.0
|
|
#
|
|
# Two propulsers are worth exactly what they were worth before, so nothing a
|
|
# player has already built is devalued; it is the fifth and sixth that stop
|
|
# paying for themselves.
|
|
func set_distorder_vessel_count(count: int) -> void:
|
|
if count <= 1:
|
|
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER
|
|
return
|
|
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER \
|
|
+ log(float(count)) / log(2.0)
|
|
set_fleet_speed_multiplier(fleet_speed_multiplier)
|
|
|
|
|
|
# #63: currency gated behind conquering a research_lab planet - required by
|
|
# both progression paths (skill tree nodes, vessel/upgrade research) on top
|
|
# of their existing ore/resource costs.
|
|
signal research_points_changed(total: int)
|
|
var research_points: int = 0
|
|
|
|
# Set only by start_dev_game(), which the main menu offers only when running
|
|
# from Godot. Makes WorldGen plant one of every planet type around the spawn.
|
|
# Cleared by reset_new_game(), so starting a normal game after a dev one
|
|
# cannot leave a dev world behind.
|
|
#
|
|
# The setter mirrors it into WorldGen, which cannot read it back: world
|
|
# generation is static and dependency-free by design. This stays the single
|
|
# place to set it - assigning dev_mode anywhere, tests included, reaches
|
|
# world generation.
|
|
var dev_mode: bool = false:
|
|
set(value):
|
|
dev_mode = value
|
|
WORLD_GEN.dev_planets_enabled = value
|
|
|
|
# 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 -------------------------------------------------------------
|
|
|
|
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
|
|
const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
|
|
const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID
|
|
const RESOURCE_DARK_MATTER: String = RESOURCE_DEFINITIONS.DARK_MATTER_ID
|
|
const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
|
|
|
|
# All resource-bearing locations use the same container. The old public
|
|
# names below are computed compatibility accessors, not separate storage.
|
|
# Starts EMPTY. A new game used to open with 500 ore banked, which quietly
|
|
# skipped the opening of the game - the first mining run is what teaches the
|
|
# fleet hold, the return trip and the mothership. Dev mode (start_dev_game)
|
|
# is where a stocked start belongs.
|
|
var bank_inventory: RefCounted = INVENTORY.new()
|
|
var fleet_inventory: RefCounted = INVENTORY.new()
|
|
|
|
var rocks: int:
|
|
get: return bank_inventory.amount(RESOURCE_ORE)
|
|
set(value): bank_inventory.set_amount(RESOURCE_ORE, value)
|
|
|
|
var gas: int:
|
|
get: return bank_inventory.amount(RESOURCE_GAS)
|
|
set(value): bank_inventory.set_amount(RESOURCE_GAS, value)
|
|
|
|
var fleet_cargo: int:
|
|
get: return fleet_inventory.amount(RESOURCE_ORE)
|
|
set(value): fleet_inventory.set_amount(RESOURCE_ORE, value)
|
|
|
|
var fleet_gas_cargo: int:
|
|
get: return fleet_inventory.amount(RESOURCE_GAS)
|
|
set(value): fleet_inventory.set_amount(RESOURCE_GAS, value)
|
|
|
|
# Both return the amount actually accepted (#56: the fleet hold now has a
|
|
# finite total_capacity), so a collector can tell a full hold apart from a
|
|
# successful pickup instead of assuming everything offered was taken.
|
|
func collect_cargo(amount: int = 1) -> int:
|
|
return fleet_inventory.deposit(RESOURCE_ORE, amount)
|
|
|
|
func collect_gas_cargo(amount: int = 1) -> int:
|
|
return fleet_inventory.deposit(RESOURCE_GAS, amount)
|
|
|
|
|
|
func resource_types() -> PackedStringArray:
|
|
return RESOURCE_DEFINITIONS.ids()
|
|
|
|
|
|
func resource_definition(resource_type: String) -> Resource:
|
|
return RESOURCE_DEFINITIONS.get_definition(resource_type)
|
|
|
|
|
|
func resource_name(resource_type: String) -> String:
|
|
var definition: Resource = resource_definition(resource_type)
|
|
return String(definition.display_name) if definition != null else resource_type.capitalize()
|
|
|
|
|
|
func resource_color(resource_type: String) -> Color:
|
|
var definition: Resource = resource_definition(resource_type)
|
|
return definition.color if definition != null else Color.WHITE
|
|
|
|
|
|
func banked_resource(resource_type: String) -> int:
|
|
return bank_inventory.amount(resource_type)
|
|
|
|
|
|
func fleet_resource(resource_type: String) -> int:
|
|
return fleet_inventory.amount(resource_type)
|
|
|
|
|
|
# #60: consumed outright (ammunition), not moved to another inventory like
|
|
# transfer_resource_to_building/_fleet - returns the amount actually taken.
|
|
func withdraw_fleet_resource(resource_type: String, amount: int) -> int:
|
|
return fleet_inventory.withdraw(resource_type, amount)
|
|
|
|
|
|
# #61: generic counterparts, for resources (energy) with no dedicated
|
|
# fleet_cargo/fleet_gas_cargo-style property of their own. Deliberately NOT
|
|
# the same as deposit_resource_to_building() on a "bank" building - that
|
|
# deposits into the building's OWN per-record inventory, not bank_inventory
|
|
# itself (only building_resource()/building_inventory() special-case "bank"
|
|
# capability to redirect there).
|
|
func deposit_fleet_resource(resource_type: String, amount: int) -> int:
|
|
return fleet_inventory.deposit(resource_type, amount)
|
|
|
|
|
|
func deposit_bank_resource(resource_type: String, amount: int) -> int:
|
|
return bank_inventory.deposit(resource_type, amount)
|
|
|
|
|
|
# #56: total across every resource type in the hold, and the combined cap
|
|
# that limits it - a HUD readout wants both, not any one resource's amount.
|
|
func fleet_cargo_used() -> int:
|
|
return fleet_inventory.total()
|
|
|
|
|
|
func fleet_cargo_total_capacity() -> int:
|
|
return fleet_inventory.total_capacity
|
|
|
|
|
|
func _inventory_for_record(record: Dictionary) -> RefCounted:
|
|
var inventory: RefCounted = record.get("inventory") as RefCounted
|
|
if inventory == null:
|
|
inventory = INVENTORY.new()
|
|
record.inventory = inventory
|
|
# An outpost holds exactly what its garrison can use, and the garrison
|
|
# changes. Re-applied on every access rather than when the crew changes,
|
|
# because there is no single place a crew change goes through - a vessel
|
|
# arriving, one being released and one being shot down are three.
|
|
if is_outpost(record):
|
|
# Every resource gets a ceiling, not only the ones a magazine wants:
|
|
# an outpost is not a depot, and ore parked at one has nothing to do
|
|
# there and no transporter coming for it.
|
|
var capacities: Dictionary = {}
|
|
var magazines: Dictionary = garrison_magazines(int(record.id))
|
|
for resource_type in resource_types():
|
|
capacities[resource_type] = int(magazines.get(resource_type, 0))
|
|
inventory.resource_capacities = capacities
|
|
# Compatibility migration for state made before #33. Erase each legacy
|
|
# field once absorbed so it cannot drift out of step with the inventory.
|
|
if record.has("stock"):
|
|
inventory.set_amount(RESOURCE_ORE, int(record.stock))
|
|
record.erase("stock")
|
|
if record.has("gas_stock"):
|
|
inventory.set_amount(RESOURCE_GAS, int(record.gas_stock))
|
|
record.erase("gas_stock")
|
|
return inventory
|
|
|
|
|
|
func building_inventory(id: int) -> RefCounted:
|
|
var building: Dictionary = find_building(id)
|
|
if building.is_empty():
|
|
return null
|
|
if building_has_capability(building, "bank"):
|
|
return bank_inventory
|
|
return _inventory_for_record(building)
|
|
|
|
|
|
func building_resource(id: int, resource_type: String) -> int:
|
|
var inventory: RefCounted = building_inventory(id)
|
|
return 0 if inventory == null else inventory.amount(resource_type)
|
|
|
|
|
|
func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int:
|
|
return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount)
|
|
|
|
|
|
func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int:
|
|
var source: RefCounted = building_inventory(id)
|
|
return 0 if source == null else source.transfer_to(fleet_inventory, resource_type, amount)
|
|
|
|
|
|
# The batch size the transfer menu offers, next to "All". A batch rather
|
|
# than only all-or-nothing: the hold is the part that can be lost, and
|
|
# emptying the bank in one tap would strand research, which spends from it.
|
|
const CARGO_LOAD_BATCH: int = 25
|
|
|
|
|
|
# --- moving ore by hand, in either direction (#24) --------------------------
|
|
#
|
|
# One pair of primitives the transfer menu drives, rather than the two
|
|
# one-way special cases that used to live inside the skill and craft panels
|
|
# ("load ore" at the mothership, "store ore here" at a crafter).
|
|
#
|
|
# The mothership is deliberately not a special case in the UI: it is simply
|
|
# the building whose holdings ARE the bank. Depositing at one banks the ore,
|
|
# withdrawing from one draws on the bank, and every other building keeps its
|
|
# own stock (#16). That is what lets one menu serve every structure.
|
|
|
|
|
|
func building_holdings(id: int) -> int:
|
|
return building_resource(id, RESOURCE_ORE)
|
|
|
|
func building_gas_holdings(id: int) -> int:
|
|
return building_resource(id, RESOURCE_GAS)
|
|
|
|
|
|
func transfer_to_building(id: int, amount: int) -> int:
|
|
# Hold -> this building. Returns how much actually moved, which is capped
|
|
# by the hold: a caller asking for "all" passes a number too big on
|
|
# purpose and reads the result back.
|
|
return transfer_resource_to_building(id, RESOURCE_ORE, amount)
|
|
|
|
|
|
func transfer_to_fleet(id: int, amount: int) -> int:
|
|
# This building -> hold. Capped by what the building actually holds, so
|
|
# there is no overdraft in either direction.
|
|
return transfer_resource_to_fleet(id, RESOURCE_ORE, amount)
|
|
|
|
func transfer_gas_to_building(id: int, amount: int) -> int:
|
|
return transfer_resource_to_building(id, RESOURCE_GAS, amount)
|
|
|
|
func transfer_gas_to_fleet(id: int, amount: int) -> int:
|
|
return transfer_resource_to_fleet(id, RESOURCE_GAS, amount)
|
|
|
|
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"
|
|
# #102: a forward position the fleet can garrison, researched early because
|
|
# it is the answer to waves going after what you have built.
|
|
const BUILDING_OUTPOST: String = "outpost"
|
|
# #106: where standing orders for the whole fleet are kept.
|
|
const BUILDING_PLANNING_CENTER: String = "planning_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"
|
|
# Where vessel types are researched and new ones unlocked (#5).
|
|
const BUILDING_SHIP_LAB: String = "ship_lab"
|
|
# Converts gas into energy, both stored/moved through the building's own
|
|
# inventory exactly like every other building (#54).
|
|
const BUILDING_GAS_BURNER: String = "gas_burner"
|
|
|
|
const COMMAND_CENTER_COST: int = 15
|
|
const EXTRACTOR_COST: int = 25
|
|
const TRANSPORT_HUB_COST: int = 30
|
|
const SHIP_LAB_COST: int = 35
|
|
const GAS_BURNER_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).
|
|
# #63: research_points (default 0 where omitted) is a SEPARATE currency from
|
|
# cost (ore), spent only from GameState.research_points - earned by
|
|
# conquering a research_lab planet, never bought. Balance, decided
|
|
# deliberately: shipyards and logistics stay at 0 so a fresh game can still
|
|
# reach a ship crafter and an extractor with zero research points (see the
|
|
# closing comment on #63 for the reasoning); every other node - none of them
|
|
# required for that opening - costs at least 1.
|
|
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.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1,
|
|
"desc": "Build command centers, where the fleet formation is arranged.",
|
|
},
|
|
{
|
|
# Early on purpose (#97: "pretty early in the tech tree"). It hangs off
|
|
# coordination rather than off a deep branch, so the answer to waves
|
|
# attacking your structures is available at about the time the waves
|
|
# start being able to hurt them.
|
|
"id": "forward_bases", "name": "Forward Bases", "cost": 30, "requires": "coordination",
|
|
"x": 0.66, "unlocks": BUILDING_OUTPOST, "research_points": 1,
|
|
"desc": "Build outposts: forward positions vessels can be stationed at, to hold ground you are not standing on.",
|
|
},
|
|
{
|
|
# The same shape as freight_yards for hubs: a permanent bonus that
|
|
# applies to outposts already standing, not only to new ones, so
|
|
# 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,
|
|
"desc": "Permanent: +1 berth at every outpost, including ones already built.",
|
|
},
|
|
{
|
|
# Deep on purpose, unlike forward_bases: standing orders are a thing
|
|
# you want once the fleet is large enough that keeping track of it by
|
|
# hand has become work, and it has shipyards behind it because it
|
|
# manages what shipyards do.
|
|
"id": "logistics_command", "name": "Logistics Command", "cost": 140,
|
|
"requires": "vessel_science", "x": 0.94,
|
|
"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.",
|
|
},
|
|
{
|
|
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
|
|
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1,
|
|
"desc": "Build transport hubs. Each hub includes its own automatic transporters.",
|
|
},
|
|
{
|
|
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
|
|
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
|
|
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
|
|
},
|
|
{
|
|
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
|
|
"x": 0.18, "hub_bonus": 1, "research_points": 2,
|
|
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
|
|
},
|
|
{
|
|
"id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight",
|
|
"x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2,
|
|
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
|
|
},
|
|
{
|
|
# Cheaper than it was (30 ore / 1 point): the fleet now starts at
|
|
# BASE_FLEET_SIZE 3 rather than 5, so the first two doctrines are no
|
|
# longer a mid-game luxury but the ordinary way out of the opening.
|
|
# 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,
|
|
"desc": "Permanent: +1 vessel in the fleet.",
|
|
},
|
|
{
|
|
# #70: was x=0.50 - the same lane freight's branch already occupies at
|
|
# this same depth (power_systems), so the two nodes were drawn
|
|
# directly on top of each other. Kept in line with wing (its own
|
|
# prerequisite, also x=0.82) instead, so the whole fleet-size
|
|
# doctrine branch now runs straight rather than zigzagging into
|
|
# someone else's lane.
|
|
# 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,
|
|
"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,
|
|
"desc": "Permanent: +1 vessel in the fleet.",
|
|
},
|
|
{
|
|
# The last two tiers grant +2 rather than +1. Seven single-vessel
|
|
# nodes in one lane would be a long, monotonous chain for a branch
|
|
# that only ever does one thing; two larger, costlier steps carry the
|
|
# 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,
|
|
"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,
|
|
"desc": "Permanent: +2 vessels in the fleet.",
|
|
},
|
|
]
|
|
|
|
# How many SLOTS the fleet has before any doctrine is researched. Distinct
|
|
# from STARTING_SHIP_COUNT below: this is capacity, that is how many vessels
|
|
# are actually flying. The two used to be one constant, which forced a new
|
|
# game to open with a full fleet and left the first crafter with nothing to
|
|
# do - there was no room to put a vessel until a doctrine was researched.
|
|
const BASE_FLEET_SIZE: int = 3
|
|
# A new game opens with a SINGLE vessel. Building the fleet up is the early
|
|
# game, so the two empty slots are the first thing a ship crafter is for.
|
|
const STARTING_SHIP_COUNT: int = 1
|
|
|
|
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:
|
|
var node: Dictionary = skill_node(id)
|
|
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \
|
|
and research_points >= skill_research_point_cost(id)
|
|
|
|
|
|
func skill_research_point_cost(id: String) -> int:
|
|
return int(skill_node(id).get("research_points", 0))
|
|
|
|
|
|
func unlock(id: String) -> bool:
|
|
if not can_unlock(id):
|
|
return false
|
|
var node: Dictionary = skill_node(id)
|
|
if not spend(int(node.cost)):
|
|
return false
|
|
var points_needed: int = skill_research_point_cost(id)
|
|
if points_needed > 0:
|
|
research_points -= points_needed
|
|
research_points_changed.emit(research_points)
|
|
unlocked.append(id)
|
|
if int(node.get("hub_bonus", 0)) > 0:
|
|
_sync_hub_transporters()
|
|
return true
|
|
|
|
|
|
func is_building_unlocked(type: String) -> bool:
|
|
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()
|
|
vessel_levels.clear()
|
|
|
|
|
|
# Called from the main menu's Play button (#47): the autoload survives
|
|
# change_scene_to_file(), so without this a second playthrough in the same
|
|
# process would start from wherever the last one left off instead of a
|
|
# fresh game.
|
|
func reset_new_game() -> void:
|
|
has_return_position = false
|
|
surviving_ship_count = STARTING_SHIP_COUNT
|
|
surviving_vessel_types = PackedStringArray()
|
|
fleet_vessel_types = PackedStringArray()
|
|
return_world_position = Vector2.ZERO
|
|
return_camera_zoom = 1.0
|
|
planet_name = ""
|
|
planet_id = ""
|
|
planet_definition_id = "rocky"
|
|
fleet_speed_multiplier = 1.0
|
|
_distorder_drain_progress = 0.0
|
|
boost_depleted_notice = false
|
|
research_points = 0
|
|
dev_mode = false
|
|
explored_chunks.clear()
|
|
bank_inventory = INVENTORY.new()
|
|
fleet_inventory = INVENTORY.new()
|
|
buildings.clear()
|
|
next_building_id = 1
|
|
stored_vessels.clear()
|
|
vessel_deliveries.clear()
|
|
extractors.clear()
|
|
transporters.clear()
|
|
planet_capture.clear()
|
|
vessel_targets.clear()
|
|
reset_waves()
|
|
reset_progression()
|
|
reset_formation()
|
|
|
|
|
|
# Amounts a dev game opens with: enough of everything to reach any part of
|
|
# the game immediately, without being so large that costs stop registering.
|
|
const DEV_RESOURCE_AMOUNT: int = 2000
|
|
const DEV_RESEARCH_POINTS: int = 500
|
|
|
|
|
|
# A new game stocked for play-testing: every resource banked, research points
|
|
# to spend, and one of each planet type next to the spawn (WorldGen reads
|
|
# dev_mode for that). Offered by the main menu only when running from Godot -
|
|
# see MainMenu.gd - so it cannot be reached in a shipped build.
|
|
#
|
|
# Deliberately built ON TOP of reset_new_game() rather than beside it, so a
|
|
# dev game is a normal game plus a stock rather than a second, separately
|
|
# maintained definition of what a fresh game is.
|
|
func start_dev_game() -> void:
|
|
reset_new_game()
|
|
dev_mode = true
|
|
for resource_type in resource_types():
|
|
bank_inventory.set_amount(resource_type, DEV_RESOURCE_AMOUNT)
|
|
research_points = DEV_RESEARCH_POINTS
|
|
research_points_changed.emit(research_points)
|
|
|
|
|
|
# --- 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
|
|
|
|
# Finished vessels waiting at the mothership for the player to collect
|
|
# (#23), as a list of VESSEL_* types - a shield vessel must not come back as
|
|
# an interceptor. They are NOT handed to the fleet automatically: the player
|
|
# picks what to crew from the hangar, so a full fleet no longer stops a
|
|
# crafter from building.
|
|
#
|
|
# Production is simulated here, in the autoload, the same way extractor
|
|
# haulers are, so a build finishes whether or not the player is in space.
|
|
var stored_vessels: Array = []
|
|
# Finished crafter orders travel to the mothership before appearing in its
|
|
# hangar. State is authoritative here so delivery continues off-screen.
|
|
# {"type", "from", "to", "progress"}
|
|
var vessel_deliveries: Array = []
|
|
const VESSEL_DELIVERY_SPEED: float = 180.0
|
|
|
|
signal order_completed(building_id: int, type: String)
|
|
# #99: fired after the record is already gone, so a listener that reacts by
|
|
# asking GameState about the world sees the world without it.
|
|
signal building_destroyed(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,
|
|
"inventory": INVENTORY.new(), "orders": [],
|
|
"hp": building_max_hp(type),
|
|
})
|
|
var building: Dictionary = buildings[-1]
|
|
if building_has_capability(building, "transport_hub"):
|
|
_provision_hub_transporters(building)
|
|
return id
|
|
|
|
|
|
func find_building(id: int) -> Dictionary:
|
|
for b in buildings:
|
|
if b.get("id", 0) == id:
|
|
return b
|
|
return {}
|
|
|
|
|
|
# --- what a structure can take before it comes down (#99) ------------------
|
|
#
|
|
# Health lives on the RECORD, not on the Building node. Main.tscn is torn
|
|
# down and rebuilt on every trip to a planet surface, so damage kept on a
|
|
# node would heal itself the moment the player landed - the same reason the
|
|
# buildings themselves live here.
|
|
|
|
func building_definition(type: String) -> Resource:
|
|
return BUILDING_DEFINITIONS.get_definition(type)
|
|
|
|
|
|
func building_max_hp(type: String) -> int:
|
|
var definition: Resource = building_definition(type)
|
|
return 1 if definition == null else maxi(int(definition.max_hp), 1)
|
|
|
|
|
|
func building_hp(id: int) -> int:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return 0
|
|
# Records written before #99 carry no "hp" at all. Reading that as zero
|
|
# would present every structure in an existing save as rubble, so a
|
|
# missing field means undamaged.
|
|
return int(b.get("hp", building_max_hp(String(b.type))))
|
|
|
|
|
|
func building_health_fraction(id: int) -> float:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return 0.0
|
|
return clampf(float(building_hp(id)) / float(building_max_hp(String(b.type))), 0.0, 1.0)
|
|
|
|
|
|
# The mothership is where the bank, the hangar and research all live, and it
|
|
# is where a wiped-out fleet is rebuilt. Losing it would delete the player's
|
|
# savings and their stored vessels with no route back, so it bottoms out at
|
|
# one hit point instead of dying. Keyed on the capability rather than on the
|
|
# type: whatever building holds the bank is the one that cannot be lost.
|
|
func building_is_indestructible(building: Dictionary) -> bool:
|
|
return building_has_capability(building, "bank")
|
|
|
|
|
|
# Returns true when this killed the building.
|
|
func damage_building(id: int, amount: int) -> bool:
|
|
if amount <= 0:
|
|
return false
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return false
|
|
var floor_hp: int = 1 if building_is_indestructible(b) else 0
|
|
b.hp = maxi(building_hp(id) - amount, floor_hp)
|
|
if int(b.hp) > 0:
|
|
return false
|
|
return destroy_building(id)
|
|
|
|
|
|
func repair_building(id: int, amount: int) -> int:
|
|
if amount <= 0:
|
|
return 0
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return 0
|
|
var before: int = building_hp(id)
|
|
b.hp = mini(before + amount, building_max_hp(String(b.type)))
|
|
return int(b.hp) - before
|
|
|
|
|
|
func destroy_building(id: int) -> bool:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty() or building_is_indestructible(b):
|
|
return false
|
|
var type: String = String(b.type)
|
|
var was_hub: bool = building_has_capability(b, "transport_hub")
|
|
buildings.erase(b)
|
|
if was_hub:
|
|
# Its transporters belonged to it and have nowhere to go home to.
|
|
var kept: Array = []
|
|
for transporter in transporters:
|
|
if int(transporter.get("hub_id", 0)) != id:
|
|
kept.append(transporter)
|
|
transporters = kept
|
|
# Everything else keyed on a building id already copes with the building
|
|
# being gone: a transporter mid-delivery puts its cargo back rather than
|
|
# destroying it (see _arrive), and find_building simply returns empty.
|
|
building_destroyed.emit(id, type)
|
|
return true
|
|
|
|
|
|
# --- outpost garrisons (#103) ----------------------------------------------
|
|
#
|
|
# Two separate things live on an outpost's record, and conflating them was
|
|
# the first thing to get wrong here:
|
|
#
|
|
# garrison what the player ASKED for - {vessel_type: count}. Set in
|
|
# the outpost's own menu and otherwise untouched.
|
|
# garrison_crew who is actually THERE - a list of vessel types. #104
|
|
# fills it from the mothership; nothing else writes it.
|
|
#
|
|
# Keeping the order as a wish rather than as an inventory is what lets an
|
|
# outpost ask for a vessel nobody has yet and simply wait, instead of the
|
|
# request evaporating.
|
|
|
|
const GARRISON_BASE_SLOTS: int = 2
|
|
# Which vessels can hold a position. #97: "Only ships that attack can be
|
|
# assigned" - so a shield vessel, a hauler or an engine block cannot, however
|
|
# much the player might want the shield out there. Written as capabilities
|
|
# rather than as a list of types, so a new armed vessel is eligible the day
|
|
# it is added and an unarmed one never is by accident.
|
|
#
|
|
# #105 shipped with "weapon" only, because firing the laser and the gas bomb
|
|
# lived in the player's controllers rather than in the vessels' own
|
|
# components - a garrisoned laser would have held its position and never
|
|
# shot. Both effects now live in the world as LaserBeamEffect/GasBombEffect
|
|
# and both components expose the same fire_ability() seam, so a garrison can
|
|
# pull either trigger and the list is no longer a apology for a missing
|
|
# feature.
|
|
const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser", "gas_bomber"]
|
|
|
|
|
|
func outpost_slot_capacity() -> int:
|
|
var n: int = GARRISON_BASE_SLOTS
|
|
for id in unlocked:
|
|
n += int(skill_node(id).get("outpost_bonus", 0))
|
|
return n
|
|
|
|
|
|
func vessel_can_garrison(vessel_type: String) -> bool:
|
|
var definition: Resource = vessel_definition(vessel_type)
|
|
if definition == null or not definition.fleet_eligible:
|
|
return false
|
|
for capability in GARRISON_CAPABILITIES:
|
|
if capability in definition.capabilities:
|
|
return true
|
|
return false
|
|
|
|
|
|
func garrison_vessel_types() -> PackedStringArray:
|
|
var out: PackedStringArray = PackedStringArray()
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
if vessel_can_garrison(String(definition.definition_id)):
|
|
out.append(String(definition.definition_id))
|
|
return out
|
|
|
|
|
|
func is_outpost(building: Dictionary) -> bool:
|
|
return building_has_capability(building, "garrison")
|
|
|
|
|
|
func garrison_desired(id: int) -> Dictionary:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty() or not is_outpost(b):
|
|
return {}
|
|
if not b.has("garrison"):
|
|
b.garrison = {}
|
|
return b.garrison
|
|
|
|
|
|
func garrison_desired_total(id: int) -> int:
|
|
var n: int = 0
|
|
for count in garrison_desired(id).values():
|
|
n += int(count)
|
|
return n
|
|
|
|
|
|
func garrison_desired_of(id: int, vessel_type: String) -> int:
|
|
return int(garrison_desired(id).get(vessel_type, 0))
|
|
|
|
|
|
# Returns the count actually set, so the caller can show what happened when
|
|
# the request was clamped rather than silently doing something else.
|
|
func set_garrison_desired(id: int, vessel_type: String, count: int) -> int:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty() or not is_outpost(b) or not vessel_can_garrison(vessel_type):
|
|
return 0
|
|
var wanted: Dictionary = garrison_desired(id)
|
|
var others: int = garrison_desired_total(id) - int(wanted.get(vessel_type, 0))
|
|
var room: int = maxi(outpost_slot_capacity() - others, 0)
|
|
var final_count: int = clampi(count, 0, room)
|
|
if final_count <= 0:
|
|
wanted.erase(vessel_type)
|
|
else:
|
|
wanted[vessel_type] = final_count
|
|
return final_count
|
|
|
|
|
|
func adjust_garrison_desired(id: int, vessel_type: String, delta: int) -> int:
|
|
return set_garrison_desired(id, vessel_type,
|
|
garrison_desired_of(id, vessel_type) + delta)
|
|
|
|
|
|
func garrison_crew(id: int) -> Array:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty() or not is_outpost(b):
|
|
return []
|
|
if not b.has("garrison_crew"):
|
|
b.garrison_crew = []
|
|
return b.garrison_crew
|
|
|
|
|
|
# --- what a garrison keeps beside it ---------------------------------------
|
|
#
|
|
# An outpost stores ammunition for the vessels actually stationed there: 50
|
|
# gas for each gas bomber, 50 energy for each laser. The numbers come off the
|
|
# VESSEL definitions, so the outpost never learns which vessel needs what -
|
|
# add an armed vessel with a magazine and its outpost storage follows.
|
|
#
|
|
# This is a CAPACITY, not a stock. Hub transporters fill it the same way they
|
|
# fill a gas burner's buffer (see building_logistics_deficit), and the
|
|
# garrison AI spends from it.
|
|
|
|
func garrison_magazine_capacity(id: int, resource_type: String) -> int:
|
|
var total: int = 0
|
|
for vessel_type in garrison_crew(id):
|
|
var definition: Resource = vessel_definition(String(vessel_type))
|
|
if definition == null:
|
|
continue
|
|
if String(definition.garrison_ammo_resource) != resource_type:
|
|
continue
|
|
total += int(definition.garrison_ammo_capacity)
|
|
return total
|
|
|
|
|
|
# Every resource this outpost wants held, as {resource_type: capacity}.
|
|
func garrison_magazines(id: int) -> Dictionary:
|
|
var out: Dictionary = {}
|
|
for vessel_type in garrison_crew(id):
|
|
var definition: Resource = vessel_definition(String(vessel_type))
|
|
if definition == null:
|
|
continue
|
|
var resource_type: String = String(definition.garrison_ammo_resource)
|
|
if resource_type == "" or int(definition.garrison_ammo_capacity) <= 0:
|
|
continue
|
|
out[resource_type] = int(out.get(resource_type, 0)) \
|
|
+ int(definition.garrison_ammo_capacity)
|
|
return out
|
|
|
|
|
|
# Spends from the outpost's own store. Returns false when it cannot pay in
|
|
# full - a half-priced shot would be worse than no shot.
|
|
func spend_garrison_ammo(id: int, resource_type: String, amount: int) -> bool:
|
|
if amount <= 0:
|
|
return true
|
|
var inventory: RefCounted = building_inventory(id)
|
|
if inventory == null or inventory.amount(resource_type) < amount:
|
|
return false
|
|
inventory.withdraw(resource_type, amount)
|
|
return true
|
|
|
|
|
|
func garrison_crew_of(id: int, vessel_type: String) -> int:
|
|
var n: int = 0
|
|
for crewed in garrison_crew(id):
|
|
if String(crewed) == vessel_type:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
# What this outpost is still short of, as {vessel_type: count}. The one
|
|
# question #104 has to answer every tick, so it is asked here rather than
|
|
# recomputed by whoever needs it.
|
|
func garrison_shortfall(id: int) -> Dictionary:
|
|
var out: Dictionary = {}
|
|
for vessel_type in garrison_desired(id):
|
|
# What is already on its way counts. Without this the dispatcher
|
|
# would send the whole hangar to fill one berth, because the crew
|
|
# does not grow until the first arrival lands.
|
|
var missing: int = int(garrison_desired(id)[vessel_type]) \
|
|
- garrison_crew_of(id, String(vessel_type)) \
|
|
- garrison_inbound_of(id, String(vessel_type))
|
|
if missing > 0:
|
|
out[vessel_type] = missing
|
|
return out
|
|
|
|
|
|
func garrison_inbound_of(id: int, vessel_type: String) -> int:
|
|
var n: int = 0
|
|
for delivery in vessel_deliveries:
|
|
if int(delivery.get("to_outpost", 0)) == id \
|
|
and String(delivery.type) == vessel_type:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func garrison_inbound(id: int) -> int:
|
|
var n: int = 0
|
|
for delivery in vessel_deliveries:
|
|
if int(delivery.get("to_outpost", 0)) == id:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func outpost_ids() -> Array:
|
|
var out: Array = []
|
|
for b in buildings:
|
|
if is_outpost(b):
|
|
out.append(int(b.id))
|
|
return out
|
|
|
|
|
|
func building_has_capability(building: Dictionary, capability: String) -> bool:
|
|
if building.is_empty():
|
|
return false
|
|
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
|
|
return definition != null and capability in definition.capabilities
|
|
|
|
|
|
func building_logistics_profile(building: Dictionary) -> Resource:
|
|
if building.is_empty():
|
|
return null
|
|
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
|
|
return null if definition == null else definition.logistics
|
|
|
|
|
|
# --- vessel types and their upgrades (#5) ---------------------------------
|
|
|
|
# Combat vessels can join the player fleet. Transporter metadata lives in the
|
|
# same definition catalog so the lab can upgrade its cargo and travel stats,
|
|
# but transporters themselves belong to hubs and are never production orders.
|
|
const VESSEL_INTERCEPTOR: String = "interceptor"
|
|
const VESSEL_SHIELD: String = "shield"
|
|
const VESSEL_GAS_BOMBER: String = "gas_bomber"
|
|
const VESSEL_TRANSPORTER: String = "transporter"
|
|
const VESSEL_CARRIER: String = "carrier"
|
|
const VESSEL_LASER: String = "laser"
|
|
const VESSEL_DISTORDER: String = "distorder"
|
|
|
|
# Compatibility constants for callers and old tests. Runtime metadata is
|
|
# resolved from VesselDefinition below.
|
|
const SHIELD_COST: int = 14
|
|
const LAB_RESEARCH_TIME: float = 8.0
|
|
|
|
|
|
# Test seam. Headless tests need the timed path - queued, started, finishing
|
|
# - without sitting through a real 10-second build each, and a suite that
|
|
# slow stops being run. Left at 1.0 in the game.
|
|
var build_time_scale: float = 1.0
|
|
|
|
|
|
func build_time(product: String) -> float:
|
|
var definition: Resource = VESSEL_DEFINITIONS.get_definition(product)
|
|
return (definition.build_time if definition != null else 10.0) * build_time_scale
|
|
|
|
|
|
func lab_research_time() -> float:
|
|
return LAB_RESEARCH_TIME * build_time_scale
|
|
|
|
# Compatibility constants. VesselDefinition owns the live base figures.
|
|
const SHIP_BASE_DAMAGE: int = 1
|
|
const SHIP_BASE_COOLDOWN: float = 0.5
|
|
const SHIP_BASE_RANGE: float = 320.0
|
|
const SHIP_BASE_SPEED: float = 460.0
|
|
const SHIP_BASE_ACCEL: float = 450.0
|
|
const SHIELD_BASE_CHARGES: int = 1
|
|
const SHIELD_BASE_RECHARGE: float = 6.0
|
|
var vessel_levels: Dictionary = {}
|
|
|
|
signal vessel_upgraded(upgrade_id: String)
|
|
|
|
|
|
func has_ship_lab() -> bool:
|
|
for b in buildings:
|
|
if building_has_capability(b, "vessel_research"):
|
|
return true
|
|
return false
|
|
|
|
|
|
func shield_vessel_unlocked() -> bool:
|
|
return vessel_is_unlocked(VESSEL_SHIELD)
|
|
|
|
|
|
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
|
|
const UNLOCK_SHIELD_COST: int = 45
|
|
|
|
|
|
func unlock_shield_vessel() -> bool:
|
|
return unlock_vessel(VESSEL_SHIELD)
|
|
|
|
|
|
func vessel_definition(vessel: String) -> Resource:
|
|
return VESSEL_DEFINITIONS.get_definition(vessel)
|
|
|
|
|
|
func vessel_name(vessel: String) -> String:
|
|
var definition: Resource = vessel_definition(vessel)
|
|
return definition.display_name if definition != null else vessel.capitalize()
|
|
|
|
|
|
func vessel_has_capability(vessel: String, capability: String) -> bool:
|
|
var definition: Resource = vessel_definition(vessel)
|
|
return definition != null and definition.has_capability(capability)
|
|
|
|
|
|
func vessel_is_fleet_eligible(vessel: String) -> bool:
|
|
var definition: Resource = vessel_definition(vessel)
|
|
return definition != null and definition.fleet_eligible
|
|
|
|
|
|
func vessel_is_unlocked(vessel: String) -> bool:
|
|
var definition: Resource = vessel_definition(vessel)
|
|
if definition == null:
|
|
return false
|
|
return definition.unlock_id == "" or int(vessel_levels.get(definition.unlock_id, 0)) > 0
|
|
|
|
|
|
func vessel_for_unlock(unlock_id: String) -> Resource:
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
if definition.unlock_id == unlock_id:
|
|
return definition
|
|
return null
|
|
|
|
|
|
func unlock_vessel(vessel: String) -> bool:
|
|
var definition: Resource = vessel_definition(vessel)
|
|
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
|
|
return false
|
|
var costs: Dictionary = research_costs(definition.unlock_id)
|
|
var points_needed: int = int(definition.research_point_cost)
|
|
if research_points < points_needed or not spend_resources(costs):
|
|
return false
|
|
if points_needed > 0:
|
|
research_points -= points_needed
|
|
research_points_changed.emit(research_points)
|
|
vessel_levels[definition.unlock_id] = 1
|
|
vessel_upgraded.emit(definition.unlock_id)
|
|
return true
|
|
|
|
|
|
func upgrade_def(id: String) -> Dictionary:
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
for upgrade in definition.upgrades:
|
|
if upgrade.upgrade_id == id:
|
|
return {"id": upgrade.upgrade_id, "vessel": definition.definition_id,
|
|
"name": upgrade.display_name, "desc": upgrade.description,
|
|
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
|
|
"icon": upgrade.icon, "resource": upgrade}
|
|
return {}
|
|
|
|
|
|
func upgrades_for(vessel: String) -> Array:
|
|
var out: Array = []
|
|
var definition: Resource = vessel_definition(vessel)
|
|
if definition == null:
|
|
return out
|
|
for upgrade in definition.upgrades:
|
|
out.append({"id": upgrade.upgrade_id, "vessel": vessel,
|
|
"name": upgrade.display_name, "desc": upgrade.description,
|
|
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
|
|
"icon": upgrade.icon, "resource": upgrade})
|
|
return out
|
|
|
|
|
|
func upgrade_level(id: String) -> int:
|
|
return int(vessel_levels.get(id, 0))
|
|
|
|
|
|
func upgrade_maxed(id: String) -> bool:
|
|
var d: Dictionary = upgrade_def(id)
|
|
return d.is_empty() or upgrade_level(id) >= int(d.max_level)
|
|
|
|
|
|
func upgrade_cost(id: String) -> int:
|
|
var d: Dictionary = upgrade_def(id)
|
|
if d.is_empty():
|
|
return 0
|
|
return int(d.cost) * (upgrade_level(id) + 1)
|
|
|
|
|
|
func can_buy_upgrade(id: String) -> bool:
|
|
return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) \
|
|
and research_points >= research_point_cost(id)
|
|
|
|
|
|
func buy_upgrade(id: String) -> bool:
|
|
if not can_buy_upgrade(id):
|
|
return false
|
|
if not spend(upgrade_cost(id)):
|
|
return false
|
|
var points_needed: int = research_point_cost(id)
|
|
if points_needed > 0:
|
|
research_points -= points_needed
|
|
research_points_changed.emit(research_points)
|
|
vessel_levels[id] = upgrade_level(id) + 1
|
|
vessel_upgraded.emit(id)
|
|
return true
|
|
|
|
|
|
func vessel_stat(vessel: String, stat: String) -> float:
|
|
var definition: Resource = vessel_definition(vessel)
|
|
if definition == null:
|
|
return 0.0
|
|
var value: float = float(definition.base_stats.get(stat, 0.0))
|
|
for upgrade in definition.upgrades:
|
|
var level: int = upgrade_level(upgrade.upgrade_id)
|
|
value += float(upgrade.additive_modifiers.get(stat, 0.0)) * level
|
|
if upgrade.multiplicative_modifiers.has(stat):
|
|
value *= pow(float(upgrade.multiplicative_modifiers[stat]), level)
|
|
if definition.minimum_stats.has(stat):
|
|
value = maxf(value, float(definition.minimum_stats[stat]))
|
|
return value
|
|
|
|
|
|
func ship_damage(vessel: String) -> int: return int(vessel_stat(vessel, "damage"))
|
|
func ship_fire_cooldown(vessel: String) -> float: return vessel_stat(vessel, "fire_cooldown")
|
|
func ship_fire_range(vessel: String) -> float: return vessel_stat(vessel, "fire_range")
|
|
func ship_max_speed(vessel: String) -> float: return vessel_stat(vessel, "max_speed")
|
|
func ship_acceleration(vessel: String) -> float: return vessel_stat(vessel, "acceleration")
|
|
|
|
func shield_max_charges() -> int:
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
|
|
return int(vessel_stat(definition.definition_id, "shield_charges"))
|
|
|
|
func shield_recharge_time() -> float:
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
|
|
return vessel_stat(definition.definition_id, "shield_recharge")
|
|
|
|
# #56: sums cargo_capacity over ships that actually declare the "cargo"
|
|
# capability, so combat specialists (no capability, whatever their stat
|
|
# dict says) contribute nothing - data-driven rather than a vessel-id list.
|
|
# Takes the composition as an argument rather than reading it off a live
|
|
# Fleet node: GameState has no fixed reference to "the" Fleet (space and a
|
|
# planet surface each own a separate one), so the caller passes its own
|
|
# Fleet.vessel_types().
|
|
func fleet_cargo_capacity(vessel_types: PackedStringArray) -> int:
|
|
var total: int = 0
|
|
for vessel_type in vessel_types:
|
|
if vessel_has_capability(vessel_type, "cargo"):
|
|
total += int(vessel_stat(vessel_type, "cargo_capacity"))
|
|
return total
|
|
|
|
|
|
func sync_fleet_cargo_capacity(vessel_types: PackedStringArray) -> void:
|
|
fleet_inventory.total_capacity = fleet_cargo_capacity(vessel_types)
|
|
|
|
|
|
func transporter_load_limit() -> int:
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
|
|
return int(vessel_stat(definition.definition_id, "cargo_capacity"))
|
|
|
|
func transporter_speed() -> float:
|
|
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
|
|
return vessel_stat(definition.definition_id, "transport_speed")
|
|
|
|
|
|
const PRODUCT_SHIP: String = "interceptor"
|
|
|
|
|
|
func product_cost(product: String) -> int:
|
|
return int(product_costs(product).get(RESOURCE_ORE, 0))
|
|
|
|
|
|
func product_costs(product: String) -> Dictionary:
|
|
var definition: Resource = vessel_definition(product)
|
|
if definition == null:
|
|
return {}
|
|
if not definition.build_resource_costs.is_empty():
|
|
return definition.build_resource_costs.duplicate()
|
|
return {RESOURCE_ORE: definition.build_cost} if definition.build_cost > 0 else {}
|
|
|
|
|
|
func can_produce(type: String) -> bool:
|
|
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
|
|
return definition != null and "production" in definition.capabilities
|
|
|
|
|
|
func building_stock(id: int) -> int:
|
|
return building_resource(id, RESOURCE_ORE)
|
|
|
|
|
|
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 String(o.product) == product:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func order_list(id: int) -> Array:
|
|
# What the queue panel draws: product, and how far along the one being
|
|
# built is. Everything behind it reads as simply waiting.
|
|
var out: Array = []
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return out
|
|
for o in b.get("orders", []):
|
|
var product: String = String(o.product)
|
|
out.append({
|
|
"product": product,
|
|
"started": bool(o.started),
|
|
"progress": clampf(float(o.elapsed) / maxf(build_time(product), 0.001), 0.0, 1.0),
|
|
})
|
|
return out
|
|
|
|
|
|
func cancel_order(id: int, index: int) -> bool:
|
|
# Refunds whatever was actually taken: a build that has started has its
|
|
# ore in the building already, one still waiting never paid.
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return false
|
|
var orders: Array = b.get("orders", [])
|
|
if index < 0 or index >= orders.size():
|
|
return false
|
|
var order: Dictionary = orders[index]
|
|
if bool(order.started):
|
|
var inventory: RefCounted = _inventory_for_record(b)
|
|
var costs: Dictionary = product_costs(String(order.product))
|
|
for resource_type in costs:
|
|
inventory.deposit(String(resource_type), int(costs[resource_type]))
|
|
orders.remove_at(index)
|
|
return true
|
|
|
|
|
|
func outstanding_cost(id: int) -> int:
|
|
return outstanding_resource_cost(id, RESOURCE_ORE)
|
|
|
|
|
|
func outstanding_costs(id: int) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for resource_type in resource_types():
|
|
var need: int = outstanding_resource_cost(id, resource_type)
|
|
if need > 0:
|
|
result[resource_type] = need
|
|
return result
|
|
|
|
|
|
func outstanding_resource_cost(id: int, resource_type: String) -> 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", []):
|
|
if not bool(o.started):
|
|
need += int(product_costs(String(o.product)).get(resource_type, 0))
|
|
for order in b.get("research_orders", []):
|
|
if not bool(order.started):
|
|
need += int(research_costs(String(order.id)).get(resource_type, 0))
|
|
return maxi(need - _inventory_for_record(b).amount(resource_type), 0)
|
|
|
|
|
|
func deposit_to_building(id: int, amount: int) -> int:
|
|
return deposit_resource_to_building(id, RESOURCE_ORE, amount)
|
|
|
|
|
|
func deposit_resource_to_building(id: int, resource_type: String, amount: int) -> int:
|
|
if amount <= 0:
|
|
return 0
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return 0
|
|
return _inventory_for_record(b).deposit(resource_type, 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
|
|
var definition: Resource = vessel_definition(product)
|
|
if definition == null or not definition.fleet_eligible \
|
|
or not vessel_is_unlocked(product):
|
|
return false
|
|
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
|
|
return true
|
|
|
|
|
|
func _advance_production(delta: float) -> void:
|
|
for b in buildings:
|
|
if not can_produce(b.type):
|
|
continue
|
|
var orders: Array = b.get("orders", [])
|
|
if orders.is_empty():
|
|
continue
|
|
|
|
# ONE at a time, in order. A queue the player can watch and cancel
|
|
# has to have a thing being built and things waiting behind it -
|
|
# clearing the whole queue the instant the ore lands (which is what
|
|
# this did before #23) leaves nothing to see or cancel.
|
|
var order: Dictionary = orders[0]
|
|
var product: String = String(order.product)
|
|
var costs: Dictionary = product_costs(product)
|
|
var inventory: RefCounted = _inventory_for_record(b)
|
|
var definition: Resource = vessel_definition(product)
|
|
|
|
if not bool(order.started):
|
|
var affordable := true
|
|
for resource_type in costs:
|
|
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
|
|
affordable = false
|
|
break
|
|
if not affordable:
|
|
continue
|
|
for resource_type in costs:
|
|
inventory.withdraw(String(resource_type), int(costs[resource_type]))
|
|
order.started = true
|
|
|
|
order.elapsed = float(order.elapsed) + delta
|
|
if float(order.elapsed) < build_time(product):
|
|
continue
|
|
|
|
orders.pop_front()
|
|
# A finished combat hull launches from the crafter and physically flies
|
|
# to the mothership hangar. Hub transporters are provisioned by hubs and
|
|
# never enter this production path.
|
|
var mothership: Variant = get_mothership_position()
|
|
if mothership == null:
|
|
stored_vessels.append(product)
|
|
else:
|
|
vessel_deliveries.append({"type": product, "from": b.position,
|
|
"to": mothership, "progress": 0.0})
|
|
order_completed.emit(int(b.id), product)
|
|
|
|
|
|
# --- crewing an outpost from the mothership (#104) --------------------------
|
|
#
|
|
# "the game should transfer, whenever available, ships from the mothership to
|
|
# the outpost to always fullfill the outpost conditions if possible."
|
|
#
|
|
# Vessels come from the HANGAR - stored_vessels - and never from the fleet.
|
|
# The fleet is what the player is flying; taking a vessel out of it to crew a
|
|
# building would be the game undoing the player's own arrangement. "Whenever
|
|
# available" therefore means "when one is sitting at the mothership", and an
|
|
# outpost asking for a hull nobody has built simply waits.
|
|
#
|
|
# They travel rather than teleporting, down the same vessel_deliveries path a
|
|
# finished crafter order already uses to reach the mothership.
|
|
|
|
func _garrison_still_wants(id: int, vessel_type: String) -> bool:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty() or not is_outpost(b):
|
|
return false
|
|
return garrison_crew_of(id, vessel_type) < garrison_desired_of(id, vessel_type)
|
|
|
|
|
|
func _take_stored_vessel(vessel_type: String) -> bool:
|
|
var index: int = stored_vessels.find(vessel_type)
|
|
if index < 0:
|
|
return false
|
|
stored_vessels.remove_at(index)
|
|
return true
|
|
|
|
|
|
func _advance_garrison_dispatch() -> void:
|
|
var mothership: Variant = get_mothership_position()
|
|
if mothership == null:
|
|
return
|
|
for b in buildings:
|
|
if not is_outpost(b):
|
|
continue
|
|
var id: int = int(b.id)
|
|
# Over strength first: an order the player has just lowered should
|
|
# release the vessel back to the hangar rather than stranding it.
|
|
_return_surplus_garrison(b)
|
|
for vessel_type in garrison_shortfall(id):
|
|
var needed: int = int(garrison_shortfall(id)[vessel_type])
|
|
for i in range(needed):
|
|
if not _take_stored_vessel(String(vessel_type)):
|
|
break
|
|
vessel_deliveries.append({
|
|
"type": String(vessel_type), "from": mothership,
|
|
"to": b.position, "progress": 0.0, "to_outpost": id,
|
|
})
|
|
|
|
|
|
func _return_surplus_garrison(b: Dictionary) -> void:
|
|
var id: int = int(b.id)
|
|
var mothership: Variant = get_mothership_position()
|
|
if mothership == null:
|
|
return
|
|
var crew: Array = garrison_crew(id)
|
|
for i in range(crew.size() - 1, -1, -1):
|
|
var vessel_type: String = String(crew[i])
|
|
if garrison_crew_of(id, vessel_type) <= garrison_desired_of(id, vessel_type):
|
|
continue
|
|
crew.remove_at(i)
|
|
vessel_deliveries.append({
|
|
"type": vessel_type, "from": b.position, "to": mothership,
|
|
"progress": 0.0, "to_outpost": 0,
|
|
})
|
|
|
|
|
|
# --- standing orders, and the census behind them (#106) --------------------
|
|
#
|
|
# "how many ships of a give type should always be available over the whole
|
|
# game (fleet + storage + outposts)".
|
|
#
|
|
# The targets are GLOBAL, not per building. There is one plan for the whole
|
|
# game; a second Planning Center is a second place to read and edit the same
|
|
# plan, not a competing one. Storing them per building would mean two
|
|
# centres could hold contradictory orders and the crafters would be told to
|
|
# satisfy both.
|
|
#
|
|
# The census is the harder half, and the rule is that every vessel is
|
|
# counted exactly once, wherever it happens to be at that instant - flying
|
|
# in the fleet, parked in the hangar, stationed at an outpost, or in the air
|
|
# between any two of those. Miss the in-flight ones and the planner queues a
|
|
# replacement for a vessel that is merely en route.
|
|
|
|
var vessel_targets: Dictionary = {}
|
|
|
|
|
|
func has_planning_center() -> bool:
|
|
for b in buildings:
|
|
if building_has_capability(b, "planning"):
|
|
return true
|
|
return false
|
|
|
|
|
|
func vessel_target(vessel_type: String) -> int:
|
|
return int(vessel_targets.get(vessel_type, 0))
|
|
|
|
|
|
func set_vessel_target(vessel_type: String, count: int) -> int:
|
|
if not vessel_is_fleet_eligible(vessel_type):
|
|
return 0
|
|
var final_count: int = maxi(count, 0)
|
|
if final_count == 0:
|
|
vessel_targets.erase(vessel_type)
|
|
else:
|
|
vessel_targets[vessel_type] = final_count
|
|
return final_count
|
|
|
|
|
|
func adjust_vessel_target(vessel_type: String, delta: int) -> int:
|
|
return set_vessel_target(vessel_type, vessel_target(vessel_type) + delta)
|
|
|
|
|
|
func planned_vessel_types() -> PackedStringArray:
|
|
var out: PackedStringArray = PackedStringArray()
|
|
for definition in VESSEL_DEFINITIONS.all():
|
|
if bool(definition.fleet_eligible):
|
|
out.append(String(definition.definition_id))
|
|
return out
|
|
|
|
|
|
# How many of this type exist anywhere. Broken out per place rather than
|
|
# summed inline so the census can be read - and tested - one location at a
|
|
# time.
|
|
func vessel_census(vessel_type: String) -> int:
|
|
return vessel_count_in_fleet(vessel_type) \
|
|
+ vessel_count_in_hangar(vessel_type) \
|
|
+ vessel_count_in_garrisons(vessel_type) \
|
|
+ vessel_count_in_transit(vessel_type)
|
|
|
|
|
|
func vessel_count_in_fleet(vessel_type: String) -> int:
|
|
var n: int = 0
|
|
for flying in fleet_vessel_types:
|
|
if String(flying) == vessel_type:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func vessel_count_in_hangar(vessel_type: String) -> int:
|
|
var n: int = 0
|
|
for stored in stored_vessels:
|
|
if String(stored) == vessel_type:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func vessel_count_in_garrisons(vessel_type: String) -> int:
|
|
var n: int = 0
|
|
for id in outpost_ids():
|
|
n += garrison_crew_of(id, vessel_type)
|
|
return n
|
|
|
|
|
|
# Both directions of flight: a finished hull on its way to the mothership,
|
|
# and a vessel being moved to or from an outpost. All of them are vessels
|
|
# that exist and will land somewhere.
|
|
func vessel_count_in_transit(vessel_type: String) -> int:
|
|
var n: int = 0
|
|
for delivery in vessel_deliveries:
|
|
if String(delivery.type) == vessel_type:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
# Ordered but not yet built. Kept separate from the census on purpose: these
|
|
# do not exist yet, so a readout has to be able to say "4 of 6, 2 building"
|
|
# rather than claiming six.
|
|
func vessel_count_on_order(vessel_type: String) -> int:
|
|
var n: int = 0
|
|
for b in buildings:
|
|
if not can_produce(String(b.type)):
|
|
continue
|
|
for order in b.get("orders", []):
|
|
if String(order.product) == vessel_type:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func vessel_shortfall(vessel_type: String) -> int:
|
|
return maxi(vessel_target(vessel_type)
|
|
- vessel_census(vessel_type) - vessel_count_on_order(vessel_type), 0)
|
|
|
|
|
|
# --- the planner working the crafters (#107) -------------------------------
|
|
#
|
|
# "the planning center automatically manage the ship craft building queues to
|
|
# ensure that the needed vessels are produced to fullfill the constraints."
|
|
#
|
|
# Three rules keep this from fighting the player:
|
|
#
|
|
# It TOPS UP, never rewrites. Orders placed by hand are left exactly where
|
|
# they are; the planner only ever appends, and only for a type that is
|
|
# genuinely short. Cancelling one of the player's orders to make room for
|
|
# one of its own would be the game arguing with them.
|
|
#
|
|
# Everything counts. A vessel already queued, already flying to the
|
|
# mothership, or already stationed at an outpost is a vessel that exists or
|
|
# shortly will - see vessel_shortfall(). Without that it re-queues the same
|
|
# shortfall every tick and buries the player in hulls.
|
|
#
|
|
# Nothing happens without a planning centre. Destroy it and the standing
|
|
# orders stop being acted on, though they are remembered, so rebuilding
|
|
# picks up where it left off rather than starting from a blank plan.
|
|
|
|
func _crafter_for_planning() -> Dictionary:
|
|
# The shortest queue, so orders spread across the crafters that exist
|
|
# rather than piling onto whichever happens to be first in the list.
|
|
var best: Dictionary = {}
|
|
var best_queue: int = 0
|
|
for b in buildings:
|
|
if not can_produce(String(b.type)):
|
|
continue
|
|
var queued: int = (b.get("orders", []) as Array).size()
|
|
if best.is_empty() or queued < best_queue:
|
|
best = b
|
|
best_queue = queued
|
|
return best
|
|
|
|
|
|
func _advance_planning() -> void:
|
|
if vessel_targets.is_empty() or not has_planning_center():
|
|
return
|
|
for vessel_type in vessel_targets:
|
|
var type_id: String = String(vessel_type)
|
|
if not vessel_is_unlocked(type_id):
|
|
continue
|
|
var missing: int = vessel_shortfall(type_id)
|
|
if missing <= 0:
|
|
continue
|
|
var crafter: Dictionary = _crafter_for_planning()
|
|
if crafter.is_empty():
|
|
return
|
|
# ONE per pass, not the whole shortfall. The queue is spread across
|
|
# crafters by _crafter_for_planning(), and asking for the whole
|
|
# shortfall at once would put it all on the shortest queue before
|
|
# that had a chance to change.
|
|
queue_order(int(crafter.id), type_id)
|
|
|
|
|
|
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 == "":
|
|
return false
|
|
var orders: Array = lab.get("research_orders", [])
|
|
var unlock_definition: Resource = vessel_for_unlock(upgrade_id)
|
|
if not orders.is_empty() or (unlock_definition == null and upgrade_maxed(upgrade_id)):
|
|
return false
|
|
orders.append({"id": upgrade_id, "elapsed": 0.0, "started": false})
|
|
lab.research_orders = orders
|
|
return true
|
|
|
|
|
|
func lab_research_order(lab_id: int) -> Dictionary:
|
|
var lab: Dictionary = find_building(lab_id)
|
|
var orders: Array = lab.get("research_orders", [])
|
|
return {} if orders.is_empty() else orders[0]
|
|
|
|
|
|
func research_cost(id: String) -> int:
|
|
return int(research_costs(id).get(RESOURCE_ORE, 0))
|
|
|
|
|
|
func research_point_cost(id: String) -> int:
|
|
var unlock_definition: Resource = vessel_for_unlock(id)
|
|
if unlock_definition != null:
|
|
return int(unlock_definition.research_point_cost)
|
|
var d: Dictionary = upgrade_def(id)
|
|
return int(d.resource.research_point_cost) if not d.is_empty() else 0
|
|
|
|
|
|
func research_costs(id: String) -> Dictionary:
|
|
var unlock_definition: Resource = vessel_for_unlock(id)
|
|
if unlock_definition != null:
|
|
if not unlock_definition.unlock_resource_costs.is_empty():
|
|
return unlock_definition.unlock_resource_costs.duplicate()
|
|
return {RESOURCE_ORE: unlock_definition.unlock_cost} if unlock_definition.unlock_cost > 0 else {}
|
|
var cost: int = upgrade_cost(id)
|
|
return {RESOURCE_ORE: cost} if cost > 0 else {}
|
|
|
|
|
|
func _advance_lab_research(delta: float) -> void:
|
|
for lab in buildings:
|
|
if not building_has_capability(lab, "vessel_research"):
|
|
continue
|
|
var orders: Array = lab.get("research_orders", [])
|
|
if orders.is_empty():
|
|
continue
|
|
var order: Dictionary = orders[0]
|
|
var id: String = String(order.id)
|
|
var unlock_definition: Resource = vessel_for_unlock(id)
|
|
var costs: Dictionary = research_costs(id)
|
|
var inventory: RefCounted = _inventory_for_record(lab)
|
|
if not bool(order.started):
|
|
var affordable := true
|
|
for resource_type in costs:
|
|
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
|
|
affordable = false
|
|
break
|
|
# #63: research points are a fleet-wide currency, not delivered
|
|
# to the lab like ore/gas - checked and spent here too so a
|
|
# shortfall stalls the order the same way an undelivered
|
|
# resource does, rather than letting it start regardless.
|
|
var points_needed: int = research_point_cost(id)
|
|
if points_needed > research_points:
|
|
affordable = false
|
|
if not affordable:
|
|
continue
|
|
for resource_type in costs:
|
|
inventory.withdraw(String(resource_type), int(costs[resource_type]))
|
|
if points_needed > 0:
|
|
research_points -= points_needed
|
|
research_points_changed.emit(research_points)
|
|
order.started = true
|
|
order.elapsed = float(order.elapsed) + delta
|
|
if float(order.elapsed) < lab_research_time():
|
|
continue
|
|
if unlock_definition != null:
|
|
vessel_levels[id] = 1
|
|
else:
|
|
vessel_levels[id] = upgrade_level(id) + 1
|
|
vessel_upgraded.emit(id)
|
|
orders.pop_front()
|
|
|
|
|
|
func claim_stored_vessel(index: int) -> String:
|
|
# Hands one finished vessel over to be crewed. Returns its type, or ""
|
|
# when there is nothing there - the caller decides whether the fleet
|
|
# actually has room.
|
|
if index < 0 or index >= stored_vessels.size():
|
|
return ""
|
|
return String(stored_vessels[index])
|
|
|
|
|
|
func take_stored_vessel(index: int) -> String:
|
|
var product: String = claim_stored_vessel(index)
|
|
if product == "":
|
|
return ""
|
|
stored_vessels.remove_at(index)
|
|
return product
|
|
|
|
|
|
func has_mothership() -> bool:
|
|
return get_mothership_position() != null
|
|
|
|
|
|
func has_building_of_type(type: String) -> bool:
|
|
for building in buildings:
|
|
if building.type == type:
|
|
return true
|
|
return false
|
|
|
|
|
|
func remove_buildings_of_type(type: String) -> int:
|
|
var kept: Array = []
|
|
var removed_hub_ids: Array = []
|
|
var removed: int = 0
|
|
for b in buildings:
|
|
if b.type == type:
|
|
removed += 1
|
|
if building_has_capability(b, "transport_hub"):
|
|
removed_hub_ids.append(int(b.id))
|
|
else:
|
|
kept.append(b)
|
|
buildings = kept
|
|
if not removed_hub_ids.is_empty():
|
|
var kept_transporters: Array = []
|
|
for transporter in transporters:
|
|
if int(transporter.get("hub_id", 0)) not in removed_hub_ids:
|
|
kept_transporters.append(transporter)
|
|
transporters = kept_transporters
|
|
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 building_has_capability(b, "bank"):
|
|
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.
|
|
# Ten slots, because max_fleet_size() caps itself on this array's length -
|
|
# so the wedge has to be long enough for every doctrine to be worth buying.
|
|
# The last pair continues the V rather than starting a new shape.
|
|
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),
|
|
Vector2(-120, 112), Vector2(120, 112),
|
|
]
|
|
|
|
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 ------------------------------------------------------------
|
|
|
|
# Extractors mine into their OWN local stock. They do not own a magical
|
|
# hauler: ore becomes spendable only when a hub-stationed transporter picks
|
|
# up a full load and physically delivers it to the mothership. Entries are
|
|
# {"position", "planet_id", "planet_name", "resource_type", "inventory", "mining_progress"}.
|
|
# Ore per second, per extractor.
|
|
#
|
|
# Halved once alongside the planet-density change in WorldGen (roughly 2.5x as
|
|
# many planets to build on, so holding the rate would have multiplied passive
|
|
# income rather than moving where it comes from), and halved again because it
|
|
# still outran the rest of the economy. At 0.5 an extractor pays back its own
|
|
# 25-ore cost in fifty seconds and yields 30 a minute after that, so a
|
|
# single lucky early site cannot carry a game and expanding is what grows one.
|
|
const EXTRACTOR_RATE: float = 0.5
|
|
|
|
var extractors: Array = []
|
|
|
|
|
|
func has_extractor_at(planet_key: String) -> bool:
|
|
for e in extractors:
|
|
# Old saves have no planet_id; their name remains a migration fallback.
|
|
if String(e.get("planet_id", e.get("planet_name", ""))) == planet_key:
|
|
return true
|
|
return false
|
|
|
|
|
|
func extractor_index_at(planet_key: String) -> int:
|
|
for i in range(extractors.size()):
|
|
var extractor: Dictionary = extractors[i]
|
|
if String(extractor.get("planet_id", extractor.get("planet_name", ""))) == planet_key:
|
|
return i
|
|
return -1
|
|
|
|
|
|
signal extractor_added(planet_name: String)
|
|
|
|
|
|
func add_extractor(planet_name: String, at_position: Vector2, resource_type: String = "ore",
|
|
planet_key: String = "") -> void:
|
|
var stable_id: String = planet_key if planet_key != "" else planet_name
|
|
extractors.append({
|
|
"position": at_position,
|
|
"planet_id": stable_id,
|
|
"planet_name": planet_name,
|
|
"resource_type": resource_type,
|
|
"inventory": INVENTORY.new(),
|
|
"mining_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(stable_id)
|
|
|
|
|
|
func extractor_stock(index: int) -> int:
|
|
if index < 0 or index >= extractors.size():
|
|
return 0
|
|
var extractor: Dictionary = extractors[index]
|
|
return _extractor_inventory(extractor).amount(String(extractor.get("resource_type", RESOURCE_ORE)))
|
|
|
|
|
|
func extractor_resource(index: int, resource_type: String) -> int:
|
|
if index < 0 or index >= extractors.size():
|
|
return 0
|
|
return _extractor_inventory(extractors[index]).amount(resource_type)
|
|
|
|
|
|
func transfer_resource_to_extractor(index: int, resource_type: String, amount: int) -> int:
|
|
if index < 0 or index >= extractors.size():
|
|
return 0
|
|
return fleet_inventory.transfer_to(_extractor_inventory(extractors[index]), resource_type, amount)
|
|
|
|
|
|
func transfer_resource_from_extractor(index: int, resource_type: String, amount: int) -> int:
|
|
if index < 0 or index >= extractors.size():
|
|
return 0
|
|
return _extractor_inventory(extractors[index]).transfer_to(fleet_inventory, resource_type, amount)
|
|
|
|
|
|
func _extractor_inventory(extractor: Dictionary) -> RefCounted:
|
|
var inventory: RefCounted = extractor.get("inventory") as RefCounted
|
|
if inventory == null:
|
|
inventory = INVENTORY.new()
|
|
extractor.inventory = inventory
|
|
# Old extractor state had one stock field whose material was described by
|
|
# resource_type. Absorb it into that key on first access.
|
|
if extractor.has("stock"):
|
|
inventory.set_amount(String(extractor.get("resource_type", RESOURCE_ORE)), int(extractor.stock))
|
|
extractor.erase("stock")
|
|
return inventory
|
|
|
|
|
|
# --- building logistics and resource conversion ---------------------------
|
|
|
|
func building_logistics_deficit(id: int, resource_type: String) -> int:
|
|
var building: Dictionary = find_building(id)
|
|
var target: int = 0
|
|
var profile: Resource = building_logistics_profile(building)
|
|
if profile != null:
|
|
target = int(profile.input_buffers.get(resource_type, 0))
|
|
# An outpost has no logistics profile at all - what it wants held depends
|
|
# 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))
|
|
return maxi(0, target - building_resource(id, resource_type))
|
|
|
|
|
|
func building_requested_resource(id: int, resource_type: String) -> int:
|
|
return maxi(outstanding_resource_cost(id, resource_type),
|
|
building_logistics_deficit(id, resource_type))
|
|
|
|
|
|
func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String:
|
|
var profile: Resource = building_logistics_profile(building)
|
|
if profile == null:
|
|
return ""
|
|
for resource_type in profile.collected_outputs:
|
|
if building_resource(int(building.id), resource_type) >= minimum_amount:
|
|
return resource_type
|
|
return ""
|
|
|
|
|
|
# #78: a converter can be switched off. A gas burner turns gas the player may
|
|
# want for bombs into energy they may not need yet, and until now it did that
|
|
# relentlessly - the only way to stop it was to demolish it. Paused is stored
|
|
# on the building record like every other piece of its state.
|
|
func is_building_paused(id: int) -> bool:
|
|
return bool(find_building(id).get("paused", false))
|
|
|
|
|
|
func set_building_paused(id: int, paused: bool) -> void:
|
|
var building: Dictionary = find_building(id)
|
|
if building.is_empty():
|
|
return
|
|
building.paused = paused
|
|
building_paused_changed.emit(id, paused)
|
|
|
|
|
|
func toggle_building_paused(id: int) -> bool:
|
|
var paused: bool = not is_building_paused(id)
|
|
set_building_paused(id, paused)
|
|
return paused
|
|
|
|
|
|
signal building_paused_changed(id: int, paused: bool)
|
|
|
|
|
|
func _advance_resource_converters(delta: float) -> void:
|
|
for building in buildings:
|
|
var profile: Resource = building_logistics_profile(building)
|
|
if profile == null or profile.conversion_input == "" \
|
|
or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \
|
|
or profile.conversion_yield <= 0:
|
|
continue
|
|
if bool(building.get("paused", false)):
|
|
# Progress is left exactly where it was rather than reset, so
|
|
# switching a burner off and on again does not throw away the
|
|
# fraction of a unit it had already worked through.
|
|
continue
|
|
var inventory: RefCounted = _inventory_for_record(building)
|
|
if inventory.amount(profile.conversion_input) <= 0:
|
|
continue
|
|
var progress: float = float(building.get("conversion_progress", 0.0)) \
|
|
+ delta * profile.conversion_rate
|
|
var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input))
|
|
if whole > 0:
|
|
inventory.withdraw(profile.conversion_input, whole)
|
|
inventory.deposit(profile.conversion_output, whole * profile.conversion_yield)
|
|
progress -= whole
|
|
building.conversion_progress = progress
|
|
|
|
|
|
func _advance_distorder_drain(delta: float) -> void:
|
|
if fleet_speed_multiplier <= 1.0:
|
|
# Nothing owed at 1.0x - and no stale fractional progress should
|
|
# carry over into a LATER, unrelated boost.
|
|
_distorder_drain_progress = 0.0
|
|
return
|
|
# Normalized against the CURRENT ceiling, so "full boost" always costs
|
|
# ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it -
|
|
# a bigger fleet buys more speed, not cheaper speed.
|
|
var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \
|
|
* ENERGY_DRAIN_PER_SECOND
|
|
_distorder_drain_progress += rate * delta
|
|
var whole: int = int(floor(_distorder_drain_progress))
|
|
if whole <= 0:
|
|
return
|
|
_distorder_drain_progress -= whole
|
|
var available: int = fleet_inventory.amount(RESOURCE_ENERGY)
|
|
if available >= whole:
|
|
fleet_inventory.withdraw(RESOURCE_ENERGY, whole)
|
|
return
|
|
# Ran out mid-drain: take whatever is left (down to exactly zero) and
|
|
# force the boost back to its default, rather than let the fleet keep
|
|
# travelling fast on credit.
|
|
fleet_inventory.withdraw(RESOURCE_ENERGY, available)
|
|
fleet_speed_multiplier = 1.0
|
|
_distorder_drain_progress = 0.0
|
|
boost_depleted_notice = true
|
|
|
|
|
|
# Planet conquest: a planet must have every one of its capture points held
|
|
# before an extractor can be built there (see BuildController's placement
|
|
# gates). Keyed by the same stable planet_id as `extractors`, entirely
|
|
# separate from it - a planet can be conquered long before anyone builds an
|
|
# extractor on it, or never get one at all.
|
|
var planet_capture: Dictionary = {}
|
|
|
|
signal planet_conquered(planet_id: String)
|
|
|
|
|
|
func register_capture_points(planet_key: String, total: int, definition_id: String = "",
|
|
reward_override: int = 0) -> void:
|
|
# Idempotent: landing again on a planet must not wipe points already
|
|
# captured on a previous visit.
|
|
if planet_capture.has(planet_key):
|
|
return
|
|
var captured: Array = []
|
|
captured.resize(total)
|
|
captured.fill(false)
|
|
# A planet demanding zero points is vacuously already held (#55: a
|
|
# black hole's "void" profile has no authored PlanetSetup at all, so
|
|
# this is the only registration it ever gets) - every planet WITH real
|
|
# points still starts unconquered, exactly as before. The planet definition
|
|
# supplies an optional conquest reward, avoiding planet-id special cases in
|
|
# the capture system.
|
|
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
|
|
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
|
|
# A map may be worth more than its planet type's flat rate - a harder
|
|
# lab should pay better, and only the authored layout knows how hard it
|
|
# is. Zero means the setup did not express an opinion.
|
|
if reward_override > 0:
|
|
point_reward = reward_override
|
|
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0,
|
|
"research_point_reward": point_reward, "research_awarded": false}
|
|
|
|
|
|
func capture_state(planet_key: String) -> Dictionary:
|
|
return planet_capture.get(planet_key, {"captured": [], "conquered": false})
|
|
|
|
|
|
func is_point_captured(planet_key: String, index: int) -> bool:
|
|
var captured: Array = capture_state(planet_key).get("captured", [])
|
|
if index < 0 or index >= captured.size():
|
|
return false
|
|
return bool(captured[index])
|
|
|
|
|
|
func mark_point_captured(planet_key: String, index: int) -> void:
|
|
if not planet_capture.has(planet_key):
|
|
return
|
|
var state: Dictionary = planet_capture[planet_key]
|
|
var captured: Array = state.captured
|
|
if index < 0 or index >= captured.size():
|
|
return
|
|
captured[index] = true
|
|
if state.conquered:
|
|
return
|
|
if captured.all(func(c): return bool(c)):
|
|
state.conquered = true
|
|
planet_conquered.emit(planet_key)
|
|
# #63: definition-authored research points, exactly once per planet.
|
|
# state.conquered flipping above already makes a second call to this
|
|
# whole function a no-op via the early return above it, but
|
|
# research_awarded is its own explicit guard on the award itself
|
|
# regardless of how conquest was reached.
|
|
var reward: int = int(state.get("research_point_reward", 0))
|
|
if reward > 0 and not bool(state.get("research_awarded", false)):
|
|
state.research_awarded = true
|
|
research_points += reward
|
|
research_points_changed.emit(research_points)
|
|
|
|
|
|
func is_planet_conquered(planet_key: String) -> bool:
|
|
return bool(capture_state(planet_key).get("conquered", false))
|
|
|
|
|
|
func captured_point_count(planet_key: String) -> int:
|
|
var captured: Array = capture_state(planet_key).get("captured", [])
|
|
var count: int = 0
|
|
for c in captured:
|
|
if c:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func total_capture_points(planet_key: String) -> int:
|
|
return capture_state(planet_key).get("captured", []).size()
|
|
|
|
|
|
# --- waves (#100) ----------------------------------------------------------
|
|
#
|
|
# #97 replaces the roaming spawner - one enemy every few seconds, forever -
|
|
# with waves: a long quiet, a warning, then an attack. The CLOCK lives here
|
|
# rather than in the space scene for the usual reason, that Main.tscn is
|
|
# destroyed on every landing; a wave must not be cancelled, nor restarted
|
|
# from zero, by a trip to a planet.
|
|
#
|
|
# What is deliberately NOT here is the spawning. GameState cannot see the
|
|
# world, so EnemySpawner reads the phase and puts the enemies in it. That
|
|
# also settles what happens if the wave turns while the player is on a
|
|
# surface: the phase goes to ACTIVE with nothing to spawn it, and the wave
|
|
# is waiting in space when they come back.
|
|
const WAVE_PHASE_PEACE: String = "peace"
|
|
const WAVE_PHASE_WARNING: String = "warning"
|
|
const WAVE_PHASE_ACTIVE: String = "active"
|
|
|
|
const WAVE_PEACE_SECONDS: float = 50.0
|
|
# How long the alert is up before anything arrives. Long enough to fly home
|
|
# and take a position, short enough that it is a warning and not a chore.
|
|
const WAVE_WARNING_SECONDS: float = 9.0
|
|
# A wave that wandered off and never died must not stall the next one for
|
|
# ever. The director normally ends the phase by clearing the wave.
|
|
const WAVE_MAX_SECONDS: float = 240.0
|
|
|
|
var wave_number: int = 0
|
|
var wave_phase: String = WAVE_PHASE_PEACE
|
|
var wave_timer: float = 0.0
|
|
# Set when a wave turns ACTIVE, cleared by whoever puts it in the world.
|
|
# Without it a director arriving mid-wave (the player just came back from a
|
|
# planet) could not tell "this wave still owes you enemies" from "this wave
|
|
# is already out there and half dead".
|
|
var wave_pending: bool = false
|
|
|
|
|
|
func reset_waves() -> void:
|
|
wave_number = 0
|
|
wave_phase = WAVE_PHASE_PEACE
|
|
wave_timer = 0.0
|
|
wave_pending = false
|
|
|
|
|
|
func wave_is_incoming() -> bool:
|
|
return wave_phase == WAVE_PHASE_WARNING
|
|
|
|
|
|
# Deliberately NOT a remaining-seconds accessor. #97: "No timer should be
|
|
# visible" - so the one number that could be rendered as a countdown is not
|
|
# offered to the UI at all, and the alert cannot drift into being a clock.
|
|
func wave_warning_pulse() -> float:
|
|
if wave_phase != WAVE_PHASE_WARNING:
|
|
return 0.0
|
|
return 0.5 + 0.5 * sin(wave_timer * 7.0)
|
|
|
|
|
|
# Strength is a function of the FLEET, per #97 - a bigger fleet is a bigger
|
|
# target, so growing it raises the stakes rather than trivialising them.
|
|
# The wave number is a slower second term, so a player who never grows still
|
|
# meets escalation rather than the same two chasers all game.
|
|
func wave_strength(fleet_size: int) -> int:
|
|
# The flat term matters: without it the opening wave against a one-vessel
|
|
# fleet is a single chaser, which reads as the system not working rather
|
|
# than as an easy first wave.
|
|
return 3 + maxi(fleet_size, 1) * 3 + wave_number * 2
|
|
|
|
|
|
# Spent most-expensive-first, so a large budget buys heavier enemies rather
|
|
# than an ever-larger swarm of chasers. min_strength keeps the openings
|
|
# honest: the first waves are chasers whatever the arithmetic says.
|
|
const WAVE_ROSTER: Array = [
|
|
{"id": "artillery", "cost": 9, "min_strength": 26},
|
|
{"id": "sniper", "cost": 6, "min_strength": 16},
|
|
{"id": "lancer", "cost": 4, "min_strength": 12},
|
|
{"id": "chaser", "cost": 3, "min_strength": 0},
|
|
]
|
|
const WAVE_MAX_ENEMIES: int = 18
|
|
|
|
|
|
func wave_composition(fleet_size: int) -> PackedStringArray:
|
|
var budget: int = wave_strength(fleet_size)
|
|
var strength: int = budget
|
|
var out: PackedStringArray = PackedStringArray()
|
|
for entry in WAVE_ROSTER:
|
|
if strength < int(entry.min_strength):
|
|
continue
|
|
var cost: int = int(entry.cost)
|
|
# The cheapest tier is last and takes whatever is left, so the budget
|
|
# is always spent rather than rounded away.
|
|
var share: int = budget if String(entry.id) == "chaser" else budget / 2
|
|
while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES:
|
|
out.append(String(entry.id))
|
|
budget -= cost
|
|
share -= cost
|
|
if out.is_empty():
|
|
out.append("chaser")
|
|
return out
|
|
|
|
|
|
func advance_waves(delta: float) -> void:
|
|
wave_timer += delta
|
|
match wave_phase:
|
|
WAVE_PHASE_PEACE:
|
|
if wave_timer >= WAVE_PEACE_SECONDS:
|
|
wave_phase = WAVE_PHASE_WARNING
|
|
wave_timer = 0.0
|
|
WAVE_PHASE_WARNING:
|
|
if wave_timer >= WAVE_WARNING_SECONDS:
|
|
wave_phase = WAVE_PHASE_ACTIVE
|
|
wave_timer = 0.0
|
|
wave_number += 1
|
|
wave_pending = true
|
|
WAVE_PHASE_ACTIVE:
|
|
if wave_timer >= WAVE_MAX_SECONDS:
|
|
wave_cleared()
|
|
|
|
|
|
func wave_cleared() -> void:
|
|
wave_phase = WAVE_PHASE_PEACE
|
|
wave_timer = 0.0
|
|
wave_pending = false
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
# Runs in the autoload, so mining and transport continue while the player
|
|
# is on a planet's surface or nowhere near either.
|
|
advance_waves(delta)
|
|
_advance_production(delta)
|
|
_advance_lab_research(delta)
|
|
_advance_vessel_deliveries(delta)
|
|
_advance_garrison_dispatch()
|
|
_advance_planning()
|
|
_advance_resource_converters(delta)
|
|
_advance_distorder_drain(delta)
|
|
for e in extractors:
|
|
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
|
|
var whole: int = int(floor(float(e.mining_progress)))
|
|
if whole > 0:
|
|
_extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole)
|
|
e.mining_progress = float(e.mining_progress) - whole
|
|
_advance_transporters(delta)
|
|
|
|
|
|
func vessel_delivery_position(delivery: Dictionary) -> Vector2:
|
|
return (delivery.from as Vector2).lerp(delivery.to as Vector2,
|
|
clampf(float(delivery.progress), 0.0, 1.0))
|
|
|
|
|
|
func _advance_vessel_deliveries(delta: float) -> void:
|
|
for i in range(vessel_deliveries.size() - 1, -1, -1):
|
|
var delivery: Dictionary = vessel_deliveries[i]
|
|
var distance: float = (delivery.from as Vector2).distance_to(delivery.to as Vector2)
|
|
var delivery_speed: float = vessel_stat(String(delivery.type), "delivery_speed")
|
|
if delivery_speed <= 0.0:
|
|
delivery_speed = VESSEL_DELIVERY_SPEED
|
|
delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001)
|
|
if float(delivery.progress) < 1.0:
|
|
continue
|
|
vessel_deliveries.remove_at(i)
|
|
# #104: a vessel bound for an outpost joins its crew. If the outpost
|
|
# was levelled while it was in the air, or the order was cancelled
|
|
# under it, it lands in the hangar instead - the vessel is never
|
|
# destroyed by its destination going away, the same rule a
|
|
# transporter mid-delivery follows with its cargo.
|
|
var outpost_id: int = int(delivery.get("to_outpost", 0))
|
|
if outpost_id != 0 and _garrison_still_wants(outpost_id, String(delivery.type)):
|
|
garrison_crew(outpost_id).append(String(delivery.type))
|
|
else:
|
|
stored_vessels.append(String(delivery.type))
|
|
|
|
|
|
# --- 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"}.
|
|
# How many transporters each hub owns. Raised for EVERY hub - built already
|
|
# or not - by research, and provisioned automatically rather than crafted.
|
|
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"
|
|
const TRANSPORTER_TO_EXTRACTOR: String = "to_extractor"
|
|
const TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: String = "extractor_to_mothership"
|
|
# Collecting an output resource (energy) directly from a regular building's
|
|
# own inventory - a gas burner, not an extractor - and banking it. Reuses
|
|
# `target_id` exactly like the ordinary supply path does, rather than
|
|
# `extractor_index`: the source is a building, already addressable that way.
|
|
const TRANSPORTER_TO_BUILDING_PICKUP: String = "to_building_pickup"
|
|
const TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: String = "building_to_mothership"
|
|
|
|
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 building_has_capability(b, "transport_hub"):
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func _add_hub_transporter(hub: Dictionary) -> void:
|
|
transporters.append({
|
|
"hub_id": int(hub.id), "state": TRANSPORTER_IDLE, "target_id": 0,
|
|
"extractor_index": -1, "carrying": 0, "resource_type": RESOURCE_ORE,
|
|
"progress": 1.0, "from": hub.position, "to": hub.position,
|
|
})
|
|
|
|
|
|
func _provision_hub_transporters(hub: Dictionary) -> void:
|
|
var stationed: int = 0
|
|
for transporter in transporters:
|
|
if int(transporter.get("hub_id", 0)) == int(hub.id):
|
|
stationed += 1
|
|
while stationed < hub_capacity():
|
|
_add_hub_transporter(hub)
|
|
stationed += 1
|
|
|
|
|
|
func _sync_hub_transporters() -> void:
|
|
for hub in buildings:
|
|
if building_has_capability(hub, "transport_hub"):
|
|
_provision_hub_transporters(hub)
|
|
|
|
|
|
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:
|
|
# Delivery-purpose states only (#54): a building can have BOTH a
|
|
# transporter delivering it something (gas, for a burner) and a
|
|
# separate one collecting something from it (energy) at once, since
|
|
# they are two different transporters and two different legs. Matching
|
|
# on target_id alone, regardless of state, would make either purpose
|
|
# block the other from ever being dispatched to the same building.
|
|
for t in transporters:
|
|
if (t.state == TRANSPORTER_TO_MOTHERSHIP or t.state == TRANSPORTER_TO_BUILDING) \
|
|
and int(t.target_id) == building_id:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _transporter_inbound_for_pickup(building_id: int) -> bool:
|
|
for t in transporters:
|
|
if (t.state == TRANSPORTER_TO_BUILDING_PICKUP or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING) \
|
|
and int(t.target_id) == building_id:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _transporter_inbound_to_extractor(index: int) -> bool:
|
|
for t in transporters:
|
|
if t.state == TRANSPORTER_TO_EXTRACTOR \
|
|
or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
|
|
if int(t.get("extractor_index", -1)) == index:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _dispatch_transporters() -> void:
|
|
var mothership: Variant = get_mothership_position()
|
|
if mothership == null:
|
|
return # nowhere to load from
|
|
# Supply already-queued production and research before collecting passive
|
|
# extractor output. Extractors can refill forever, so servicing them first
|
|
# can otherwise consume every transporter each time one becomes idle and
|
|
# permanently starve a partly supplied order.
|
|
for b in buildings:
|
|
var needed_resource := ""
|
|
for resource_type in resource_types():
|
|
var need: int = building_requested_resource(int(b.id), resource_type)
|
|
if need > 0 and bank_inventory.amount(resource_type) > 0:
|
|
needed_resource = resource_type
|
|
break
|
|
if needed_resource == "":
|
|
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 resources 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)
|
|
chosen.resource_type = needed_resource
|
|
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
|
|
|
|
# A distant extractor is worth visiting only once it has a full hold ready.
|
|
# This keeps the transporter system meaningful: mining is local until a
|
|
# vessel physically carries a researched-sized load home.
|
|
var load: int = transporter_load_limit()
|
|
for i in range(extractors.size()):
|
|
var extractor: Dictionary = extractors[i]
|
|
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
|
|
if _extractor_inventory(extractor).amount(resource_id) < load or _transporter_inbound_to_extractor(i):
|
|
continue
|
|
var chosen_extractor: Dictionary = {}
|
|
var best_extractor_distance: float = INF
|
|
for t in transporters:
|
|
if t.state != TRANSPORTER_IDLE:
|
|
continue
|
|
var d: float = transporter_position(t).distance_squared_to(extractor.position)
|
|
if d < best_extractor_distance:
|
|
best_extractor_distance = d
|
|
chosen_extractor = t
|
|
if chosen_extractor.is_empty():
|
|
return
|
|
chosen_extractor.extractor_index = i
|
|
chosen_extractor.resource_type = resource_id
|
|
chosen_extractor.target_id = 0
|
|
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
|
|
TRANSPORTER_TO_EXTRACTOR)
|
|
|
|
# Collect building outputs last: queued deliveries and extractor collection
|
|
# both outrank resources that are merely ready for pickup.
|
|
for b in buildings:
|
|
var pickup_resource: String = _collectable_building_resource(b, load)
|
|
if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)):
|
|
continue
|
|
var chosen_pickup: Dictionary = {}
|
|
var best_pickup_distance: float = INF
|
|
for t in transporters:
|
|
if t.state != TRANSPORTER_IDLE:
|
|
continue
|
|
var d: float = transporter_position(t).distance_squared_to(b.position)
|
|
if d < best_pickup_distance:
|
|
best_pickup_distance = d
|
|
chosen_pickup = t
|
|
if chosen_pickup.is_empty():
|
|
return
|
|
chosen_pickup.target_id = int(b.id)
|
|
chosen_pickup.resource_type = pickup_resource
|
|
chosen_pickup.extractor_index = -1
|
|
_begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position,
|
|
TRANSPORTER_TO_BUILDING_PICKUP)
|
|
|
|
|
|
func _advance_transporters(delta: float) -> void:
|
|
_sync_hub_transporters()
|
|
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 resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
|
|
var want: int = building_requested_resource(int(t.target_id), resource_type)
|
|
var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit())
|
|
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
|
|
bank_inventory.withdraw(resource_type, take)
|
|
t.carrying = take
|
|
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
|
|
TRANSPORTER_TO_BUILDING:
|
|
# The cargo moves at exactly the moment the vessel arrives.
|
|
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
|
|
if deposit_resource_to_building(int(t.target_id), resource_type, int(t.carrying)) <= 0:
|
|
# Building demolished in transit - put the cargo back rather
|
|
# than quietly destroying it.
|
|
bank_inventory.deposit(resource_type, 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_TO_EXTRACTOR:
|
|
var extractor_index: int = int(t.get("extractor_index", -1))
|
|
if extractor_index < 0 or extractor_index >= extractors.size():
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
|
return
|
|
var extractor: Dictionary = extractors[extractor_index]
|
|
var load: int = transporter_load_limit()
|
|
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
|
|
var extractor_inventory: RefCounted = _extractor_inventory(extractor)
|
|
if extractor_inventory.amount(resource_id) < load:
|
|
t.extractor_index = -1
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
|
return
|
|
extractor_inventory.withdraw(resource_id, load)
|
|
t.carrying = load
|
|
t.resource_type = resource_id
|
|
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
|
|
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
|
|
# Bank only on arrival; cargo retains the extractor's resource type.
|
|
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ORE)), int(t.carrying))
|
|
t.carrying = 0
|
|
t.extractor_index = -1
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
|
TRANSPORTER_TO_BUILDING_PICKUP:
|
|
var source: Dictionary = find_building(int(t.target_id))
|
|
var resource_type: String = String(t.get("resource_type", RESOURCE_ENERGY))
|
|
var take: int = 0 if source.is_empty() \
|
|
else mini(building_resource(int(t.target_id), resource_type), transporter_load_limit())
|
|
if take <= 0 or source.is_empty():
|
|
t.target_id = 0
|
|
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
|
return
|
|
_inventory_for_record(source).withdraw(resource_type, take)
|
|
t.carrying = take
|
|
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING)
|
|
TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING:
|
|
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ENERGY)), 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
|
|
|
|
|
|
# --- paying for things ------------------------------------------------------
|
|
#
|
|
# EVERY payment draws on the bank AND on what the ships are carrying. The
|
|
# player can already move resources between the two by hand at any building
|
|
# that takes them, so refusing a purchase that a couple of taps would have
|
|
# made affordable was pure ceremony.
|
|
#
|
|
# In one case it was worse than ceremony. A fresh game starts with an empty
|
|
# bank, ore is mined into the fleet hold, and the only way to bank it is to
|
|
# deliver it to a mothership - so charging construction to the bank alone
|
|
# made the first mothership cost ore that could not yet exist. Mine forever,
|
|
# build nothing.
|
|
#
|
|
# Bank first, hold second. The hold is also the fleet's mining capacity, so
|
|
# leaving it as full as possible means the next trip out can still carry
|
|
# something back.
|
|
#
|
|
# The one deliberate exception is AMMO - see AimedFleetAbilityController,
|
|
# which checks fleet_resource() directly. A gas bomb is fired from what the
|
|
# ships are physically carrying; that is a fact about where the matter is,
|
|
# not a convenience about where the money is.
|
|
|
|
|
|
func available_resource(resource_type: String) -> int:
|
|
return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type)
|
|
|
|
|
|
func available_ore() -> int:
|
|
return available_resource(RESOURCE_ORE)
|
|
|
|
|
|
func can_afford(cost: int) -> bool:
|
|
return available_ore() >= cost
|
|
|
|
|
|
func can_afford_resources(costs: Dictionary) -> bool:
|
|
for resource_type in costs:
|
|
if available_resource(String(resource_type)) < int(costs[resource_type]):
|
|
return false
|
|
return true
|
|
|
|
|
|
func spend(cost: int) -> bool:
|
|
return spend_resources({RESOURCE_ORE: cost})
|
|
|
|
|
|
func spend_resources(costs: Dictionary) -> bool:
|
|
# Affordability is settled in full BEFORE anything is taken. Charging for
|
|
# part of a purchase that then fails is worse than refusing it, and with
|
|
# several resource types in one price that is a real possibility rather
|
|
# than a theoretical one.
|
|
if not can_afford_resources(costs):
|
|
return false
|
|
for resource_type in costs:
|
|
var id: String = String(resource_type)
|
|
var owed: int = int(costs[resource_type])
|
|
owed -= bank_inventory.withdraw(id, owed)
|
|
if owed > 0:
|
|
fleet_inventory.withdraw(id, owed)
|
|
return true
|