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