Compose vessels from definitions and capabilities

This commit is contained in:
Loïc Wisniewski 2026-08-14 22:12:57 +02:00
commit 14081ad230
39 changed files with 705 additions and 386 deletions

View file

@ -3,6 +3,7 @@ extends Node
const INVENTORY = preload("res://Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
# Autoload singleton - the one thing that survives change_scene_to_file()
@ -404,21 +405,10 @@ const VESSEL_INTERCEPTOR: String = "interceptor"
const VESSEL_SHIELD: String = "shield"
const VESSEL_TRANSPORTER: String = "transporter"
const VESSEL_NAMES: Dictionary = {
VESSEL_INTERCEPTOR: "Interceptor",
VESSEL_SHIELD: "Shield Vessel",
VESSEL_TRANSPORTER: "Transporter",
}
# Compatibility constants for callers and old tests. Runtime metadata is
# resolved from VesselDefinition below.
const SHIELD_COST: int = 14
# How long one vessel takes to build, once its ore is actually in the
# building. A queue you can watch and cancel needs time to exist in.
const BUILD_TIMES: Dictionary = {
VESSEL_INTERCEPTOR: 10.0,
VESSEL_SHIELD: 16.0,
VESSEL_TRANSPORTER: 8.0,
}
const TRANSPORTER_COST: int = 10
const LAB_RESEARCH_TIME: float = 8.0
@ -429,57 +419,14 @@ var build_time_scale: float = 1.0
func build_time(product: String) -> float:
return float(BUILD_TIMES.get(product, 10.0)) * build_time_scale
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
# Researched at the ship lab, per vessel type. Separate from SKILL_TREE on
# purpose: that gates what the player may build at all and is a strategic
# spine, while these are repeatable tuning on a vessel you already have.
# `cost` is the FIRST level; each level after costs cost * (level + 1), so
# the third point in something is not the same price as the first.
const VESSEL_UPGRADES: Array = [
{
"id": "int_damage", "vessel": VESSEL_INTERCEPTOR, "name": "Heavy Slugs",
"cost": 30, "max_level": 3, "desc": "+1 damage per shot.",
},
{
"id": "int_rate", "vessel": VESSEL_INTERCEPTOR, "name": "Autoloader",
"cost": 25, "max_level": 3, "desc": "Fires 15% faster per level.",
},
{
"id": "int_range", "vessel": VESSEL_INTERCEPTOR, "name": "Targeting Array",
"cost": 20, "max_level": 3, "desc": "+60 firing range per level.",
},
{
"id": "int_speed", "vessel": VESSEL_INTERCEPTOR, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "shd_hits", "vessel": VESSEL_SHIELD, "name": "Layered Emitters",
"cost": 60, "max_level": 2, "desc": "+1 hit absorbed before the shield drops.",
},
{
"id": "shd_recharge", "vessel": VESSEL_SHIELD, "name": "Fast Cycling",
"cost": 35, "max_level": 3, "desc": "Shield returns 1.2s sooner per level.",
},
{
"id": "shd_speed", "vessel": VESSEL_SHIELD, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "trn_capacity", "vessel": VESSEL_TRANSPORTER, "name": "Expanded Hold",
"cost": 30, "max_level": 3, "desc": "Carries 8 more ore per supply run.",
},
{
"id": "trn_speed", "vessel": VESSEL_TRANSPORTER, "name": "Freight Engines",
"cost": 30, "max_level": 3, "desc": "+70 speed on supply runs.",
},
]
# Base figures every vessel starts from, before anything is researched.
# 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
@ -503,9 +450,7 @@ func has_ship_lab() -> bool:
func shield_vessel_unlocked() -> bool:
# Unlocked AT the lab, by paying for it there - a vessel type rather than
# a stat, so it is a one-off rather than a level.
return int(vessel_levels.get(UNLOCK_SHIELD_VESSEL, 0)) > 0
return vessel_is_unlocked(VESSEL_SHIELD)
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
@ -513,27 +458,69 @@ const UNLOCK_SHIELD_COST: int = 45
func unlock_shield_vessel() -> bool:
if shield_vessel_unlocked() or not has_ship_lab():
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_unlocked(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null:
return false
if not spend(UNLOCK_SHIELD_COST):
return definition.unlock_id == "" or int(vessel_levels.get(definition.unlock_id, 0)) > 0
func vessel_for_unlock(unlock_id: String) -> Resource:
for definition in VESSEL_DEFINITIONS.all():
if definition.unlock_id == unlock_id:
return definition
return null
func unlock_vessel(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
return false
vessel_levels[UNLOCK_SHIELD_VESSEL] = 1
vessel_upgraded.emit(UNLOCK_SHIELD_VESSEL)
if not spend(definition.unlock_cost):
return false
vessel_levels[definition.unlock_id] = 1
vessel_upgraded.emit(definition.unlock_id)
return true
func upgrade_def(id: String) -> Dictionary:
for u in VESSEL_UPGRADES:
if u.id == id:
return u
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 = []
for u in VESSEL_UPGRADES:
if u.vessel == vessel:
out.append(u)
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
@ -567,66 +554,51 @@ func buy_upgrade(id: String) -> bool:
return true
# --- what those upgrades actually come to ---------------------------------
# Read live rather than baked into a vessel when it is built, so researching
# something improves the ships already flying instead of only the next ones.
func ship_damage(vessel: String) -> int:
return SHIP_BASE_DAMAGE + (upgrade_level("int_damage") if vessel == VESSEL_INTERCEPTOR else 0)
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_fire_cooldown(vessel: String) -> float:
if vessel != VESSEL_INTERCEPTOR:
return SHIP_BASE_COOLDOWN
return SHIP_BASE_COOLDOWN * pow(0.85, float(upgrade_level("int_rate")))
func ship_fire_range(vessel: String) -> float:
return SHIP_BASE_RANGE + (60.0 * upgrade_level("int_range") if vessel == VESSEL_INTERCEPTOR else 0.0)
func _speed_levels(vessel: String) -> int:
if vessel == VESSEL_SHIELD:
return upgrade_level("shd_speed")
return upgrade_level("int_speed")
func ship_max_speed(vessel: String) -> float:
return SHIP_BASE_SPEED + 40.0 * _speed_levels(vessel)
func ship_acceleration(vessel: String) -> float:
return SHIP_BASE_ACCEL + 60.0 * _speed_levels(vessel)
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:
return SHIELD_BASE_CHARGES + upgrade_level("shd_hits")
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return int(vessel_stat(definition.definition_id, "shield_charges"))
func shield_recharge_time() -> float:
return maxf(2.0, SHIELD_BASE_RECHARGE - 1.2 * upgrade_level("shd_recharge"))
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return vessel_stat(definition.definition_id, "shield_recharge")
func transporter_load_limit() -> int:
return TRANSPORTER_BASE_LOAD + 8 * upgrade_level("trn_capacity")
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return int(vessel_stat(definition.definition_id, "cargo_capacity"))
func transporter_speed() -> float:
return TRANSPORTER_BASE_SPEED + 70.0 * upgrade_level("trn_speed")
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return vessel_stat(definition.definition_id, "transport_speed")
const PRODUCT_SHIP: String = "interceptor"
const PRODUCT_TRANSPORTER: String = "transporter"
const TRANSPORTER_COST: int = 10
func product_cost(product: String) -> int:
if product == PRODUCT_TRANSPORTER:
return TRANSPORTER_COST
if product == VESSEL_SHIELD:
return SHIELD_COST
return SHIP_COST
var definition: Resource = vessel_definition(product)
return definition.build_cost if definition != null else 0
func can_produce(type: String) -> bool:
@ -713,13 +685,14 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or not can_produce(b.type):
return false
if product == VESSEL_SHIELD and not shield_vessel_unlocked():
var definition: Resource = vessel_definition(product)
if definition == null or not vessel_is_unlocked(product):
return false
if product == PRODUCT_TRANSPORTER and not has_transport_hub():
if definition.has_capability("transport") and not has_transport_hub():
# Nowhere to station it. Refused rather than queued, so the player is
# told now instead of watching an order sit forever.
return false
if product == PRODUCT_TRANSPORTER and not can_station_transporter():
if definition.has_capability("transport") and not can_station_transporter():
# Every berth is taken or spoken for (#20).
return false
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
@ -742,15 +715,16 @@ func _advance_production(delta: float) -> void:
var product: String = String(order.product)
var cost: int = product_cost(product)
var inventory: RefCounted = _inventory_for_record(b)
var definition: Resource = vessel_definition(product)
if not bool(order.started):
if inventory.amount(RESOURCE_ORE) < cost:
continue
if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity():
if definition.has_capability("transport") and transporters.size() >= transport_capacity():
# A hub was demolished after this was ordered, so the berth it
# was going to use is gone. Hold rather than overfill.
continue
if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
if definition.has_capability("transport") and nearest_hub_to(b.position).is_empty():
continue
inventory.withdraw(RESOURCE_ORE, cost)
order.started = true
@ -760,7 +734,7 @@ func _advance_production(delta: float) -> void:
continue
orders.pop_front()
if product == PRODUCT_TRANSPORTER:
if definition.has_capability("transport"):
# Transporters are unchanged: they take themselves to the nearest
# hub and wait for work (#19).
_station_transporter(b.position)
@ -782,7 +756,8 @@ func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
if not building_has_capability(lab, "vessel_research") or upgrade_id == "":
return false
var orders: Array = lab.get("research_orders", [])
if not orders.is_empty() or (upgrade_id != UNLOCK_SHIELD_VESSEL and upgrade_maxed(upgrade_id)):
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
@ -804,7 +779,8 @@ func _advance_lab_research(delta: float) -> void:
continue
var order: Dictionary = orders[0]
var id: String = String(order.id)
var cost: int = UNLOCK_SHIELD_COST if id == UNLOCK_SHIELD_VESSEL else upgrade_cost(id)
var unlock_definition: Resource = vessel_for_unlock(id)
var cost: int = unlock_definition.unlock_cost if unlock_definition != null else upgrade_cost(id)
var inventory: RefCounted = _inventory_for_record(lab)
if not bool(order.started):
if inventory.amount(RESOURCE_ORE) < cost:
@ -814,7 +790,7 @@ func _advance_lab_research(delta: float) -> void:
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time():
continue
if id == UNLOCK_SHIELD_VESSEL:
if unlock_definition != null:
vessel_levels[id] = 1
else:
vessel_levels[id] = upgrade_level(id) + 1
@ -1041,7 +1017,7 @@ func pending_transporter_orders() -> int:
var n: int = 0
for b in buildings:
for o in b.get("orders", []):
if String(o.product) == PRODUCT_TRANSPORTER:
if vessel_has_capability(String(o.product), "transport"):
n += 1
return n