- 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.
299 lines
11 KiB
GDScript
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())
|