Store resources in generic inventories

This commit is contained in:
Loïc Wisniewski 2026-08-14 20:32:58 +02:00
commit 1721edd123
24 changed files with 385 additions and 172 deletions

View file

@ -1,5 +1,8 @@
extends Node extends Node
const INVENTORY = preload("res://Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
# Autoload singleton - the one thing that survives change_scene_to_file() # Autoload singleton - the one thing that survives change_scene_to_file()
# scene swaps between space (Main.tscn) and a planet's surface # scene swaps between space (Main.tscn) and a planet's surface
@ -23,126 +26,101 @@ var explored_chunks: Dictionary = {}
# --- Resources ------------------------------------------------------------- # --- Resources -------------------------------------------------------------
# Ore that has been BANKED at the mothership. This is the only pool anything const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
# can be bought with. const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
# Starts stocked for play-testing so buildings can be tried without mining const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
# first - drop this back to 0 for a real playthrough.
var rocks: int = 500
# Gas is a second, banked resource. It is deliberately separate from ore: # All resource-bearing locations use the same container. The old public
# construction still spends ore, while future gas technologies can consume # names below are computed compatibility accessors, not separate storage.
# gas without making the two materials silently interchangeable. var bank_inventory: RefCounted = INVENTORY.new({RESOURCE_ORE: 500})
var gas: int = 0 var fleet_inventory: RefCounted = INVENTORY.new()
# Ore the fleet is physically carrying and has not delivered yet (#14). Mining var rocks: int:
# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a get: return bank_inventory.amount(RESOURCE_ORE)
# long mining run is a real trip with something to lose rather than instant set(value): bank_inventory.set_amount(RESOURCE_ORE, value)
# income from anywhere on the map.
#
# Lives here, like everything else that has to survive Main.tscn being torn
# down and rebuilt on every trip to a planet's surface - and cargo mined ON a
# surface would otherwise be dropped the moment the player took off.
var fleet_cargo: int = 0
var fleet_gas_cargo: int = 0
# Resource-facing UI and logistics iterate this list instead of growing a var gas: int:
# second set of controls and controller branches every time a material is get: return bank_inventory.amount(RESOURCE_GAS)
# introduced. Keep presentation data here with the state it describes so a set(value): bank_inventory.set_amount(RESOURCE_GAS, value)
# resource has one name and colour everywhere it appears.
const RESOURCE_ORE: String = "ore" var fleet_cargo: int:
const RESOURCE_GAS: String = "gas" get: return fleet_inventory.amount(RESOURCE_ORE)
const RESOURCE_TYPES: PackedStringArray = [RESOURCE_ORE, RESOURCE_GAS] set(value): fleet_inventory.set_amount(RESOURCE_ORE, value)
const RESOURCE_NAMES: Dictionary = {
RESOURCE_ORE: "Ore", var fleet_gas_cargo: int:
RESOURCE_GAS: "Gas", get: return fleet_inventory.amount(RESOURCE_GAS)
} set(value): fleet_inventory.set_amount(RESOURCE_GAS, value)
const RESOURCE_COLORS: Dictionary = {
RESOURCE_ORE: Color(0.0, 0.72, 0.82),
RESOURCE_GAS: Color(0.85, 0.52, 1.0),
}
func collect_cargo(amount: int = 1) -> void: func collect_cargo(amount: int = 1) -> void:
fleet_cargo += amount fleet_inventory.deposit(RESOURCE_ORE, amount)
func collect_gas_cargo(amount: int = 1) -> void: func collect_gas_cargo(amount: int = 1) -> void:
fleet_gas_cargo += amount 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: func resource_name(resource_type: String) -> String:
return String(RESOURCE_NAMES.get(resource_type, resource_type.capitalize())) 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: func resource_color(resource_type: String) -> Color:
return RESOURCE_COLORS.get(resource_type, Color.WHITE) var definition: Resource = resource_definition(resource_type)
return definition.color if definition != null else Color.WHITE
func banked_resource(resource_type: String) -> int: func banked_resource(resource_type: String) -> int:
match resource_type: return bank_inventory.amount(resource_type)
RESOURCE_ORE: return rocks
RESOURCE_GAS: return gas
_: return 0
func fleet_resource(resource_type: String) -> int: func fleet_resource(resource_type: String) -> int:
match resource_type: return fleet_inventory.amount(resource_type)
RESOURCE_ORE: return fleet_cargo
RESOURCE_GAS: return fleet_gas_cargo
_: return 0 func _inventory_for_record(record: Dictionary) -> RefCounted:
var inventory: RefCounted = record.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
record.inventory = inventory
# 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.type == BUILDING_MOTHERSHIP:
return bank_inventory
return _inventory_for_record(building)
func building_resource(id: int, resource_type: String) -> int: func building_resource(id: int, resource_type: String) -> int:
var building: Dictionary = find_building(id) var inventory: RefCounted = building_inventory(id)
if building.is_empty(): return 0 if inventory == null else inventory.amount(resource_type)
return 0
if building.type == BUILDING_MOTHERSHIP:
return banked_resource(resource_type)
match resource_type:
RESOURCE_ORE: return int(building.get("stock", 0))
RESOURCE_GAS: return int(building.get("gas_stock", 0))
_: return 0
func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int: func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int:
var building: Dictionary = find_building(id) return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount)
if building.is_empty():
return 0
var moved: int = mini(maxi(amount, 0), fleet_resource(resource_type))
if moved <= 0:
return 0
match resource_type:
RESOURCE_ORE: fleet_cargo -= moved
RESOURCE_GAS: fleet_gas_cargo -= moved
_: return 0
if building.type == BUILDING_MOTHERSHIP:
match resource_type:
RESOURCE_ORE: rocks += moved
RESOURCE_GAS: gas += moved
else:
match resource_type:
RESOURCE_ORE: building.stock = int(building.get("stock", 0)) + moved
RESOURCE_GAS: building.gas_stock = int(building.get("gas_stock", 0)) + moved
return moved
func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int: func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int:
var building: Dictionary = find_building(id) var source: RefCounted = building_inventory(id)
if building.is_empty(): return 0 if source == null else source.transfer_to(fleet_inventory, resource_type, amount)
return 0
var moved: int = mini(maxi(amount, 0), building_resource(id, resource_type))
if moved <= 0:
return 0
if building.type == BUILDING_MOTHERSHIP:
match resource_type:
RESOURCE_ORE: rocks -= moved
RESOURCE_GAS: gas -= moved
else:
match resource_type:
RESOURCE_ORE: building.stock = int(building.get("stock", 0)) - moved
RESOURCE_GAS: building.gas_stock = int(building.get("gas_stock", 0)) - moved
match resource_type:
RESOURCE_ORE: fleet_cargo += moved
RESOURCE_GAS: fleet_gas_cargo += moved
return moved
# The batch size the transfer menu offers, next to "All". A batch rather # The batch size the transfer menu offers, next to "All". A batch rather
@ -401,8 +379,8 @@ func add_building(type: String, at_position: Vector2) -> int:
var id: int = next_building_id var id: int = next_building_id
next_building_id += 1 next_building_id += 1
buildings.append({ buildings.append({
"id": id, "type": type, "position": at_position, "stock": 0, "id": id, "type": type, "position": at_position,
"gas_stock": 0, "orders": [], "inventory": INVENTORY.new(), "orders": [],
}) })
return id return id
@ -655,8 +633,7 @@ func can_produce(type: String) -> bool:
func building_stock(id: int) -> int: func building_stock(id: int) -> int:
var b: Dictionary = find_building(id) return building_resource(id, RESOURCE_ORE)
return 0 if b.is_empty() else int(b.get("stock", 0))
func pending_orders(id: int) -> int: func pending_orders(id: int) -> int:
@ -703,7 +680,7 @@ func cancel_order(id: int, index: int) -> bool:
return false return false
var order: Dictionary = orders[index] var order: Dictionary = orders[index]
if bool(order.started): if bool(order.started):
b.stock = int(b.get("stock", 0)) + product_cost(String(order.product)) _inventory_for_record(b).deposit(RESOURCE_ORE, product_cost(String(order.product)))
orders.remove_at(index) orders.remove_at(index)
return true return true
@ -718,7 +695,7 @@ func outstanding_cost(id: int) -> int:
# A started build has already paid, so it is not still owed. # A started build has already paid, so it is not still owed.
if not bool(o.started): if not bool(o.started):
need += product_cost(String(o.product)) need += product_cost(String(o.product))
return maxi(need - int(b.get("stock", 0)), 0) return maxi(need - _inventory_for_record(b).amount(RESOURCE_ORE), 0)
func deposit_to_building(id: int, amount: int) -> int: func deposit_to_building(id: int, amount: int) -> int:
@ -727,8 +704,7 @@ func deposit_to_building(id: int, amount: int) -> int:
var b: Dictionary = find_building(id) var b: Dictionary = find_building(id)
if b.is_empty(): if b.is_empty():
return 0 return 0
b.stock = int(b.get("stock", 0)) + amount return _inventory_for_record(b).deposit(RESOURCE_ORE, amount)
return amount
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
@ -763,9 +739,10 @@ func _advance_production(delta: float) -> void:
var order: Dictionary = orders[0] var order: Dictionary = orders[0]
var product: String = String(order.product) var product: String = String(order.product)
var cost: int = product_cost(product) var cost: int = product_cost(product)
var inventory: RefCounted = _inventory_for_record(b)
if not bool(order.started): if not bool(order.started):
if int(b.get("stock", 0)) < cost: if inventory.amount(RESOURCE_ORE) < cost:
continue continue
if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity(): if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity():
# A hub was demolished after this was ordered, so the berth it # A hub was demolished after this was ordered, so the berth it
@ -773,7 +750,7 @@ func _advance_production(delta: float) -> void:
continue continue
if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
continue continue
b.stock = int(b.stock) - cost inventory.withdraw(RESOURCE_ORE, cost)
order.started = true order.started = true
order.elapsed = float(order.elapsed) + delta order.elapsed = float(order.elapsed) + delta
@ -826,10 +803,11 @@ func _advance_lab_research(delta: float) -> void:
var order: Dictionary = orders[0] var order: Dictionary = orders[0]
var id: String = String(order.id) var id: String = String(order.id)
var cost: int = UNLOCK_SHIELD_COST if id == UNLOCK_SHIELD_VESSEL else upgrade_cost(id) var cost: int = UNLOCK_SHIELD_COST if id == UNLOCK_SHIELD_VESSEL else upgrade_cost(id)
var inventory: RefCounted = _inventory_for_record(lab)
if not bool(order.started): if not bool(order.started):
if int(lab.get("stock", 0)) < cost: if inventory.amount(RESOURCE_ORE) < cost:
continue continue
lab.stock = int(lab.stock) - cost inventory.withdraw(RESOURCE_ORE, cost)
order.started = true order.started = true
order.elapsed = float(order.elapsed) + delta order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time(): if float(order.elapsed) < lab_research_time():
@ -918,7 +896,7 @@ func rotate_formation(radians: float) -> void:
# Extractors mine into their OWN local stock. They do not own a magical # Extractors mine into their OWN local stock. They do not own a magical
# hauler: ore becomes spendable only when a hub-stationed transporter picks # hauler: ore becomes spendable only when a hub-stationed transporter picks
# up a full load and physically delivers it to the mothership. Entries are # up a full load and physically delivers it to the mothership. Entries are
# {"position", "planet_name", "resource_type", "stock", "mining_progress"}. # {"position", "planet_name", "resource_type", "inventory", "mining_progress"}.
const EXTRACTOR_RATE: float = 2.0 # ore per second const EXTRACTOR_RATE: float = 2.0 # ore per second
var extractors: Array = [] var extractors: Array = []
@ -939,7 +917,7 @@ func add_extractor(planet_name: String, at_position: Vector2, resource_type: Str
"position": at_position, "position": at_position,
"planet_name": planet_name, "planet_name": planet_name,
"resource_type": resource_type, "resource_type": resource_type,
"stock": 0, "inventory": INVENTORY.new(),
"mining_progress": 0.0, "mining_progress": 0.0,
}) })
# An extractor is built from SPACE, with the fleet parked on the planet - # An extractor is built from SPACE, with the fleet parked on the planet -
@ -952,7 +930,21 @@ func add_extractor(planet_name: String, at_position: Vector2, resource_type: Str
func extractor_stock(index: int) -> int: func extractor_stock(index: int) -> int:
if index < 0 or index >= extractors.size(): if index < 0 or index >= extractors.size():
return 0 return 0
return int(extractors[index].get("stock", 0)) var extractor: Dictionary = extractors[index]
return _extractor_inventory(extractor).amount(String(extractor.get("resource_type", RESOURCE_ORE)))
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
func _process(delta: float) -> void: func _process(delta: float) -> void:
@ -965,7 +957,7 @@ func _process(delta: float) -> void:
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
var whole: int = int(floor(float(e.mining_progress))) var whole: int = int(floor(float(e.mining_progress)))
if whole > 0: if whole > 0:
e.stock = int(e.get("stock", 0)) + whole _extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole)
e.mining_progress = float(e.mining_progress) - whole e.mining_progress = float(e.mining_progress) - whole
_advance_transporters(delta) _advance_transporters(delta)
@ -1127,7 +1119,8 @@ func _dispatch_transporters() -> void:
var load: int = transporter_load_limit() var load: int = transporter_load_limit()
for i in range(extractors.size()): for i in range(extractors.size()):
var extractor: Dictionary = extractors[i] var extractor: Dictionary = extractors[i]
if int(extractor.get("stock", 0)) < load or _transporter_inbound_to_extractor(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 continue
var chosen_extractor: Dictionary = {} var chosen_extractor: Dictionary = {}
var best_extractor_distance: float = INF var best_extractor_distance: float = INF
@ -1141,7 +1134,7 @@ func _dispatch_transporters() -> void:
if chosen_extractor.is_empty(): if chosen_extractor.is_empty():
return return
chosen_extractor.extractor_index = i chosen_extractor.extractor_index = i
chosen_extractor.resource_type = String(extractor.get("resource_type", "ore")) chosen_extractor.resource_type = resource_id
chosen_extractor.target_id = 0 chosen_extractor.target_id = 0
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
TRANSPORTER_TO_EXTRACTOR) TRANSPORTER_TO_EXTRACTOR)
@ -1192,13 +1185,13 @@ func _arrive(t: Dictionary) -> void:
TRANSPORTER_TO_MOTHERSHIP: TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id)) var target: Dictionary = find_building(int(t.target_id))
var want: int = outstanding_cost(int(t.target_id)) var want: int = outstanding_cost(int(t.target_id))
var take: int = mini(mini(want, rocks), transporter_load_limit()) var take: int = mini(mini(want, bank_inventory.amount(RESOURCE_ORE)), transporter_load_limit())
if take <= 0 or target.is_empty(): if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty. # Nothing to carry, or the building is gone. Go home empty.
t.target_id = 0 t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return return
rocks -= take bank_inventory.withdraw(RESOURCE_ORE, take)
t.carrying = take t.carrying = take
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING) _begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
TRANSPORTER_TO_BUILDING: TRANSPORTER_TO_BUILDING:
@ -1206,7 +1199,7 @@ func _arrive(t: Dictionary) -> void:
if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0: if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0:
# Building demolished in transit - put the ore back rather # Building demolished in transit - put the ore back rather
# than quietly destroying it. # than quietly destroying it.
rocks += int(t.carrying) bank_inventory.deposit(RESOURCE_ORE, int(t.carrying))
t.carrying = 0 t.carrying = 0
t.target_id = 0 t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
@ -1217,20 +1210,19 @@ func _arrive(t: Dictionary) -> void:
return return
var extractor: Dictionary = extractors[extractor_index] var extractor: Dictionary = extractors[extractor_index]
var load: int = transporter_load_limit() var load: int = transporter_load_limit()
if int(extractor.get("stock", 0)) < load: 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 t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return return
extractor.stock = int(extractor.stock) - load extractor_inventory.withdraw(resource_id, load)
t.carrying = load t.carrying = load
t.resource_type = String(extractor.get("resource_type", "ore")) t.resource_type = resource_id
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR) _begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
# Bank only on arrival; cargo retains the extractor's resource type. # Bank only on arrival; cargo retains the extractor's resource type.
if String(t.get("resource_type", "ore")) == "gas": bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ORE)), int(t.carrying))
gas += int(t.carrying)
else:
rocks += int(t.carrying)
t.carrying = 0 t.carrying = 0
t.extractor_index = -1 t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
@ -1240,11 +1232,11 @@ func _arrive(t: Dictionary) -> void:
func can_afford(cost: int) -> bool: func can_afford(cost: int) -> bool:
return rocks >= cost return bank_inventory.amount(RESOURCE_ORE) >= cost
func spend(cost: int) -> bool: func spend(cost: int) -> bool:
if not can_afford(cost): if not can_afford(cost):
return false return false
rocks -= cost bank_inventory.withdraw(RESOURCE_ORE, cost)
return true return true

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasResourceGlyph.tscn" id="2"]
[resource]
script = ExtResource("1")
resource_id = "gas"
display_name = "Gas"
color = Color(0.85, 0.52, 1, 1)
glyph_scene = ExtResource("2")

