Complete lab, quest, garrison, and energy tickets

This commit is contained in:
Loïc Wisniewski 2026-09-03 00:12:08 +02:00
commit 0f7edff15a
20 changed files with 238 additions and 45 deletions

View file

@ -12,7 +12,7 @@ input_buffers = {
collected_outputs = PackedStringArray("energy")
conversion_input = "gas"
conversion_output = "energy"
conversion_rate = 1.0
conversion_rate = 0.2
conversion_yield = 2
[resource]

View file

@ -143,6 +143,8 @@ func _process(delta: float) -> void:
build_menu.refresh_option_states(_planet_under_fleet())
if craft_menu.visible:
craft_menu.refresh()
if lab_menu.visible:
_refresh_lab()
if transfer_menu.visible:
transfer_menu.refresh()
if world_map.visible or _menu_is_open():
@ -592,6 +594,8 @@ func _on_lab_buy() -> void:
func _refresh_lab() -> void:
lab_menu.set_status("")
lab_menu.hide_research_progress()
var definitions: Array = VESSEL_DEFINITIONS.all()
lab_menu.render_vessels(definitions, lab_vessel)
lab_menu.vessel_heading.text = "%s research · %d RP" % [
@ -622,13 +626,16 @@ func _refresh_lab() -> void:
or not GameState.upgrade_maxed(selected.id))
if not queued.is_empty():
var queued_points: int = GameState.research_point_cost(String(queued.id))
var progress: float = float(queued.elapsed) \
/ maxf(GameState.lab_research_time(), 0.001)
if not bool(queued.started) and GameState.research_points < queued_points:
lab_menu.set_status("Need %d research point%s (have %d)" % [queued_points,
"" if queued_points == 1 else "s", GameState.research_points])
lab_menu.show_research_progress(
"Need %d research point%s (have %d)" % [queued_points,
"" if queued_points == 1 else "s", GameState.research_points], progress)
else:
lab_menu.set_status("%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_menu.show_research_progress(
"Researching" if bool(queued.started) else "Waiting for resources",
progress)
func _on_transfer_pressed() -> void:

26
core/CombatTarget.gd Normal file
View file

@ -0,0 +1,26 @@
class_name CombatTarget
extends RefCounted
# One opt-in contract for anything a player weapon may deliberately attack.
# Aim assist, autonomous garrisons and projectile impacts all consume the same
# group, so a new target composition does not need a concrete-type branch in
# each combat system.
const GROUP: StringName = &"player_weapon_targets"
const PRIORITY_META: StringName = &"player_weapon_target_priority"
# A garrison protects its post before mining nearby debris. Distances still
# decide between targets at the same priority.
const RESOURCE_PRIORITY: int = 0
const DANGER_PRIORITY: int = 100
static func register(node: Node, priority: int = RESOURCE_PRIORITY) -> void:
if node == null:
return
node.add_to_group(GROUP)
node.set_meta(PRIORITY_META, priority)
static func priority(node: Node) -> int:
return int(node.get_meta(PRIORITY_META, RESOURCE_PRIORITY)) if node != null \
else RESOURCE_PRIORITY

1
core/CombatTarget.gd.uid Normal file
View file

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

View file

@ -242,6 +242,14 @@ const CHAIN: Array = [
"done": "Three kinds of world surveyed. Not one of them holds any sign of the convoy.",
"important": true,
},
{
"id": "propulser",
"title": "A LONGER REACH",
"objective": "Build an Energy Propulser vessel",
"goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "distorder", "count": 1},
"brief": "The trail may run far beyond this cluster. Unlock an Energy Propulser at the ship lab, build one at the yard, and crew it. Its drive will let the whole fleet search faster.",
"done": "The Energy Propulser is in formation. We can cast the search wider now.",
},
{
"id": "trail",
"title": "THE TRAIL",

View file

@ -1,5 +1,7 @@
extends Area2D
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
signal destroyed
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
@ -30,10 +32,10 @@ var max_hp: int
func _ready() -> void:
add_to_group("enemies")
# Weapons discover anything the player may deliberately aim at through one
# compositional contract. Asteroids implement the same contract without the
# aim-assist code having to know either concrete type (#181).
add_to_group("player_aim_targets")
# Weapons discover anything the player may deliberately attack through one
# compositional contract. Asteroids implement the same contract without
# combat systems having to know either concrete type (#181, #186).
COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY)
definition = ENEMY_DEFINITIONS.get_definition(enemy_id)
speed = definition.speed
acceleration = definition.acceleration

View file

@ -1,7 +1,7 @@
extends Node2D
# Preview, not a test - the ship lab at the real 720x1280 viewport, with a
# vessel selected and its upgrades listed.
# vessel selected, its upgrades listed, and active research in progress.
#
# bash tests/screenshot.sh res://tests/_lab_menu_preview.tscn /tmp/lab.png 25 720x1280
@ -13,10 +13,21 @@ func _ready() -> void:
GameState.bank_inventory.set_amount(GameState.RESOURCE_ORE, 310)
GameState.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 47)
GameState.research_points = 4
GameState.build_time_scale = 1.0
var lab_id: int = GameState.add_building(GameState.BUILDING_SHIP_LAB,
Vector2.ZERO)
GameState.queue_lab_research(lab_id, "int_rate")
GameState.deposit_resource_to_building(lab_id, GameState.RESOURCE_ORE,
GameState.upgrade_cost("int_rate"))
GameState.call("_advance_lab_research", GameState.lab_research_time() * 0.42)
# Freeze the model so a slow software-rendered capture cannot finish the
# research before it takes the frame this preview exists to show.
GameState.set_process(false)
var main: Node = load("res://world/Main.tscn").instantiate()
add_child(main)
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
var build: Node = main.get_node("BuildController")
build.lab_menu.visible = true
build.lab_building_id = lab_id
build.lab_menu.open_for(lab_id)
build._refresh_lab()

