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

@ -680,6 +680,13 @@ func _confirm_build() -> void:
if not GameState.is_planet_conquered(planet.planet_id):
build_status.text = "%s is not captured yet" % planet.planet_name
return
if String(planet.resource_type) == "":
# #63: a research_lab planet's whole point is that it yields
# nothing to mine - an extractor there would sit on an empty
# resource id with no ResourceDefinition, breaking its own
# glyph/HUD rendering the moment it produced anything.
build_status.text = "%s has nothing to extract" % planet.planet_name
return
if not GameState.spend(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
return
@ -723,14 +730,18 @@ func _refresh_skill_menu(id: String = "") -> void:
# leaving the panel blank, which reads as broken.
skill_detail.text = "Select a node to see what it unlocks."
skill_cost.amount = 0
skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size()
skill_status.text = "Fleet capacity %d · Research points %d" \
% [GameState.max_fleet_size(), GameState.research_points]
skill_unlock_button.visible = false
return
var node: Dictionary = GameState.skill_node(id)
if node.is_empty():
return
var points_needed: int = int(node.get("research_points", 0))
skill_detail.text = "%s\n%s" % [node.name, node.desc]
if points_needed > 0:
skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed]
skill_cost.amount = int(node.cost)
if GameState.is_unlocked(id):
@ -742,6 +753,12 @@ func _refresh_skill_menu(id: String = "") -> void:
elif not GameState.can_afford(int(node.cost)):
skill_status.text = "Not enough rocks - you have %d" % GameState.rocks
skill_unlock_button.visible = false
elif GameState.research_points < points_needed:
# #63: a distinct reason from the ore one above - conflating them
# would leave the player trying to fix the wrong shortage.
skill_status.text = "Not enough research points - you have %d, need %d" \
% [GameState.research_points, points_needed]
skill_unlock_button.visible = false
else:
skill_status.text = ""
skill_unlock_button.visible = true
@ -902,7 +919,8 @@ func _refresh_lab() -> void:
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_name(lab_vessel)
lab_vessel_label.text = "%s research · %d RP" % [
GameState.vessel_name(lab_vessel), GameState.research_points]
var options: Array = _lab_options()
for i in range(lab_upgrade_buttons.size()):
@ -914,6 +932,9 @@ func _refresh_lab() -> void:
var option: Dictionary = options[i]
var price_text: String = _format_costs(option.costs if bool(option.unlock) \
else GameState.research_costs(option.id))
var point_cost: int = GameState.research_point_cost(String(option.id))
if point_cost > 0:
price_text += " · %d RP" % point_cost
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" % [
@ -932,11 +953,22 @@ func _refresh_lab() -> void:
lab_choice = clampi(lab_choice, 0, options.size() - 1)
var choice: Dictionary = options[lab_choice]
var choice_points: int = GameState.research_point_cost(String(choice.id))
lab_detail.text = "%s\n%s" % [choice.name, choice.desc]
if choice_points > 0:
lab_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, choice_points]
var queued: Dictionary = GameState.lab_research_order(lab_building_id)
lab_buy_button.enabled = queued.is_empty() and (choice.unlock or not GameState.upgrade_maxed(choice.id))
if not queued.is_empty():
lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))]
var queued_points: int = GameState.research_point_cost(String(queued.id))
if not bool(queued.started) and GameState.research_points < queued_points:
# #63: distinct from "Waiting for resources" - flying more ore/gas
# to this lab cannot fix a research-point shortfall, only
# conquering a research lab elsewhere can.
lab_status.text = "Need %d research point%s (have %d)" \
% [queued_points, "" if queued_points == 1 else "s", GameState.research_points]
else:
lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))]
lab_buy_button.queue_redraw()

View file

@ -7,5 +7,6 @@ display_name = "Reinforced Hold"
description = "+30 cargo capacity per level."
base_cost = 40
max_level = 3
research_point_cost = 1
icon = 21
additive_modifiers = {"cargo_capacity": 30.0}

View file

@ -22,4 +22,5 @@ unlock_name = "Unlock Carrier"
unlock_description = "Research a dedicated hauler hull that trades weapons for a much larger cargo hold."
unlock_cost = 0
unlock_resource_costs = {"ore": 60}
research_point_cost = 1
unlock_icon = -1

View file

@ -21,4 +21,5 @@ unlock_name = "Unlock Distorder"
unlock_description = "Research a space-time distortion hull - while one flies with the fleet, a fader lets you push cruise speed beyond its default."
unlock_cost = 0
unlock_resource_costs = {"energy": 50}
research_point_cost = 1
unlock_icon = -1

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:

