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:
+622
-4
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user