Add research lab planets and research points (#63)

This commit is contained in:
Loïc Wisniewski 2026-08-16 20:14:29 +02:00
commit 3f3a22227d
43 changed files with 459 additions and 30 deletions

View file

@ -4,6 +4,7 @@ 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")
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
# Autoload singleton - the one thing that survives change_scene_to_file()
@ -44,6 +45,13 @@ var boost_depleted_notice: bool = false
func set_fleet_speed_multiplier(value: float) -> void:
fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER)
# #63: currency gated behind conquering a research_lab planet - required by
# both progression paths (skill tree nodes, vessel/upgrade research) on top
# of their existing ore/resource costs.
signal research_points_changed(total: int)
var research_points: int = 0
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on
# every scene swap) so exploration progress isn't lost every time the
@ -272,6 +280,13 @@ var buildings: Array = []
# tree always reads root-at-the-bottom and cannot drift out of step with the
# dependencies when nodes are added. `requires` is a single prerequisite id
# ("" for a root).
# #63: research_points (default 0 where omitted) is a SEPARATE currency from
# cost (ore), spent only from GameState.research_points - earned by
# conquering a research_lab planet, never bought. Balance, decided
# deliberately: shipyards and logistics stay at 0 so a fresh game can still
# reach a ship crafter and an extractor with zero research points (see the
# closing comment on #63 for the reasoning); every other node - none of them
# required for that opening - costs at least 1.
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
@ -285,42 +300,42 @@ const SKILL_TREE: Array = [
},
{
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
"x": 0.50, "unlocks": BUILDING_COMMAND_CENTER,
"x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1,
"desc": "Build command centers, where the fleet formation is arranged.",
},
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1,
"desc": "Build transport hubs. Each hub includes its own automatic transporters.",
},
{
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
"x": 0.82, "unlocks": BUILDING_SHIP_LAB,
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.18, "hub_bonus": 1,
"x": 0.18, "hub_bonus": 1, "research_points": 2,
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
},
{
"id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight",
"x": 0.50, "unlocks": BUILDING_GAS_BURNER,
"x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2,
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
},
{
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1,
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.50, "fleet_bonus": 1,
"x": 0.50, "fleet_bonus": 1, "research_points": 2,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.50, "fleet_bonus": 1,
"x": 0.50, "fleet_bonus": 1, "research_points": 3,
"desc": "Permanent: +1 vessel in the fleet.",
},
]
@ -370,16 +385,23 @@ func prerequisite_met(id: String) -> bool:
func can_unlock(id: String) -> bool:
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
var node: Dictionary = skill_node(id)
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \
and research_points >= int(node.get("research_points", 0))
func unlock(id: String) -> bool:
if not can_unlock(id):
return false
if not spend(int(skill_node(id).cost)):
var node: Dictionary = skill_node(id)
if not spend(int(node.cost)):
return false
var points_needed: int = int(node.get("research_points", 0))
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
unlocked.append(id)
if int(skill_node(id).get("hub_bonus", 0)) > 0:
if int(node.get("hub_bonus", 0)) > 0:
_sync_hub_transporters()
return true
@ -424,6 +446,7 @@ func reset_new_game() -> void:
planet_id = ""
planet_definition_id = "rocky"
fleet_speed_multiplier = 1.0
research_points = 0
explored_chunks.clear()
bank_inventory = INVENTORY.new({RESOURCE_ORE: 500})
fleet_inventory = INVENTORY.new()
@ -600,10 +623,14 @@ func unlock_vessel(vessel: String) -> bool:
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
return false
var costs: Dictionary = research_costs(definition.unlock_id)
if not can_afford_resources(costs):
var points_needed: int = int(definition.research_point_cost)
if not can_afford_resources(costs) or research_points < points_needed:
return false
for resource_type in costs:
bank_inventory.withdraw(String(resource_type), int(costs[resource_type]))
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
vessel_levels[definition.unlock_id] = 1
vessel_upgraded.emit(definition.unlock_id)
return true
@ -650,7 +677,8 @@ func upgrade_cost(id: String) -> int:
func can_buy_upgrade(id: String) -> bool:
return has_ship_lab() and 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)) \
and research_points >= research_point_cost(id)
func buy_upgrade(id: String) -> bool:
@ -658,6 +686,10 @@ func buy_upgrade(id: String) -> bool:
return false
if not spend(upgrade_cost(id)):
return false
var points_needed: int = research_point_cost(id)
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
return true
@ -920,6 +952,14 @@ func research_cost(id: String) -> int:
return int(research_costs(id).get(RESOURCE_ORE, 0))
func research_point_cost(id: String) -> int:
var unlock_definition: Resource = vessel_for_unlock(id)
if unlock_definition != null:
return int(unlock_definition.research_point_cost)
var d: Dictionary = upgrade_def(id)
return int(d.resource.research_point_cost) if not d.is_empty() else 0
func research_costs(id: String) -> Dictionary:
var unlock_definition: Resource = vessel_for_unlock(id)
if unlock_definition != null:
@ -948,10 +988,20 @@ func _advance_lab_research(delta: float) -> void:
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
affordable = false
break
# #63: research points are a fleet-wide currency, not delivered
# to the lab like ore/gas - checked and spent here too so a
# shortfall stalls the order the same way an undelivered
# resource does, rather than letting it start regardless.
var points_needed: int = research_point_cost(id)
if points_needed > research_points:
affordable = false
if not affordable:
continue
for resource_type in costs:
inventory.withdraw(String(resource_type), int(costs[resource_type]))
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time():
@ -1212,7 +1262,7 @@ var planet_capture: Dictionary = {}
signal planet_conquered(planet_id: String)
func register_capture_points(planet_key: String, total: int) -> void:
func register_capture_points(planet_key: String, total: int, definition_id: String = "") -> void:
# Idempotent: landing again on a planet must not wipe points already
# captured on a previous visit.
if planet_capture.has(planet_key):
@ -1223,8 +1273,13 @@ func register_capture_points(planet_key: String, total: int) -> void:
# A planet demanding zero points is vacuously already held (#55: a
# black hole's "void" profile has no authored PlanetSetup at all, so
# this is the only registration it ever gets) - every planet WITH real
# points still starts unconquered, exactly as before.
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0}
# points still starts unconquered, exactly as before. The planet definition
# supplies an optional conquest reward, avoiding planet-id special cases in
# the capture system.
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0,
"research_point_reward": point_reward, "research_awarded": false}
func capture_state(planet_key: String) -> Dictionary:
@ -1251,6 +1306,16 @@ func mark_point_captured(planet_key: String, index: int) -> void:
if captured.all(func(c): return bool(c)):
state.conquered = true
planet_conquered.emit(planet_key)
# #63: definition-authored research points, exactly once per planet.
# state.conquered flipping above already makes a second call to this
# whole function a no-op via the early return above it, but
# research_awarded is its own explicit guard on the award itself
# regardless of how conquest was reached.
var reward: int = int(state.get("research_point_reward", 0))
if reward > 0 and not bool(state.get("research_awarded", false)):
state.research_awarded = true
research_points += reward
research_points_changed.emit(research_points)
func is_planet_conquered(planet_key: String) -> bool: