Typed owed vessels, ship lab building type, shield vessel gate

Groundwork for #5. vessels_owed carries the VESSEL TYPE rather than a
bare count, so a shield vessel finishing while the player is on a planet
comes back as a shield vessel. Fleet.add_ship takes a type, and vessels
already flying are re-statted when an upgrade is researched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 23:10:07 +02:00
commit 19ac758da1
9 changed files with 80 additions and 27 deletions

View file

@ -557,7 +557,7 @@ func _refresh_craft_status() -> void:
# A full fleet cannot take another vessel, so the order would be refused. # A full fleet cannot take another vessel, so the order would be refused.
# Showing that on the button is the difference between "not available yet" # Showing that on the button is the difference between "not available yet"
# and a button that looks live and silently does nothing when pressed. # and a button that looks live and silently does nothing when pressed.
var room: bool = fleet.ships.size() + GameState.ships_owed \ var room: bool = fleet.ships.size() + GameState.vessels_owed.size() \
+ GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \
< GameState.max_fleet_size() < GameState.max_fleet_size()
craft_interceptor_button.enabled = room craft_interceptor_button.enabled = room
@ -568,7 +568,7 @@ func _refresh_craft_status() -> void:
craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()]
else: else:
var queued_ships: int = GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) var queued_ships: int = GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP)
var owed: int = GameState.ships_owed + queued_ships var owed: int = GameState.vessels_owed.size() + queued_ships
var pending_note: String = "" if owed == 0 else " (%d already on order)" % owed var pending_note: String = "" if owed == 0 else " (%d already on order)" % owed
craft_status.text = "No room in the fleet: %d/%d%s - research capacity at the mothership\n%s" % [ craft_status.text = "No room in the fleet: %d/%d%s - research capacity at the mothership\n%s" % [
fleet.ships.size(), GameState.max_fleet_size(), pending_note, craft_status.text fleet.ships.size(), GameState.max_fleet_size(), pending_note, craft_status.text
@ -586,8 +586,8 @@ func _on_craft_interceptor() -> void:
if craft_building_id == 0: if craft_building_id == 0:
return return
# Still refused when there is nowhere to put the vessel - queueing an # Still refused when there is nowhere to put the vessel - queueing an
# order that can never be delivered would just bank ships_owed forever. # order that can never be delivered would just bank vessels_owed forever.
if fleet.ships.size() + GameState.ships_owed \ if fleet.ships.size() + GameState.vessels_owed.size() \
+ GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \
>= GameState.max_fleet_size(): >= GameState.max_fleet_size():
_say("Fleet is full (%d slots)" % GameState.max_fleet_size()) _say("Fleet is full (%d slots)" % GameState.max_fleet_size())

View file

@ -32,6 +32,18 @@ var anchor: Vector2 = Vector2.ZERO
var steer_direction: Vector2 = Vector2.ZERO var steer_direction: Vector2 = Vector2.ZERO
func _ready() -> void:
# Researching a vessel upgrade improves the ships already flying, not
# just the next one built (#5).
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
func _on_vessel_upgraded(_id: String) -> void:
for ship in ships:
if is_instance_valid(ship):
ship.apply_vessel_stats()
func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
for ship in ships: for ship in ships:
if is_instance_valid(ship): if is_instance_valid(ship):
@ -55,13 +67,16 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
fleet_target = center fleet_target = center
func add_ship() -> bool: func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool:
# Adds one vessel in the next free formation slot (bought from a ship # Adds one vessel in the next free formation slot (bought from a ship
# crafter). Returns false when the wedge is already full. # crafter). Returns false when the wedge is already full.
if ships.size() >= GameState.max_fleet_size(): if ships.size() >= GameState.max_fleet_size():
return false return false
var offset: Vector2 = GameState.formation[ships.size()] var offset: Vector2 = GameState.formation[ships.size()]
var ship: Area2D = ship_scene.instantiate() var ship: Area2D = ship_scene.instantiate()
# Before add_child, so Ship._ready() builds the right vessel - the shield
# ring is created there and only for a shield vessel.
ship.vessel_type = vessel_type
# From the formation origin, not the centroid: with a lopsided layout the # From the formation origin, not the centroid: with a lopsided layout the
# centroid is already displaced by the average offset, so adding the new # centroid is already displaced by the average offset, so adding the new
# ship's own offset to it would drop it well outside the wedge. # ship's own offset to it would drop it well outside the wedge.
@ -128,12 +143,12 @@ func _process(delta: float) -> void:
func _claim_owed_ships() -> void: func _claim_owed_ships() -> void:
# Production completes in the GameState autoload, which may well happen # Production completes in the GameState autoload, which may well happen
# while the player is on a planet's surface with no Fleet node in # while the player is on a planet's surface with no Fleet node in
# existence to hand a vessel to. Those ships wait as `ships_owed` until a # existence to hand a vessel to. Those vessels wait in `vessels_owed`
# fleet with room can take them. # fleet with room can take them.
while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size(): while not GameState.vessels_owed.is_empty() and ships.size() < GameState.max_fleet_size():
if not add_ship(): if not add_ship(String(GameState.vessels_owed[0])):
return return
GameState.ships_owed -= 1 GameState.vessels_owed.pop_front()
func _assign_slot_targets() -> void: func _assign_slot_targets() -> void:

View file

@ -104,10 +104,13 @@ const BUILDING_EXTRACTOR: String = "extractor"
# Hosts the transport vessels that supply other buildings (#18). Built in # Hosts the transport vessels that supply other buildings (#18). Built in
# space like the rest; it is the transporters (#19) that do the moving. # space like the rest; it is the transporters (#19) that do the moving.
const BUILDING_TRANSPORT_HUB: String = "transport_hub" const BUILDING_TRANSPORT_HUB: String = "transport_hub"
# Where vessel types are researched and new ones unlocked (#5).
const BUILDING_SHIP_LAB: String = "ship_lab"
const COMMAND_CENTER_COST: int = 15 const COMMAND_CENTER_COST: int = 15
const EXTRACTOR_COST: int = 25 const EXTRACTOR_COST: int = 25
const TRANSPORT_HUB_COST: int = 30 const TRANSPORT_HUB_COST: int = 30
const SHIP_LAB_COST: int = 35
var buildings: Array = [] var buildings: Array = []
@ -145,6 +148,11 @@ const SKILL_TREE: Array = [
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"desc": "Build transport hubs, which supply your other buildings with ore.", "desc": "Build transport hubs, which supply your other buildings with ore.",
}, },
{
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
"x": 0.62, "unlocks": BUILDING_SHIP_LAB,
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
},
{ {
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.05, "hub_bonus": 1, "x": 0.05, "hub_bonus": 1,
@ -269,12 +277,12 @@ func reset_progression() -> void:
# on the same spot. # on the same spot.
var next_building_id: int = 1 var next_building_id: int = 1
# Ships that have been paid for and built but not yet handed to the fleet. # Vessels that have been paid for and built but not yet handed to the fleet,
# Production is simulated here, in the autoload, the same way extractor # as a list of VESSEL_* types - a shield vessel finishing while the player is
# haulers are - so an order can complete while the player is down on a # on a planet must not come back as an interceptor. Production is simulated
# planet's surface, where there is no Fleet node to hand a ship to. The fleet # here, in the autoload, the same way extractor haulers are, so an order can
# claims them when it next can. # complete where there is no Fleet node to hand a ship to.
var ships_owed: int = 0 var vessels_owed: Array = []
signal order_completed(building_id: int, type: String) signal order_completed(building_id: int, type: String)
@ -372,6 +380,33 @@ var vessel_levels: Dictionary = {}
signal vessel_upgraded(upgrade_id: String) signal vessel_upgraded(upgrade_id: String)
func has_ship_lab() -> bool:
for b in buildings:
if b.type == BUILDING_SHIP_LAB:
return true
return false
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
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
const UNLOCK_SHIELD_COST: int = 45
func unlock_shield_vessel() -> bool:
if shield_vessel_unlocked() or not has_ship_lab():
return false
if not spend(UNLOCK_SHIELD_COST):
return false
vessel_levels[UNLOCK_SHIELD_VESSEL] = 1
vessel_upgraded.emit(UNLOCK_SHIELD_VESSEL)
return true
func upgrade_def(id: String) -> Dictionary: func upgrade_def(id: String) -> Dictionary:
for u in VESSEL_UPGRADES: for u in VESSEL_UPGRADES:
if u.id == id: if u.id == id:
@ -404,7 +439,7 @@ func upgrade_cost(id: String) -> int:
func can_buy_upgrade(id: String) -> bool: func can_buy_upgrade(id: String) -> bool:
return not upgrade_maxed(id) and can_afford(upgrade_cost(id)) return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id))
func buy_upgrade(id: String) -> bool: func buy_upgrade(id: String) -> bool:
@ -531,6 +566,8 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id) var b: Dictionary = find_building(id)
if b.is_empty() or not can_produce(b.type): if b.is_empty() or not can_produce(b.type):
return false return false
if product == VESSEL_SHIELD and not shield_vessel_unlocked():
return false
if product == PRODUCT_TRANSPORTER and not has_transport_hub(): if product == PRODUCT_TRANSPORTER and not has_transport_hub():
# Nowhere to station it. Refused rather than queued, so the player is # Nowhere to station it. Refused rather than queued, so the player is
# told now instead of watching an order sit forever. # told now instead of watching an order sit forever.
@ -570,7 +607,7 @@ func _advance_production() -> void:
if product == PRODUCT_TRANSPORTER: if product == PRODUCT_TRANSPORTER:
_station_transporter(b.position) _station_transporter(b.position)
else: else:
ships_owed += 1 vessels_owed.append(product)
order_completed.emit(int(b.id), product) order_completed.emit(int(b.id), product)

