- Introduced StageGizmos for hover highlighting, selection outlines, and rotation handles in the sandbox stage builder. - Added StageGrid for an optional world-space grid overlay that adjusts with camera panning and zooming. - Implemented StageSelection for geometric hit-testing and selection management of nodes in the sandbox. - Created StageSpawner as a registry-driven factory for spawning terrain, props, and stickmen, allowing for dynamic template management. - Each script includes necessary constants, state management, and public API methods for interaction.
236 lines
8.6 KiB
GDScript
236 lines
8.6 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 STICKMAN_FACTORY := preload("res://scripts/stickman_factory.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 _stickman_data: Dictionary = {}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _init(world: Node2D) -> void:
|
|
_world = world
|
|
_stickman_data = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
|
|
if _stickman_data.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)
|
|
|
|
|
|
## 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)
|
|
_:
|
|
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()
|
|
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": "crate", "label": "Crate", "kind": "prop",
|
|
"payload": PROP_UTILS.create_box(), "preset": PROP_BLOCK.MaterialPreset.WOOD,
|
|
"spawn_offset": Vector2.ZERO,
|
|
},
|
|
{
|
|
"id": "ball", "label": "Ball", "kind": "prop",
|
|
"payload": PROP_UTILS.create_ball(), "preset": PROP_BLOCK.MaterialPreset.RUBBER,
|
|
"spawn_offset": Vector2.ZERO,
|
|
},
|
|
{
|
|
"id": "stickman", "label": "Stickman", "kind": "stickman",
|
|
"spawn_offset": STICKMAN_FOOT_OFFSET,
|
|
},
|
|
]
|
|
|
|
|
|
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
|
|
return block
|
|
|
|
|
|
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
|
|
var payload: Dictionary = entry["payload"]
|
|
var preset: int = int(entry.get("preset", PROP_BLOCK.MaterialPreset.WOOD))
|
|
return PROP_UTILS.spawn_prop(_world, world_position, payload, preset, Vector2.ZERO)
|
|
|
|
|
|
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
|
|
if _stickman_data.is_empty():
|
|
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
|
|
return null
|
|
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(_stickman_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())
|