19
GasResourceGlyph.gd Normal file
View file

@ -0,0 +1,19 @@
extends Control
@export var colour: Color = Color(0.85, 0.52, 1.0)
func _draw() -> void:
var centre: Vector2 = size * 0.5
var radius: float = minf(size.x, size.y) * 0.34
var bubbles: Array = [
[centre + Vector2(-radius * 0.38, radius * 0.12), radius * 0.46],
[centre + Vector2(radius * 0.34, radius * 0.26), radius * 0.58],
[centre + Vector2(radius * 0.38, -radius * 0.48), radius * 0.32],
]
for bubble in bubbles:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
NeonDraw.draw_glowing_polygon(self, ring, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))

1
GasResourceGlyph.gd.uid Normal file
View file

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

13
GasResourceGlyph.tscn Normal file
View file

@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://GasResourceGlyph.gd" id="1"]
[node name="GlyphVisual" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

79
Inventory.gd Normal file
View file

@ -0,0 +1,79 @@
class_name Inventory
extends RefCounted
var total_capacity: int = -1
var resource_capacities: Dictionary = {}
var _amounts: Dictionary = {}
func _init(initial: Dictionary = {}, capacity: int = -1) -> void:
total_capacity = capacity
for resource_id in initial:
set_amount(String(resource_id), int(initial[resource_id]))
func amount(resource_id: String) -> int:
return int(_amounts.get(resource_id, 0))
func total() -> int:
var result: int = 0
for value in _amounts.values():
result += int(value)
return result
func capacity_for(resource_id: String) -> int:
return int(resource_capacities.get(resource_id, -1))
func space_for(resource_id: String) -> int:
var space: int = 1 << 30
if total_capacity >= 0:
space = maxi(total_capacity - total(), 0)
var resource_capacity: int = capacity_for(resource_id)
if resource_capacity >= 0:
space = mini(space, maxi(resource_capacity - amount(resource_id), 0))
return space
func set_amount(resource_id: String, value: int) -> int:
_amounts.erase(resource_id)
var accepted: int = mini(maxi(value, 0), space_for(resource_id))
if accepted > 0:
_amounts[resource_id] = accepted
return amount(resource_id)
func deposit(resource_id: String, requested: int) -> int:
var moved: int = mini(maxi(requested, 0), space_for(resource_id))
if moved > 0:
_amounts[resource_id] = amount(resource_id) + moved
return moved
func withdraw(resource_id: String, requested: int) -> int:
var moved: int = mini(maxi(requested, 0), amount(resource_id))
if moved <= 0:
return 0
var remaining: int = amount(resource_id) - moved
if remaining > 0:
_amounts[resource_id] = remaining
else:
_amounts.erase(resource_id)
return moved
func transfer_to(destination: Inventory, resource_id: String, requested: int) -> int:
if destination == null:
return 0
var moved: int = mini(mini(maxi(requested, 0), amount(resource_id)), destination.space_for(resource_id))
if moved <= 0:
return 0
withdraw(resource_id, moved)
destination.deposit(resource_id, moved)
return moved
func snapshot() -> Dictionary:
return _amounts.duplicate()

1
Inventory.gd.uid Normal file
View file

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

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://OreResourceGlyph.tscn" id="2"]
[resource]
script = ExtResource("1")
resource_id = "ore"
display_name = "Ore"
color = Color(0, 0.72, 0.82, 1)
glyph_scene = ExtResource("2")

14
OreResourceGlyph.gd Normal file
View file

@ -0,0 +1,14 @@
extends Control
@export var colour: Color = Color(0.0, 0.72, 0.82)
func _draw() -> void:
var centre: Vector2 = size * 0.5
var radius: float = minf(size.x, size.y) * 0.34
var points := PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 - PI * 0.5
points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))
NeonDraw.draw_glowing_line(self, points[5], points[2], colour, 0.55, maxf(0.4, radius * 0.025), colour.darkened(0.78))