View file

@ -23,4 +23,5 @@ unlock_name = "Unlock Gas Bomber"
unlock_description = "Research volatile-gas containment and unlock the Gas Bomber for ship crafters."
unlock_cost = 0
unlock_resource_costs = {"gas": 30}
research_point_cost = 1
unlock_icon = 31

View file

@ -7,5 +7,6 @@ display_name = "Heavy Slugs"
description = "+1 damage per shot."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 15
additive_modifiers = {"damage": 1.0}

View file

@ -7,5 +7,6 @@ display_name = "Targeting Array"
description = "+60 firing range per level."
base_cost = 20
max_level = 3
research_point_cost = 1
icon = 17
additive_modifiers = {"fire_range": 60.0}

View file

@ -7,5 +7,6 @@ display_name = "Autoloader"
description = "Fires 15% faster per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 16
multiplicative_modifiers = {"fire_cooldown": 0.85}

View file

@ -7,5 +7,6 @@ display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -7,5 +7,6 @@ display_name = "Capacitor Bank"
description = "Recharges 0.6s sooner per level."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 20
additive_modifiers = {"beam_cooldown": -0.6}

View file

@ -7,5 +7,6 @@ display_name = "Focusing Lens"
description = "+1 damage per shot."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 15
additive_modifiers = {"damage": 1.0}

View file

@ -7,5 +7,6 @@ display_name = "Beam Collimator"
description = "+150 beam range per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 17
additive_modifiers = {"beam_range": 150.0}

View file

@ -26,4 +26,5 @@ unlock_name = "Unlock Laser Ship"
unlock_description = "Research a directed-energy hull for the ship crafter - manually aimed, no ammo, cools down between shots."
unlock_cost = 0
unlock_resource_costs = {"energy": 40}
research_point_cost = 1
unlock_icon = -1

View file

@ -2086,9 +2086,9 @@ anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -64.0
offset_top = -456.0
offset_top = -400.0
offset_right = 76.0
offset_bottom = -392.0
offset_bottom = -336.0
script = ExtResource("19")
digit_height = 52.0
@ -2098,9 +2098,9 @@ anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = -386.0
offset_top = -326.0
offset_right = -32.0
offset_bottom = -318.0
offset_bottom = -250.0
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
horizontal_alignment = 1
@ -2112,9 +2112,9 @@ anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -74.0
offset_top = -304.0
offset_top = -236.0
offset_right = 74.0
offset_bottom = -156.0
offset_bottom = -88.0
script = ExtResource("10")
icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1)

View file

@ -8,3 +8,7 @@ extends Resource
@export var surface_profile_id: String = "rocky"
@export var resource_id: String = "ore"
@export var map_color: Color = Color(0.7, 0.65, 0.55)
# Optional reward granted once when every authored capture point is claimed.
# Keeping it on the definition lets conquest remain generic as new planet
# rewards are introduced.
@export var conquest_research_points: int = 0

View file

@ -4,11 +4,13 @@ extends RefCounted
const ROCKY_ID: String = "rocky"
const GAS_GIANT_ID: String = "gas_giant"
const BLACK_HOLE_ID: String = "black_hole"
const RESEARCH_LAB_ID: String = "research_lab"
const DEFAULT_DEFINITION_ID: String = ROCKY_ID
const DEFINITIONS: Dictionary = {
ROCKY_ID: preload("res://RockyPlanetDefinition.tres"),
GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"),
BLACK_HOLE_ID: preload("res://BlackHolePlanetDefinition.tres"),
RESEARCH_LAB_ID: preload("res://ResearchLabPlanetDefinition.tres"),
}

View file

@ -19,7 +19,8 @@ func apply_setup(new_setup: Resource) -> void:
if setup == null:
return
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size())
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(),
GameState.planet_definition_id)
for i in range(setup.capture_points.size()):
_add_capture_point(i, setup.capture_points[i])
for spawn in setup.enemy_spawns:

View file

