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") collected_outputs = PackedStringArray("energy")
conversion_input = "gas" conversion_input = "gas"
conversion_output = "energy" conversion_output = "energy"
conversion_rate = 1.0 conversion_rate = 0.2
conversion_yield = 2 conversion_yield = 2
[resource] [resource]

View file

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

View file

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

View file

@ -1,7 +1,7 @@
extends Node2D extends Node2D
# Preview, not a test - the ship lab at the real 720x1280 viewport, with a # 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 # 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.bank_inventory.set_amount(GameState.RESOURCE_ORE, 310)
GameState.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 47) GameState.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 47)
GameState.research_points = 4 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() var main: Node = load("res://world/Main.tscn").instantiate()
add_child(main) add_child(main)
main.get_node("EnemySpawner").set_process(false) main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false) main.get_node("AsteroidField").set_process(false)
var build: Node = main.get_node("BuildController") 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() 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)) var logistics: Resource = game_state.building_logistics_profile(game_state.find_building(burner_id))
conversion_rate = logistics.conversion_rate conversion_rate = logistics.conversion_rate
conversion_yield = logistics.conversion_yield 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: 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) print("energy is generic: in resource_types(), own definition, own colour %s" % energy_color)
# --- B. produces energy from local gas at the configured rate - # --- B. produces energy from local gas at the configured rate -
if elapsed < 2.5: if elapsed < 5.5:
return false return false
var gas_left: int = game_state.building_resource(burner_id, GameState.RESOURCE_GAS) 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 energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY)
var gas_burned: int = 20 - gas_left var gas_burned: int = 20 - gas_left
if gas_burned < 2: if gas_burned < 1:
return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, conversion_rate]) return _fail("burner burned no gas in 5.5s at rate %.2f/s" % conversion_rate)
if energy_made != gas_burned * conversion_yield: if energy_made != gas_burned * conversion_yield:
return _fail("burner made %d energy for %d gas burned, expected %dx" % [ return _fail("burner made %d energy for %d gas burned, expected %dx" % [
energy_made, gas_burned, conversion_yield energy_made, gas_burned, conversion_yield

View file

@ -84,7 +84,7 @@ func _process(delta: float) -> bool:
return false return false
1: 1:
# --- B. running, it converts ---------------------------------- # --- B. running, it converts ----------------------------------
if elapsed < 1.5: if elapsed < 5.5:
return false return false
if _stock(GameState.RESOURCE_ENERGY) <= 0: if _stock(GameState.RESOURCE_ENERGY) <= 0:
return _fail("a running burner made no energy in %.1fs" % elapsed) return _fail("a running burner made no energy in %.1fs" % elapsed)
@ -119,7 +119,7 @@ func _process(delta: float) -> bool:
elapsed = 0.0 elapsed = 0.0
return false return false
3: 3:
if elapsed < 1.5: if elapsed < 5.5:
return false return false
if _stock(GameState.RESOURCE_ENERGY) <= energy_at_pause: if _stock(GameState.RESOURCE_ENERGY) <= energy_at_pause:
return _fail("a resumed burner made no further energy") 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 # E. it goes back to patrolling once the enemy is gone
# F. losing a garrison vessel frees the berth, so it gets crewed again # 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 # 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 game_state: Node
var main: Node var main: Node
@ -30,6 +32,7 @@ var outpost_id: int = 0
var guard: Node2D var guard: Node2D
var component: Node var component: Node
var enemy: Node2D var enemy: Node2D
var asteroid: Node2D
var stage: int = 0 var stage: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
var orbit_min: float = INF 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") return _fail("the garrison is still fighting an enemy that is gone")
print("stood down and went back to its patrol") 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 ------------------------ # --- F. a lost vessel frees its berth ------------------------
if game_state.garrison_crew(outpost_id).size() != 1: if game_state.garrison_crew(outpost_id).size() != 1:
return _fail("the crew is not 1 before the kill") return _fail("the crew is not 1 before the kill")
guard.take_damage(1) guard.take_damage(1)
elapsed = 0.0 elapsed = 0.0
stage = 6 stage = 8
return false return false
6: 8:
if elapsed < 0.3: if elapsed < 0.3:
return false return false
if not game_state.garrison_crew(outpost_id).is_empty(): 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) return _fail("the quest after the mothership is %s, not exploration" % after.id)
print("the mothership answers with distance, and exploration follows it") 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 --------- # --- R. #145: every structure quest can actually be finished ---------
# The extractor quest could not be. Extractors are placed onto a planet # 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 # 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 fleet: Node
var field: Node var field: Node
var controller: Node var controller: Node
var lab_id: int = 0
var crafter_id: int = 0 var crafter_id: int = 0
var stage: int = 0 var stage: int = 0
var elapsed: float = 0.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.unlocked = ["shipyards", "vessel_science", "coordination", "wing", "squadron"]
game_state.research_points = 10 game_state.research_points = 10
field.place(GameState.BUILDING_SHIP_LAB, Vector2(-700.0, 0.0)) 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)) field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(700.0, 0.0))
crafter_id = int(game_state.buildings[-1].id) crafter_id = int(game_state.buildings[-1].id)
elapsed = 0.0 elapsed = 0.0
@ -130,6 +132,29 @@ func _process(delta: float) -> bool:
return true return true
print("lab cards are vessel-specific and selectable") 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 ------------------ # --- 3. buying raises the level and charges ------------------
var before_rocks: int = game_state.rocks var before_rocks: int = game_state.rocks
var first_cost: int = game_state.upgrade_cost("int_damage") var first_cost: int = game_state.upgrade_cost("int_damage")
@ -225,3 +250,9 @@ func _process(delta: float) -> bool:
quit(0) quit(0)
return true return true
return false 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 vessel_heading: Label = $VesselLabel
@onready var detail: Label = $DetailLabel @onready var detail: Label = $DetailLabel
@onready var status: Label = $StatusLabel @onready var status: Label = $StatusLabel
@onready var research_progress: ProgressBar = $ResearchProgress
@onready var resource_bar: Control = $Bar @onready var resource_bar: Control = $Bar
var building_id: int = 0 var building_id: int = 0
@ -96,6 +97,16 @@ func set_status(message: String) -> void:
status.text = message 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: func _build_price_tags() -> void:
for i in range(upgrade_labels.size()): for i in range(upgrade_labels.size()):
var label: Control = upgrade_labels[i] 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/IconButton.gd" id="10"]
[ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"]
[ext_resource type="Script" path="res://ui/menus/LabMenu.gd" id="menu_script"] [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"]] [node name="LabMenu" type="Control" groups=["modal_menus"]]
visible = false visible = false
mouse_filter = 2 mouse_filter = 2
@ -283,12 +302,29 @@ anchor_bottom = 1.0
offset_left = 32 offset_left = 32
offset_top = -174 offset_top = -174
offset_right = -32 offset_right = -32
offset_bottom = -120 offset_bottom = -128
theme_override_font_sizes/font_size = 30 theme_override_font_sizes/font_size = 30
autowrap_mode = 2 autowrap_mode = 2
horizontal_alignment = 1 horizontal_alignment = 1
text = "" 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="."] [node name="NextButton" type="Control" parent="."]
visible = false visible = false
anchor_left = 0.5 anchor_left = 0.5

View file

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

View file

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

View file

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

View file

@ -36,6 +36,7 @@ var fire_timer: float = 0.0
var fire_target: Vector2 = Vector2.ZERO var fire_target: Vector2 = Vector2.ZERO
var has_fire_target: bool = false var has_fire_target: bool = false
var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn") 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. # By path, not by class name - see the note at the top of MuzzleSparkVFX.
const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") 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 # smaller than the directional cone at ordinary weapon range: it catches a
# pointer placed beside something without making the whole screen magnetic. # pointer placed beside something without making the whole screen magnetic.
const ASSIST_CURSOR_RADIUS: float = 180.0 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: # 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 cursor_distance: float = INF
var direction_pick: Node2D = null var direction_pick: Node2D = null
var direction_distance: float = INF 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) \ if not is_instance_valid(target_node) or not (target_node is Node2D) \
or target_node.is_queued_for_deletion(): or target_node.is_queued_for_deletion():
continue continue

View file

@ -1,5 +1,7 @@
extends Area2D extends Area2D
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
# A drifting rock that can be shot apart for the ore inside it. # 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 # 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: func _ready() -> void:
randomize() randomize()
add_to_group("asteroids") add_to_group("asteroids")
add_to_group("player_aim_targets") COMBAT_TARGET.register(self, COMBAT_TARGET.RESOURCE_PRIORITY)
area_entered.connect(_on_area_entered) area_entered.connect(_on_area_entered)
var point_count: int = 7 var point_count: int = 7