feat: Implement Phase 4 Trigger Events System

- Added a new event-driven system for reactive storytelling, allowing rules like "When X happens, do Y."
- Introduced TriggerArea class for placeable sensors in the stage.
- Enhanced StickmanRig to emit signals for actions and arrivals.
- Updated StageDirectorVisuals to render rules visually with labels and badges.
- Modified StageSpawner to support spawning TriggerAreas.
- Improved text baseline calculations in speech bubbles and rule labels.
- Added tests for text baseline fixes to ensure proper rendering.
- Documented the implementation plan for Phase 4 in PHASE_4_TRIGGER_EVENTS.md.
- Created a polish plan for Phase 4 in PHASE_4b_POLISH.md.
This commit is contained in:
2026-09-01 08:58:07 -04:00
parent bf11a5fab5
commit f208127917
17 changed files with 1538 additions and 8 deletions
+17
View File
@@ -88,6 +88,14 @@ var _collision_polygon: CollisionPolygon2D
var _collision_shape: CollisionShape2D
var _circle_shape: CircleShape2D
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when this prop's body makes contact with another physics body.
## `other` is the body that entered contact (Phase 4 trigger source).
signal collided(other: Node)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -96,6 +104,15 @@ func _ready() -> void:
_ensure_children()
_apply_shape()
_apply_style()
# Enable contact reporting so the body_entered signal fires (Phase 4).
contact_monitor = true
max_contacts_reported = 8
if not body_entered.is_connected(_on_body_entered):
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node) -> void:
collided.emit(body)
# ---------------------------------------------------------------------------
# Internal build / apply
+622 -4
View File
@@ -26,6 +26,9 @@ const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd
enum StageMode { EDIT, PLAY }
## Phase 4 rule-builder state machine steps.
enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
@@ -56,6 +59,28 @@ const ACT_WAIT := 2
const ACT_RAGDOLL := 3
const ACT_RECOVER := 4
## Phase 4 rule-builder item ids. ACT_WHEN appends to the action popup; the
## TRIG_* ids drive the trigger sub-menu.
const ACT_WHEN := 5
const TRIG_ARRIVED := 0
const TRIG_ACTION_FINISHED := 1
const TRIG_SPEECH_FINISHED := 2
const TRIG_ENTERED_AREA := 3
const TRIG_COLLIDED := 4
const TRIG_BACK := 5
## Phase 4 "add another / done" popup item ids.
const RULE_MORE_ADD := 0
const RULE_MORE_DONE := 1
## Phase 4 "done" item id for the rule-action popup (edit mode only). Distinct
## from ACT_WALK..ACT_RECOVER (0..4) so it never collides with an action id.
const RULE_ACTION_DONE := 6
## Max distance (px) between an arrival event position and a rule's waypoint for
## the arrived_at_waypoint trigger to match.
const WAYPOINT_MATCH_EPSILON := 24.0
## Debug gate for the Phase 3a stage trace. Ship OFF.
const DEBUG_STAGE := false
@@ -139,6 +164,35 @@ var _director_visuals = null # StageDirectorVisuals (preloaded)
var _nav_region: NavigationRegion2D = null
var _nav_dirty: bool = true
# ---------------------------------------------------------------------------
# Rule / event system state (Phase 4)
# ---------------------------------------------------------------------------
## Stored "When X -> do Y" rules. Persist across mode toggles; NOT saved to disk.
var _event_rules: Array[Dictionary] = []
var _next_rule_id: int = 0
## Rule-builder state machine.
var _rule_step: RuleStep = RuleStep.IDLE
var _rule_builder: Dictionary = {}
var _rule_context_rig: StickmanRig = null
var _rule_hint: String = ""
var _rule_editing_id: int = -1
## Rule-builder popups (built in _build_ui).
var _trigger_popup: PopupMenu = null
var _rule_action_popup: PopupMenu = null
var _rule_more_popup: PopupMenu = null
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
## and "<rig_id>:<prop_id>".
var _area_overlap: Dictionary = {}
var _collision_pairs: Dictionary = {}
## Lightweight toast text + countdown (cleared in _process on expiry).
var _toast_text: String = ""
var _toast_timer: float = 0.0
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -162,18 +216,26 @@ func _ready() -> void:
_refresh_status()
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
if _nav_dirty:
_nav_dirty = false
_rebake_navigation()
if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT:
_update_ghost_position()
if _toast_timer > 0.0:
_toast_timer -= delta
if _toast_timer <= 0.0:
_toast_text = ""
_refresh_status()
func _physics_process(_delta: float) -> void:
if _restore_frames_left > 0:
_restore_frames_left -= 1
_restore_authored_state()
if current_mode == StageMode.PLAY:
_update_area_entry()
_update_stickman_prop_collision()
# ---------------------------------------------------------------------------
# Input
@@ -205,7 +267,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
if current_mode == StageMode.EDIT:
delete_selected()
KEY_ESCAPE:
if _pending_walk_target:
if _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_refresh_status()
@@ -237,6 +301,19 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
if current_mode != StageMode.EDIT:
return
var world_pos := _camera.get_global_mouse_position()
# Rule-builder click routing is highest priority (Phase 4).
if _rule_step != RuleStep.IDLE:
if mb.pressed:
_handle_rule_click(world_pos)
return
# Rule label / delete icon hit-testing, before gizmo/placement/selection.
var rule_hit: Dictionary = _director_visuals.hit_test_rule(world_pos)
if not rule_hit.is_empty() and mb.pressed:
if rule_hit["part"] == "delete":
_delete_rule(int(rule_hit["id"]))
else:
_begin_edit_rule(int(rule_hit["id"]))
return
if _direct_mode:
if mb.pressed:
_handle_direct_click(world_pos)
@@ -311,6 +388,9 @@ func _enter_edit_mode() -> void:
_director_visuals.set_enabled(true)
_apply_grid_settings()
_set_build_controls_visible(true)
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
_area_overlap.clear()
_collision_pairs.clear()
func _enter_play_mode() -> void:
@@ -327,6 +407,9 @@ func _enter_play_mode() -> void:
_director_visuals.set_enabled(false)
_apply_grid_settings()
_set_build_controls_visible(false)
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
_area_overlap.clear()
_collision_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
(node as RigidBody2D).freeze = false
@@ -395,7 +478,13 @@ func _place_at(world_pos: Vector2) -> void:
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.queue_changed.connect(_director_visuals.mark_dirty)
rig.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
prop.collided.connect(_on_prop_collided.bind(prop))
if node is TerrainBlock:
_nav_dirty = true
object_placed.emit(node)
@@ -503,6 +592,7 @@ func delete_selected() -> void:
nav_changed = true
_clear_object_state(node)
node.queue_free()
_cleanup_rules_for_nodes(selected)
_selection.clear_selection()
if nav_changed:
_nav_dirty = true
@@ -527,9 +617,14 @@ func _refresh_status() -> void:
sel_text = String(sel[0].name)
else:
sel_text = "%d objects" % sel.size()
_status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
var text := "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
if _pending_walk_target:
_status_label.text += " | Click stage for walk target (Esc to cancel)"
text += " | Click stage for walk target (Esc to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
text += " | " + _rule_hint
if not _toast_text.is_empty():
text = _toast_text + " | " + text
_status_label.text = text
if _mode_button != null:
_mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY)
_mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit"
@@ -604,9 +699,37 @@ func _build_ui() -> void:
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_action_popup.add_separator()
_action_popup.add_item("⚡ When...", ACT_WHEN)
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
ui.add_child(_action_popup)
_trigger_popup = PopupMenu.new()
_trigger_popup.add_item("📍 Arrives at a waypoint", TRIG_ARRIVED)
_trigger_popup.add_item("✅ Completes any action", TRIG_ACTION_FINISHED)
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
ui.add_child(_trigger_popup)
_rule_action_popup = PopupMenu.new()
_rule_action_popup.add_item("🚶 Walk To", ACT_WALK)
_rule_action_popup.add_item("💬 Speak", ACT_SPEAK)
_rule_action_popup.add_item("⏳ Wait", ACT_WAIT)
_rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_rule_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_rule_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
ui.add_child(_rule_action_popup)
_rule_more_popup = PopupMenu.new()
_rule_more_popup.add_item(" Add another action", RULE_MORE_ADD)
_rule_more_popup.add_item("✅ Done", RULE_MORE_DONE)
_rule_more_popup.id_pressed.connect(_on_rule_more_id_pressed)
ui.add_child(_rule_more_popup)
_speak_dialog = AcceptDialog.new()
_speak_dialog.title = "Speak"
_speak_dialog.confirmed.connect(_on_speak_confirmed)
@@ -802,19 +925,514 @@ func _on_action_popup_id_pressed(id: int) -> void:
_context_rig.queue_action({ "type": "ragdoll" })
ACT_RECOVER:
_context_rig.queue_action({ "type": "recover" })
ACT_WHEN:
_rule_builder = {
"trigger": { "source": _context_rig.get_instance_id(), "target": -1, "params": {} },
"actions": [],
}
_rule_editing_id = -1
_rule_context_rig = _context_rig
_rule_step = RuleStep.SELECT_TRIGGER
_trigger_popup.popup(_mouse_popup_rect())
func _on_speak_confirmed() -> void:
if _rule_step == RuleStep.PARAMS:
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0 })
_open_rule_more_popup()
return
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
func _on_wait_confirmed() -> void:
if _rule_step == RuleStep.PARAMS:
_set_rule_action_params({ "duration": _wait_spin.value })
_open_rule_more_popup()
return
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
# ---------------------------------------------------------------------------
# Rule / event system (Phase 4)
# ---------------------------------------------------------------------------
## Popup rect at the current mouse position (used by every rule popup).
func _mouse_popup_rect() -> Rect2i:
var mouse := get_viewport().get_mouse_position()
return Rect2i(Vector2i(mouse), Vector2i.ZERO)
func _open_rule_action_popup() -> void:
_sync_rule_action_done_item()
_rule_action_popup.popup(_mouse_popup_rect())
## Shows the "✅ Done" item in the rule-action popup only while editing an
## existing rule (otherwise there is no way to save without adding an action).
func _sync_rule_action_done_item() -> void:
var idx: int = _rule_action_popup.get_item_index(RULE_ACTION_DONE)
var editing: bool = _rule_editing_id >= 0
if editing and idx < 0:
_rule_action_popup.add_item("✅ Done", RULE_ACTION_DONE)
elif not editing and idx >= 0:
_rule_action_popup.remove_item(idx)
func _open_rule_more_popup() -> void:
_rule_more_popup.popup(_mouse_popup_rect())
func _on_trigger_popup_id_pressed(id: int) -> void:
if id == TRIG_BACK:
_cancel_rule_build()
return
var trigger: Dictionary = _rule_builder.get("trigger", {})
trigger["type"] = _trigger_type_string(id)
_rule_builder["trigger"] = trigger
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED:
_rule_step = RuleStep.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
_rule_hint = "Click the waypoint to trigger on (Esc to cancel)"
TRIG_ENTERED_AREA:
_rule_hint = "Click the trigger area to watch (Esc to cancel)"
TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
func _on_rule_action_popup_id_pressed(id: int) -> void:
if id == RULE_ACTION_DONE:
_finalize_rule()
return
var action := { "type": _action_type_string(id), "target": -1, "params": {} }
var actions: Array = _rule_builder.get("actions", [])
actions.append(action)
_rule_builder["actions"] = actions
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will %s (Esc to cancel)" % _action_verb(id)
_refresh_status()
func _on_rule_more_id_pressed(id: int) -> void:
match id:
RULE_MORE_ADD:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
RULE_MORE_DONE:
_finalize_rule()
func _trigger_type_string(id: int) -> String:
match id:
TRIG_ARRIVED:
return "arrived_at_waypoint"
TRIG_ACTION_FINISHED:
return "action_finished"
TRIG_SPEECH_FINISHED:
return "speech_finished"
TRIG_ENTERED_AREA:
return "entered_area"
TRIG_COLLIDED:
return "collided"
_:
return ""
func _action_type_string(id: int) -> String:
match id:
ACT_WALK:
return "walk_to"
ACT_SPEAK:
return "speak"
ACT_WAIT:
return "wait"
ACT_RAGDOLL:
return "ragdoll"
ACT_RECOVER:
return "recover"
_:
return ""
func _action_verb(id: int) -> String:
match id:
ACT_WALK:
return "walk"
ACT_SPEAK:
return "speak"
ACT_WAIT:
return "wait"
ACT_RAGDOLL:
return "ragdoll"
ACT_RECOVER:
return "recover"
_:
return "act"
func _handle_rule_click(world_pos: Vector2) -> void:
match _rule_step:
RuleStep.TRIGGER_TARGET:
_handle_trigger_target_click(world_pos)
RuleStep.ACTION_TARGET:
_handle_action_target_click(world_pos)
RuleStep.ACTION_POSITION:
_handle_action_position_click(world_pos)
func _handle_trigger_target_click(world_pos: Vector2) -> void:
var trigger: Dictionary = _rule_builder.get("trigger", {})
match String(trigger.get("type", "")):
"arrived_at_waypoint":
var wp: Vector2 = _director_visuals.hit_test_waypoint(world_pos)
if wp.is_finite():
trigger["params"] = { "waypoint_pos": wp }
trigger["target"] = -1
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a waypoint dot (Esc to cancel)"
_refresh_status()
"entered_area":
var hit := _selection.hit_test(world_pos)
if hit is TriggerArea:
trigger["target"] = hit.get_instance_id()
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a trigger area (Esc to cancel)"
_refresh_status()
"collided":
var hit2 := _selection.hit_test(world_pos)
if hit2 is PropBlock:
trigger["target"] = hit2.get_instance_id()
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
_refresh_status()
func _handle_action_target_click(world_pos: Vector2) -> void:
var hit := _selection.hit_test(world_pos)
if not (hit is STICKMAN_RIG):
_refresh_status()
return
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
action["target"] = (hit as StickmanRig).get_instance_id()
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
match String(action.get("type", "")):
"walk_to":
_rule_step = RuleStep.ACTION_POSITION
_rule_hint = "Click where %s should walk (Esc to cancel)" % String(hit.name)
_refresh_status()
"speak":
_rule_step = RuleStep.PARAMS
_speak_edit.text = ""
_speak_dialog.popup_centered()
_speak_edit.grab_focus()
"wait":
_rule_step = RuleStep.PARAMS
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
_:
# ragdoll / recover need no params.
_open_rule_more_popup()
func _handle_action_position_click(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
var params: Dictionary = action.get("params", {})
params["target"] = world_pos
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
## Writes dialog-confirmed params onto the last action in the builder.
func _set_rule_action_params(params: Dictionary) -> void:
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
func _finalize_rule() -> void:
var rule: Dictionary = _rule_builder.duplicate(true)
if _rule_editing_id >= 0:
# Replace the existing rule in place (same index).
var idx := -1
for i: int in _event_rules.size():
if int(_event_rules[i].get("id", -1)) == _rule_editing_id:
idx = i
break
if idx >= 0:
rule["id"] = _rule_editing_id
_event_rules[idx] = rule
_show_toast("Rule updated!")
else:
rule["id"] = _next_rule_id
_next_rule_id += 1
_event_rules.append(rule)
_show_toast("Rule created!")
else:
rule["id"] = _next_rule_id
_next_rule_id += 1
_event_rules.append(rule)
_show_toast("Rule created!")
_director_visuals.set_rules(_event_rules)
_reset_rule_builder()
_refresh_status()
func _cancel_rule_build() -> void:
_reset_rule_builder()
if _trigger_popup != null:
_trigger_popup.hide()
if _rule_action_popup != null:
_rule_action_popup.hide()
if _rule_more_popup != null:
_rule_more_popup.hide()
_refresh_status()
func _reset_rule_builder() -> void:
_rule_step = RuleStep.IDLE
_rule_builder = {}
_rule_context_rig = null
_rule_hint = ""
_rule_editing_id = -1
func _delete_rule(id: int) -> void:
var before := _event_rules.size()
_event_rules = _event_rules.filter(func(r): return int(r.get("id", -1)) != id)
if _event_rules.size() != before:
_director_visuals.set_rules(_event_rules)
_refresh_status()
## Re-enters the builder at SELECT_ACTION pre-populated from the stored rule
## (trigger edits are delete + rebuild, per the approved design).
func _begin_edit_rule(id: int) -> void:
for rule: Dictionary in _event_rules:
if int(rule.get("id", -1)) != id:
continue
var trigger: Dictionary = (rule.get("trigger", {}) as Dictionary).duplicate(true)
var actions: Array = []
for a: Dictionary in rule.get("actions", []):
actions.append(a.duplicate(true))
_rule_builder = { "trigger": trigger, "actions": actions }
_rule_editing_id = id
var source_id := int(trigger.get("source", -1))
if source_id >= 0:
var src := instance_from_id(source_id)
if src is StickmanRig:
_rule_context_rig = src
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
return
## True when any of trigger.source / trigger.target / an action.target is in ids.
func _rule_references_any(rule: Dictionary, ids: Array[int]) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
if ids.has(int(trigger.get("source", -1))):
return true
if ids.has(int(trigger.get("target", -1))):
return true
for a: Dictionary in rule.get("actions", []):
if ids.has(int(a.get("target", -1))):
return true
return false
func _cleanup_rules_for_nodes(nodes: Array[Node2D]) -> void:
var ids: Array[int] = []
for n: Node2D in nodes:
ids.append(n.get_instance_id())
_event_rules = _event_rules.filter(func(r): return not _rule_references_any(r, ids))
_director_visuals.set_rules(_event_rules)
# ---------------------------------------------------------------------------
# Event engine (Phase 4)
# ---------------------------------------------------------------------------
func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
_handle_event({ "type": "arrived_at_waypoint", "source": rig, "target": null, "position": target, "action": {} })
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
func _on_rig_speech_finished(rig: StickmanRig) -> void:
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
func _on_prop_collided(other: Node, prop: PropBlock) -> void:
_handle_event({ "type": "collided", "source": prop, "target": other, "position": prop.global_position, "action": {} })
## Iterates every rule in order; every matching rule executes (no short-circuit).
func _handle_event(event: Dictionary) -> void:
for rule: Dictionary in _event_rules:
if _rule_matches(rule, event):
_execute_rule_actions(rule)
func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
var rule_type := String(trigger.get("type", ""))
if rule_type != String(event.get("type", "")):
return false
var source := event.get("source") as Node2D
var target := event.get("target") as Node2D
var source_id := int(trigger.get("source", -1))
var target_id := 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)
return (event.get("position", Vector2.INF) as Vector2).distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON
"action_finished":
if not _ids_match(source_id, source):
return false
var trig_params: Dictionary = trigger.get("params", {})
var want := String(trig_params.get("action_type", ""))
var action: Dictionary = event.get("action", {})
return want.is_empty() or want == String(action.get("type", ""))
"speech_finished":
return _ids_match(source_id, source)
"entered_area":
return _ids_match(target_id, target)
"collided":
return _ids_match(source_id, source) and _ids_match(target_id, target)
_:
return false
func _ids_match(a: int, b: Node2D) -> bool:
return a == -1 or (b != null and is_instance_valid(b) and b.get_instance_id() == a)
func _execute_rule_actions(rule: Dictionary) -> void:
for a: Dictionary in rule.get("actions", []):
var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig
if rig == null or not is_instance_valid(rig):
continue
rig.enqueue_reactive([_action_for_rig(a)])
## Converts a rule action into the queue-action shape the runner understands.
func _action_for_rig(a: Dictionary) -> Dictionary:
var params: Dictionary = a.get("params", {})
var out: Dictionary = { "type": String(a.get("type", "")) }
match String(a.get("type", "")):
"walk_to":
out["target"] = params.get("target", Vector2.ZERO)
"speak":
out["text"] = String(params.get("text", ""))
out["duration"] = float(params.get("duration", 2.0))
"wait":
out["duration"] = float(params.get("duration", 0.0))
return out
## Geometric overlap: TriggerArea children vs ANIMATED stickmen + unfrozen props.
## Edge-triggered on entry; emits an entered_area event per new overlap.
func _update_area_entry() -> void:
var areas: Array[TriggerArea] = []
for child: Node in _world.get_children():
if child is TriggerArea:
areas.append(child as TriggerArea)
if areas.is_empty():
return
var movables: Array[Node2D] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
if rig.state == StickmanRig.RigState.ANIMATED:
movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
movables.append(node)
for area: TriggerArea in areas:
var area_rect := (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables:
var point := _movable_point(node)
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var inside: bool = area_rect.has_point(point)
var was: bool = bool(_area_overlap.get(key, false))
if inside and not was:
_area_overlap[key] = true
_handle_event({ "type": "entered_area", "source": node, "target": area, "position": point, "action": {} })
elif not inside and was:
_area_overlap[key] = false
## Geometric stickman-vs-prop collision (ANIMATED rigs x unfrozen props),
## edge-triggered via the prop's world AABB containing the rig's feet/root point.
func _update_stickman_prop_collision() -> void:
var rigs: Array[StickmanRig] = []
var props: Array[PropBlock] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
if rig.state == StickmanRig.RigState.ANIMATED:
rigs.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
props.append(node as PropBlock)
for rig: StickmanRig in rigs:
for prop: PropBlock in props:
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var feet: Vector2 = rig.global_position - StickmanRig.FOOT_OFFSET
var overlap: bool = STAGE_SELECTION.get_world_aabb(prop).has_point(feet)
var was: bool = bool(_collision_pairs.get(key, false))
if overlap and not was:
_collision_pairs[key] = true
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": rig.global_position, "action": {} })
elif not overlap and was:
_collision_pairs[key] = false
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
func _movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG:
return node.global_position - StickmanRig.FOOT_OFFSET
return node.global_position
func _show_toast(msg: String) -> void:
_toast_text = msg
_toast_timer = 2.0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+214
View File
@@ -21,11 +21,27 @@ const ICON_COLOR := Color(1.0, 1.0, 1.0, 0.9)
const ICON_SIZE_PX := 12.0
const BADGE_STACK_STEP := Vector2(0.0, -28.0)
## Phase 4 rule rendering constants.
const WAYPOINT_HIT_RADIUS_PX := 14.0
const RULE_TRIGGER_COLOR := Color(0.2, 0.85, 0.3)
const RULE_ACTION_COLOR := Color(1.0, 0.55, 0.15)
const RULE_LABEL_BG := Color(0.0, 0.0, 0.0, 0.7)
const RULE_LABEL_BORDER := Color(1.0, 1.0, 1.0, 0.25)
const RULE_LABEL_FONT_SIZE_PX := 14.0
const RULE_BADGE_RADIUS_PX := 7.0
const RULE_DELETE_HIT_PX := 14.0
const RULE_DELETE_COLOR := Color(1.0, 0.3, 0.3, 0.9)
var camera: Camera2D = null
var world: Node2D = null
var enabled: bool = true
var _dirty: bool = true
## Phase 4 rule rendering: stored rules plus per-frame hit regions for the label
## and delete icon ({"rect": Rect2, "id": int, "part": String}).
var rules: Array[Dictionary] = []
var _rule_hit_regions: Array[Dictionary] = []
func set_enabled(value: bool) -> void:
enabled = value
visible = value
@@ -34,6 +50,33 @@ func set_enabled(value: bool) -> void:
func mark_dirty() -> void:
_dirty = true
func set_rules(r: Array[Dictionary]) -> void:
rules = r
mark_dirty()
## Returns the hit region under `world_pos` (label/delete), or an empty dict.
func hit_test_rule(world_pos: Vector2) -> Dictionary:
for region: Dictionary in _rule_hit_regions:
var rect: Rect2 = region.get("rect", Rect2())
if rect.has_point(world_pos):
return { "id": int(region.get("id", -1)), "part": String(region.get("part", "")) }
return {}
## Nearest waypoint dot to `world_pos` within a screen-constant radius, reusing
## the same anchor math as _draw_rig_queue. Returns Vector2.INF on miss.
func hit_test_waypoint(world_pos: Vector2) -> Vector2:
var radius := WAYPOINT_HIT_RADIUS_PX / _zoom()
var best := Vector2.INF
var best_dist := radius
for wp: Vector2 in _collect_waypoints():
var d := world_pos.distance_to(wp)
if d <= best_dist:
best_dist = d
best = wp
return best
func _process(_delta: float) -> void:
if _dirty:
_dirty = false
@@ -56,9 +99,11 @@ func _collect_rigs() -> Array[StickmanRig]:
func _draw() -> void:
if not enabled:
return
_rule_hit_regions.clear()
var zoom := _zoom()
for rig: StickmanRig in _collect_rigs():
_draw_rig_queue(rig, zoom)
_draw_rules(zoom)
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
var queue := rig.get_queue()
@@ -127,3 +172,172 @@ func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) ->
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
_draw_number(anchor + Vector2(s, -s), number, zoom)
# ---------------------------------------------------------------------------
# Rule rendering (Phase 4)
# ---------------------------------------------------------------------------
## Collects every walk_to waypoint position, mirroring _draw_rig_queue's anchor
## math exactly so waypoint hit-testing matches rendering.
func _collect_waypoints() -> Array[Vector2]:
var result: Array[Vector2] = []
for rig: StickmanRig in _collect_rigs():
var queue := rig.get_queue()
if queue.is_empty():
continue
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
for i: int in queue.size():
var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to":
var target: Vector2 = action.get("target", current)
result.append(target)
current = target
return result
func _draw_rules(zoom: float) -> void:
for rule: Dictionary in rules:
_draw_rule(rule, zoom)
func _draw_rule(rule: Dictionary, zoom: float) -> void:
var trigger: Dictionary = rule.get("trigger", {})
var trigger_anchor := _rule_trigger_anchor(trigger)
if not trigger_anchor.is_finite():
return
var action_anchor := _rule_action_anchor(rule, trigger_anchor)
if not action_anchor.is_finite():
action_anchor = trigger_anchor
# Dashed white connector (trigger -> action).
if trigger_anchor.distance_to(action_anchor) > 0.5:
_draw_dashed(trigger_anchor, action_anchor, zoom)
# Badges.
_draw_rule_badge(trigger_anchor, zoom, "", RULE_TRIGGER_COLOR)
_draw_rule_badge(action_anchor, zoom, "", RULE_ACTION_COLOR)
# Label at the line midpoint on a dark rounded rect.
var summary := rule_summary(rule)
var mid := (trigger_anchor + action_anchor) * 0.5
var font := ThemeDB.fallback_font
var font_size := int(RULE_LABEL_FONT_SIZE_PX / zoom)
var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var padding := Vector2(6.0, 4.0) / zoom
var box := Rect2(mid - text_size * 0.5 - padding, text_size + padding * 2.0)
draw_rect(box, RULE_LABEL_BG, true)
draw_rect(box, RULE_LABEL_BORDER, false, 1.0 / zoom)
var baseline := box.position + padding + Vector2(0.0, font.get_ascent(font_size))
draw_string(font, baseline, summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE)
_rule_hit_regions.append({ "rect": box, "id": int(rule.get("id", -1)), "part": "label" })
# Delete icon (✕) right of the label, drawn as two crossing lines.
var del_size := RULE_DELETE_HIT_PX / zoom
var del_center := Vector2(box.end.x + del_size * 0.5 + 4.0 / zoom, box.position.y + box.size.y * 0.5)
var del_rect := Rect2(del_center - Vector2(del_size, del_size) * 0.5, Vector2(del_size, del_size))
var half := del_size * 0.35
draw_line(del_center + Vector2(-half, -half), del_center + Vector2(half, half), RULE_DELETE_COLOR, 2.0 / zoom, true)
draw_line(del_center + Vector2(half, -half), del_center + Vector2(-half, half), RULE_DELETE_COLOR, 2.0 / zoom, true)
_rule_hit_regions.append({ "rect": del_rect, "id": int(rule.get("id", -1)), "part": "delete" })
func _draw_rule_badge(anchor: Vector2, zoom: float, glyph: String, color: Color) -> void:
var radius := RULE_BADGE_RADIUS_PX / zoom
draw_circle(anchor, radius, color)
draw_arc(anchor, radius, 0.0, TAU, 32, Color.WHITE, 2.0 / zoom, true)
var font := ThemeDB.fallback_font
var font_size := int(ICON_SIZE_PX / zoom)
var glyph_size := font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
draw_string(font, anchor + Vector2(-glyph_size.x * 0.5, glyph_size.y * 0.5), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE)
## Trigger badge anchor: waypoint pos for arrived_at_waypoint, area center for
## entered_area (or source pos fallback), source position otherwise. Vector2.INF
## on unresolved source.
func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
match String(trigger.get("type", "")):
"arrived_at_waypoint":
var params: Dictionary = trigger.get("params", {})
return params.get("waypoint_pos", Vector2.INF)
"entered_area":
var area := instance_from_id(int(trigger.get("target", -1)))
if area is Node2D and is_instance_valid(area):
return (area as Node2D).global_position
return _rule_source_position(trigger)
_:
return _rule_source_position(trigger)
func _rule_source_position(trigger: Dictionary) -> Vector2:
var src := instance_from_id(int(trigger.get("source", -1)))
if src is Node2D and is_instance_valid(src):
return (src as Node2D).global_position
return Vector2.INF
## Action badge anchor: position of the first action's target stickman, else the
## trigger anchor.
func _rule_action_anchor(rule: Dictionary, fallback: Vector2) -> Vector2:
var actions: Array = rule.get("actions", [])
if actions.is_empty():
return fallback
var target := instance_from_id(int(actions[0].get("target", -1)))
if target is Node2D and is_instance_valid(target):
return (target as Node2D).global_position
return fallback
## "When arrives → Speak 'Hello there!'" summary of a rule.
static func rule_summary(rule: Dictionary) -> String:
var trigger: Dictionary = rule.get("trigger", {})
var verb := _trigger_verb(String(trigger.get("type", "")))
var actions: Array = rule.get("actions", [])
var descs: Array[String] = []
for a: Dictionary in actions:
descs.append(_action_desc(a))
if descs.is_empty():
return "When %s" % verb
if descs.size() == 1:
return "When %s%s" % [verb, descs[0]]
if descs.size() == 2:
return "When %s%s then %s" % [verb, descs[0], descs[1]]
return "When %s%s (+%d more)" % [verb, descs[0], descs.size() - 1]
static func _trigger_verb(type: String) -> String:
match type:
"arrived_at_waypoint":
return "arrives"
"action_finished":
return "completes an action"
"speech_finished":
return "finishes speaking"
"entered_area":
return "enters area"
"collided":
return "collides"
_:
return "triggers"
static func _action_desc(action: Dictionary) -> String:
var params: Dictionary = action.get("params", {})
match String(action.get("type", "")):
"walk_to":
return "Walks"
"speak":
return "Speak '%s'" % String(params.get("text", ""))
"wait":
return "Wait %ss" % _fmt_duration(float(params.get("duration", 0.0)))
"ragdoll":
return "Ragdolls"
"recover":
return "Recovers"
_:
return "Acts"
static func _fmt_duration(v: float) -> String:
if v == roundf(v):
return str(int(v))
return str(v)
+3
View File
@@ -129,6 +129,9 @@ func box_select(rect: Rect2, additive: bool) -> void:
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)
+18
View File
@@ -15,6 +15,7 @@ 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")
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
# ---------------------------------------------------------------------------
# Constants
@@ -87,6 +88,8 @@ func spawn(id: String, world_position: Vector2) -> Node2D:
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
@@ -98,6 +101,9 @@ func spawn(id: String, world_position: Vector2) -> Node2D:
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)
@@ -174,6 +180,10 @@ func _build_registry() -> void:
"id": "stickman", "label": "Stickman", "kind": "stickman",
"spawn_offset": STICKMAN_FOOT_OFFSET,
},
{
"id": "area", "label": "Area", "kind": "area",
"spawn_offset": Vector2.ZERO,
},
]
@@ -213,6 +223,14 @@ func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
return PROP_UTILS.spawn_prop(_world, world_position, payload, 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:
if _stickman_data.is_empty():
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
+20 -2
View File
@@ -272,7 +272,7 @@ signal state_changed(new_state: int)
# Director signals (Phase 3a)
# ---------------------------------------------------------------------------
signal arrived # walk_to reached its destination
signal arrived(target: Vector2) # walk_to reached its destination
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
@@ -1134,7 +1134,7 @@ func _finish_walk(reason: String = "") -> void:
_restore_standing_markers()
_walk_done = true
_walking = false
arrived.emit()
arrived.emit(_walk_target_feet)
func _cancel_walking() -> void:
@@ -1215,6 +1215,24 @@ func queue_size() -> int:
return action_queue.size()
## Append `actions` to the queue and, when the runner is idle, resume execution
## at the first newly-appended action WITHOUT replaying the existing queue.
## Used by the Phase 4 event engine to inject reactive actions onto a stickman.
func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty():
return
var start := action_queue.size()
action_queue.append_array(actions)
queue_changed.emit()
if _runner_state == RunnerState.IDLE:
# Resume at the first appended action (not at index 0), so any queued
# sequential actions are skipped over rather than replayed.
_current_index = start - 1
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_stop_requested = false
# ---------------------------------------------------------------------------
# Runner state machine (Phase 3a)
# ---------------------------------------------------------------------------
+2 -1
View File
@@ -47,4 +47,5 @@ func _draw() -> void:
Vector2(TAIL_WIDTH * 0.5, -TAIL_HEIGHT),
Vector2(0.0, 0.0),
]), BG_COLOR)
draw_string(font, box.position + PADDING, _text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE, TEXT_COLOR)
var baseline := Vector2(box.position.x + PADDING.x, box.position.y + PADDING.y + font.get_ascent(FONT_SIZE))
draw_string(font, baseline, _text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE, TEXT_COLOR)
+50
View File
@@ -0,0 +1,50 @@
class_name TriggerArea
extends Node2D
## TriggerArea - Placeable rectangular sensor for the Sandbox Stage (Phase 4).
##
## A drawn Node2D (NOT a physics Area2D) that the stage's geometric event engine
## polls for movable overlap. Purely visual plus a size query; no signals, no
## physics. Draws a translucent green fill with a dashed green border so it reads
## as a trigger zone in EDIT mode. World-space child, so no zoom division is
## needed for the dashed border.
## Half-extents of the rectangular sensor in local space.
@export var size: Vector2 = Vector2(96.0, 96.0):
set(value):
size = value
queue_redraw()
## Local-space rectangle (centered on the node origin) used for overlap tests.
func get_area_rect() -> Rect2:
return Rect2(-size * 0.5, size)
func _draw() -> void:
var rect := get_area_rect()
draw_rect(rect, Color(0.2, 0.8, 0.3, 0.12), true)
_draw_dashed_rect(rect, Color(0.2, 0.8, 0.3, 0.6), 2.0, 6.0, 4.0)
## Dashed border along each edge of `rect` (top/right/bottom/left), drawn with a
## small manual dash loop using draw_line.
func _draw_dashed_rect(rect: Rect2, color: Color, width: float, dash: float, gap: float) -> void:
var tl := rect.position
var tr := rect.position + Vector2(rect.size.x, 0.0)
var br := rect.position + rect.size
var bl := rect.position + Vector2(0.0, rect.size.y)
_draw_dashed_edge(tl, tr, color, width, dash, gap)
_draw_dashed_edge(tr, br, color, width, dash, gap)
_draw_dashed_edge(br, bl, color, width, dash, gap)
_draw_dashed_edge(bl, tl, color, width, dash, gap)
func _draw_dashed_edge(from: Vector2, to: Vector2, color: Color, width: float, dash: float, gap: float) -> void:
var dir := from.direction_to(to)
var total := from.distance_to(to)
var dist := 0.0
while dist < total:
var start := from + dir * dist
var len := minf(dash, total - dist)
draw_line(start, start + dir * len, color, width, true)
dist += dash + gap
+1
View File
@@ -0,0 +1 @@
uid://dfx0hi5ey1auw