@ -0,0 +1,42 @@
[gd_resource type="Resource" script_class="PlanetSetup" format=3]
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1"]
[ext_resource type="Script" path="res://PlanetSetup.gd" id="2"]
[sub_resource type="Resource" id="Resource_ta1"]
script = ExtResource("1")
enemy_id = "turret_spawner"
position = Vector2(-620.0, -180.0)
[sub_resource type="Resource" id="Resource_ta2"]
script = ExtResource("1")
enemy_id = "turret_spawner"
position = Vector2(640.0, -160.0)
[sub_resource type="Resource" id="Resource_ta3"]
script = ExtResource("1")
enemy_id = "artillery"
position = Vector2(0.0, -720.0)
[sub_resource type="Resource" id="Resource_ta4"]
script = ExtResource("1")
enemy_id = "sniper"
position = Vector2(-380.0, 420.0)
[sub_resource type="Resource" id="Resource_ta5"]
script = ExtResource("1")
enemy_id = "sniper"
position = Vector2(400.0, 440.0)
[sub_resource type="Resource" id="Resource_ta6"]
script = ExtResource("1")
enemy_id = "chaser"
position = Vector2(120.0, 60.0)
[resource]
script = ExtResource("2")
setup_id = "research_lab_1"
display_name = "Research Lab Garrison"
surface_profile_id = "research_lab"
capture_points = PackedVector2Array(-450.0, -60.0, 460.0, -40.0, 0.0, 480.0)
enemy_spawns = Array[Resource]([SubResource("Resource_ta1"), SubResource("Resource_ta2"), SubResource("Resource_ta3"), SubResource("Resource_ta4"), SubResource("Resource_ta5"), SubResource("Resource_ta6")])

View file

@ -6,6 +6,7 @@ const PROFILES: Dictionary = {
"rocky": preload("res://RockySurfaceProfile.tres"),
"gas": preload("res://GasSurfaceProfile.tres"),
"void": preload("res://VoidSurfaceProfile.tres"),
"research_lab": preload("res://ResearchLabSurfaceProfile.tres"),
}

View file

@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://ResearchLabPlanetVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "research_lab"
display_name = "Research Lab"
names = PackedStringArray("Blacksite Zero", "Cordoned Array", "Dead Signal Station", "The Vault", "Interdicted Post")
visual_scene = ExtResource("2")
surface_profile_id = "research_lab"
resource_id = ""
map_color = Color(0.7, 0.2, 0.15, 1)
conquest_research_points = 1

View file

@ -0,0 +1,55 @@
extends Node2D
# Same informal interface as GasGiantVisual/RockyPlanetVisual - configure(),
# extractor_anchor(), animates via its own _process(). Deliberately angular
# and armoured rather than a natural silhouette (#63: this is an ENEMY
# installation, not a celestial body), with a rotating dish and blinking
# warning lights to read as occupied/hostile at a glance.
var phase: float = 0.0
const RADIUS: float = 108.0
const HULL_COLOR: Color = Color(0.55, 0.1, 0.08)
const CORE_COLOR: Color = Color(0.9, 0.25, 0.18)
const WARNING_COLOR: Color = Color(1.0, 0.2, 0.15)
const DARK_CORE: Color = Color(0.14, 0.02, 0.02)
func configure(_shape_seed: int) -> void:
queue_redraw()
func extractor_anchor() -> Vector2:
return Vector2(0.0, -(RADIUS + 40.0))
func _process(delta: float) -> void:
phase += delta
queue_redraw()
func _draw() -> void:
var hull: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0
hull.append(Vector2(cos(angle), sin(angle)) * RADIUS)
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 0.85, DARK_CORE)
var inner: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 + PI / 6.0
inner.append(Vector2(cos(angle), sin(angle)) * RADIUS * 0.55)
NeonDraw.draw_glowing_polygon(self, inner, CORE_COLOR, 0.9, DARK_CORE)
# A slow-rotating dish - reads as actively transmitting, not derelict.
var dish_angle: float = phase * 0.6
var dish_tip: Vector2 = Vector2(cos(dish_angle), sin(dish_angle)) * RADIUS * 1.35
NeonDraw.draw_glowing_line(self, Vector2.ZERO, dish_tip, CORE_COLOR, 0.8, 0.55, DARK_CORE)
NeonDraw.draw_glowing_circle(self, dish_tip, 11.0, CORE_COLOR, 1.0, DARK_CORE)
# Blinking hull-corner warning lights.
var blink: float = 0.4 + 0.6 * absf(sin(phase * 4.0))
for i in range(6):
var angle: float = TAU * float(i) / 6.0
var at: Vector2 = Vector2(cos(angle), sin(angle)) * RADIUS
NeonDraw.draw_glowing_circle(self, at, 5.0, WARNING_COLOR, blink, DARK_CORE)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ResearchLabPlanetVisual.gd" id="1"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
profile_id = "research_lab"
base_color = Color(0.13, 0.1, 0.1, 1)
crater_color = Color(0.05, 0.03, 0.03, 1)
highlight_color = Color(0.55, 0.15, 0.1, 1)
caption_color = Color(0.9, 0.6, 0.55, 1)
caption_outline_color = Color(0.05, 0.02, 0.02, 0.9)

