From 5326852807655dc692269521cc298b5f4e2c025d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:41:37 +0200 Subject: [PATCH] Add planet setups as authored resources (#42) PlanetSetup/PlanetSetupSpawn define a curated capture-point/enemy layout for a surface profile; PlanetSetups scans res://PlanetSetups/ at first use (not a hardcoded preload table) so dropping in a new .tres is the only step needed to add one, and resolves the same setup for the same planet deterministically, same pattern as WorldGen.planet_data_at_chunk. PlanetSurfaceController composes a PlanetSetupHost onto the landed surface alongside the existing resource-provider feature scene. Ships two starter setups (rocky_default, gas_default) with placeholder capture-point markers; #43 replaces the markers with the real CapturePoint scene through the same apply_setup() entry point. Part of the #40 conquest-mechanic breakdown. --- PlanetSetup.gd | 16 ++++ PlanetSetup.gd.uid | 1 + PlanetSetupHost.gd | 33 +++++++ PlanetSetupHost.gd.uid | 1 + PlanetSetupHost.tscn | 6 ++ PlanetSetupSpawn.gd | 7 ++ PlanetSetupSpawn.gd.uid | 1 + PlanetSetups.gd | 69 +++++++++++++++ PlanetSetups.gd.uid | 1 + PlanetSetups/gas_default.tres | 27 ++++++ PlanetSetups/rocky_default.tres | 22 +++++ PlanetSurfaceController.gd | 21 ++++- tests/_setup_markers_preview.gd | 12 +++ tests/_setup_markers_preview.gd.uid | 1 + tests/_setup_markers_preview.tscn | 6 ++ tests/test_planet_setups.gd | 129 ++++++++++++++++++++++++++++ tests/test_planet_setups.gd.uid | 1 + 17 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 PlanetSetup.gd create mode 100644 PlanetSetup.gd.uid create mode 100644 PlanetSetupHost.gd create mode 100644 PlanetSetupHost.gd.uid create mode 100644 PlanetSetupHost.tscn create mode 100644 PlanetSetupSpawn.gd create mode 100644 PlanetSetupSpawn.gd.uid create mode 100644 PlanetSetups.gd create mode 100644 PlanetSetups.gd.uid create mode 100644 PlanetSetups/gas_default.tres create mode 100644 PlanetSetups/rocky_default.tres create mode 100644 tests/_setup_markers_preview.gd create mode 100644 tests/_setup_markers_preview.gd.uid create mode 100644 tests/_setup_markers_preview.tscn create mode 100644 tests/test_planet_setups.gd create mode 100644 tests/test_planet_setups.gd.uid diff --git a/PlanetSetup.gd b/PlanetSetup.gd new file mode 100644 index 0000000..fd81af1 --- /dev/null +++ b/PlanetSetup.gd @@ -0,0 +1,16 @@ +class_name PlanetSetup +extends Resource + +# A hand-authored capture-point/enemy layout for a planet surface. Chosen +# deterministically per planet (see PlanetSetups.setup_for_planet) rather +# than generated - #40 wants curated setups, not procedural ones - and +# stored as a committed .tres under res://PlanetSetups/, not in a save. + +@export var setup_id: String = "" +@export var display_name: String = "" +# Which PlanetSurfaceProfiles id (e.g. "rocky", "gas") this setup was +# authored for; only setups matching a planet's own profile are eligible. +@export var surface_profile_id: String = "rocky" +# Surface-local positions (the fleet lands at Vector2.ZERO). +@export var capture_points: PackedVector2Array = PackedVector2Array() +@export var enemy_spawns: Array[Resource] = [] diff --git a/PlanetSetup.gd.uid b/PlanetSetup.gd.uid new file mode 100644 index 0000000..8e9aef7 --- /dev/null +++ b/PlanetSetup.gd.uid @@ -0,0 +1 @@ +uid://d1govxko60n03 diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd new file mode 100644 index 0000000..f2df9c5 --- /dev/null +++ b/PlanetSetupHost.gd @@ -0,0 +1,33 @@ +class_name PlanetSetupHost +extends Node2D + +# Instantiates the contents of a PlanetSetup onto a landed surface. Capture +# points are placeholder markers here; #43 replaces them with the real +# CapturePoint scene through the same apply_setup() entry point, and enemy +# spawning is filled in by the enemy-foundation ticket (#44). Keeping this +# seam stable is the point of this ticket. + +const MARKER_COLOR: Color = Color(0.55, 0.85, 1.0) +const MARKER_RADIUS: float = 36.0 + +var setup: Resource + + +func apply_setup(new_setup: Resource) -> void: + setup = new_setup + for child in get_children(): + child.queue_free() + if setup == null: + return + for point in setup.capture_points: + _add_capture_point_marker(point) + + +func _add_capture_point_marker(at: Vector2) -> void: + var marker: NeonPiece = NeonPiece.create(self, at, MARKER_COLOR) + var points: PackedVector2Array = PackedVector2Array() + for i in range(16): + var angle: float = float(i) / 16.0 * TAU + points.append(Vector2(cos(angle), sin(angle)) * MARKER_RADIUS) + marker.add_polygon(points, 0.6) + marker.queue_redraw() diff --git a/PlanetSetupHost.gd.uid b/PlanetSetupHost.gd.uid new file mode 100644 index 0000000..f65f628 --- /dev/null +++ b/PlanetSetupHost.gd.uid @@ -0,0 +1 @@ +uid://ckxmhx54fi5s1 diff --git a/PlanetSetupHost.tscn b/PlanetSetupHost.tscn new file mode 100644 index 0000000..f87cc83 --- /dev/null +++ b/PlanetSetupHost.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://PlanetSetupHost.gd" id="1"] + +[node name="PlanetSetupHost" type="Node2D"] +script = ExtResource("1") diff --git a/PlanetSetupSpawn.gd b/PlanetSetupSpawn.gd new file mode 100644 index 0000000..5918b8a --- /dev/null +++ b/PlanetSetupSpawn.gd @@ -0,0 +1,7 @@ +class_name PlanetSetupSpawn +extends Resource + +# One authored enemy placement within a PlanetSetup. + +@export var enemy_id: String = "chaser" +@export var position: Vector2 = Vector2.ZERO diff --git a/PlanetSetupSpawn.gd.uid b/PlanetSetupSpawn.gd.uid new file mode 100644 index 0000000..29438be --- /dev/null +++ b/PlanetSetupSpawn.gd.uid @@ -0,0 +1 @@ +uid://c8spn4nio735d diff --git a/PlanetSetups.gd b/PlanetSetups.gd new file mode 100644 index 0000000..81105a4 --- /dev/null +++ b/PlanetSetups.gd @@ -0,0 +1,69 @@ +class_name PlanetSetups +extends RefCounted + +# Registry of authored PlanetSetup resources. Deliberately scans +# res://PlanetSetups/ at first use instead of a hardcoded preload +# dictionary (contrast PlanetDefinitions.gd), so dropping a new .tres in +# that folder is the only step needed to add a setup - the map editor +# (#48) writes there directly. + +const SETUPS_DIR: String = "res://PlanetSetups" +# Compared against get_script() rather than an `is PlanetSetup` check - the +# same load-order hazard as PlanetSurfaceController's setup_host: a global +# class_name identifier can be looked up before an import pass has +# registered it, while a direct script preload never depends on that cache. +const PLANET_SETUP_SCRIPT: Script = preload("res://PlanetSetup.gd") + +static var _all: Array = [] +static var _scanned: bool = false + + +static func _ensure_scanned() -> void: + if _scanned: + return + _scanned = true + var dir: DirAccess = DirAccess.open(SETUPS_DIR) + if dir == null: + return + dir.list_dir_begin() + var file_name: String = dir.get_next() + while file_name != "": + if not dir.current_is_dir() and file_name.ends_with(".tres"): + var res: Resource = load(SETUPS_DIR + "/" + file_name) + if res != null and res.get_script() == PLANET_SETUP_SCRIPT: + _all.append(res) + file_name = dir.get_next() + dir.list_dir_end() + + +static func rescan() -> void: + # The map editor calls this after saving so a freshly written setup is + # visible without restarting the process. + _all = [] + _scanned = false + _ensure_scanned() + + +static func all() -> Array: + _ensure_scanned() + return _all + + +static func setups_for_profile(profile_id: String) -> Array: + var result: Array = [] + for setup in all(): + if setup.surface_profile_id == profile_id: + result.append(setup) + return result + + +static func setup_for_planet(planet_id: String, profile_id: String) -> Resource: + # Same pattern as WorldGen.planet_data_at_chunk: seeded purely from the + # planet's own stable id, so the same planet always resolves to the + # same setup from anywhere, with nothing stored. + var candidates: Array = setups_for_profile(profile_id) + if candidates.is_empty(): + return null + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = hash(planet_id) + return candidates[rng.randi() % candidates.size()] diff --git a/PlanetSetups.gd.uid b/PlanetSetups.gd.uid new file mode 100644 index 0000000..403182c --- /dev/null +++ b/PlanetSetups.gd.uid @@ -0,0 +1 @@ +uid://c5ndxvompklip diff --git a/PlanetSetups/gas_default.tres b/PlanetSetups/gas_default.tres new file mode 100644 index 0000000..bafb1de --- /dev/null +++ b/PlanetSetups/gas_default.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3] + +[ext_resource type="Script" path="res://PlanetSetup.gd" id="1"] +[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"] + +[sub_resource type="Resource" id="Resource_g1"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(500, -300) + +[sub_resource type="Resource" id="Resource_g2"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(-450, 400) + +[sub_resource type="Resource" id="Resource_g3"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(200, 700) + +[resource] +script = ExtResource("1") +setup_id = "gas_default" +display_name = "Gas Default" +surface_profile_id = "gas" +capture_points = PackedVector2Array(600, -200, -550, -250, 0, 650, -700, 450) +enemy_spawns = Array[Resource]([SubResource("Resource_g1"), SubResource("Resource_g2"), SubResource("Resource_g3")]) diff --git a/PlanetSetups/rocky_default.tres b/PlanetSetups/rocky_default.tres new file mode 100644 index 0000000..cd4767b --- /dev/null +++ b/PlanetSetups/rocky_default.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3] + +[ext_resource type="Script" path="res://PlanetSetup.gd" id="1"] +[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"] + +[sub_resource type="Resource" id="Resource_r1"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(450, 250) + +[sub_resource type="Resource" id="Resource_r2"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(-600, -350) + +[resource] +script = ExtResource("1") +setup_id = "rocky_default" +display_name = "Rocky Default" +surface_profile_id = "rocky" +capture_points = PackedVector2Array(700, 100, -650, 300, 50, -750) +enemy_spawns = Array[Resource]([SubResource("Resource_r1"), SubResource("Resource_r2")]) diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 294f5f1..7569796 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -2,6 +2,8 @@ extends Node2D const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd") const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") +const PLANET_SETUPS = preload("res://PlanetSetups.gd") +const SETUP_HOST_SCENE: PackedScene = preload("res://PlanetSetupHost.tscn") # Bootstraps the surviving fleet, including each vessel type, on this scene's # own startup (from GameState, since this is a fresh scene tree - nothing here @@ -46,10 +48,21 @@ func _compose_surface(profile: Resource) -> void: var caption: Label = get_node(caption_path) caption.add_theme_color_override("font_color", profile.caption_color) caption.add_theme_color_override("font_outline_color", profile.caption_outline_color) - if profile.features_scene == null: - return - var features: Node = profile.features_scene.instantiate() - feature_host.add_child(features) + if profile.features_scene != null: + var features: Node = profile.features_scene.instantiate() + feature_host.add_child(features) + + var setup: Resource = PLANET_SETUPS.setup_for_planet(GameState.planet_id, profile.profile_id) + if setup != null: + # Untyped on purpose: a static PlanetSetupHost annotation here would + # need the global class_name cache to already know about it, which + # is only true after an editor/import pass has run (see #31/#35's + # load-order notes) - a plain Node reference plus duck-typed + # apply_setup() sidesteps that entirely, same as BuildingDefinitions + # and VesselDefinitions already do for their own resource types. + var setup_host: Node = SETUP_HOST_SCENE.instantiate() + feature_host.add_child(setup_host) + setup_host.apply_setup(setup) func _on_exit_pressed() -> void: diff --git a/tests/_setup_markers_preview.gd b/tests/_setup_markers_preview.gd new file mode 100644 index 0000000..8069d45 --- /dev/null +++ b/tests/_setup_markers_preview.gd @@ -0,0 +1,12 @@ +extends Node2D + + +func _ready() -> void: + GameState.planet_name = "Kepler's Rest" + GameState.planet_id = "chunk:3:7" + GameState.planet_definition_id = "rocky" + GameState.surviving_ship_count = 3 + GameState.rocks = 128 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) diff --git a/tests/_setup_markers_preview.gd.uid b/tests/_setup_markers_preview.gd.uid new file mode 100644 index 0000000..8df3b02 --- /dev/null +++ b/tests/_setup_markers_preview.gd.uid @@ -0,0 +1 @@ +uid://dtpta85omp8cm diff --git a/tests/_setup_markers_preview.tscn b/tests/_setup_markers_preview.tscn new file mode 100644 index 0000000..99ed228 --- /dev/null +++ b/tests/_setup_markers_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_setup_markers_preview.gd" id="1"] + +[node name="SetupMarkersPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_planet_setups.gd b/tests/test_planet_setups.gd new file mode 100644 index 0000000..9a735e7 --- /dev/null +++ b/tests/test_planet_setups.gd @@ -0,0 +1,129 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_setups.gd +# Verifies: +# 1. setup_for_planet is deterministic per planet_id and only returns +# setups matching the requested surface profile +# 2. dropping a new .tres into res://PlanetSetups/ makes it appear via +# PlanetSetups.rescan() with no other code change +# 3. landing on a rocky/gas planet composes a PlanetSetupHost carrying the +# resolved setup, with one marker per capture point + +const SCRATCH_SETUP_PATH: String = "res://PlanetSetups/_test_scratch_setup.tres" + +var game_state: Node +var rocky_setup: Resource +var stage: int = 0 +var elapsed: float = 0.0 +var pending_surface: Node + + +func _initialize() -> void: + # --- 1. determinism and profile filtering --- + rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky") + if rocky_setup == null: + return _fail("no rocky setup resolved for a rocky planet id") + if rocky_setup.surface_profile_id != "rocky": + return _fail("resolved setup does not match the requested profile") + var rocky_setup_again: Resource = PlanetSetups.setup_for_planet("chunk:3:7", "rocky") + if rocky_setup != rocky_setup_again: + return _fail("setup_for_planet was not deterministic for the same planet id") + + var gas_setup: Resource = PlanetSetups.setup_for_planet("chunk:3:7", "gas") + if gas_setup == null: + return _fail("no gas setup resolved for the same planet id under the gas profile") + if gas_setup.surface_profile_id != "gas": + return _fail("gas request returned a mismatched-profile setup") + + for setup in PlanetSetups.setups_for_profile("rocky"): + if setup.surface_profile_id != "rocky": + return _fail("setups_for_profile('rocky') leaked a non-rocky setup") + + # --- 2. dropping a new .tres is picked up by rescan() --- + var before_count: int = PlanetSetups.setups_for_profile("rocky").size() + var scratch: Resource = PlanetSetup.new() + scratch.setup_id = "_test_scratch_setup" + scratch.display_name = "Scratch" + scratch.surface_profile_id = "rocky" + scratch.capture_points = PackedVector2Array([Vector2(100, 100)]) + var save_err: int = ResourceSaver.save(scratch, SCRATCH_SETUP_PATH) + if save_err != OK: + return _fail("could not save the scratch setup for the pickup check (err %d)" % save_err) + PlanetSetups.rescan() + var after_count: int = PlanetSetups.setups_for_profile("rocky").size() + DirAccess.remove_absolute(ProjectSettings.globalize_path(SCRATCH_SETUP_PATH)) + var import_file: String = SCRATCH_SETUP_PATH + ".import" + if FileAccess.file_exists(import_file): + DirAccess.remove_absolute(ProjectSettings.globalize_path(import_file)) + PlanetSetups.rescan() + if after_count != before_count + 1: + return _fail("registry did not pick up a newly saved setup (before=%d after=%d)" % [ + before_count, after_count + ]) + + # --- 3. landing composes a PlanetSetupHost with the resolved setup --- + # Deferred to _process(): a scene instantiated and added to the tree + # inside _initialize() has not had its _ready() chain flushed yet (this + # runs before the engine's first frame), so children added during + # PlanetSurfaceController._ready() are not observable until a frame has + # actually processed - see test_gas_system.gd for the same pattern. + game_state = root.get_node("GameState") + game_state.planet_name = "Setup Test" + game_state.planet_id = "chunk:3:7" + game_state.planet_definition_id = "rocky" + pending_surface = load("res://PlanetSurface.tscn").instantiate() + root.add_child(pending_surface) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.1: + return false + var host: Node = pending_surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost") + if host == null: + pending_surface.free() + return _fail("landing did not compose a PlanetSetupHost") + if host.setup != rocky_setup: + pending_surface.free() + return _fail("host did not receive the deterministically resolved setup") + var marker_count: int = 0 + for child in host.get_children(): + if child is NeonPiece: + marker_count += 1 + if marker_count != rocky_setup.capture_points.size(): + pending_surface.free() + return _fail("host placed %d markers for %d capture points" % [ + marker_count, rocky_setup.capture_points.size() + ]) + pending_surface.free() + + game_state.planet_definition_id = "gas_giant" + pending_surface = load("res://PlanetSurface.tscn").instantiate() + root.add_child(pending_surface) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.1: + return false + var gas_host: Node = pending_surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost") + if gas_host == null or gas_host.setup.surface_profile_id != "gas": + pending_surface.free() + return _fail("gas landing did not compose a matching-profile setup host") + pending_surface.free() + + print("TEST_PLANET_SETUPS: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_PLANET_SETUPS: FAIL (%s)" % message) + if FileAccess.file_exists(SCRATCH_SETUP_PATH): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SCRATCH_SETUP_PATH)) + quit(1) + return true diff --git a/tests/test_planet_setups.gd.uid b/tests/test_planet_setups.gd.uid new file mode 100644 index 0000000..0f9cd41 --- /dev/null +++ b/tests/test_planet_setups.gd.uid @@ -0,0 +1 @@ +uid://d2ekw3y7ipgy2