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())