View file

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

View file

@ -53,6 +53,8 @@ func _initialize() -> void:
var logistics: Resource = game_state.building_logistics_profile(game_state.find_building(burner_id))
conversion_rate = logistics.conversion_rate
conversion_yield = logistics.conversion_yield
if not is_equal_approx(conversion_rate, 0.2):
_fail("burner conversion rate is %.2f/s, expected the reduced 0.20/s" % conversion_rate)
func _process(delta: float) -> bool:
@ -72,13 +74,13 @@ func _process(delta: float) -> bool:
print("energy is generic: in resource_types(), own definition, own colour %s" % energy_color)
# --- B. produces energy from local gas at the configured rate -
if elapsed < 2.5:
if elapsed < 5.5:
return false
var gas_left: int = game_state.building_resource(burner_id, GameState.RESOURCE_GAS)
var energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY)
var gas_burned: int = 20 - gas_left
if gas_burned < 2:
return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, conversion_rate])
if gas_burned < 1:
return _fail("burner burned no gas in 5.5s at rate %.2f/s" % conversion_rate)
if energy_made != gas_burned * conversion_yield:
return _fail("burner made %d energy for %d gas burned, expected %dx" % [
energy_made, gas_burned, conversion_yield

View file

@ -84,7 +84,7 @@ func _process(delta: float) -> bool:
return false
1:
# --- B. running, it converts ----------------------------------
if elapsed < 1.5:
if elapsed < 5.5:
return false
if _stock(GameState.RESOURCE_ENERGY) <= 0:
return _fail("a running burner made no energy in %.1fs" % elapsed)
@ -119,7 +119,7 @@ func _process(delta: float) -> bool:
elapsed = 0.0
return false
3:
if elapsed < 1.5:
if elapsed < 5.5:
return false
if _stock(GameState.RESOURCE_ENERGY) <= energy_at_pause:
return _fail("a resumed burner made no further energy")

View file

@ -21,6 +21,8 @@ extends SceneTree
# E. it goes back to patrolling once the enemy is gone
# F. losing a garrison vessel frees the berth, so it gets crewed again
# G. enemies fight back - a garrison vessel is a target like any other
# H. #186 - with no hostile nearby, the same weapon-target contract lets a
# garrison shoot asteroids for ore
var game_state: Node
var main: Node
@ -30,6 +32,7 @@ var outpost_id: int = 0
var guard: Node2D
var component: Node
var enemy: Node2D
var asteroid: Node2D
var stage: int = 0
var elapsed: float = 0.0
var orbit_min: float = INF
@ -231,14 +234,40 @@ func _process(delta: float) -> bool:
return _fail("the garrison is still fighting an enemy that is gone")
print("stood down and went back to its patrol")
# --- H. asteroids are valid lower-priority targets -----------
asteroid = load("res://world/Asteroid.tscn").instantiate()
asteroid.position = Vector2(300.0, 0.0)
asteroid.velocity = Vector2.ZERO
main.add_child(asteroid)
asteroid.hp = 100000
dummy_hp_before = asteroid.hp
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 2.5:
return false
if not component.is_engaging():
return _fail("an asteroid beside the outpost was not engaged")
if asteroid.hp >= dummy_hp_before:
return _fail("the garrison engaged an asteroid but never shot it")
print("garrison shot a nearby asteroid through the shared target contract")
asteroid.queue_free()
elapsed = 0.0
stage = 7
return false
7:
if elapsed < 0.5:
return false
# --- F. a lost vessel frees its berth ------------------------
if game_state.garrison_crew(outpost_id).size() != 1:
return _fail("the crew is not 1 before the kill")
guard.take_damage(1)
elapsed = 0.0
stage = 6
stage = 8
return false
6:
8:
if elapsed < 0.3:
return false
if not game_state.garrison_crew(outpost_id).is_empty():

View file

@ -133,6 +133,23 @@ func _catalogue_cases() -> bool:
return _fail("the quest after the mothership is %s, not exploration" % after.id)
print("the mothership answers with distance, and exploration follows it")
# #185: the final wide search has a propulsion lesson immediately before
# it. The goal uses the stable vessel id while the player-facing copy uses
# the renamed Energy Propulser name.
var trail: Dictionary = QUESTS.by_id("trail")
var trail_at: int = QUESTS.CHAIN.find(trail)
if trail_at <= 0:
return _fail("the Chart 150 quest is missing or opens the chain")
var before_trail: Dictionary = QUESTS.at(trail_at - 1)
if String(before_trail.id) != "propulser" \
or String(before_trail.goal.kind) != QUESTS.GOAL_FLEET_VESSEL \
or String(before_trail.goal.vessel) != GameState.VESSEL_DISTORDER:
return _fail("Chart 150 is preceded by %s, not the Energy Propulser vessel quest" \
% before_trail.get("id", "nothing"))
if not "Energy Propulser" in String(before_trail.objective):
return _fail("the propulsion quest does not name the Energy Propulser to the player")
print("the Energy Propulser vessel quest leads directly into Chart 150")
# --- R. #145: every structure quest can actually be finished ---------
# The extractor quest could not be. Extractors are placed onto a planet
# from space and live in their own array, not in `buildings`, so the

View file

@ -25,6 +25,7 @@ var scene: Node
var fleet: Node
var field: Node
var controller: Node
var lab_id: int = 0
var crafter_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
@ -83,6 +84,7 @@ func _process(delta: float) -> bool:
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))
lab_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(700.0, 0.0))
crafter_id = int(game_state.buildings[-1].id)
elapsed = 0.0
@ -130,6 +132,29 @@ func _process(delta: float) -> bool:
return true
print("lab cards are vessel-specific and selectable")
# #184: a queued research uses the bar as its changing value. The
# status names the state but never repeats it as a percentage.
var lab_progress: ProgressBar = controller.get_node(
"../UI/LabMenu/ResearchProgress")
var lab_status: Label = controller.get_node("../UI/LabMenu/StatusLabel")
controller.lab_building_id = lab_id
if not game_state.queue_lab_research(lab_id, "int_rate"):
return _fail("could not queue research for the progress-bar check")
controller.call("_refresh_lab")
if not lab_progress.visible or lab_progress.show_percentage:
return _fail("pending research did not show a percentage-free progress bar")
if "%" in lab_status.text:
return _fail("pending research still prints a percentage: '%s'" % lab_status.text)
game_state.deposit_resource_to_building(lab_id, GameState.RESOURCE_ORE,
game_state.upgrade_cost("int_rate"))
game_state.call("_advance_lab_research", game_state.lab_research_time() * 0.25)
controller.call("_refresh_lab")
if lab_progress.value < 0.2 or lab_progress.value > 0.3 \
or lab_status.text != "Researching":
return _fail("research bar did not track active work (value %.2f, status '%s')" \
% [lab_progress.value, lab_status.text])
print("pending lab research is shown by a live progress bar, without percentage text")
# --- 3. buying raises the level and charges ------------------
var before_rocks: int = game_state.rocks
var first_cost: int = game_state.upgrade_cost("int_damage")
@ -225,3 +250,9 @@ func _process(delta: float) -> bool:
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_SHIP_LAB: FAIL (%s)" % message)
quit(1)
return true