View file

@ -7,5 +7,6 @@ display_name = "Layered Emitters"
description = "+1 hit absorbed before the shield drops."
base_cost = 60
max_level = 2
research_point_cost = 1
icon = 19
additive_modifiers = {"shield_charges": 1.0}

View file

@ -7,5 +7,6 @@ display_name = "Fast Cycling"
description = "Shield returns 1.2s sooner per level."
base_cost = 35
max_level = 3
research_point_cost = 1
icon = 20
additive_modifiers = {"shield_recharge": -1.2}

View file

@ -7,5 +7,6 @@ display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -24,4 +24,5 @@ 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
research_point_cost = 1
unlock_icon = 23

View file

@ -7,5 +7,6 @@ display_name = "Expanded Hold"
description = "Carries 8 more resources per supply run."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 21
additive_modifiers = {"cargo_capacity": 8.0}

View file

@ -7,5 +7,6 @@ display_name = "Freight Engines"
description = "+70 speed on supply runs."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 22
additive_modifiers = {"transport_speed": 70.0}

View file

@ -23,6 +23,10 @@ extends Resource
@export_multiline var unlock_description: String
@export var unlock_cost: int
@export var unlock_resource_costs: Dictionary = {}
# #63: spent from GameState.research_points, alongside unlock_resource_costs
# rather than instead of it. 0 (the default) means this unlock needs no
# research points at all.
@export var research_point_cost: int = 0
@export var unlock_icon: int = -1

View file

@ -9,3 +9,7 @@ extends Resource
@export var icon: int
@export var additive_modifiers: Dictionary = {}
@export var multiplicative_modifiers: Dictionary = {}
# #63: spent from GameState.research_points per level bought, alongside
# base_cost (ore) rather than instead of it. 0 (the default) means this
# level needs no research points at all.
@export var research_point_cost: int = 0

View file

@ -22,6 +22,10 @@ const GAS_GIANT_THRESHOLD: float = 0.55
# chance independent of biome - not a second smooth region to tune against
# the first.
const BLACK_HOLE_CHANCE: float = 0.05
# #63: findable without excessive exploration - research points gate mid/
# late skill-tree nodes and vessel research, so a player needs to run into
# one of these reasonably often, not just as a rare curiosity.
const RESEARCH_LAB_CHANCE: float = 0.08
# A predictable early gas source for play-testing. The origin chunk is
# deterministically normal space. Its explicit in-chunk position puts it
@ -95,6 +99,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
# roll doesn't shift which random values any LATER draw (planet_name,
# below) consumes for chunks that would have existed before it.
var black_hole_roll: float = rng.randf()
# Same principle, its own independent roll (#63).
var research_lab_roll: float = rng.randf()
# A stable per-chunk roll makes gas giants persistent landmarks rather
# than a different resource whenever the scene reloads.
var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE
@ -104,6 +110,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
# that chunk's whole purpose is to always be the same reachable source.
if not guaranteed_nearby_giant and black_hole_roll < BLACK_HOLE_CHANCE:
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
elif not guaranteed_nearby_giant and research_lab_roll < RESEARCH_LAB_CHANCE:
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var names: PackedStringArray = definition.names
var planet_name: String = names[rng.randi() % names.size()]

View file

@ -8,7 +8,13 @@ extends Node2D
# ... and again at 720x960 to check a shorter screen format.
func _ready() -> void:
var configured: bool = false
func _process(_delta: float) -> void:
if configured:
return
configured = true
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
@ -16,9 +22,12 @@ func _ready() -> void:
GameState.gas = 36
GameState.fleet_cargo = 24
GameState.fleet_gas_cargo = 12
GameState.unlocked = ["shipyards", "logistics", "freight"]
GameState.research_points = 0
GameState.unlocked = ["shipyards"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
main.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
var mothership_id: int = int(GameState.buildings[-1].id)
var bc: Node = main.get_node("BuildController")
@ -27,5 +36,5 @@ func _ready() -> void:
main.get_node("UI/SkillMenu").visible = true
# Select a node so the description, price and status all have text - an
# empty description cannot collide with anything.
bc.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("freight")
bc.skill_tree.selected_id = "coordination"
bc._refresh_skill_menu("coordination")

View file

@ -48,6 +48,7 @@ func _initialize() -> void:
game_state.reset_progression()
game_state.build_time_scale = 0.02
game_state.rocks = 500
game_state.research_points = 1
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -21,6 +21,7 @@ func _initialize() -> void:
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.research_points = 1
game_state.add_extractor("Test Planet", EXTRACTOR_AT)
extractor_index = 0

View file

@ -27,6 +27,7 @@ func _initialize() -> void:
game_state.fleet_cargo = 0
game_state.fleet_gas_cargo = 0
game_state.build_time_scale = 0.01
game_state.research_points = 1
# #60: after research/crafting resets the fleet hold below, top it up
# with fleet-carried gas for the shot itself (ammunition, not the ore/gas
# spent researching or crafting the bomber).

View file

@ -10,6 +10,7 @@ func _initialize() -> void:
game.vessel_levels.clear()
game.build_time_scale = 0.02
game.rocks = 500
game.research_points = 1
lab_id = game.add_building(GameState.BUILDING_SHIP_LAB, Vector2.ZERO)
if not game.queue_lab_research(lab_id, "int_damage"):
_fail("could not queue research")

View file

@ -12,6 +12,7 @@ func _initialize() -> void:
state.transporters.clear()
state.reset_progression()
state.rocks = 100
state.research_points = 1
state.build_time_scale = 0.02
state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO)
state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(50, 0))

View file

@ -10,6 +10,7 @@ func _initialize() -> void:
state.reset_progression()
state.rocks = 0
state.gas = 10
state.research_points = 1
state.build_time_scale = 0.01
state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)

View file