1
OreResourceGlyph.gd.uid Normal file
View file

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

13
OreResourceGlyph.tscn Normal file
View file

@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://OreResourceGlyph.gd" id="1"]
[node name="GlyphVisual" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

View file

@ -3,7 +3,7 @@ extends Control
# Compact resource table shared by crafting, research and the ship lab. # Compact resource table shared by crafting, research and the ship lab.
# Rows are resources; columns are locations. Adding another material to # Rows are resources; columns are locations. Adding another material to
# GameState.RESOURCE_TYPES automatically gives it a row here. # The resource-definition catalog automatically gives each material a row.
var building_id: int = 0 var building_id: int = 0
@ -24,7 +24,7 @@ func _ready() -> void:
add_child(label) add_child(label)
_header_labels.append(label) _header_labels.append(label)
for resource_type in GameState.RESOURCE_TYPES: for resource_type in GameState.resource_types():
var glyph := ResourceGlyph.new() var glyph := ResourceGlyph.new()
glyph.resource_type = resource_type glyph.resource_type = resource_type
add_child(glyph) add_child(glyph)
@ -78,7 +78,7 @@ func _layout() -> void:
var row_height: float = (size.y - HEADER_HEIGHT - 4.0) / maxf(1.0, float(_rows.size())) var row_height: float = (size.y - HEADER_HEIGHT - 4.0) / maxf(1.0, float(_rows.size()))
var row_index: int = 0 var row_index: int = 0
for resource_type in GameState.RESOURCE_TYPES: for resource_type in GameState.resource_types():
var row: Dictionary = _rows[resource_type] var row: Dictionary = _rows[resource_type]
var y: float = HEADER_HEIGHT + float(row_index) * row_height var y: float = HEADER_HEIGHT + float(row_index) * row_height
row.glyph.position = Vector2(2.0, y + 2.0) row.glyph.position = Vector2(2.0, y + 2.0)
@ -94,7 +94,7 @@ func _layout() -> void:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
for resource_type in GameState.RESOURCE_TYPES: for resource_type in GameState.resource_types():
var row: Dictionary = _rows.get(resource_type, {}) var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty(): if row.is_empty():
continue continue

7
ResourceDefinition.gd Normal file
View file

@ -0,0 +1,7 @@
class_name ResourceDefinition
extends Resource
@export var resource_id: String = "ore"
@export var display_name: String = "Ore"
@export var color: Color = Color.WHITE
@export var glyph_scene: PackedScene

View file

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

18
ResourceDefinitions.gd Normal file
View file

@ -0,0 +1,18 @@
class_name ResourceDefinitions
extends RefCounted
const ORE_ID: String = "ore"
const GAS_ID: String = "gas"
const ORDER: PackedStringArray = [ORE_ID, GAS_ID]
const DEFINITIONS: Dictionary = {
ORE_ID: preload("res://OreResourceDefinition.tres"),
GAS_ID: preload("res://GasResourceDefinition.tres"),
}
static func get_definition(resource_id: String) -> Resource:
return DEFINITIONS.get(resource_id)
static func ids() -> PackedStringArray:
return ORDER

View file

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

View file

@ -1,6 +1,8 @@
class_name ResourceGlyph class_name ResourceGlyph
extends Control extends Control
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
# A small, non-interactive resource mark shared by tables and transfer rows. # A small, non-interactive resource mark shared by tables and transfer rows.
# Resource buttons used to borrow IconButton, which made decorative icons # Resource buttons used to borrow IconButton, which made decorative icons
# look tappable and encouraged each screen to invent its own version. # look tappable and encouraged each screen to invent its own version.
@ -12,43 +14,21 @@ func set_resource_type(value: String) -> void:
if resource_type == value: if resource_type == value:
return return
resource_type = value resource_type = value
queue_redraw() if is_node_ready():
_rebuild_visual()
func _ready() -> void: func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
queue_redraw() _rebuild_visual()
func _draw() -> void: func _rebuild_visual() -> void:
var colour: Color = GameState.resource_color(resource_type) for child in get_children():
var centre: Vector2 = size * 0.5 child.queue_free()
var radius: float = minf(size.x, size.y) * 0.34 var definition: Resource = RESOURCE_DEFINITIONS.get_definition(resource_type)
match resource_type: if definition == null or definition.glyph_scene == null:
GameState.RESOURCE_GAS: return
_draw_gas(centre, radius, colour) var visual: Control = definition.glyph_scene.instantiate()
_: visual.colour = definition.color
_draw_ore(centre, radius, colour) add_child(visual)
func _draw_ore(centre: Vector2, radius: float, colour: Color) -> void:
var points := PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 - PI * 0.5
points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))
NeonDraw.draw_glowing_line(self, points[5], points[2], colour, 0.55, maxf(0.4, radius * 0.025), colour.darkened(0.78))
func _draw_gas(centre: Vector2, radius: float, colour: Color) -> void:
var bubbles: Array = [
[centre + Vector2(-radius * 0.38, radius * 0.12), radius * 0.46],
[centre + Vector2(radius * 0.34, radius * 0.26), radius * 0.58],
[centre + Vector2(radius * 0.38, -radius * 0.48), radius * 0.32],
]
for bubble in bubbles:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
NeonDraw.draw_glowing_polygon(self, ring, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035))

