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

@ -1,6 +1,7 @@
extends Node2D
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
# Space-side building UI. Owns:
# - the build menu (place a mothership, ship crafter or command centre)
@ -32,6 +33,10 @@ const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
get_node("../UI/LabMenu/VesselC"),
]
@onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel")
@onready var lab_vessel_labels: Array = [
get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"),
get_node("../UI/LabMenu/VesselCLabel"),
]
@onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel")
@onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel")
@onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton")
@ -98,6 +103,12 @@ var lab_building_id: int = 0
@onready var craft_interceptor_label: Label = get_node("../UI/CraftMenu/InterceptorLabel")
@onready var craft_transporter_label: Label = get_node("../UI/CraftMenu/TransporterLabel")
@onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel")
@onready var craft_vessel_buttons: Array = [
craft_interceptor_button, craft_shield_button, craft_transporter_button,
]
@onready var craft_vessel_labels: Array = [
craft_interceptor_label, craft_shield_label, craft_transporter_label,
]
# --- the transfer panel (#24) ---------------------------------------------
# Ore used to be moved from inside whichever panel happened to be open: "load
@ -159,7 +170,6 @@ func _ready() -> void:
build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(_close_build_menu)
_configure_build_menu_entries()
craft_shield_button.pressed.connect(_on_craft_shield)
lab_close_button.pressed.connect(func(): lab_menu.visible = false)
lab_buy_button.pressed.connect(_on_lab_buy)
for i in range(lab_vessel_buttons.size()):
@ -172,8 +182,9 @@ func _ready() -> void:
action_button.pressed.connect(_on_context_action_pressed)
building_action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor)
craft_transporter_button.pressed.connect(_on_craft_transporter)
for i in range(mini(craft_vessel_buttons.size(), VESSEL_DEFINITIONS.all().size())):
var definition: Resource = VESSEL_DEFINITIONS.all()[i]
craft_vessel_buttons[i].pressed.connect(_on_craft_vessel.bind(definition.definition_id))
craft_close_button.pressed.connect(func(): craft_menu.visible = false)
transfer_button.pressed.connect(_on_transfer_pressed)
@ -692,26 +703,19 @@ func _refresh_craft_status() -> void:
# Prices live with the buttons they describe. The top of the screen is
# intentionally reserved for the shared resource bar and a short action
# message; repeating fleet, queue and transport counts here buried the UI.
craft_interceptor_label.text = "Interceptor\n%d ore" % GameState.SHIP_COST
craft_transporter_label.text = "Transporter\n%d ore" % GameState.TRANSPORTER_COST
craft_shield_label.text = "Shield Vessel\n%d ore" % GameState.SHIELD_COST
craft_status.text = ""
# Nowhere to station one means the order would be refused, so say so
# rather than letting the button look available and do nothing.
craft_transporter_button.enabled = GameState.has_transport_hub() \
and GameState.can_station_transporter()
craft_transporter_button.queue_redraw()
craft_shield_button.enabled = GameState.shield_vessel_unlocked()
craft_shield_button.queue_redraw()
# Fleet capacity no longer gates ORDERING (#23). A finished vessel waits
# at the mothership until the player crews it, so a full fleet is a
# reason not to collect one yet - not a reason the crafter cannot build.
# This is also what stopped the crafter being inert on a fresh save,
# where the fleet starts at exactly its cap.
craft_interceptor_button.enabled = true
craft_interceptor_button.queue_redraw()
var definitions: Array = VESSEL_DEFINITIONS.all()
for i in range(mini(definitions.size(), craft_vessel_buttons.size())):
var definition: Resource = definitions[i]
var button: IconButton = craft_vessel_buttons[i]
craft_vessel_labels[i].text = "%s\n%d ore" % [definition.display_name, definition.build_cost]
button.icon = definition.icon
button.glow_color = definition.glow_color
button.enabled = GameState.vessel_is_unlocked(definition.definition_id)
if definition.has_capability("transport"):
button.enabled = button.enabled and GameState.has_transport_hub() \
and GameState.can_station_transporter()
button.queue_redraw()
_refresh_queue()
if craft_message != "":
if Time.get_ticks_msec() < craft_message_until:
@ -721,40 +725,40 @@ func _refresh_craft_status() -> void:
func _on_craft_interceptor() -> void:
_on_craft_vessel(GameState.VESSEL_INTERCEPTOR)
func _on_craft_vessel(vessel: String) -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
# No affordability check: an order the crafter cannot pay for yet is
# QUEUED, and completes by itself once ore is delivered here (#16). But
# queueing silently looks identical to a dead button, so it says which
# happened and what the crafter is still short of.
GameState.queue_order(craft_building_id)
_report_order(GameState.PRODUCT_SHIP)
var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel)
if definition == null:
return
if not GameState.queue_order(craft_building_id, vessel):
if not GameState.vessel_is_unlocked(vessel):
_say("%s must be unlocked at a ship lab first" % definition.display_name)
elif definition.has_capability("transport") and not GameState.has_transport_hub():
_say("Build a Transport Hub first - a transporter needs somewhere to be stationed")
elif definition.has_capability("transport"):
_say("All %d berths are taken - build another hub, or research Freight Yards" %
GameState.transport_capacity())
return
_report_order(vessel)
func _report_order(product: String) -> void:
var short: int = GameState.outstanding_cost(craft_building_id)
if short > 0:
_say("%s queued - %d more ore must be delivered here" % [
GameState.VESSEL_NAMES.get(product, product), short
GameState.vessel_name(product), short
])
else:
_say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product))
_say("%s ordered - building now" % GameState.vessel_name(product))
func _on_craft_transporter() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER):
# Two different refusals, and the player needs to know WHICH.
if not GameState.has_transport_hub():
_say("Build a Transport Hub first - a transporter needs somewhere to be stationed")
else:
_say("All %d berths are taken - build another hub, or research Freight Yards" %
GameState.transport_capacity())
return
_refresh_craft_status()
_on_craft_vessel(GameState.VESSEL_TRANSPORTER)
# --- ship lab (#5) --------------------------------------------------------
@ -764,10 +768,13 @@ func _lab_options() -> Array:
# entry, because unlocking the type and upgrading it belong in the same
# place - and its upgrades are pointless until it exists.
var options: Array = []
if lab_vessel == GameState.VESSEL_SHIELD and not GameState.shield_vessel_unlocked():
options.append({"unlock": true, "name": "Unlock Shield Vessel",
"desc": "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up.",
"cost": GameState.UNLOCK_SHIELD_COST})
var definition: Resource = VESSEL_DEFINITIONS.get_definition(lab_vessel)
if definition == null:
return options
if not GameState.vessel_is_unlocked(lab_vessel):
options.append({"unlock": true, "id": definition.unlock_id,
"name": definition.unlock_name, "desc": definition.unlock_description,
"cost": definition.unlock_cost, "icon": IconButton.Icon.UPGRADE_UNLOCK_SHIELD})
return options
for u in GameState.upgrades_for(lab_vessel):
options.append({"unlock": false, "id": u.id, "name": u.name, "desc": u.desc})
@ -775,8 +782,10 @@ func _lab_options() -> Array:
func _on_lab_vessel(index: int) -> void:
lab_vessel = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_TRANSPORTER][index]
var definitions: Array = VESSEL_DEFINITIONS.all()
if index < 0 or index >= definitions.size():
return
lab_vessel = definitions[index].definition_id
lab_choice = 0
_refresh_lab()
@ -794,7 +803,7 @@ func _on_lab_buy() -> void:
if options.is_empty():
return
var choice: Dictionary = options[lab_choice]
var id: String = GameState.UNLOCK_SHIELD_VESSEL if choice.unlock else String(choice.id)
var id: String = String(choice.id)
if not GameState.queue_lab_research(lab_building_id, id):
lab_status.text = "Research is already queued or complete"
return
@ -803,14 +812,17 @@ func _on_lab_buy() -> void:
func _refresh_lab() -> void:
for i in range(lab_vessel_buttons.size()):
var this_vessel: String = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_TRANSPORTER][i]
var definitions: Array = VESSEL_DEFINITIONS.all()
for i in range(mini(lab_vessel_buttons.size(), definitions.size())):
var definition: Resource = definitions[i]
var this_vessel: String = definition.definition_id
lab_vessel_buttons[i].enabled = true
lab_vessel_buttons[i].glow_color = _vessel_colour(this_vessel)
lab_vessel_buttons[i].icon = definition.icon
lab_vessel_buttons[i].glow_color = definition.glow_color
lab_vessel_labels[i].text = definition.display_name
lab_vessel_buttons[i].highlighted = this_vessel == lab_vessel
lab_vessel_buttons[i].queue_redraw()
lab_vessel_label.text = "%s research" % GameState.VESSEL_NAMES.get(lab_vessel, lab_vessel)
lab_vessel_label.text = "%s research" % GameState.vessel_name(lab_vessel)
var options: Array = _lab_options()
for i in range(lab_upgrade_buttons.size()):
@ -820,7 +832,7 @@ func _refresh_lab() -> void:
if not visible:
continue
var option: Dictionary = options[i]
var price_text: String = "%d ore" % (GameState.UNLOCK_SHIELD_COST if bool(option.unlock) else GameState.upgrade_cost(option.id))
var price_text: String = "%d ore" % (int(option.cost) if bool(option.unlock) else GameState.upgrade_cost(option.id))
if not bool(option.unlock) and GameState.upgrade_maxed(option.id):
price_text = "MAX"
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
@ -848,29 +860,11 @@ func _refresh_lab() -> void:
func _upgrade_icon(option: Dictionary) -> IconButton.Icon:
if bool(option.unlock):
return IconButton.Icon.UPGRADE_UNLOCK_SHIELD
match String(option.id):
"int_damage": return IconButton.Icon.UPGRADE_DAMAGE
"int_rate": return IconButton.Icon.UPGRADE_RATE
"int_range": return IconButton.Icon.UPGRADE_RANGE
"int_speed", "shd_speed": return IconButton.Icon.UPGRADE_THRUSTERS
"shd_hits": return IconButton.Icon.UPGRADE_SHIELD_HITS
"shd_recharge": return IconButton.Icon.UPGRADE_SHIELD_RECHARGE
"trn_capacity": return IconButton.Icon.UPGRADE_CARGO
"trn_speed": return IconButton.Icon.UPGRADE_ENGINES
_: return IconButton.Icon.SHIP_LAB
return int(option.get("icon", IconButton.Icon.SHIP_LAB))
func _on_craft_shield() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
if not GameState.shield_vessel_unlocked():
_say("Shield vessels must be unlocked at a ship lab first")
return
GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD)
_report_order(GameState.VESSEL_SHIELD)
_on_craft_vessel(GameState.VESSEL_SHIELD)
# --- moving ore, in both directions, at any building (#24) ----------------
@ -957,8 +951,8 @@ func _refresh_queue() -> void:
if not slot.visible:
continue
var order: Dictionary = orders[i]
slot.icon = IconButton.Icon.TRANSPORT_HUB if order.product == GameState.PRODUCT_TRANSPORTER \
else IconButton.Icon.SHIP
var definition: Resource = VESSEL_DEFINITIONS.get_definition(String(order.product))
slot.icon = definition.icon
slot.glow_color = _vessel_colour(String(order.product))
# Only the one under construction reads as active; the rest are
# waiting, and greyed says so without needing a caption each.
@ -971,29 +965,24 @@ func _refresh_queue() -> void:
var head: Dictionary = orders[0]
if bool(head.started):
queue_detail.text = "Building %s %d%%\nTap to cancel" % [
GameState.VESSEL_NAMES.get(head.product, head.product),
GameState.vessel_name(String(head.product)),
int(round(float(head.progress) * 100.0)),
]
else:
queue_detail.text = "%s waiting for ore\nTap to cancel" % \
GameState.VESSEL_NAMES.get(head.product, head.product)
GameState.vessel_name(String(head.product))
func _vessel_colour(product: String) -> Color:
match product:
GameState.VESSEL_SHIELD:
return Color(0.45, 0.8, 1.0)
GameState.PRODUCT_TRANSPORTER:
return Color(0.95, 0.55, 0.85)
_:
return Color(0.25, 0.85, 1.0)
var definition: Resource = VESSEL_DEFINITIONS.get_definition(product)
return definition.glow_color if definition != null else Color(0.25, 0.85, 1.0)
func _on_queue_slot(index: int) -> void:
var orders: Array = GameState.order_list(craft_building_id)
if index >= orders.size():
return
var vessel: String = GameState.VESSEL_NAMES.get(orders[index].product, orders[index].product)
var vessel: String = GameState.vessel_name(String(orders[index].product))
var refunded: bool = bool(orders[index].started)
if not GameState.cancel_order(craft_building_id, index):
return
@ -1018,12 +1007,12 @@ func _on_hangar_crew_type(index: int) -> void:
return
var stored_index: int = GameState.stored_vessels.find(type)
if stored_index < 0:
hangar_status.text = "No %s available" % GameState.VESSEL_NAMES.get(type, type)
hangar_status.text = "No %s available" % GameState.vessel_name(type)
return
if not fleet.add_ship(GameState.take_stored_vessel(stored_index)):
hangar_status.text = "Could not crew that vessel"
return
hangar_status.text = "%s joined the fleet" % GameState.VESSEL_NAMES.get(type, type)
hangar_status.text = "%s joined the fleet" % GameState.vessel_name(type)
_refresh_hangar()
@ -1035,32 +1024,40 @@ func _on_hangar_store_type(index: int) -> void:
ship_index = i
break
if ship_index < 0:
hangar_status.text = "No %s in the fleet" % GameState.VESSEL_NAMES.get(type, type)
hangar_status.text = "No %s in the fleet" % GameState.vessel_name(type)
return
var stored: String = fleet.store_ship(ship_index)
if stored == "":
hangar_status.text = "Keep one ship deployed"
return
GameState.stored_vessels.append(stored)
hangar_status.text = "%s stored in the hangar" % GameState.VESSEL_NAMES.get(stored, stored)
hangar_status.text = "%s stored in the hangar" % GameState.vessel_name(stored)
_refresh_hangar()
func _hangar_types() -> Array:
return [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER]
var types: Array = []
for definition in VESSEL_DEFINITIONS.all():
types.append(definition.definition_id)
return types
func _refresh_hangar() -> void:
var types: Array = _hangar_types()
for i in range(types.size()):
var type: String = types[i]
var definition: Resource = VESSEL_DEFINITIONS.get_definition(type)
var available: int = GameState.stored_vessels.count(type)
var deployed: int = 0
for ship in fleet.ships:
if is_instance_valid(ship) and String(ship.vessel_type) == type:
deployed += 1
hangar_available_labels[i].text = "%s\n%d available" % [GameState.VESSEL_NAMES.get(type, type), available]
hangar_fleet_labels[i].text = "%s\n%d in fleet" % [GameState.VESSEL_NAMES.get(type, type), deployed]
hangar_available_labels[i].text = "%s\n%d available" % [definition.display_name, available]
hangar_fleet_labels[i].text = "%s\n%d in fleet" % [definition.display_name, deployed]
hangar_available_slots[i].icon = definition.icon
hangar_fleet_slots[i].icon = definition.icon
hangar_available_slots[i].glow_color = definition.glow_color
hangar_fleet_slots[i].glow_color = definition.glow_color
hangar_available_slots[i].enabled = available > 0 and fleet.ships.size() < GameState.max_fleet_size()
hangar_fleet_slots[i].enabled = deployed > 0 and fleet.ships.size() > 1
hangar_available_slots[i].queue_redraw()

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

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_damage"
display_name = "Heavy Slugs"
description = "+1 damage per shot."
base_cost = 30
max_level = 3
icon = 15
additive_modifiers = {"damage": 1.0}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_range"
display_name = "Targeting Array"
description = "+60 firing range per level."
base_cost = 20
max_level = 3
icon = 17
additive_modifiers = {"fire_range": 60.0}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_rate"
display_name = "Autoloader"
description = "Fires 15% faster per level."
base_cost = 25
max_level = 3
icon = 16
multiplicative_modifiers = {"fire_cooldown": 0.85}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://InterceptorDamageUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://InterceptorRateUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://InterceptorRangeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://InterceptorSpeedUpgrade.tres" id="6"]
[resource]
script = ExtResource("1")
definition_id = "interceptor"
display_name = "Interceptor"
description = "Fast combat vessel with a rapid-firing cannon."
build_cost = 6
build_time = 10.0
icon = 2
glow_color = Color(0.25, 0.85, 1, 1)
capabilities = PackedStringArray("weapon")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5"), ExtResource("6")])