View file

@ -45,7 +45,7 @@ func _initialize() -> void:
game_state.extractors.clear() game_state.extractors.clear()
game_state.rocks = 500 game_state.rocks = 500
game_state.fleet_cargo = 0 game_state.fleet_cargo = 0
game_state.ships_owed = 0 game_state.vessels_owed.clear()
# Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet # Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet
# starts at exactly 5, so without this every completed order would have # starts at exactly 5, so without this every completed order would have
# nowhere to put its vessel and the test would be measuring the cap # nowhere to put its vessel and the test would be measuring the cap
@ -180,7 +180,7 @@ func _process(delta: float) -> bool:
# --- 6 + 7. no scene, no fleet: production still completes -- # --- 6 + 7. no scene, no fleet: production still completes --
# The scene has to be GONE before the order is placed. With a # The scene has to be GONE before the order is placed. With a
# fleet still alive and under its cap it claims the vessel the # fleet still alive and under its cap it claims the vessel the
# same frame production finishes, which would leave ships_owed # same frame production finishes, which would leave vessels_owed
# back at 0 and prove nothing about orders surviving without it. # back at 0 and prove nothing about orders surviving without it.
ships_at_start = fleet.ships.size() ships_at_start = fleet.ships.size()
scene.queue_free() scene.queue_free()
@ -207,11 +207,11 @@ func _process(delta: float) -> bool:
print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)") print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)")
quit(1) quit(1)
return true return true
if game_state.ships_owed < 1: if game_state.vessels_owed.is_empty():
print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)") print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)")
quit(1) quit(1)
return true return true
print("no scene at all -> order completed, %d vessel owed" % game_state.ships_owed) print("no scene at all -> order completed, %d vessel owed" % game_state.vessels_owed.size())
# Come back to space: the fleet should claim what it is owed. # Come back to space: the fleet should claim what it is owed.
scene = load("res://Main.tscn").instantiate() scene = load("res://Main.tscn").instantiate()
@ -223,8 +223,8 @@ func _process(delta: float) -> bool:
8: 8:
if elapsed < 0.5: if elapsed < 0.5:
return false return false
if game_state.ships_owed != 0: if not game_state.vessels_owed.is_empty():
print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.ships_owed) print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.vessels_owed.size())
quit(1) quit(1)
return true return true
print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size()) print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size())

View file

@ -38,7 +38,7 @@ func _initialize() -> void:
game_state.unlocked = ["shipyards", "coordination", "wing"] game_state.unlocked = ["shipyards", "coordination", "wing"]
game_state.rocks = 100 game_state.rocks = 100
game_state.fleet_cargo = 0 game_state.fleet_cargo = 0
game_state.ships_owed = 0 game_state.vessels_owed.clear()
scene = load("res://Main.tscn").instantiate() scene = load("res://Main.tscn").instantiate()
root.add_child(scene) root.add_child(scene)
controller = scene.get_node("BuildController") controller = scene.get_node("BuildController")

View file

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

View file

@ -34,7 +34,7 @@ func _initialize() -> void:
game_state.transporters.clear() game_state.transporters.clear()
game_state.rocks = 900 game_state.rocks = 900
game_state.fleet_cargo = 0 game_state.fleet_cargo = 0
game_state.ships_owed = 0 game_state.vessels_owed.clear()
game_state.unlocked = ["shipyards", "logistics", "freight"] game_state.unlocked = ["shipyards", "logistics", "freight"]
scene = load("res://Main.tscn").instantiate() scene = load("res://Main.tscn").instantiate()
root.add_child(scene) root.add_child(scene)

View file

@ -50,7 +50,7 @@ func _initialize() -> void:
game_state.extractors.clear() game_state.extractors.clear()
game_state.rocks = 40 game_state.rocks = 40
game_state.fleet_cargo = 0 game_state.fleet_cargo = 0
game_state.ships_owed = 0 game_state.vessels_owed.clear()
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate() scene = load("res://Main.tscn").instantiate()
root.add_child(scene) root.add_child(scene)

View file

@ -56,7 +56,7 @@ func _initialize() -> void:
game_state.transporters.clear() game_state.transporters.clear()
game_state.rocks = 200 game_state.rocks = 200
game_state.fleet_cargo = 0 game_state.fleet_cargo = 0
game_state.ships_owed = 0 game_state.vessels_owed.clear()
game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"] game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate() scene = load("res://Main.tscn").instantiate()
root.add_child(scene) root.add_child(scene)