Add new stage editor scripts for target capture, terrain painting, theme management, rule engine, and spatial indexing

- Implement `StageTargetCaptureSession` for managing target capture sessions with throw and set down positions.
- Create `StageTerrainPainter` for terrain drag painting and overlap classification with stroke preview.
- Introduce `StageThemeController` to handle theme values and resource loading for the Sandbox Stage.
- Develop `StageRuleEngine` for runtime event matching and collision bookkeeping.
- Add `StageSpatialIndex` for broadphase stage lookups with advisory spatial indexing.
This commit is contained in:
2026-10-10 12:13:01 -04:00
parent 1d4a71525a
commit 373f40cf75
37 changed files with 8087 additions and 4243 deletions
+242
View File
@@ -0,0 +1,242 @@
class_name StageRuleEngine
extends RefCounted
## Runtime event matching, area-entry edges, and collision contact bookkeeping.
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STICKMAN_RIG_SCRIPT := preload("res://scripts/characters/stickman/stickman_rig.gd")
const WAYPOINT_MATCH_EPSILON: float = 24.0
var _area_overlap: Dictionary = {}
var _area_previous_bounds: Dictionary = {}
var _collision_pairs: Dictionary = {}
var _suppressed_contact_pairs: Dictionary = {}
func handle_event(event: Dictionary, rules: Array[Dictionary]) -> Array[Dictionary]:
var matched_rules: Array[Dictionary] = []
for rule: Dictionary in rules:
if matches_rule(rule, event):
matched_rules.append(rule)
return matched_rules
func matches_rule(rule: Dictionary, event: Dictionary) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
var rule_type: String = String(trigger.get("type", ""))
if rule_type != String(event.get("type", "")):
return false
var source: Node2D = event.get("source") as Node2D
var target: Node2D = event.get("target") as Node2D
var source_id: int = int(trigger.get("source", -1))
var target_id: int = int(trigger.get("target", -1))
match rule_type:
"arrived_at_waypoint":
if not ids_match(source_id, source):
return false
var params: Dictionary = trigger.get("params", {})
var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF)
var position: Vector2 = event.get("position", Vector2.INF)
return position.distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON
"action_finished":
if not ids_match(source_id, source):
return false
var params: Dictionary = trigger.get("params", {})
var wanted_type: String = String(params.get("action_type", ""))
var action: Dictionary = event.get("action", {})
return wanted_type.is_empty() or ACTION_REGISTRY.canonical_type(wanted_type) == ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
"speech_finished":
return ids_match(source_id, source)
"entered_area", "collided", "prop_picked_up", "prop_dropped", "prop_thrown":
return ids_match(source_id, source) and ids_match(target_id, target)
_:
return false
func ids_match(expected_id: int, node: Node2D) -> bool:
return expected_id == -1 or (node != null and is_instance_valid(node) and node.get_instance_id() == expected_id)
func physics_tick(world_children: Array[Node2D]) -> Array[Dictionary]:
var areas: Array[TriggerArea] = []
for node: Node2D in world_children:
if node is TriggerArea:
areas.append(node as TriggerArea)
if areas.is_empty():
return []
var movables: Array[Node2D] = []
for node: Node2D in world_children:
if node is STICKMAN_RIG_SCRIPT:
var rig: StickmanRig = node as StickmanRig
if rig.state == StickmanRig.RigState.ANIMATED:
movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
movables.append(node)
var swept_bounds: Dictionary = {}
var live_ids: Dictionary = {}
for node: Node2D in movables:
var node_id: int = node.get_instance_id()
var current_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
var swept: Rect2 = current_bounds
if _area_previous_bounds.has(node_id):
swept = swept.merge((_area_previous_bounds[node_id] as Rect2).abs())
swept_bounds[node_id] = swept
_area_previous_bounds[node_id] = current_bounds
live_ids[node_id] = true
for old_id: int in _area_previous_bounds.keys().duplicate():
if not live_ids.has(old_id):
_area_previous_bounds.erase(old_id)
var events: Array[Dictionary] = []
for area: TriggerArea in areas:
var area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables:
var key: String = "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var swept: Rect2 = swept_bounds.get(node.get_instance_id(), Rect2()) as Rect2
var inside: bool = swept.intersects(area_rect, true)
var was_inside: bool = bool(_area_overlap.get(key, false))
if inside and not was_inside:
_area_overlap[key] = true
events.append({"type": "entered_area", "source": node, "target": area, "position": movable_point(node), "action": {}})
elif not inside and was_inside:
_area_overlap[key] = false
return events
func refresh_event_edge_baseline(world_children: Array[Node2D]) -> void:
var areas: Array[TriggerArea] = []
for node: Node2D in world_children:
if node is TriggerArea:
areas.append(node as TriggerArea)
var movables: Array[Node2D] = []
for node: Node2D in world_children:
if node is STICKMAN_RIG_SCRIPT and (node as StickmanRig).state == StickmanRig.RigState.ANIMATED:
movables.append(node)
elif node is PropBlock:
movables.append(node)
_area_overlap.clear()
_area_previous_bounds.clear()
for node: Node2D in movables:
var node_id: int = node.get_instance_id()
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
_area_previous_bounds[node_id] = bounds
for area: TriggerArea in areas:
var key: String = "%d:%d" % [area.get_instance_id(), node_id]
var area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
_area_overlap[key] = bounds.intersects(area_rect, true)
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
for node: Node2D in world_children:
if not node is PropBlock:
continue
var prop: PropBlock = node as PropBlock
for other: Node in prop.get_colliding_bodies():
if other == null or not is_instance_valid(other):
continue
_suppressed_contact_pairs[contact_key(prop, other)] = true
if not other.has_meta("stickman_rig"):
continue
var rig: StickmanRig = other.get_meta("stickman_rig") as StickmanRig
if rig == null or not is_instance_valid(rig):
continue
var key: String = collision_key(rig, prop)
var pair: Dictionary = _collision_pairs.get(key, {"rig_id": rig.get_instance_id(), "prop_id": prop.get_instance_id(), "bodies": {}})
var body_ids: Dictionary = pair.get("bodies", {})
body_ids[other.get_instance_id()] = true
pair["bodies"] = body_ids
_collision_pairs[key] = pair
func register_contact(prop: PropBlock, other: Node) -> bool:
var key: String = contact_key(prop, other)
if _suppressed_contact_pairs.has(key):
return false
_suppressed_contact_pairs[key] = true
return true
func register_rig_body_contact(rig: StickmanRig, prop: PropBlock, body: Node) -> Dictionary:
var key: String = collision_key(rig, prop)
var pair: Dictionary = _collision_pairs.get(key, {
"rig_id": rig.get_instance_id(),
"prop_id": prop.get_instance_id(),
"bodies": {},
})
var bodies: Dictionary = pair.get("bodies", {})
var body_id: int = body.get_instance_id()
if bodies.has(body_id):
return {"accepted": false, "first_contact": false}
var first_contact: bool = bodies.is_empty()
bodies[body_id] = true
pair["bodies"] = bodies
_collision_pairs[key] = pair
return {"accepted": true, "first_contact": first_contact}
func unregister_contact(prop: PropBlock, other: Node) -> void:
_suppressed_contact_pairs.erase(contact_key(prop, other))
func unregister_rig_body_contact(rig: StickmanRig, prop: PropBlock, body: Node) -> void:
var key: String = collision_key(rig, prop)
var pair: Dictionary = _collision_pairs.get(key, {})
if pair.is_empty():
return
var bodies: Dictionary = pair.get("bodies", {})
bodies.erase(body.get_instance_id())
if bodies.is_empty():
_collision_pairs.erase(key)
else:
pair["bodies"] = bodies
_collision_pairs[key] = pair
func purge_collision_pairs_for_ids(ids: Array[int]) -> void:
for key: String in _collision_pairs.keys().duplicate():
var pair: Dictionary = _collision_pairs[key]
if ids.has(int(pair.get("rig_id", -1))) or ids.has(int(pair.get("prop_id", -1))):
_collision_pairs.erase(key)
func reset_runtime_state() -> void:
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
func movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG_SCRIPT:
return node.global_position - StickmanLocomotion.FOOT_OFFSET
return node.global_position
func movable_overlaps_area(node: Node2D, area_rect: Rect2) -> bool:
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
if bounds.size.is_zero_approx():
return area_rect.has_point(movable_point(node))
return area_rect.intersects(bounds, true)
func get_area_overlap() -> Dictionary:
return _area_overlap
func get_area_previous_bounds() -> Dictionary:
return _area_previous_bounds
func get_collision_pairs() -> Dictionary:
return _collision_pairs
func get_suppressed_contact_pairs() -> Dictionary:
return _suppressed_contact_pairs
func contact_key(prop: PropBlock, other: Node) -> String:
return "%d:%d" % [prop.get_instance_id(), other.get_instance_id()]
func collision_key(rig: StickmanRig, prop: PropBlock) -> String:
return "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
+1
View File
@@ -0,0 +1 @@
uid://cbdfvuscs7b47
+82
View File
@@ -0,0 +1,82 @@
class_name StageSpatialIndex
extends RefCounted
## Advisory spatial index for broadphase stage lookups.
##
## This index never decides collision or selection. It keeps the existing
## 16-pixel AABB rasterization, including its treatment of edge cells.
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
var _cells: Dictionary = {}
func rebuild(nodes: Array[Node2D]) -> void:
clear()
for node: Node2D in nodes:
add(node)
func add(node: Node2D) -> void:
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
return
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(node)
for cell: Vector2i in rasterize_aabb_to_cells(aabb):
var cell_nodes: Array = _cells.get(cell, [])
if not cell_nodes.has(node):
cell_nodes.append(node)
_cells[cell] = cell_nodes
func clear() -> void:
_cells.clear()
func candidates_for_aabb(aabb: Rect2) -> Array[Node2D]:
var candidates: Array[Node2D] = []
var seen: Dictionary = {}
for cell: Vector2i in rasterize_aabb_to_cells(aabb):
for value: Variant in _cells.get(cell, []):
var node: Node2D = value as Node2D
if node == null or not is_instance_valid(node):
continue
var instance_id: int = node.get_instance_id()
if seen.has(instance_id):
continue
seen[instance_id] = true
candidates.append(node)
return candidates
func candidates_at(world_pos: Vector2) -> Array[Node2D]:
var candidates: Array[Node2D] = []
var cell: Vector2i = world_to_cell(world_pos)
for value: Variant in _cells.get(cell, []):
var node: Node2D = value as Node2D
if node != null and is_instance_valid(node):
candidates.append(node)
return candidates
func get_cells() -> Dictionary:
return _cells
static func world_to_cell(pos: Vector2) -> Vector2i:
var cell_size: float = STAGE_SPAWNER.TERRAIN_GRID_SIZE
return Vector2i(int(roundf(pos.x / cell_size)), int(roundf(pos.y / cell_size)))
static func rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]:
var cells: Array[Vector2i] = []
if aabb.size.x <= 0.0 or aabb.size.y <= 0.0:
return cells
var cell_size: float = STAGE_SPAWNER.TERRAIN_GRID_SIZE
var min_x: int = int(floorf(aabb.position.x / cell_size))
var max_x: int = int(floorf((aabb.end.x - 0.0001) / cell_size))
var min_y: int = int(floorf(aabb.position.y / cell_size))
var max_y: int = int(floorf((aabb.end.y - 0.0001) / cell_size))
for y: int in range(min_y, max_y + 1):
for x: int in range(min_x, max_x + 1):
cells.append(Vector2i(x, y))
return cells
+1
View File
@@ -0,0 +1 @@
uid://b6wxhc8fh5o15