11
ShieldHitsUpgrade.tres Normal file
View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_hits"
display_name = "Layered Emitters"
description = "+1 hit absorbed before the shield drops."
base_cost = 60
max_level = 2
icon = 19
additive_modifiers = {"shield_charges": 1.0}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_recharge"
display_name = "Fast Cycling"
description = "Shield returns 1.2s sooner per level."
base_cost = 35
max_level = 3
icon = 20
additive_modifiers = {"shield_recharge": -1.2}

11
ShieldSpeedUpgrade.tres Normal file
View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -0,0 +1,25 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://VesselShieldComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://ShieldHitsUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://ShieldRechargeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://ShieldSpeedUpgrade.tres" id="6"]
[resource]
script = ExtResource("1")
definition_id = "shield"
display_name = "Shield Vessel"
description = "Combat vessel whose rechargeable personal shield absorbs lethal impacts."
build_cost = 14
build_time = 16.0
icon = 2
glow_color = Color(0.45, 0.8, 1, 1)
capabilities = PackedStringArray("weapon", "shield")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "shield_charges": 1.0, "shield_recharge": 6.0}
minimum_stats = {"shield_recharge": 2.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5"), ExtResource("6")])
unlock_id = "unlock_shield"
unlock_name = "Unlock Shield Vessel"
unlock_description = "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up."
unlock_cost = 45

234
Ship.gd
View file

@ -2,224 +2,134 @@ extends Area2D
signal destroyed
# Deliberately faster than Fleet.cruise_speed: the fleet as a whole travels
# at the cruise rate, and this headroom is what lets an individual ship
# close on its formation slot - so a pivot mid-flight resolves quickly
# instead of the ship having no speed budget left over for it.
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
@export var max_speed: float = 460.0
@export var acceleration: float = 450.0
@export var rotation_speed: float = 8.0
@export var arrive_radius: float = 6.0
@export var fire_range: float = 320.0
@export var fire_cooldown: float = 0.5
# How long to wait before looking for targets again after finding none.
@export var idle_rescan_interval: float = 0.25
@export var projectile_damage: int = 1
# Which kind of vessel this is (GameState.VESSEL_*). Drives both its
# researched statistics and whether it carries a shield (#5).
@export var vessel_type: String = "interceptor"
# --- shield (#5) ----------------------------------------------------------
const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0)
const SHIELD_RADIUS: float = 30.0
# A shield vessel survives a hit that would destroy any other ship, and is
# then defenceless until it cycles back up. Personal, not a bubble: it
# protects the vessel carrying it and nothing else.
var shield_charges: int = 0
var shield_recharge_left: float = 0.0
var _shield_ring: Node2D = null
# Neon colours, assigned by Fleet on spawn so a scene can tint its fleet.
# The lunar surface is a bright grey, where the default near-white core all
# but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw.
var definition: Resource
var glow_color: Color = Color(0.25, 0.85, 1.0)
var core_color: Color = Color(1.0, 1.0, 1.0)
var core_color: Color = Color.WHITE
var velocity: Vector2 = Vector2.ZERO
# The fleet's own velocity, supplied by Fleet as CONTEXT rather than as a
# facing command - the ship works out where to point from it.
#
# It is needed because `velocity` below is not the ship's true motion while
# it is holding formation: the slot it chases sits inside arrive_radius, so
# the ship snaps onto it and reports zero velocity even though it is being
# carried along at cruise speed. Facing derived from `velocity` alone
# therefore froze at whatever heading the ship last happened to travel.
# Adding the two gives the ship's ACTUAL motion through the world, which is
# the honest thing to point along in every case: it equals the fleet's
# heading when station-keeping, the ship's own heading when manoeuvring
# alone, and the true blend of both while strafing into a new slot.
var fleet_velocity: Vector2 = Vector2.ZERO
var move_target: Vector2
var fire_timer: float = 0.0
var projectile_scene: PackedScene = preload("res://Projectile.tscn")
# Compatibility accessors keep old saves/tests readable while shield state
# itself belongs exclusively to the optional ShieldComponent.
var shield_charges: int:
get:
var component: Node = get_component("shield")
return component.charges if component != null else 0
set(value):
var component: Node = get_component("shield")
if component != null:
component.set_charges(value)
var shield_recharge_left: float:
get:
var component: Node = get_component("shield")
return component.recharge_left if component != null else 0.0
set(value):
var component: Node = get_component("shield")
if component != null:
component.recharge_left = value
var projectile_damage: int:
get:
var component: Node = get_component("weapon")
return component.projectile_damage if component != null else 0
func _ready() -> void:
move_target = position
add_to_group("player_ships")
area_entered.connect(_on_area_entered)
if vessel_type == GameState.VESSEL_SHIELD:
_build_shield_ring()
shield_charges = GameState.shield_max_charges()
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition == null:
return
glow_color = definition.glow_color
for component_scene in definition.component_scenes:
var component: Node = component_scene.instantiate()
add_child(component)
if component.has_method("setup"):
component.setup()
apply_vessel_stats()
queue_redraw()
func get_component(capability: String) -> Node:
for child in get_children():
if child.get("capability_id") == capability:
return child
return null
func has_capability(capability: String) -> bool:
return get_component(capability) != null
func set_move_target(world_pos: Vector2) -> void:
move_target = world_pos
func apply_vessel_stats() -> void:
# Read from GameState rather than baked in at spawn, so researching an
# upgrade improves the ships already flying and not just the next one
# built. Fleet calls this on every vessel when something is bought.
max_speed = GameState.ship_max_speed(vessel_type)
acceleration = GameState.ship_acceleration(vessel_type)
fire_range = GameState.ship_fire_range(vessel_type)
fire_cooldown = GameState.ship_fire_cooldown(vessel_type)
projectile_damage = GameState.ship_damage(vessel_type)
if vessel_type == GameState.VESSEL_SHIELD:
# Topping up a live shield with researched capacity, without
# refilling one that is currently down and cycling.
var was_up: bool = shield_charges > 0
shield_charges = GameState.shield_max_charges() if was_up else shield_charges
_update_shield_visual()
if definition == null:
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition == null:
return
max_speed = GameState.vessel_stat(vessel_type, "max_speed")
acceleration = GameState.vessel_stat(vessel_type, "acceleration")
for child in get_children():
if child.has_method("apply_stats"):
child.apply_stats()
func has_shield() -> bool:
return vessel_type == GameState.VESSEL_SHIELD and shield_charges > 0
func _update_shield_visual() -> void:
if _shield_ring == null:
return
# visible only - per #10 the ring is rasterised once and never redrawn to
# turn it on and off.
_shield_ring.visible = has_shield()
func _tick_shield(delta: float) -> void:
if vessel_type != GameState.VESSEL_SHIELD:
return
if shield_charges >= GameState.shield_max_charges():
return
shield_recharge_left -= delta
if shield_recharge_left <= 0.0:
shield_charges += 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
var component: Node = get_component("shield")
return component != null and component.is_active()
func _physics_process(delta: float) -> void:
_tick_shield(delta)
for child in get_children():
if child.has_method("tick"):
child.tick(delta)
var to_target: Vector2 = move_target - position
var distance: float = to_target.length()
if distance <= arrive_radius:
# Snap and stop dead - prevents residual float-level jitter that
# would otherwise keep nudging back and forth around the target.
position = move_target
velocity = Vector2.ZERO
else:
# Braking-distance formula: the fastest speed we can be going right
# now and still be able to decelerate to 0 exactly at the target,
# given our acceleration. Without this the ship approaches at full
# speed, overshoots (stopping distance = max_speed^2 / (2*accel)),
# and oscillates back and forth forever instead of settling.
var braking_speed: float = sqrt(2.0 * acceleration * distance)
var desired_speed: float = min(max_speed, braking_speed)
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
position += velocity * delta
# One owner, one rule: face the way this ship is actually travelling.
var motion: Vector2 = velocity + fleet_velocity
if motion.length() > 8.0:
var target_angle: float = motion.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
_update_weapon(delta)
func _update_weapon(delta: float) -> void:
fire_timer -= delta
if fire_timer > 0.0:
return
var target: Node2D = _find_nearest_enemy()
if target == null:
# Back off before scanning again. Without this, fire_timer stays at
# or below zero and every ship queries the whole "enemies" group and
# loops it on EVERY physics frame whenever nothing is in range -
# which is most of the time.
fire_timer = idle_rescan_interval
return
fire_timer = fire_cooldown
var proj: Node2D = projectile_scene.instantiate()
proj.position = position
proj.damage = projectile_damage
get_tree().root.add_child(proj)
proj.launch(target.position - position)
func _find_nearest_enemy() -> Node2D:
var enemies: Array = get_tree().get_nodes_in_group("enemies")
var nearest: Node2D = null
var best_dist: float = fire_range
for e in enemies:
if not is_instance_valid(e):
continue
var d: float = position.distance_to(e.position)
if d <= best_dist:
best_dist = d
nearest = e
return nearest
func _on_area_entered(area: Area2D) -> void:
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
return
if has_shield():
# Absorbed. The thing that hit it is still destroyed - a shield stops
# the vessel dying, it does not make the rock harmless - but the
# vessel survives and is defenceless until the shield cycles back.
shield_charges -= 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
if area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
return
for child in get_children():
if child.has_method("absorb_hit") and child.absorb_hit():
if area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
return
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
func _build_shield_ring() -> void:
# Its own child, drawn once, toggled with `visible` (#10). A shield that
# came and went by redrawing the ship would re-tessellate the hull every
# time it cycled.
_shield_ring = NeonPiece.create(self, Vector2.ZERO, SHIELD_COLOR)
var points: PackedVector2Array = PackedVector2Array()
for i in range(14):
var a: float = (float(i) / 14.0) * TAU
points.append(Vector2(cos(a), sin(a)) * SHIELD_RADIUS)
_shield_ring.add_polygon(points, 0.7)
_shield_ring.queue_redraw()
_shield_ring.visible = false
func _draw() -> void:
var nose: Vector2 = Vector2(0, -22)
var left: Vector2 = Vector2(-14, 16)
var right: Vector2 = Vector2(14, 16)
var points: PackedVector2Array = PackedVector2Array([nose, left, right])
# Constant glow, drawn once. No pulse means nothing changes frame to
# frame, so the ship never needs redrawing after _ready() - which also
# removes four antialiased polyline rebuilds per ship per frame.
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
])
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "trn_capacity"
display_name = "Expanded Hold"
description = "Carries 8 more resources per supply run."
base_cost = 30
max_level = 3
icon = 21
additive_modifiers = {"cargo_capacity": 8.0}

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "trn_speed"
display_name = "Freight Engines"
description = "+70 speed on supply runs."
base_cost = 30
max_level = 3
icon = 22
additive_modifiers = {"transport_speed": 70.0}

