Files
stickman/scripts/stage_spawner.gd
T
ryan 1f91f3d2e5 Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
2026-09-04 15:08:08 -04:00

299 lines
11 KiB
GDScript

class_name StageSpawner
extends RefCounted
## StageSpawner - Registry-driven factory for the Sandbox Stage Builder (Phase 2).
##
## A spawn registry maps an id to a terrain/prop/stickman template. Adding a new
## spawnable type only requires appending a registry entry - no hard-coded match
## statements on ids. Reuses TerrainUtils, PropUtils and StickmanFactory.
# ---------------------------------------------------------------------------
# Preloaded dependencies (resolved directly, independent of the global class
# registry, so this script compiles even when the editor's class cache is stale)
# ---------------------------------------------------------------------------
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Default stickman asset (the only complete, upright figure).
const DEFAULT_STICKMAN_PATH := "res://stickmen/test.stk"
## The rig's feet rest ~385 px below its root (hips), so placing the root 385 px
## above the cursor puts the feet on it.
const STICKMAN_FOOT_OFFSET := Vector2(0.0, -385.0)
const TERRAIN_GRID_SIZE: float = 16.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _world: Node2D
var _registry: Array[Dictionary] = []
var selected_stickman_path: String = DEFAULT_STICKMAN_PATH
var selected_prop_id: String = "crate"
var _stickman_cache: Dictionary = {}
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _init(world: Node2D) -> void:
_world = world
_stickman_cache[DEFAULT_STICKMAN_PATH] = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
if (_stickman_cache[DEFAULT_STICKMAN_PATH] as Dictionary).is_empty():
push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH)
_build_registry()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func get_spawnable_ids() -> Array[String]:
var ids: Array[String] = []
for entry: Dictionary in _registry:
ids.append(String(entry["id"]))
return ids
func get_label(id: String) -> String:
var entry := _find_entry(id)
return String(entry.get("label", id)) if not entry.is_empty() else id
func get_spawn_offset(id: String) -> Vector2:
var entry := _find_entry(id)
if entry.is_empty():
return Vector2.ZERO
return entry.get("spawn_offset", Vector2.ZERO)
func get_selected_stickman_path() -> String:
return selected_stickman_path
func get_selected_prop_id() -> String:
return selected_prop_id
## True when `id` names a terrain template (ground/ramp/step).
func is_terrain_id(id: String) -> bool:
var entry := _find_entry(id)
return String(entry.get("kind", "")) == "terrain"
## Local-space AABB of a terrain template's centered polygon (before placement),
## used to size ghosts and rasterize the terrain-painting occupancy cells.
## The AABB is computed over the SAME sanitized geometry that `_spawn_terrain`
## actually places (TerrainUtils.sanitize_points at TERRAIN_GRID_SIZE), so the
## D1 paint stride matches the real block footprint (e.g. the 200px-wide Ground
## template sanitizes to a 192px footprint and therefore a 192px stride, which
## makes horizontal runs tile edge-to-edge with no gaps).
func get_template_aabb(id: String) -> Rect2:
var entry := _find_entry(id)
if entry.is_empty() or String(entry.get("kind", "")) != "terrain":
return Rect2()
var template: PackedVector2Array = entry["points"]
if template.is_empty():
return Rect2()
var center := _points_center(template)
var centered := PackedVector2Array()
for p: Vector2 in template:
centered.append(p - center)
var cleaned := TERRAIN_UTILS.sanitize_points(centered, TERRAIN_GRID_SIZE)
if cleaned.is_empty():
return Rect2()
var rect := Rect2(cleaned[0], Vector2.ZERO)
for p: Vector2 in cleaned:
rect = rect.expand(p)
return rect
## Spawn the registry type at `world_position`; returns null + push_warning on
## an unknown id.
func spawn(id: String, world_position: Vector2) -> Node2D:
var entry := _find_entry(id)
if entry.is_empty():
push_warning("StageSpawner: unknown spawn id '%s'." % id)
return null
var offset: Vector2 = entry.get("spawn_offset", Vector2.ZERO)
var pos := world_position + offset
match String(entry.get("kind", "")):
"terrain":
return _spawn_terrain(entry, pos)
"prop":
return _spawn_prop(entry, pos)
"stickman":
return _spawn_stickman(pos)
"area":
return _spawn_area(pos)
_:
push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", ""))
return null
## World-space AABB for a spawnable node: union of the Polygon2D child's world
## points; for a stickman rig, union of its mounted `Body/*` shape geometry; a
## point rect otherwise.
static func get_world_aabb(node: Node2D) -> Rect2:
if node == null or not is_instance_valid(node):
return Rect2()
# Duck-typed TriggerArea: expose its centered local rect through the transform.
if node.has_method("get_area_rect"):
return node.global_transform * (node.call("get_area_rect") as Rect2)
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if poly != null and not poly.polygon.is_empty():
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
for p: Vector2 in poly.polygon:
rect = rect.expand(node.to_global(p))
return rect
var body := node.get_node_or_null(NodePath("Body")) as Node2D
if body != null:
var acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
_collect_visual_points(body, acc)
if acc["min_x"] <= acc["max_x"]:
return Rect2(Vector2(acc["min_x"], acc["min_y"]), Vector2(acc["max_x"] - acc["min_x"], acc["max_y"] - acc["min_y"]))
if node.get_node_or_null(NodePath("Skeleton2D")) != null:
return node.global_transform * Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
return Rect2(node.global_position, Vector2.ZERO)
static func _collect_visual_points(node: Node, acc: Dictionary) -> void:
if node is Line2D:
for p: Vector2 in (node as Line2D).points:
_accumulate((node as Line2D).to_global(p), acc)
elif node is Polygon2D:
for p: Vector2 in (node as Polygon2D).polygon:
_accumulate((node as Polygon2D).to_global(p), acc)
for child: Node in node.get_children():
_collect_visual_points(child, acc)
static func _accumulate(p: Vector2, acc: Dictionary) -> void:
acc["min_x"] = minf(acc["min_x"], p.x)
acc["min_y"] = minf(acc["min_y"], p.y)
acc["max_x"] = maxf(acc["max_x"], p.x)
acc["max_y"] = maxf(acc["max_y"], p.y)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
func _build_registry() -> void:
_registry = [
{
"id": "ground", "label": "Ground", "kind": "terrain",
"points": PackedVector2Array([Vector2(-100, -16), Vector2(100, -16), Vector2(100, 16), Vector2(-100, 16)]),
"fill": Color(0.25, 0.55, 0.25), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
"spawn_offset": Vector2.ZERO,
},
{
"id": "ramp", "label": "Ramp", "kind": "terrain",
"points": PackedVector2Array([Vector2(-96, 32), Vector2(96, -96), Vector2(96, -32), Vector2(-96, 96)]),
"fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
"spawn_offset": Vector2.ZERO,
},
{
"id": "step", "label": "Step", "kind": "terrain",
"points": PackedVector2Array([
Vector2(-128, 128), Vector2(128, 128), Vector2(128, -128), Vector2(64, -128),
Vector2(64, -64), Vector2(0, -64), Vector2(0, 0), Vector2(-64, 0),
Vector2(-64, 64), Vector2(-128, 64),
]),
"fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
"spawn_offset": Vector2.ZERO,
},
{
"id": "prop", "label": "Prop", "kind": "prop",
"spawn_offset": Vector2.ZERO,
},
{
"id": "stickman", "label": "Stickman", "kind": "stickman",
"spawn_offset": STICKMAN_FOOT_OFFSET,
},
{
"id": "area", "label": "Area", "kind": "area",
"spawn_offset": Vector2.ZERO,
},
]
func _find_entry(id: String) -> Dictionary:
for entry: Dictionary in _registry:
if String(entry.get("id", "")) == id:
return entry
return {}
# ---------------------------------------------------------------------------
# Spawn helpers
# ---------------------------------------------------------------------------
## Center the terrain template on its local origin so the block rotates about
## its own center, then place the block at the cursor.
func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
var template: PackedVector2Array = entry["points"]
var center := _points_center(template)
var centered := PackedVector2Array()
for p: Vector2 in template:
centered.append(p - center)
var block: TerrainBlock = TERRAIN_UTILS.spawn_block(
_world,
centered,
TERRAIN_GRID_SIZE,
entry.get("fill", TERRAIN_UTILS.DEFAULT_FILL_COLOR),
entry.get("outline", TERRAIN_UTILS.DEFAULT_OUTLINE_COLOR),
float(entry.get("width", 2.0))
)
block.position = world_position
block.spawn_id = String(entry.get("id", ""))
return block
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
var t: Dictionary = PROP_LIBRARY.get_entry(selected_prop_id)
if t.is_empty():
push_warning("StageSpawner: unknown selected prop '%s'." % selected_prop_id)
return null
return PROP_UTILS.spawn_prop(_world, world_position, t["payload"], int(t["material_preset"]), Vector2.ZERO)
func _spawn_area(world_position: Vector2) -> Node2D:
var area: Node2D = TRIGGER_AREA.new()
area.name = "TriggerArea"
area.position = world_position
_world.add_child(area)
return area
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
var data: Dictionary = _stickman_cache.get(selected_stickman_path, {})
if data.is_empty():
data = STICKMAN_FACTORY.load_stk(selected_stickman_path)
_stickman_cache[selected_stickman_path] = data
if data.is_empty():
push_warning("StageSpawner: no stickman data for '%s'." % selected_stickman_path)
return null
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
if rig == null:
push_warning("StageSpawner: failed to spawn stickman.")
return null
rig.position = world_position
_world.add_child(rig)
return rig
static func _points_center(pts: PackedVector2Array) -> Vector2:
if pts.is_empty():
return Vector2.ZERO
var sum := Vector2.ZERO
for p: Vector2 in pts:
sum += p
return sum / float(pts.size())