3464 lines
130 KiB
GDScript
3464 lines
130 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")
|
|
const QUESTS = preload("res://core/Quests.gd")
|
|
const CATALOG_CHECK = preload("res://core/CatalogCheck.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()
|
|
# Where the fleet is, written by Fleet each frame. Only #158 needs it: a
|
|
# building may draw on the hold when the hold is actually there, and this
|
|
# autoload has no other way to know.
|
|
var fleet_position: Vector2 = Vector2.ZERO
|
|
# #122: the prologue is running. Set by reset_new_game() so it plays once at
|
|
# the start of a NEW game and never on a resumed one, and cleared by the
|
|
# director when the prologue hands over.
|
|
#
|
|
# It is a flag rather than a separate scene because the prologue DIRECTS the
|
|
# real game: the real fleet, the real enemies, the real asteroid field. That
|
|
# is also what makes "then the game starts" cost nothing - the director stops
|
|
# steering and there is nothing left to load.
|
|
var intro_active: bool = false
|
|
# #173: what the startup catalog check found, if anything. Empty in a healthy
|
|
# build; see CatalogCheck for why a build can be unhealthy without anything
|
|
# else noticing.
|
|
var catalog_faults: PackedStringArray = PackedStringArray()
|
|
# #124: the breach takes the wheel. Movement input is ignored while this is
|
|
# set, so the fleet goes where the script sends it rather than fighting the
|
|
# player for the last few seconds of the prologue.
|
|
var intro_locks_movement: bool = false
|
|
# Short in-world reveals (currently the boss arrival) use their own lock so
|
|
# they do not pretend the prologue is active merely to steer the fleet for a
|
|
# few seconds.
|
|
var encounter_locks_movement: bool = false
|
|
# #123: the prologue's convoy is bigger than any fleet the player can field
|
|
# yet - it is not their fleet, it is the one they are about to lose. An
|
|
# explicit override rather than a fake doctrine, so nothing about the
|
|
# player's own progression has to be bent to fit a cutscene.
|
|
var intro_fleet_size: int = 0
|
|
|
|
# --- what the player is actually holding (#171) -----------------------------
|
|
#
|
|
# "The web export should auto detect keyboard mouse or screen and remove the
|
|
# touch screen controls if not needed."
|
|
#
|
|
# DisplayServer.is_touchscreen_available() is the wrong question in a browser
|
|
# and always was. It answers "could a touch arrive", and a desktop browser on
|
|
# a laptop with a touch-capable panel - or one that merely reports
|
|
# navigator.maxTouchPoints - says yes. So the thumb sticks and the pivot pair
|
|
# sat over a screen being played with a mouse, and mouse aiming, which the
|
|
# same query gates, was switched off on the machine it exists for.
|
|
#
|
|
# The right question is what the player LAST actually used, which is a fact
|
|
# rather than a capability, and it is asked here rather than in a controller
|
|
# because two of them need the same answer: GameController shows or hides the
|
|
# on-screen controls, and MouseCombatController decides whether a click fires.
|
|
# One answer, so they can never disagree.
|
|
#
|
|
# The catch is that Godot can manufacture the other kind of event from each
|
|
# one - the pointing/emulate_* settings in project.godot turn taps into clicks
|
|
# and clicks into taps. Taken at face value a phone would look like it had a
|
|
# mouse the instant a finger touched the glass. So a touch SUPPRESSES mouse
|
|
# evidence for a moment afterwards: a manufactured click always follows its
|
|
# touch within the same frame, well inside the window, while a player who
|
|
# genuinely puts a finger down and then reaches for the mouse takes far longer
|
|
# than this to do it. Correct whether that emulation is on or off - with it
|
|
# off there is simply nothing for the window to suppress.
|
|
const POINTER_TOUCH_GRACE_MSEC: int = 1000
|
|
# Seeded from the display's own answer so the very first frame is sensible on
|
|
# a phone, and immediately overwritten by the first real input either way.
|
|
var pointer_is_touch: bool = DisplayServer.is_touchscreen_available()
|
|
# When a touch was last seen, in engine milliseconds. -1 for never. Public so
|
|
# a test can age it out rather than sleeping for a second.
|
|
var last_touch_msec: int = -1
|
|
|
|
|
|
func note_touch_input() -> void:
|
|
last_touch_msec = Time.get_ticks_msec()
|
|
pointer_is_touch = true
|
|
|
|
|
|
# A mouse or a key. Ignored while a touch is still recent, because that is
|
|
# what a manufactured event looks like.
|
|
func note_pointer_input() -> void:
|
|
if last_touch_msec >= 0 \
|
|
and Time.get_ticks_msec() - last_touch_msec < POINTER_TOUCH_GRACE_MSEC:
|
|
return
|
|
pointer_is_touch = false
|
|
|
|
|
|
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_unlimited_resources: bool = false
|
|
var dev_mode: bool = false:
|
|
set(value):
|
|
dev_mode = value
|
|
if not value:
|
|
dev_unlimited_resources = false
|
|
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 SHIP_COST: int = 6
|
|
# #138: the per-building cost constants that used to sit here are gone. A
|
|
# structure's price lives in its own BuildingDefinition and is read through
|
|
# building_costs(), which is the only thing that knows about the resources
|
|
# beyond ore that some of them now want. Two places holding the same number
|
|
# is how they end up disagreeing.
|
|
|
|
# --- 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"
|
|
# #116: repairs what the waves knock down.
|
|
const BUILDING_WORKSHOP: String = "spacial_workshop"
|
|
# Extractors are not placed in space like the others - they are built ON a
|
|
# planet's surface and tracked separately (see `extractors`).
|
|
const BUILDING_EXTRACTOR: String = "extractor"
|
|
# 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"
|
|
|
|
|
|
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",
|
|
"costs": {"ore": 30, "gas": 12},
|
|
"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",
|
|
"costs": {"ore": 110, "gas": 35},
|
|
"x": 0.66, "outpost_bonus": 1, "research_points": 2,
|
|
"icon": 40,
|
|
"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,
|
|
"costs": {"ore": 140, "gas": 40},
|
|
"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.",
|
|
},
|
|
{
|
|
# Behind power_systems rather than off the fleet branch: a workshop
|
|
# is infrastructure that looks after infrastructure, and it should
|
|
# arrive after the player has some worth looking after.
|
|
"id": "field_engineering", "name": "Field Engineering", "cost": 95,
|
|
"costs": {"ore": 95, "gas": 35, "energy": 20},
|
|
"requires": "power_systems", "x": 0.34,
|
|
"unlocks": BUILDING_WORKSHOP, "research_points": 2,
|
|
"desc": "Build spacial workshops. Their drones fly out and repair damaged structures, spending ore delivered to the workshop.",
|
|
},
|
|
{
|
|
# Same shape as freight_yards and garrison_bays: a permanent bonus
|
|
# that reaches workshops already standing.
|
|
"id": "drone_bays", "name": "Drone Bays", "cost": 150,
|
|
"costs": {"ore": 150, "gas": 50, "energy": 35},
|
|
"requires": "field_engineering", "x": 0.34,
|
|
"workshop_bonus": 1, "research_points": 2, "icon": 43,
|
|
"desc": "Permanent: +1 repair drone at every spacial workshop, including ones already built.",
|
|
},
|
|
{
|
|
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
|
|
"costs": {"ore": 40, "gas": 15},
|
|
"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",
|
|
"costs": {"ore": 35, "gas": 10},
|
|
"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",
|
|
"costs": {"ore": 80, "gas": 30},
|
|
"x": 0.18, "hub_bonus": 1, "research_points": 2,
|
|
"icon": 12,
|
|
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
|
|
},
|
|
{
|
|
"id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight",
|
|
"costs": {"ore": 45, "gas": 35},
|
|
"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,
|
|
"icon": 2,
|
|
"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",
|
|
"costs": {"ore": 35, "gas": 12},
|
|
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
|
|
"icon": 2,
|
|
"desc": "Permanent: +1 vessel in the fleet.",
|
|
},
|
|
{
|
|
"id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron",
|
|
"costs": {"ore": 90, "gas": 30, "energy": 15},
|
|
"x": 0.82, "fleet_bonus": 1, "research_points": 2,
|
|
"icon": 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",
|
|
"costs": {"ore": 180, "gas": 60, "energy": 40},
|
|
"x": 0.82, "fleet_bonus": 2, "research_points": 4,
|
|
"icon": 2,
|
|
"desc": "Permanent: +2 vessels in the fleet.",
|
|
},
|
|
{
|
|
"id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup",
|
|
"costs": {"ore": 320, "gas": 110, "energy": 80, "dark_matter": 20},
|
|
"x": 0.82, "fleet_bonus": 2, "research_points": 6,
|
|
"icon": 2,
|
|
"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)
|
|
|
|
|
|
# What one doctrine costs. Same rule as buildings and vessels (#138): the
|
|
# node's own dictionary when it has one, its ore figure otherwise.
|
|
#
|
|
# `cost` stays the ore figure either way rather than being folded into the
|
|
# dictionary and deleted, so the tree's own display - and everything that
|
|
# ever compared two nodes by price - keeps working on a plain number.
|
|
func skill_costs(id: String) -> Dictionary:
|
|
var node: Dictionary = skill_node(id)
|
|
if node.is_empty():
|
|
return {}
|
|
var costs: Dictionary = node.get("costs", {})
|
|
if not costs.is_empty():
|
|
return costs.duplicate()
|
|
return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {}
|
|
|
|
|
|
func can_unlock(id: String, payment_position: Variant = null) -> bool:
|
|
var node: Dictionary = skill_node(id)
|
|
return not is_unlocked(id) and prerequisite_met(id) \
|
|
and can_afford_resources(skill_costs(id), payment_position) \
|
|
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, payment_position: Variant = null) -> bool:
|
|
if not can_unlock(id, payment_position):
|
|
return false
|
|
var node: Dictionary = skill_node(id)
|
|
if not spend_resources(skill_costs(id), payment_position):
|
|
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()
|
|
if int(node.get("workshop_bonus", 0)) > 0:
|
|
_sync_workshop_drones()
|
|
return true
|
|
|
|
|
|
# #112: the mark drawn inside a skill node. A node that unlocks a structure
|
|
# borrows that structure's own menu icon, so the tree and the build menu
|
|
# cannot disagree about what a shipyard looks like; the rest - doctrines and
|
|
# permanent bonuses, which have no building to point at - carry an explicit
|
|
# one. -1 means "no mark", and the node is drawn hollow as it always was.
|
|
func skill_node_icon(node: Dictionary) -> int:
|
|
if node.is_empty():
|
|
return -1
|
|
var unlocks: String = String(node.get("unlocks", ""))
|
|
if unlocks != "":
|
|
var definition: Resource = building_definition(unlocks)
|
|
if definition != null:
|
|
return int(definition.menu_icon)
|
|
return int(node.get("icon", -1))
|
|
|
|
|
|
func is_building_unlocked(type: String) -> bool:
|
|
for node in SKILL_TREE:
|
|
if node.get("unlocks", "") == type:
|
|
return is_unlocked(node.id)
|
|
return true
|
|
|
|
|
|
# The doctrine that unlocks a structure, BY ID - the handle everything else
|
|
# in the tree is keyed on (skill_depth, skill_costs, is_unlocked).
|
|
func research_node_for_building(type: String) -> String:
|
|
for node in SKILL_TREE:
|
|
if node.get("unlocks", "") == type:
|
|
return String(node.id)
|
|
return ""
|
|
|
|
|
|
# The same thing by NAME, which is what a message shows the player. Kept
|
|
# apart from the id above because the two are easy to mix up and the mistake
|
|
# is silent: skill_depth() of a display name finds no node and answers 0, so
|
|
# a deep structure reads as an ungated one.
|
|
func research_for_building(type: String) -> String:
|
|
var id: String = research_node_for_building(type)
|
|
return "" if id == "" else String(skill_node(id).name)
|
|
|
|
|
|
func max_fleet_size() -> int:
|
|
if intro_fleet_size > 0:
|
|
return mini(intro_fleet_size, DEFAULT_FORMATION.size())
|
|
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:
|
|
intro_active = true
|
|
intro_locks_movement = false
|
|
encounter_locks_movement = false
|
|
intro_fleet_size = 0
|
|
has_return_position = false
|
|
surviving_ship_count = STARTING_SHIP_COUNT
|
|
surviving_vessel_types = PackedStringArray()
|
|
fleet_vessel_types = PackedStringArray()
|
|
fleet_position = Vector2.ZERO
|
|
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_unlimited_resources = false
|
|
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()
|
|
repair_drones.clear()
|
|
planet_capture.clear()
|
|
vessel_targets.clear()
|
|
reset_waves()
|
|
reset_quests(true)
|
|
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
|
|
const DEV_UNLIMITED_AMOUNT: int = 999999
|
|
|
|
|
|
# A post-prologue 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. Dev mode is for iterating on the game itself, not replaying the long
|
|
# scripted introduction before every check.
|
|
#
|
|
# 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
|
|
intro_active = false
|
|
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)
|
|
|
|
|
|
# Development toggles deliberately live beside the state they affect. The
|
|
# menu is only a front-end; scene swaps cannot turn an infinite bank off or
|
|
# forget which doctrines were exposed.
|
|
func set_dev_unlimited_resources(enabled: bool) -> bool:
|
|
if enabled and not dev_mode:
|
|
return false
|
|
dev_unlimited_resources = enabled
|
|
if enabled:
|
|
_refill_dev_resources()
|
|
return true
|
|
|
|
|
|
func _refill_dev_resources() -> void:
|
|
for resource_type in resource_types():
|
|
if bank_inventory.amount(resource_type) < DEV_UNLIMITED_AMOUNT:
|
|
bank_inventory.set_amount(resource_type, DEV_UNLIMITED_AMOUNT)
|
|
if research_points < DEV_UNLIMITED_AMOUNT:
|
|
research_points = DEV_UNLIMITED_AMOUNT
|
|
research_points_changed.emit(research_points)
|
|
|
|
|
|
func dev_unlock_all_skills() -> int:
|
|
if not dev_mode:
|
|
return 0
|
|
var added: int = 0
|
|
for node in SKILL_TREE:
|
|
var id: String = String(node.id)
|
|
if id in unlocked:
|
|
continue
|
|
unlocked.append(id)
|
|
added += 1
|
|
# These permanent doctrines affect structures which may already exist.
|
|
# Sync once after the batch instead of once per node.
|
|
_sync_hub_transporters()
|
|
_sync_workshop_drones()
|
|
return added
|
|
|
|
|
|
# --- 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)
|
|
if building_has_capability(building, "repair"):
|
|
_provision_workshop_drones(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.
|
|
|
|
# What one structure costs, in every resource it wants.
|
|
#
|
|
# #138: the same shape product_costs() already answers for vessels - the
|
|
# dictionary when a definition carries one, the ore figure otherwise - so
|
|
# there is one question with one answer whatever is being bought.
|
|
func building_costs(type: String) -> Dictionary:
|
|
var definition: Resource = building_definition(type)
|
|
if definition == null:
|
|
return {}
|
|
if not definition.build_resource_costs.is_empty():
|
|
return definition.build_resource_costs.duplicate()
|
|
return {RESOURCE_ORE: int(definition.cost)} if int(definition.cost) > 0 else {}
|
|
|
|
|
|
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.
|
|
# #159: "a building with no life should explode" - and nothing is exempt any
|
|
# more. The mothership used to floor at 1hp, so a battered one sat there
|
|
# reading as dead and never came down.
|
|
#
|
|
# That exemption was there to stop a soft-lock, and the soft-lock turns out
|
|
# not to exist. Losing the structure does not lose what is in it: the bank is
|
|
# GameState.bank_inventory and the hangar is stored_vessels, both of which
|
|
# outlive any building. And rebuilding is always available - the mothership
|
|
# is the one structure research never gates, it costs exactly what a single
|
|
# interceptor's hold carries, and ore comes from asteroids, which do not run
|
|
# out. So the way back is always mine, fly, rebuild.
|
|
#
|
|
# Kept as a function rather than deleted because the QUESTION is still a real
|
|
# one - something may well need to be un-killable later - and a caller asking
|
|
# it is clearer than a caller that has forgotten it was ever asked.
|
|
func building_is_indestructible(_building: Dictionary) -> bool:
|
|
return false
|
|
|
|
|
|
# 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")
|
|
|
|
|
|
# #163: how many vessels are actually standing at outposts, across all of
|
|
# them. Counts the CREW rather than what was asked for - a vessel still in
|
|
# transit has not started holding anything yet.
|
|
func garrisoned_vessel_count() -> int:
|
|
var total: int = 0
|
|
for building in buildings:
|
|
if is_outpost(building):
|
|
total += garrison_crew(int(building.id)).size()
|
|
return total
|
|
|
|
|
|
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, payment_position: Variant = null) -> 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, payment_position):
|
|
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, payment_position: Variant = null) -> bool:
|
|
return has_ship_lab() and not upgrade_maxed(id) \
|
|
and can_afford(upgrade_cost(id), payment_position) \
|
|
and research_points >= research_point_cost(id)
|
|
|
|
|
|
func buy_upgrade(id: String, payment_position: Variant = null) -> bool:
|
|
if not can_buy_upgrade(id, payment_position):
|
|
return false
|
|
if not spend(upgrade_cost(id), payment_position):
|
|
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
|
|
|
|
|
|
# What a building can actually pay a local cost with (#158).
|
|
#
|
|
# #12 made production cost resources physically DELIVERED to the building
|
|
# rather than money out of the bank, and that stands: the bank is still not
|
|
# raided from here. What #158 adds is the fleet's own hold - the one other
|
|
# place resources physically are. A hull that flew in carrying gas can hand
|
|
# it over, and making the player park, open the transfer menu and move it
|
|
# across by hand was ceremony rather than a decision.
|
|
#
|
|
# But only WHERE THE FLEET IS, and that qualifier is the whole design.
|
|
# Without it a crafter on the far side of the map reaches into the hold and
|
|
# spends ore the player withdrew to carry somewhere else - which is exactly
|
|
# the complaint that got automatic banking at the mothership removed in #24,
|
|
# arriving from the other direction. test_manual_supply_route caught it
|
|
# doing precisely that.
|
|
#
|
|
# The range is Building.interact_radius, the same distance that decides
|
|
# whether the transfer button is offered at all. So the rule is not a hidden
|
|
# one: if you could hand the cargo over by hand, the building can take it.
|
|
const LOCAL_PAY_RANGE: float = 150.0
|
|
|
|
|
|
func fleet_is_at(building: Dictionary) -> bool:
|
|
if building.is_empty():
|
|
return false
|
|
return fleet_can_pay_at(building.position)
|
|
|
|
|
|
func fleet_can_pay_at(payment_position: Variant) -> bool:
|
|
return payment_position is Vector2 \
|
|
and fleet_position.distance_to(payment_position as Vector2) <= LOCAL_PAY_RANGE
|
|
|
|
|
|
func local_payment_available(building: Dictionary, inventory: RefCounted,
|
|
resource_type: String) -> int:
|
|
var here: int = 0 if inventory == null else inventory.amount(resource_type)
|
|
return here + (fleet_inventory.amount(resource_type) if fleet_is_at(building) else 0)
|
|
|
|
|
|
func can_pay_locally(building: Dictionary, inventory: RefCounted,
|
|
costs: Dictionary) -> bool:
|
|
for resource_type in costs:
|
|
if local_payment_available(building, inventory, String(resource_type)) \
|
|
< int(costs[resource_type]):
|
|
return false
|
|
return true
|
|
|
|
|
|
# All or nothing, settled in full before anything is taken - a part-charged
|
|
# order that then fails is worse than one that never started. The building's
|
|
# own stock goes first, so what was hauled in is spent before the hold the
|
|
# player is still flying around with.
|
|
func pay_locally(building: Dictionary, inventory: RefCounted,
|
|
costs: Dictionary) -> bool:
|
|
if not can_pay_locally(building, inventory, costs):
|
|
return false
|
|
var at_hand: bool = fleet_is_at(building)
|
|
for resource_type in costs:
|
|
var id: String = String(resource_type)
|
|
var owed: int = int(costs[resource_type])
|
|
if inventory != null:
|
|
owed -= inventory.withdraw(id, owed)
|
|
if owed > 0 and at_hand:
|
|
fleet_inventory.withdraw(id, owed)
|
|
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):
|
|
# #158: the crafter's own stock, or the hold that flew in.
|
|
if not pay_locally(b, inventory, costs):
|
|
continue
|
|
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)
|
|
|
|
|
|
# --- the spacial workshop and its repair drones (#116) ---------------------
|
|
#
|
|
# Modelled on the transport hub, deliberately and closely: drones belong to a
|
|
# workshop rather than to the player, are provisioned when it is built, are
|
|
# never a production order, and can be upgraded at a ship lab like any other
|
|
# vessel. Anything that already knows how hub transporters behave knows how
|
|
# these do.
|
|
#
|
|
# What a repair COSTS is the one new idea: a share of the building's own
|
|
# price, in proportion to how much of its health is gone. Levelling a crafter
|
|
# and rebuilding it costs its full price; patching one up after a wave costs
|
|
# a fraction, and the fraction is the damage.
|
|
|
|
const BASE_WORKSHOP_DRONES: int = 2
|
|
const REPAIR_DRONE: String = "repair_drone"
|
|
# What a drone puts back per trip, before upgrades.
|
|
const BASE_REPAIR_AMOUNT: int = 25
|
|
const DRONE_IDLE: String = "idle"
|
|
const DRONE_OUTBOUND: String = "outbound"
|
|
const DRONE_RETURNING: String = "returning"
|
|
|
|
var repair_drones: Array = []
|
|
|
|
|
|
func workshop_drone_capacity() -> int:
|
|
var n: int = BASE_WORKSHOP_DRONES
|
|
for id in unlocked:
|
|
n += int(skill_node(id).get("workshop_bonus", 0))
|
|
return n
|
|
|
|
|
|
func has_workshop() -> bool:
|
|
for b in buildings:
|
|
if building_has_capability(b, "repair"):
|
|
return true
|
|
return false
|
|
|
|
|
|
func workshop_ids() -> Array:
|
|
var out: Array = []
|
|
for b in buildings:
|
|
if building_has_capability(b, "repair"):
|
|
out.append(int(b.id))
|
|
return out
|
|
|
|
|
|
# The ore a full repair of this building would take: its own price, scaled by
|
|
# the share of its health that is missing. A structure at full health costs
|
|
# nothing, one levelled to a sliver costs nearly what it did to build.
|
|
func repair_cost(id: int) -> int:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return 0
|
|
var definition: Resource = building_definition(String(b.type))
|
|
if definition == null:
|
|
return 0
|
|
var missing: float = 1.0 - building_health_fraction(id)
|
|
return int(ceil(float(definition.cost) * missing))
|
|
|
|
|
|
func repair_amount() -> int:
|
|
return int(vessel_stat(REPAIR_DRONE, "repair_amount"))
|
|
|
|
|
|
# What one drone-load of repair costs, for a building that needs one.
|
|
func repair_trip_cost(id: int) -> int:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty():
|
|
return 0
|
|
var definition: Resource = building_definition(String(b.type))
|
|
if definition == null:
|
|
return 0
|
|
var per_point: float = float(definition.cost) / float(maxi(building_max_hp(String(b.type)), 1))
|
|
var putting_back: int = mini(repair_amount(),
|
|
building_max_hp(String(b.type)) - building_hp(id))
|
|
return maxi(int(ceil(per_point * float(putting_back))), 1) if putting_back > 0 else 0
|
|
|
|
|
|
func _damaged_buildings() -> Array:
|
|
var out: Array = []
|
|
for b in buildings:
|
|
if building_health_fraction(int(b.id)) < 0.999:
|
|
out.append(b)
|
|
return out
|
|
|
|
|
|
# #116: "Required resources are sent to the spacial workshop". The workshop
|
|
# asks hub logistics for the ore its outstanding repairs will need, exactly
|
|
# as a gas burner asks for gas - see building_logistics_deficit.
|
|
func workshop_ore_demand(id: int) -> int:
|
|
var b: Dictionary = find_building(id)
|
|
if b.is_empty() or not building_has_capability(b, "repair"):
|
|
return 0
|
|
var needed: int = 0
|
|
for damaged in _damaged_buildings():
|
|
needed += repair_cost(int(damaged.id))
|
|
return mini(needed, WORKSHOP_ORE_BUFFER)
|
|
|
|
|
|
# A ceiling, so one badly-mauled base does not pull the entire bank into a
|
|
# single workshop and starve everything else.
|
|
const WORKSHOP_ORE_BUFFER: int = 120
|
|
|
|
|
|
func _provision_workshop_drones(workshop: Dictionary) -> void:
|
|
var stationed: int = 0
|
|
for drone in repair_drones:
|
|
if int(drone.get("workshop_id", 0)) == int(workshop.id):
|
|
stationed += 1
|
|
while stationed < workshop_drone_capacity():
|
|
repair_drones.append({
|
|
"workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0,
|
|
"from": workshop.position, "to": workshop.position, "progress": 1.0,
|
|
"carrying": 0,
|
|
})
|
|
stationed += 1
|
|
|
|
|
|
func _sync_workshop_drones() -> void:
|
|
for workshop in buildings:
|
|
if building_has_capability(workshop, "repair"):
|
|
_provision_workshop_drones(workshop)
|
|
|
|
|
|
func repair_drone_position(drone: Dictionary) -> Vector2:
|
|
return (drone.from as Vector2).lerp(drone.to as Vector2,
|
|
clampf(float(drone.progress), 0.0, 1.0))
|
|
|
|
|
|
func _drone_inbound_to(building_id: int) -> bool:
|
|
for drone in repair_drones:
|
|
if String(drone.state) == DRONE_OUTBOUND and int(drone.target_id) == building_id:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _advance_repairs(delta: float) -> void:
|
|
if repair_drones.is_empty():
|
|
return
|
|
var speed: float = vessel_stat(REPAIR_DRONE, "transport_speed")
|
|
for drone in repair_drones:
|
|
var workshop: Dictionary = find_building(int(drone.workshop_id))
|
|
if workshop.is_empty():
|
|
continue
|
|
if String(drone.state) == DRONE_IDLE:
|
|
_dispatch_drone(drone, workshop)
|
|
continue
|
|
var distance: float = (drone.from as Vector2).distance_to(drone.to as Vector2)
|
|
drone.progress = float(drone.progress) + delta / maxf(distance / maxf(speed, 1.0), 0.001)
|
|
if float(drone.progress) < 1.0:
|
|
continue
|
|
drone.progress = 1.0
|
|
if String(drone.state) == DRONE_OUTBOUND:
|
|
# The ore was taken when it left, so a building demolished in
|
|
# transit does not silently consume it - it goes home with the
|
|
# load and the deposit below puts it back.
|
|
var target: Dictionary = find_building(int(drone.target_id))
|
|
if not target.is_empty():
|
|
repair_building(int(drone.target_id), repair_amount())
|
|
else:
|
|
deposit_resource_to_building(int(drone.workshop_id), RESOURCE_ORE,
|
|
int(drone.carrying))
|
|
drone.carrying = 0
|
|
drone.target_id = 0
|
|
_send_drone(drone, workshop.position, DRONE_RETURNING)
|
|
else:
|
|
drone.state = DRONE_IDLE
|
|
|
|
|
|
func _dispatch_drone(drone: Dictionary, workshop: Dictionary) -> void:
|
|
var inventory: RefCounted = building_inventory(int(workshop.id))
|
|
if inventory == null:
|
|
return
|
|
var best: Dictionary = {}
|
|
var best_health: float = 1.0
|
|
for damaged in _damaged_buildings():
|
|
var id: int = int(damaged.id)
|
|
# One drone per building at a time: two arriving at the same wall
|
|
# would spend twice the ore to put back one drone-load of health.
|
|
if _drone_inbound_to(id):
|
|
continue
|
|
var cost: int = repair_trip_cost(id)
|
|
if cost <= 0 or inventory.amount(RESOURCE_ORE) < cost:
|
|
continue
|
|
var health: float = building_health_fraction(id)
|
|
if best.is_empty() or health < best_health:
|
|
best = damaged
|
|
best_health = health
|
|
if best.is_empty():
|
|
return
|
|
# Worst first: the structure closest to coming down is the one a repair
|
|
# is worth most on.
|
|
var spend: int = repair_trip_cost(int(best.id))
|
|
inventory.withdraw(RESOURCE_ORE, spend)
|
|
drone.carrying = spend
|
|
drone.target_id = int(best.id)
|
|
drone.from = workshop.position
|
|
_send_drone(drone, best.position, DRONE_OUTBOUND)
|
|
|
|
|
|
func _send_drone(drone: Dictionary, to: Vector2, state: String) -> void:
|
|
drone.from = repair_drone_position(drone)
|
|
drone.to = to
|
|
drone.progress = 0.0
|
|
drone.state = state
|
|
|
|
|
|
func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
|
|
var lab: Dictionary = find_building(lab_id)
|
|
if not building_has_capability(lab, "vessel_research") or upgrade_id == "":
|
|
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):
|
|
# #158: the lab's own stock, or the hold that flew in.
|
|
var affordable: bool = can_pay_locally(lab, inventory, costs)
|
|
# #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
|
|
if not pay_locally(lab, inventory, costs):
|
|
continue
|
|
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
|
|
|
|
|
|
# Whether a structure of this type exists ANYWHERE, however it is stored.
|
|
#
|
|
# #145: extractors are not in `buildings`. They are placed onto a planet from
|
|
# space and live in `extractors`, with their own position, their own stock
|
|
# and their own id - so has_building_of_type("extractor") is false no matter
|
|
# how many the player has built. The quest that asks for one (#133) was
|
|
# asking a question that could never be answered yes, and no amount of
|
|
# building extractors was going to finish it.
|
|
#
|
|
# Anything asking "does the player have one of these" wants THIS, and
|
|
# has_building_of_type() only where the answer really is about the
|
|
# `buildings` list itself.
|
|
func structure_exists(type: String) -> bool:
|
|
if type == BUILDING_EXTRACTOR:
|
|
return not extractors.is_empty()
|
|
return has_building_of_type(type)
|
|
|
|
|
|
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))
|
|
# #116: and a workshop asks for the ore its outstanding repairs will
|
|
# take, which likewise depends on the world rather than on a definition.
|
|
if resource_type == RESOURCE_ORE:
|
|
target = maxi(target, workshop_ore_demand(id))
|
|
return maxi(0, target - building_resource(id, resource_type))
|
|
|
|
|
|
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()
|
|
|
|
|
|
# #110: put a wave on the board now, for testing. Skips the peace AND the
|
|
# warning: the point of the button is to see the wave, and waiting out a
|
|
# nine-second alert every time would make it useless as a tool.
|
|
func force_wave_now() -> void:
|
|
wave_phase = WAVE_PHASE_ACTIVE
|
|
wave_timer = 0.0
|
|
wave_number += 1
|
|
wave_pending = true
|
|
|
|
|
|
func wave_cleared() -> void:
|
|
wave_phase = WAVE_PHASE_PEACE
|
|
wave_timer = 0.0
|
|
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.
|
|
if dev_mode and dev_unlimited_resources:
|
|
_refill_dev_resources()
|
|
advance_waves(delta)
|
|
_advance_production(delta)
|
|
_advance_lab_research(delta)
|
|
_advance_vessel_deliveries(delta)
|
|
_advance_garrison_dispatch()
|
|
_advance_planning()
|
|
_advance_repairs(delta)
|
|
_advance_resource_converters(delta)
|
|
_advance_distorder_drain(delta)
|
|
_advance_quests()
|
|
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
|
|
|
|
|
|
# --- quests (#129, #131) ---------------------------------------------------
|
|
|
|
# The chain lives in Quests.gd; what lives here is how far along it the
|
|
# player is, and the answer to "is this one done yet".
|
|
#
|
|
# Progress LATCHES, and it latches for free: the chain is walked one quest
|
|
# at a time, and the moment a goal is met the index moves on and never comes
|
|
# back. So collecting five ore and then spending it cannot un-complete the
|
|
# quest that asked for five ore - by the time it is spent, nothing is
|
|
# asking any more. That is why goals are allowed to be phrased against
|
|
# current holdings rather than against a running total nobody would keep in
|
|
# step.
|
|
#
|
|
# It sits in GameState with everything else that has to survive the
|
|
# Main.tscn <-> PlanetSurface.tscn swap - a quest completed on a surface
|
|
# must still be completed after take-off.
|
|
|
|
signal quest_started(id: String)
|
|
signal quest_completed(id: String)
|
|
|
|
# Whether this playthrough has a quest chain at all. Armed by
|
|
# reset_new_game(), which is the only door into a real game (the main menu's
|
|
# Play, and start_dev_game on top of it) - exactly the gate intro_active
|
|
# already uses. Without it, every scene ever instantiated would open on the
|
|
# first quest's briefing, including the ones in the test suite that only
|
|
# want a world to look at.
|
|
var quests_armed: bool = false
|
|
var quest_index: int = 0
|
|
# Whether the current quest has been ANNOUNCED. Kept apart from the index so
|
|
# a quest that is already satisfied the moment it starts still gets a frame
|
|
# on screen with its own name on it, rather than being skipped silently.
|
|
var quest_announced: bool = false
|
|
|
|
# Lines the chain wants said, waiting for something that can say them.
|
|
#
|
|
# Queued here rather than emitted straight at a banner because quests
|
|
# advance in this autoload, which runs on the planet surface too - where
|
|
# there is no narration banner at all. A line emitted there would simply be
|
|
# lost. QuestDirector drains this whenever it is alive, so a quest completed
|
|
# underground is read out on the way back up.
|
|
var quest_messages: Array = []
|
|
|
|
# Which planets have been LANDED ON, and which kinds of world they were.
|
|
# Distinct from explored_chunks, which is only about having flown past.
|
|
var visited_planets: Dictionary = {}
|
|
var visited_planet_kinds: Dictionary = {}
|
|
|
|
|
|
# #173: the catalog is checked once, here, in every build - not only in the
|
|
# test suite. The editor can quietly strip a property out of a .tres between
|
|
# the last test run and the export, and the shipped result is a game that
|
|
# looks fine and cannot mine. push_error rather than a crash: the game is
|
|
# still playable in whatever way survived, and on the web these lines reach
|
|
# the on-page log web_shell.html mirrors from the console, which is the only
|
|
# error a phone can read.
|
|
func _ready() -> void:
|
|
catalog_faults = CATALOG_CHECK.faults()
|
|
if catalog_faults.is_empty():
|
|
return
|
|
push_error("The definition catalog is damaged - see core/CatalogCheck.gd. %d fault(s):" \
|
|
% catalog_faults.size())
|
|
for fault in catalog_faults:
|
|
push_error(" catalog: %s" % fault)
|
|
|
|
|
|
func reset_quests(armed: bool = false) -> void:
|
|
quests_armed = armed
|
|
quest_index = 0
|
|
quest_announced = false
|
|
quest_messages.clear()
|
|
visited_planets.clear()
|
|
visited_planet_kinds.clear()
|
|
|
|
|
|
# Called when the fleet actually sets down on a world.
|
|
func note_planet_visited(id: String, definition_id: String) -> void:
|
|
if id != "":
|
|
visited_planets[id] = true
|
|
if definition_id != "":
|
|
visited_planet_kinds[definition_id] = true
|
|
|
|
|
|
func quest_count() -> int:
|
|
return QUESTS.count()
|
|
|
|
|
|
func quest_chain_complete() -> bool:
|
|
return quest_index >= QUESTS.count()
|
|
|
|
|
|
# The quest the player is on, or {} once the chain is done.
|
|
func active_quest() -> Dictionary:
|
|
return QUESTS.at(quest_index)
|
|
|
|
|
|
# One quest by position in the chain, for anything listing what is behind the
|
|
# player (#140). Out of range answers {} rather than erroring.
|
|
func quest_at(index: int) -> Dictionary:
|
|
return QUESTS.at(index)
|
|
|
|
|
|
# How far along one quest is, as (have, need). One line per goal kind, and
|
|
# every one of them is answered from state this autoload already holds - a
|
|
# goal that needed a node to ask would be a goal that stops working on a
|
|
# planet surface.
|
|
func quest_progress(quest: Dictionary) -> Vector2i:
|
|
if quest.is_empty():
|
|
return Vector2i.ZERO
|
|
var goal: Dictionary = quest.get("goal", {})
|
|
match String(goal.get("kind", "")):
|
|
QUESTS.GOAL_RESOURCE:
|
|
var want: int = int(goal.get("amount", 1))
|
|
return Vector2i(mini(available_resource(String(goal.get("resource",
|
|
RESOURCE_ORE))), want), want)
|
|
QUESTS.GOAL_BUILDING:
|
|
return Vector2i(1 if structure_exists(String(goal.get("building", ""))) else 0, 1)
|
|
QUESTS.GOAL_FLEET_VESSEL:
|
|
var need: int = int(goal.get("count", 1))
|
|
return Vector2i(mini(vessel_count_in_fleet(String(goal.get("vessel", ""))),
|
|
need), need)
|
|
QUESTS.GOAL_VESSEL_UNLOCKED:
|
|
return Vector2i(1 if vessel_is_unlocked(String(goal.get("vessel", ""))) else 0, 1)
|
|
QUESTS.GOAL_RESEARCH:
|
|
return Vector2i(1 if is_unlocked(String(goal.get("node", ""))) else 0, 1)
|
|
QUESTS.GOAL_GARRISONED:
|
|
var stationed: int = int(goal.get("count", 1))
|
|
return Vector2i(mini(garrisoned_vessel_count(), stationed), stationed)
|
|
QUESTS.GOAL_BANKED:
|
|
var banked: int = int(goal.get("amount", 1))
|
|
return Vector2i(mini(banked_resource(String(goal.get("resource",
|
|
RESOURCE_ORE))), banked), banked)
|
|
QUESTS.GOAL_EXPLORE:
|
|
var sectors: int = int(goal.get("sectors", 1))
|
|
return Vector2i(mini(explored_chunks.size(), sectors), sectors)
|
|
QUESTS.GOAL_PLANET_VISITS:
|
|
var visits: int = int(goal.get("count", 1))
|
|
return Vector2i(mini(visited_planets.size(), visits), visits)
|
|
QUESTS.GOAL_PLANET_KINDS:
|
|
var kinds: int = int(goal.get("count", 1))
|
|
return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds)
|
|
QUESTS.GOAL_CONQUERED:
|
|
var wanted: int = int(goal.get("count", 1))
|
|
var held: int = 0
|
|
for key in planet_capture:
|
|
if bool(planet_capture[key].get("conquered", false)):
|
|
held += 1
|
|
return Vector2i(mini(held, wanted), wanted)
|
|
# An unknown goal kind must not be quietly "already done" - that would
|
|
# run the whole chain off in one frame.
|
|
return Vector2i(0, 1)
|
|
|
|
|
|
func quest_goal_met(quest: Dictionary) -> bool:
|
|
if quest.is_empty():
|
|
return false
|
|
var progress: Vector2i = quest_progress(quest)
|
|
return progress.x >= progress.y
|
|
|
|
|
|
func say_quest_line(text: String) -> void:
|
|
if text.strip_edges() == "":
|
|
return
|
|
quest_messages.append({"line": text, "speaker": QUESTS.SPEAKER})
|
|
|
|
|
|
# Hands over every line waiting to be said, and empties the queue. Drained
|
|
# rather than read so two directors could never read the same line twice.
|
|
func take_quest_messages() -> Array:
|
|
var taken: Array = quest_messages.duplicate()
|
|
quest_messages.clear()
|
|
return taken
|
|
|
|
|
|
# Ends the current quest whether or not its goal was met. The chain's only
|
|
# way forward, used by _advance_quests below and by the tests.
|
|
#
|
|
# #167: `announce` is what a SKIPPED quest turns off. The index still moves
|
|
# and quest_completed still fires - the quest is credited exactly as if it
|
|
# had been played - but nothing is queued for the banner to read out.
|
|
func complete_active_quest(announce: bool = true) -> bool:
|
|
var quest: Dictionary = active_quest()
|
|
if quest.is_empty():
|
|
return false
|
|
if announce:
|
|
say_quest_line(String(quest.get("done", "")))
|
|
quest_index += 1
|
|
quest_announced = false
|
|
quest_completed.emit(String(quest.id))
|
|
return true
|
|
|
|
|
|
# #167: a quest handed out with its goal already met, credited in silence.
|
|
#
|
|
# Both signals still fire, in the order they would have fired in had it been
|
|
# played, so anything watching the chain sees an ordinary start-then-finish
|
|
# rather than a quest that was never begun. What it does NOT do is speak:
|
|
# neither the brief nor the answer reaches quest_messages, so the game is
|
|
# never stopped twice to be told about something already done.
|
|
func skip_active_quest() -> bool:
|
|
var quest: Dictionary = active_quest()
|
|
if quest.is_empty():
|
|
return false
|
|
quest_started.emit(String(quest.id))
|
|
return complete_active_quest(false)
|
|
|
|
|
|
func _advance_quests() -> void:
|
|
# The prologue is already talking, and the fleet in it is not the
|
|
# player's. The chain starts when the prologue hands over.
|
|
if not quests_armed or intro_active or quest_chain_complete():
|
|
return
|
|
var quest: Dictionary = active_quest()
|
|
if not quest_announced:
|
|
# #167: the chain hands out teaching quests, and by the time some of
|
|
# them come up the player has already done the thing - the extractor
|
|
# is down, the hangar is crewed. Announcing one of those stops the
|
|
# game to explain a task, then stops it again to congratulate them
|
|
# on a task they never did. So an unimportant quest whose goal is
|
|
# already met is banked without a word (see Quests.is_important);
|
|
# it still shows up as done in the objectives panel, which is where
|
|
# the player looks for what they have been credited with.
|
|
if quest_goal_met(quest) and not QUESTS.is_important(quest):
|
|
skip_active_quest()
|
|
# One a frame. Several already-met quests in a row are cleared
|
|
# over the next few frames rather than in a loop here, which
|
|
# keeps this function's shape - one step per call - and keeps a
|
|
# malformed chain from spinning inside a single tick.
|
|
return
|
|
quest_announced = true
|
|
say_quest_line(String(quest.get("brief", "")))
|
|
quest_started.emit(String(quest.id))
|
|
# Deliberately no completion check this frame: a quest whose goal
|
|
# happens to be met already still gets read out before it closes.
|
|
return
|
|
if quest_goal_met(quest):
|
|
complete_active_quest()
|
|
|
|
|
|
# --- paying for things ------------------------------------------------------
|
|
#
|
|
# Payments draw on the bank and may also draw on what the ships are carrying
|
|
# when the caller supplies the concrete place being paid. The player can move
|
|
# resources between the two by hand, so using nearby cargo avoids ceremony;
|
|
# #180 makes "nearby" part of the API instead of an assumption in the caller.
|
|
#
|
|
# 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, nearby hold second. A call with no position is deliberately
|
|
# bank-only: missing context must fail safe instead of teleporting cargo.
|
|
#
|
|
# 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)
|
|
|
|
|
|
# What can pay at one concrete place. Banked resources remain globally
|
|
# available; cargo in the fleet hold joins them only when the fleet is close
|
|
# enough to physically hand it to the building or construction site (#180).
|
|
# A null position deliberately means "no local payer", so a forgotten context
|
|
# can never reach into the hold from across the map.
|
|
func available_resource_at(resource_type: String, payment_position: Variant = null) -> int:
|
|
var amount: int = bank_inventory.amount(resource_type)
|
|
if fleet_can_pay_at(payment_position):
|
|
amount += fleet_inventory.amount(resource_type)
|
|
return amount
|
|
|
|
|
|
func available_ore() -> int:
|
|
return available_resource(RESOURCE_ORE)
|
|
|
|
|
|
func can_afford(cost: int, payment_position: Variant = null) -> bool:
|
|
return available_resource_at(RESOURCE_ORE, payment_position) >= cost
|
|
|
|
|
|
func can_afford_resources(costs: Dictionary, payment_position: Variant = null) -> bool:
|
|
for resource_type in costs:
|
|
if available_resource_at(String(resource_type), payment_position) \
|
|
< int(costs[resource_type]):
|
|
return false
|
|
return true
|
|
|
|
|
|
func spend(cost: int, payment_position: Variant = null) -> bool:
|
|
return spend_resources({RESOURCE_ORE: cost}, payment_position)
|
|
|
|
|
|
func spend_resources(costs: Dictionary, payment_position: Variant = null) -> 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, payment_position):
|
|
return false
|
|
var fleet_can_pay: bool = fleet_can_pay_at(payment_position)
|
|
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 and fleet_can_pay:
|
|
fleet_inventory.withdraw(id, owed)
|
|
return true
|