View file

@ -27,7 +27,7 @@ func _ready() -> void:
_destination_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _destination_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(_destination_heading) add_child(_destination_heading)
for resource_type in GameState.RESOURCE_TYPES: for resource_type in GameState.resource_types():
var colour: Color = GameState.resource_color(resource_type) var colour: Color = GameState.resource_color(resource_type)
var glyph := ResourceGlyph.new() var glyph := ResourceGlyph.new()
glyph.resource_type = resource_type glyph.resource_type = resource_type
@ -140,7 +140,7 @@ func set_destination(id: int, heading: String) -> void:
func refresh() -> void: func refresh() -> void:
for resource_type in GameState.RESOURCE_TYPES: for resource_type in GameState.resource_types():
var row: Dictionary = _rows.get(resource_type, {}) var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty(): if row.is_empty():
continue continue
@ -169,7 +169,7 @@ func _layout() -> void:
var row_height: float = (size.y - HEADER_HEIGHT) / maxf(1.0, float(_rows.size())) var row_height: float = (size.y - HEADER_HEIGHT) / maxf(1.0, float(_rows.size()))
var row_index: int = 0 var row_index: int = 0
for resource_type in GameState.RESOURCE_TYPES: for resource_type in GameState.resource_types():
var row: Dictionary = _rows[resource_type] var row: Dictionary = _rows[resource_type]
var y: float = HEADER_HEIGHT + float(row_index) * row_height var y: float = HEADER_HEIGHT + float(row_index) * row_height
var glyph_size: float = minf(66.0, row_height * 0.23) var glyph_size: float = minf(66.0, row_height * 0.23)

View file

@ -0,0 +1,48 @@
extends SceneTree
const INVENTORY = preload("res://Inventory.gd")
const TEST_RESOURCE: String = "crystal"
func _initialize() -> void:
var game_state: Node = root.get_node("GameState")
game_state.buildings.clear()
game_state.bank_inventory.set_amount(TEST_RESOURCE, 40)
game_state.fleet_inventory.set_amount(TEST_RESOURCE, 0)
var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(100, 0))
if game_state.transfer_resource_to_fleet(mothership_id, TEST_RESOURCE, 15) != 15:
return _fail("third resource could not move from bank to fleet")
if game_state.transfer_resource_to_building(crafter_id, TEST_RESOURCE, 9) != 9:
return _fail("third resource could not move from fleet to building")
if game_state.transfer_resource_to_fleet(crafter_id, TEST_RESOURCE, 4) != 4:
return _fail("third resource could not move back out of building")
if game_state.banked_resource(TEST_RESOURCE) != 25 \
or game_state.fleet_resource(TEST_RESOURCE) != 10 \
or game_state.building_resource(crafter_id, TEST_RESOURCE) != 5:
return _fail("third-resource amounts were not conserved")
var limited: RefCounted = INVENTORY.new({}, 10)
limited.resource_capacities[TEST_RESOURCE] = 6
if limited.deposit(TEST_RESOURCE, 99) != 6 or limited.deposit("water", 99) != 4:
return _fail("total or per-resource capacity was not enforced")
var legacy_id: int = game_state.next_building_id
game_state.next_building_id += 1
game_state.buildings.append({"id": legacy_id, "type": GameState.BUILDING_SHIP_CRAFTER,
"position": Vector2.ZERO, "stock": 7, "gas_stock": 3, "orders": []})
if game_state.building_resource(legacy_id, GameState.RESOURCE_ORE) != 7 \
or game_state.building_resource(legacy_id, GameState.RESOURCE_GAS) != 3:
return _fail("legacy building stock was not migrated")
var migrated: Dictionary = game_state.find_building(legacy_id)
if migrated.has("stock") or migrated.has("gas_stock") or not migrated.has("inventory"):
return _fail("legacy fields remained after migration")
print("TEST_GENERIC_INVENTORY: PASS")
quit(0)
func _fail(message: String) -> void:
print("TEST_GENERIC_INVENTORY: FAIL (%s)" % message)
quit(1)