@ -0,0 +1,143 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_research_points.gd
# Forgejo #63: a rare enemy planet type that yields no resources but grants
# one research point when conquered - a required currency for mid/late
# skill-tree nodes and vessel/lab research, gated alongside their existing
# ore/resource costs.
#
# Covers:
# A. research labs generate deterministically and are rare
# (structural) the definition yields no resources and its surface has no
# resource provider at all
# F. a fresh game can still reach a ship crafter and an extractor with
# zero research points
# D. a research-point-gated skill node is refused while unaffordable, and
# explains why
# E. conquering a research_lab awards exactly one point, once (guarded
# even against a redundant re-mark), emits the signal once, and a
# non-research_lab planet's conquest never awards one
# D continued: unlocking spends the points and the balance updates
# G. the ship-lab research path gates and spends research points the
# same way, independent of the skill tree
var game_state: Node
func _initialize() -> void:
game_state = root.get_node("GameState")
# --- A. deterministic and rare -------------------------------------------
var lab_count: int = 0
var total_planets: int = 0
for x in range(-40, 40):
for y in range(-40, 40):
var chunk: Vector2i = Vector2i(x, y)
if not WorldGen.has_planet_at_chunk(chunk):
continue
total_planets += 1
var first: Dictionary = WorldGen.planet_data_at_chunk(chunk)
var second: Dictionary = WorldGen.planet_data_at_chunk(chunk)
if first.definition_id != second.definition_id or first.local_pos != second.local_pos:
return _fail("planet_data_at_chunk(%s) is not deterministic" % chunk)
if first.definition_id == "research_lab":
lab_count += 1
print("scanned %d planet chunks: %d research labs" % [total_planets, lab_count])
if lab_count == 0:
return _fail("no research labs generated across a wide scan - the rare outcome never fires")
if lab_count * 3 >= total_planets:
return _fail("research labs (%d of %d) are not rare" % [lab_count, total_planets])
print("research labs are deterministic and rare (%d of %d planet chunks)" % [lab_count, total_planets])
# --- structural: yields nothing at all ------------------------------------
var lab_def: Resource = load("res://PlanetDefinitions.gd").get_definition("research_lab")
if String(lab_def.resource_id) != "":
return _fail("research_lab planet's resource_id is '%s', expected empty" % lab_def.resource_id)
var profile: Resource = load("res://PlanetSurfaceProfiles.gd").get_profile("research_lab")
if profile.features_scene != null:
return _fail("research_lab surface profile has a features_scene - it must yield nothing")
print("research_lab yields no resource id and no surface feature provider")
# --- F. a fresh game still reaches a crafter and an extractor -------------
game_state.reset_new_game()
if not game_state.can_unlock("shipyards"):
return _fail("shipyards is not reachable with 0 research points")
game_state.unlock("shipyards")
if not game_state.can_unlock("logistics"):
return _fail("logistics is not reachable with 0 research points after shipyards")
game_state.unlock("logistics")
print("fresh game reaches a ship crafter (shipyards) and an extractor (logistics) at 0 research points")
# --- D. a points-gated node is refused and explains why -------------------
if game_state.can_unlock("coordination"):
return _fail("coordination (costs research points) was unlockable with 0 research points")
print("a research-point-gated node (coordination) correctly refused at 0 points")
# --- E. earning exactly one point, once, with the signal ------------------
var totals_seen: Array = []
game_state.research_points_changed.connect(func(total): totals_seen.append(total))
game_state.planet_capture.clear()
game_state.register_capture_points("test_lab_planet", 2, "research_lab")
if game_state.is_planet_conquered("test_lab_planet"):
return _fail("a lab with 2 real capture points started conquered")
game_state.mark_point_captured("test_lab_planet", 0)
if game_state.research_points != 0:
return _fail("a research point was awarded before every point was captured")
game_state.mark_point_captured("test_lab_planet", 1)
if game_state.research_points != 1:
return _fail("conquering a research_lab did not award exactly 1 point: %d" % game_state.research_points)
if totals_seen != [1]:
return _fail("research_points_changed did not fire exactly once with the new total: %s" % [totals_seen])
print("conquering a research_lab awarded exactly 1 point and fired the signal once")
game_state.mark_point_captured("test_lab_planet", 0) # already captured - redundant re-mark
if game_state.research_points != 1:
return _fail("re-marking an already-captured point re-awarded a research point: %d" % game_state.research_points)
print("double-award guarded against")
game_state.register_capture_points("test_rocky_planet", 1, "rocky")
game_state.mark_point_captured("test_rocky_planet", 0)
if game_state.research_points != 1:
return _fail("conquering a non-research_lab planet awarded a research point: %d" % game_state.research_points)
print("a non-research_lab planet's conquest never awards a point")
# --- D continued: unlocking spends the points, balance updates ------------
if not game_state.can_unlock("coordination"):
return _fail("coordination is still not unlockable with 1 research point and enough ore")
if not game_state.unlock("coordination"):
return _fail("unlock(coordination) failed despite can_unlock() being true")
if game_state.research_points != 0:
return _fail("unlocking a 1-point node did not spend the point: %d remaining" % game_state.research_points)
print("unlocking a research-point-gated skill node spent the point, balance now 0")
# --- G. the ship-lab research path gates and spends the same currency -----
var carrier_def: Resource = game_state.vessel_definition(GameState.VESSEL_CARRIER)
var point_cost: int = int(carrier_def.research_point_cost)
if point_cost <= 0:
return _fail("carrier production data has no research-point cost")
game_state.vessel_levels.erase(carrier_def.unlock_id)
var lab_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2.ZERO)
if not game_state.queue_lab_research(lab_id, carrier_def.unlock_id):
return _fail("could not queue carrier research")
game_state.deposit_resource_to_building(lab_id, GameState.RESOURCE_ORE, 1000)
game_state._advance_lab_research(100.0)
if game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER):
return _fail("carrier research completed despite 0 research points")
print("lab research correctly stalled on a research-point shortfall")
game_state.research_points = point_cost
game_state._advance_lab_research(100.0)
if not game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER):
return _fail("carrier research did not complete once research points were available")
if game_state.research_points != 0:
return _fail("lab research did not spend the research points: %d remaining" % game_state.research_points)
print("lab research spent the research points once affordable, and completed")
print("TEST_RESEARCH_POINTS: PASS")
quit(0)
func _fail(message: String) -> void:
print("TEST_RESEARCH_POINTS: FAIL (%s)" % message)
quit(1)

View file

@ -81,6 +81,7 @@ func _process(delta: float) -> bool:
print("no lab -> no upgrades")
game_state.unlocked = ["shipyards", "vessel_science", "coordination", "wing", "squadron"]
game_state.research_points = 10
field.place(GameState.BUILDING_SHIP_LAB, Vector2(-700.0, 0.0))
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(700.0, 0.0))
crafter_id = int(game_state.buildings[-1].id)

View file

@ -155,6 +155,7 @@ func _process(delta: float) -> bool:
# --- fleet capacity -------------------------------------------
var base_cap: int = game_state.max_fleet_size()
game_state.research_points = 2
game_state.unlock("coordination")
var wing_ok: bool = game_state.unlock("wing")
var raised_cap: int = game_state.max_fleet_size()