View file

@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselCargoComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://VesselTransportComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://TransportCapacityUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://TransportSpeedUpgrade.tres" id="5"]
[resource]
script = ExtResource("1")
definition_id = "transporter"
display_name = "Transporter"
description = "Unarmed cargo vessel stationed at a transport hub to move resources."
build_cost = 10
build_time = 8.0
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
capabilities = PackedStringArray("cargo", "transport")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
base_stats = {"max_speed": 460.0, "acceleration": 450.0, "cargo_capacity": 20.0, "transport_speed": 340.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")])

9
VesselCargoComponent.gd Normal file
View file

@ -0,0 +1,9 @@
class_name VesselCargoComponent
extends Node
var capability_id: String = "cargo"
var capacity: int
func apply_stats() -> void:
var state: Node = get_tree().root.get_node("GameState")
capacity = int(state.vessel_stat(get_parent().vessel_type, "cargo_capacity"))

View file

@ -0,0 +1 @@
uid://bo16x4wq6hg5k

View file

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselCargoComponent.gd" id="1"]
[node name="CargoComponent" type="Node"]
script = ExtResource("1")

26
VesselDefinition.gd Normal file
View file

@ -0,0 +1,26 @@
class_name VesselDefinition
extends Resource
@export var definition_id: String
@export var display_name: String
@export_multiline var description: String
@export var build_cost: int
@export var build_time: float = 10.0
@export var icon: int = 2
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
@export var hull_points: PackedVector2Array = PackedVector2Array([
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
])
@export var capabilities: PackedStringArray = []
@export var component_scenes: Array[PackedScene] = []
@export var base_stats: Dictionary = {}
@export var minimum_stats: Dictionary = {}
@export var upgrades: Array[Resource] = []
@export var unlock_id: String
@export var unlock_name: String
@export_multiline var unlock_description: String
@export var unlock_cost: int
func has_capability(capability: String) -> bool:
return capability in capabilities

1
VesselDefinition.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://c6txy3v61blm8

24
VesselDefinitions.gd Normal file
View file

@ -0,0 +1,24 @@
class_name VesselDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["interceptor", "shield", "transporter"]
const DEFINITIONS: Dictionary = {
"interceptor": preload("res://InterceptorVesselDefinition.tres"),
"shield": preload("res://ShieldVesselDefinition.tres"),
"transporter": preload("res://TransporterVesselDefinition.tres"),
}
static func get_definition(definition_id: String) -> Resource:
return DEFINITIONS.get(definition_id)
static func all() -> Array:
var result: Array = []
for definition_id in ORDER:
result.append(DEFINITIONS[definition_id])
return result
static func with_capability(capability: String) -> Resource:
for definition in all():
if definition.has_capability(capability):
return definition
return null

1
VesselDefinitions.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cgwj1q4nf3ar7

View file

@ -1,8 +1,10 @@
extends Node2D
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
# Visual-only craft-to-mothership flight. GameState owns position/progress;
# this node just renders a finished hull headed toward the hangar.
var vessel_type: String = GameState.VESSEL_INTERCEPTOR
var vessel_type: String = "interceptor"
func _ready() -> void:
@ -11,6 +13,6 @@ func _ready() -> void:
func _draw() -> void:
var color: Color = Color(0.45, 0.8, 1.0) if vessel_type == GameState.VESSEL_SHIELD else Color(0.95, 0.55, 0.85) if vessel_type == GameState.VESSEL_TRANSPORTER else Color(0.25, 0.85, 1.0)
var hull := PackedVector2Array([Vector2(0, -20), Vector2(13, 15), Vector2(0, 9), Vector2(-13, 15)])
NeonDraw.draw_glowing_polygon(self, hull, color, 1.0)
var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition != null:
NeonDraw.draw_glowing_polygon(self, definition.hull_points, definition.glow_color, 1.0)

View file

@ -17,9 +17,9 @@ func _process(_delta: float) -> void:
var node: Node2D = active.get(i)
if node == null or not is_instance_valid(node):
node = delivery_script.new()
node.vessel_type = String(delivery.type)
get_parent().add_child(node)
active[i] = node
node.vessel_type = String(delivery.type)
node.position = pos
var direction: Vector2 = (delivery.to as Vector2) - (delivery.from as Vector2)
if direction.length() > 0.1:

69
VesselShieldComponent.gd Normal file
View file

@ -0,0 +1,69 @@
class_name VesselShieldComponent
extends Node
const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0)
const SHIELD_RADIUS: float = 30.0
var capability_id: String = "shield"
var charges: int = 0
var recharge_left: float = 0.0
var ring: Node2D
func setup() -> void:
var ship: Node2D = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
ring = NeonPiece.create(ship, Vector2.ZERO, SHIELD_COLOR)
var points := PackedVector2Array()
for i in range(14):
var angle: float = float(i) / 14.0 * TAU
points.append(Vector2(cos(angle), sin(angle)) * SHIELD_RADIUS)
ring.add_polygon(points, 0.7)
ring.queue_redraw()
charges = int(state.vessel_stat(ship.vessel_type, "shield_charges"))
_update_visual()
func apply_stats() -> void:
var was_up: bool = charges > 0
if was_up:
charges = int(_state().vessel_stat(get_parent().vessel_type, "shield_charges"))
_update_visual()
func tick(delta: float) -> void:
var maximum: int = int(_state().vessel_stat(get_parent().vessel_type, "shield_charges"))
if charges >= maximum:
return
recharge_left -= delta
if recharge_left <= 0.0:
charges += 1
recharge_left = _state().vessel_stat(get_parent().vessel_type, "shield_recharge")
_update_visual()
func is_active() -> bool:
return charges > 0
func absorb_hit() -> bool:
if not is_active():
return false
charges -= 1
recharge_left = _state().vessel_stat(get_parent().vessel_type, "shield_recharge")
_update_visual()
return true
func set_charges(value: int) -> void:
charges = value
_update_visual()
func _state() -> Node:
return get_tree().root.get_node("GameState")
func _update_visual() -> void:
if ring != null:
ring.visible = is_active()

View file

@ -0,0 +1 @@
uid://ghacatobxl11

View file

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselShieldComponent.gd" id="1"]
[node name="ShieldComponent" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,9 @@
class_name VesselTransportComponent
extends Node
var capability_id: String = "transport"
var travel_speed: float
func apply_stats() -> void:
var state: Node = get_tree().root.get_node("GameState")
travel_speed = state.vessel_stat(get_parent().vessel_type, "transport_speed")

View file

@ -0,0 +1 @@
uid://tsapg8ld1vl8

View file

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselTransportComponent.gd" id="1"]
[node name="TransportComponent" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,11 @@
class_name VesselUpgradeDefinition
extends Resource
@export var upgrade_id: String
@export var display_name: String
@export_multiline var description: String
@export var base_cost: int
@export var max_level: int = 1
@export var icon: int
@export var additive_modifiers: Dictionary = {}
@export var multiplicative_modifiers: Dictionary = {}

View file

@ -0,0 +1 @@
uid://dw41jux1cn3sw

47
VesselWeaponComponent.gd Normal file
View file

@ -0,0 +1,47 @@
class_name VesselWeaponComponent
extends Node
var capability_id: String = "weapon"
var fire_range: float
var fire_cooldown: float
var projectile_damage: int
var fire_timer: float = 0.0
var projectile_scene: PackedScene = preload("res://Projectile.tscn")
func apply_stats() -> void:
var ship: Node = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
fire_range = state.vessel_stat(ship.vessel_type, "fire_range")
fire_cooldown = state.vessel_stat(ship.vessel_type, "fire_cooldown")
projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage"))
func tick(delta: float) -> void:
fire_timer -= delta
if fire_timer > 0.0:
return
var ship: Node2D = get_parent()
var target: Node2D = _find_nearest_enemy(ship)
if target == null:
fire_timer = 0.25
return
var projectile: Node2D = projectile_scene.instantiate()
projectile.position = ship.position
projectile.damage = projectile_damage
ship.get_tree().root.add_child(projectile)
projectile.launch(target.position - ship.position)
fire_timer = fire_cooldown
func _find_nearest_enemy(ship: Node2D) -> Node2D:
var nearest: Node2D = null
var best_distance: float = fire_range
for enemy in ship.get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy):
continue
var distance: float = ship.position.distance_to(enemy.position)
if distance <= best_distance:
best_distance = distance
nearest = enemy
return nearest

View file

@ -0,0 +1 @@
uid://cwo0nkmr4e1sl

View file

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselWeaponComponent.gd" id="1"]
[node name="WeaponComponent" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1 @@
uid://bljnijxm3n20e

View file

@ -68,6 +68,7 @@ func _process(delta: float) -> bool:
# own non-overlapping berths to the right only after expansion.
var mothership: Node2D = load("res://Building.tscn").instantiate()
mothership.set("building_type", GameState.BUILDING_MOTHERSHIP)
mothership._ready()
controller.current_building = mothership
controller.context_actions_expanded = true
controller._layout_context_buttons()

View file

@ -0,0 +1,50 @@
extends SceneTree
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
var ships: Array = []
var elapsed: float = 0.0
var game_state: Node
func _initialize() -> void:
game_state = root.get_node("GameState")
var scene: PackedScene = load("res://Ship.tscn")
for definition in VESSEL_DEFINITIONS.all():
var ship: Area2D = scene.instantiate()
ship.vessel_type = definition.definition_id
root.add_child(ship)
ships.append(ship)
func _process(delta: float) -> bool:
elapsed += delta
if elapsed < 0.1:
return false
var definitions: Array = VESSEL_DEFINITIONS.all()
if ships.size() != definitions.size():
return _fail("catalog and composed fleet differ in size")
for i in range(definitions.size()):
var definition: Resource = definitions[i]
var ship: Node = ships[i]
if ship.definition == null or ship.definition.definition_id != definition.definition_id:
return _fail("%s did not resolve its definition" % definition.definition_id)
for capability in definition.capabilities:
if not ship.has_capability(capability):
return _fail("%s did not compose %s" % [definition.definition_id, capability])
if game_state.product_cost(definition.definition_id) != definition.build_cost \
or game_state.vessel_name(definition.definition_id) != definition.display_name:
return _fail("%s metadata did not come from its definition" % definition.definition_id)
if ships[0].has_capability("shield") or not ships[1].has_capability("shield"):
return _fail("shield behavior is not controlled by component presence")
if ships[2].has_capability("weapon") or not ships[2].has_capability("transport"):
return _fail("transporter capabilities were composed incorrectly")
print("TEST_VESSEL_COMPOSITION: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_VESSEL_COMPOSITION: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1 @@
uid://cq73poxld1e35