View file

@ -0,0 +1 @@
uid://53pexhmp3ns0

View file

@ -42,7 +42,8 @@ const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0)
func _total() -> int: func _total() -> int:
var sum: int = game_state.rocks + game_state.fleet_cargo var sum: int = game_state.rocks + game_state.fleet_cargo
for b in game_state.buildings: for b in game_state.buildings:
sum += int(b.get("stock", 0)) if b.type != GameState.BUILDING_MOTHERSHIP:
sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE)
return sum return sum

View file

@ -39,7 +39,7 @@ func _total() -> int:
var sum: int = game_state.rocks + game_state.fleet_cargo var sum: int = game_state.rocks + game_state.fleet_cargo
for b in game_state.buildings: for b in game_state.buildings:
if b.type != GameState.BUILDING_MOTHERSHIP: if b.type != GameState.BUILDING_MOTHERSHIP:
sum += int(b.get("stock", 0)) sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE)
return sum return sum

View file

@ -38,7 +38,7 @@ func _process(delta: float) -> bool:
match stage: match stage:
0: 0:
# --- gather a rock ------------------------------------------- # --- gather a rock -------------------------------------------
scene.get_node("RockField").set_process(false) scene.get_node("SurfaceFeatures/ResourceProvider").set_process(false)
rock = load("res://Rock.tscn").instantiate() rock = load("res://Rock.tscn").instantiate()
rock.build_shape(1) rock.build_shape(1)
# Placed OUTSIDE the pickup shape but inside attract_radius, so # Placed OUTSIDE the pickup shape but inside attract_radius, so

View file

@ -43,7 +43,8 @@ const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0)
func _total() -> int: func _total() -> int:
var sum: int = game_state.rocks + game_state.fleet_cargo var sum: int = game_state.rocks + game_state.fleet_cargo
for b in game_state.buildings: for b in game_state.buildings:
sum += int(b.get("stock", 0)) if b.type != GameState.BUILDING_MOTHERSHIP:
sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE)
for t in game_state.transporters: for t in game_state.transporters:
sum += int(t.get("carrying", 0)) sum += int(t.get("carrying", 0))
# Ore inside a build that has STARTED but not finished (#23). It has left # Ore inside a build that has STARTED but not finished (#23). It has left