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.
This commit is contained in:
parent
a1ad6cc844
commit
5326852807
17 changed files with 350 additions and 4 deletions
16
PlanetSetup.gd
Normal file
16
PlanetSetup.gd
Normal file
|
|
@ -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] = []
|
||||
1
PlanetSetup.gd.uid
Normal file
1
PlanetSetup.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d1govxko60n03
|
||||
33
PlanetSetupHost.gd
Normal file
33
PlanetSetupHost.gd
Normal file
|
|
@ -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()
|
||||
1
PlanetSetupHost.gd.uid
Normal file
1
PlanetSetupHost.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ckxmhx54fi5s1
|
||||
6
PlanetSetupHost.tscn
Normal file
6
PlanetSetupHost.tscn
Normal file
|
|
@ -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")
|
||||
7
PlanetSetupSpawn.gd
Normal file
7
PlanetSetupSpawn.gd
Normal file
|
|
@ -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
|
||||
1
PlanetSetupSpawn.gd.uid
Normal file
1
PlanetSetupSpawn.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c8spn4nio735d
|
||||
69
PlanetSetups.gd
Normal file
69
PlanetSetups.gd
Normal file
|
|
@ -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()]
|
||||
1
PlanetSetups.gd.uid
Normal file
1
PlanetSetups.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c5ndxvompklip
|
||||
27
PlanetSetups/gas_default.tres
Normal file
27
PlanetSetups/gas_default.tres
Normal file
|
|
@ -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")])
|
||||
22
PlanetSetups/rocky_default.tres
Normal file
22
PlanetSetups/rocky_default.tres
Normal file
|
|
@ -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")])
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
12
tests/_setup_markers_preview.gd
Normal file
12
tests/_setup_markers_preview.gd
Normal file
|
|
@ -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)
|
||||
1
tests/_setup_markers_preview.gd.uid
Normal file
1
tests/_setup_markers_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dtpta85omp8cm
|
||||
6
tests/_setup_markers_preview.tscn
Normal file
6
tests/_setup_markers_preview.tscn
Normal file
|
|
@ -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")
|
||||
129
tests/test_planet_setups.gd
Normal file
129
tests/test_planet_setups.gd
Normal file
|
|
@ -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
|
||||
1
tests/test_planet_setups.gd.uid
Normal file
1
tests/test_planet_setups.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d2ekw3y7ipgy2
|
||||
Loading…
Reference in a new issue