View file

@ -22,6 +22,7 @@ const VESSEL_MARGIN := 24.0
@onready var vessel_heading: Label = $VesselLabel
@onready var detail: Label = $DetailLabel
@onready var status: Label = $StatusLabel
@onready var research_progress: ProgressBar = $ResearchProgress
@onready var resource_bar: Control = $Bar
var building_id: int = 0
@ -96,6 +97,16 @@ func set_status(message: String) -> void:
status.text = message
func show_research_progress(message: String, ratio: float) -> void:
status.text = message
research_progress.value = clampf(ratio, 0.0, 1.0)
research_progress.visible = true
func hide_research_progress() -> void:
research_progress.visible = false
func _build_price_tags() -> void:
for i in range(upgrade_labels.size()):
var label: Control = upgrade_labels[i]

View file

@ -1,9 +1,28 @@
[gd_scene load_steps=4 format=3]
[gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://ui/IconButton.gd" id="10"]
[ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"]
[ext_resource type="Script" path="res://ui/menus/LabMenu.gd" id="menu_script"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_progress_background"]
bg_color = Color(0.025, 0.045, 0.09, 0.92)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.18, 0.48, 0.62, 0.9)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_progress_fill"]
bg_color = Color(0.3, 0.9, 1, 0.88)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[node name="LabMenu" type="Control" groups=["modal_menus"]]
visible = false
mouse_filter = 2
@ -283,12 +302,29 @@ anchor_bottom = 1.0
offset_left = 32
offset_top = -174
offset_right = -32
offset_bottom = -120
offset_bottom = -128
theme_override_font_sizes/font_size = 30
autowrap_mode = 2
horizontal_alignment = 1
text = ""
[node name="ResearchProgress" type="ProgressBar" parent="."]
visible = false
mouse_filter = 2
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -280
offset_top = -116
offset_right = 280
offset_bottom = -82
theme_override_styles/background = SubResource("StyleBoxFlat_progress_background")
theme_override_styles/fill = SubResource("StyleBoxFlat_progress_fill")
max_value = 1.0
step = 0.001
show_percentage = false
[node name="NextButton" type="Control" parent="."]
visible = false
anchor_left = 0.5

View file

@ -1,5 +1,7 @@
extends Area2D
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
# #86: faster. The bolt used to crawl far enough behind a moving target that
# leading a shot was guesswork rather than skill.
# #155: was 850, which took 1.1s to cross the gun's own range - long enough
@ -54,9 +56,9 @@ func _physics_process(delta: float) -> void:
func _on_area_entered(area: Area2D) -> void:
# Asteroids answer take_damage() exactly as enemies do, so both are hit by
# the same branch rather than by a second special case.
if area.is_in_group("enemies") or area.is_in_group("asteroids"):
# Anything that opted into the weapon-target contract answers take_damage(),
# so adding a new target composition needs no projectile type check.
if area.is_in_group(COMBAT_TARGET.GROUP):
area.take_damage(damage)
queue_free()

View file

@ -3,6 +3,7 @@ extends Area2D
signal destroyed
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
@export var max_speed: float = 460.0
@export var acceleration: float = 450.0
@ -200,17 +201,14 @@ func _breakaway_direction() -> Vector2:
func _on_area_entered(area: Area2D) -> void:
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
if not area.is_in_group(COMBAT_TARGET.GROUP):
return
# The enemy is told about the impact BEFORE this ship resolves its own
# damage, because resolving it can free this node - and a ship that dies
# to a ramming chaser must still take the chaser with it. Whether the
# impact actually destroys the enemy is the enemy's decision (#82).
if area.is_in_group("enemies") and area.has_method("collide_with_player_vessel"):
# A target is told about the impact BEFORE this ship resolves its own
# damage, because resolving it can free this node. Whether the impact does
# anything to the target is part of that target's composition (#82).
if area.has_method("collide_with_player_vessel"):
area.collide_with_player_vessel()
var absorbed: bool = take_damage(1)
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
take_damage(1)
func _draw() -> void:

View file

@ -1,6 +1,8 @@
class_name VesselGarrisonComponent
extends Node
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
# #105 (#97g): "When a vessel is assigned to an outpost, it navigates around
# the outpost by itself, attacking nearby ennemies if any are detected."
#
@ -66,7 +68,7 @@ func is_engaging() -> bool:
func tick(delta: float) -> void:
var ship: Node2D = get_parent()
var target: Node2D = _nearest_enemy(ship)
var target: Node2D = _nearest_target(ship)
_engaging = target != null
if target == null:
_patrol(ship, delta)
@ -176,20 +178,27 @@ func _hold_fire(ship: Node2D) -> void:
weapon.set_fire_direction(Vector2.ZERO)
func _nearest_enemy(ship: Node2D) -> Node2D:
func _nearest_target(ship: Node2D) -> Node2D:
var nearest: Node2D = null
var best: float = detection_range
for enemy in get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy) or enemy.is_queued_for_deletion():
var best_distance: float = INF
var best_priority: int = -1
for target_node in get_tree().get_nodes_in_group(COMBAT_TARGET.GROUP):
if not is_instance_valid(target_node) or not (target_node is Node2D) \
or target_node.is_queued_for_deletion():
continue
var at: Vector2 = (enemy as Node2D).global_position
var target: Node2D = target_node as Node2D
var at: Vector2 = target.global_position
# Judged from the POST, not from the vessel: otherwise a garrison
# that had drifted out to its leash would see further still, and
# could be walked away from its outpost one enemy at a time.
if station.distance_to(at) > leash_range + engage_distance:
continue
var d: float = ship.global_position.distance_to(at)
if d < best:
best = d
nearest = enemy
if d > detection_range:
continue
var priority: int = COMBAT_TARGET.priority(target)
if priority > best_priority or (priority == best_priority and d < best_distance):
best_priority = priority
best_distance = d
nearest = target
return nearest

View file

@ -36,6 +36,7 @@ var fire_timer: float = 0.0
var fire_target: Vector2 = Vector2.ZERO
var has_fire_target: bool = false
var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn")
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
# By path, not by class name - see the note at the top of MuzzleSparkVFX.
const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd")
@ -58,7 +59,6 @@ const ASSIST_CONE_DEGREES: float = 28.0
# smaller than the directional cone at ordinary weapon range: it catches a
# pointer placed beside something without making the whole screen magnetic.
const ASSIST_CURSOR_RADIUS: float = 180.0
const ASSIST_TARGET_GROUP: StringName = &"player_aim_targets"
# The concrete point an aim should stick to. A target can qualify in two ways:
@ -81,7 +81,7 @@ static func assisted_point(tree: SceneTree, origin: Vector2, point: Vector2,
var cursor_distance: float = INF
var direction_pick: Node2D = null
var direction_distance: float = INF
for target_node in tree.get_nodes_in_group(ASSIST_TARGET_GROUP):
for target_node in tree.get_nodes_in_group(COMBAT_TARGET.GROUP):
if not is_instance_valid(target_node) or not (target_node is Node2D) \
or target_node.is_queued_for_deletion():
continue

View file

@ -1,5 +1,7 @@
extends Area2D
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
# A drifting rock that can be shot apart for the ore inside it.
#
# Asteroids used to be pure hazard: they could only ever cost you a ship. Now
@ -32,7 +34,7 @@ var shape_points: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
randomize()
add_to_group("asteroids")
add_to_group("player_aim_targets")
COMBAT_TARGET.register(self, COMBAT_TARGET.RESOURCE_PRIORITY)
area_entered.connect(_on_area_entered)
var point_count: int = 7