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