- 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.
1528 lines
48 KiB
GDScript
1528 lines
48 KiB
GDScript
class_name SandboxStage
|
||
extends Node2D
|
||
## SandboxStage - Kid-friendly director sandbox stage (Phase 2).
|
||
##
|
||
## A self-contained visual stage for placing terrain, props and stickmen from a
|
||
## palette, with EDIT (build) and PLAY (simulate) modes. Selection uses geometric
|
||
## hit-testing; objects are dragged directly (no move handle) and rotated via a
|
||
## ring handle. A placement ghost previews the object under the cursor, and an
|
||
## optional snap-to-grid + grid overlay ease placement. NOT wired into the editor
|
||
## - run standalone via F6 on res://scenes/sandbox_stage.tscn.
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Preloaded helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
|
||
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
||
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
|
||
const STAGE_GRID := preload("res://scripts/stage_grid.gd")
|
||
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
|
||
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Enums
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
signal mode_changed(mode: int)
|
||
signal object_placed(node: Node2D)
|
||
signal object_selected(nodes: Array[Node2D])
|
||
signal object_deselected()
|
||
signal object_deleted(nodes: Array[Node2D])
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
const ZOOM_STEP: float = 1.10
|
||
const MIN_BOX_AREA: float = 16.0
|
||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||
|
||
const SETTINGS_PATH := "user://sandbox_settings.json"
|
||
const DEFAULT_GRID_SIZE := 15.0
|
||
const MIN_GRID_SIZE := 1.0
|
||
const MAX_GRID_SIZE := 100.0
|
||
|
||
## Director action-popup item ids (Phase 3a).
|
||
const ACT_WALK := 0
|
||
const ACT_SPEAK := 1
|
||
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
|
||
|
||
## Prints a `[stage] `-prefixed message only when DEBUG_STAGE is on.
|
||
func _stage_dbg(msg: String) -> void:
|
||
if DEBUG_STAGE:
|
||
print("[stage] ", msg)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Exported properties
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@export var min_zoom: float = 0.1
|
||
@export var max_zoom: float = 6.0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Node references
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@onready var _camera: Camera2D = $Camera2D
|
||
@onready var _world: Node2D = $World
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# State
|
||
# ---------------------------------------------------------------------------
|
||
|
||
var current_mode: StageMode = StageMode.EDIT
|
||
|
||
var _spawner: StageSpawner
|
||
var _selection: StageSelection
|
||
var _gizmos: StageGizmos
|
||
var _grid: StageGrid
|
||
|
||
var _placement_id: String = ""
|
||
|
||
var _mode_button: Button
|
||
var _palette_buttons: Dictionary = {}
|
||
var _status_label: Label
|
||
var _grid_check: CheckBox
|
||
var _snap_check: CheckBox
|
||
var _grid_size_spin: SpinBox
|
||
var _grid_size_label: Label
|
||
|
||
var _grid_size: float = DEFAULT_GRID_SIZE
|
||
var _snap_enabled: bool = false
|
||
var _show_grid: bool = true
|
||
|
||
var _panning: bool = false
|
||
var _pan_last: Vector2 = Vector2.ZERO
|
||
|
||
var _box_selecting: bool = false
|
||
var _box_start: Vector2 = Vector2.ZERO
|
||
var _box_rect: Rect2 = Rect2()
|
||
|
||
var _ghost: Node2D = null
|
||
var _ghost_holder: Node2D = null
|
||
|
||
## Authored object state (instance id -> {node, position, rotation}), updated on
|
||
## every place/move/rotate and restored when returning to EDIT.
|
||
var _authored: Dictionary = {}
|
||
|
||
## Physics frames remaining before re-asserting the authored restore (a safety
|
||
## net for engines where a same-frame freeze + teleport does not stick).
|
||
var _restore_frames_left: int = 0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Director state (Phase 3a)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
var _direct_mode: bool = false
|
||
var _direct_button: Button = null
|
||
var _action_popup: PopupMenu = null
|
||
var _context_rig: StickmanRig = null
|
||
var _pending_walk_target: bool = false
|
||
var _speak_dialog: AcceptDialog = null
|
||
var _speak_edit: LineEdit = null
|
||
var _wait_dialog: AcceptDialog = null
|
||
var _wait_spin: SpinBox = null
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _ready() -> void:
|
||
_camera.make_current()
|
||
_load_settings()
|
||
|
||
_spawner = STAGE_SPAWNER.new(_world)
|
||
_selection = STAGE_SELECTION.new(_world, _camera)
|
||
_selection.selection_changed.connect(_on_selection_changed)
|
||
_selection.hover_changed.connect(_on_hover_changed)
|
||
|
||
_build_grid_layer()
|
||
_build_gizmo_layer()
|
||
_build_director_visuals()
|
||
_build_navigation()
|
||
_build_ghost_holder()
|
||
_build_ui()
|
||
_apply_grid_settings()
|
||
_refresh_status()
|
||
|
||
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _input(event: InputEvent) -> void:
|
||
if event is InputEventMagnifyGesture:
|
||
# Touchpad pinch zoom.
|
||
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
|
||
elif event is InputEventPanGesture:
|
||
# Touchpad two-finger pan (delta is screen px; ×3 for speed parity).
|
||
_camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x
|
||
elif event is InputEventMouseButton:
|
||
_handle_mouse_button(event)
|
||
elif event is InputEventMouseMotion:
|
||
_handle_mouse_motion(event)
|
||
|
||
|
||
func _unhandled_input(event: InputEvent) -> void:
|
||
if event is InputEventMouseButton:
|
||
_handle_world_click(event)
|
||
|
||
|
||
func _unhandled_key_input(event: InputEvent) -> void:
|
||
if not (event is InputEventKey) or not event.pressed or event.echo:
|
||
return
|
||
match event.keycode:
|
||
KEY_DELETE, KEY_BACKSPACE:
|
||
if current_mode == StageMode.EDIT:
|
||
delete_selected()
|
||
KEY_ESCAPE:
|
||
if _rule_step != RuleStep.IDLE:
|
||
_cancel_rule_build()
|
||
elif _pending_walk_target:
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
_refresh_status()
|
||
elif _direct_mode:
|
||
_direct_mode = false
|
||
_direct_button.set_pressed_no_signal(false)
|
||
elif _placement_id != "":
|
||
set_placement_mode("")
|
||
elif not _selection.get_selected().is_empty():
|
||
_selection.clear_selection()
|
||
|
||
|
||
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
||
match mb.button_index:
|
||
MOUSE_BUTTON_WHEEL_UP:
|
||
if mb.pressed:
|
||
_set_zoom(_camera.zoom.x * ZOOM_STEP)
|
||
MOUSE_BUTTON_WHEEL_DOWN:
|
||
if mb.pressed:
|
||
_set_zoom(_camera.zoom.x / ZOOM_STEP)
|
||
MOUSE_BUTTON_MIDDLE:
|
||
_panning = mb.pressed
|
||
_pan_last = mb.position
|
||
|
||
|
||
func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||
if mb.button_index != MOUSE_BUTTON_LEFT:
|
||
return
|
||
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)
|
||
return
|
||
if mb.pressed:
|
||
if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE:
|
||
_gizmos.begin_drag(world_pos)
|
||
elif _placement_id != "":
|
||
_place_at(world_pos)
|
||
else:
|
||
_begin_click_select(world_pos, mb.shift_pressed)
|
||
else:
|
||
if _gizmos.is_dragging():
|
||
_gizmos.end_drag()
|
||
elif _box_selecting:
|
||
_finish_box_select(mb.shift_pressed)
|
||
|
||
|
||
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
||
if _panning:
|
||
_camera.position -= mm.relative / _camera.zoom.x
|
||
return
|
||
if current_mode != StageMode.EDIT:
|
||
return
|
||
var world_pos := _camera.get_global_mouse_position()
|
||
if _gizmos.is_dragging():
|
||
_gizmos.drag_to(world_pos)
|
||
elif _box_selecting:
|
||
_box_rect = Rect2(_box_start, world_pos - _box_start)
|
||
_gizmos.set_box_rect(_box_rect)
|
||
elif not _is_mouse_over_ui():
|
||
_selection.update_hover(world_pos)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Mode management
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func set_mode(mode: StageMode) -> void:
|
||
if mode == current_mode:
|
||
return
|
||
current_mode = mode
|
||
if mode == StageMode.EDIT:
|
||
_enter_edit_mode()
|
||
else:
|
||
_enter_play_mode()
|
||
mode_changed.emit(int(mode))
|
||
_refresh_status()
|
||
|
||
|
||
func _enter_edit_mode() -> void:
|
||
# Snap stickmen straight back to their standing pose/position (no stand-up
|
||
# tween glide), stopping any director queue first.
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
rig.stop_queue()
|
||
rig.snap_to_standing()
|
||
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
|
||
# any object that may not be in the authored map. freeze_mode is set BEFORE
|
||
# freeze so the body freezes directly as kinematic, never via the static
|
||
# layer (whose transform sync can drop a subsequent position/rotation set).
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is RigidBody2D:
|
||
var body := node as RigidBody2D
|
||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||
body.freeze = true
|
||
_restore_authored_state()
|
||
# The freeze/teleport can take a physics frame or two to settle in the engine,
|
||
# so re-assert the authored transform on the next few physics frames.
|
||
_restore_frames_left = 3
|
||
_gizmos.set_enabled(true)
|
||
_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:
|
||
_gizmos.set_enabled(false)
|
||
_selection.clear_selection()
|
||
set_placement_mode("")
|
||
if _action_popup != null:
|
||
_action_popup.hide()
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
_direct_mode = false
|
||
if _direct_button != null:
|
||
_direct_button.set_pressed_no_signal(false)
|
||
_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
|
||
var rig_count := 0
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
rig.auto_recover = false
|
||
rig.start_queue()
|
||
rig_count += 1
|
||
_stage_dbg("PLAY rigs=%d" % rig_count)
|
||
|
||
|
||
func _save_object_state(node: Node2D) -> void:
|
||
_authored[node.get_instance_id()] = {
|
||
"node": node,
|
||
"position": node.global_position,
|
||
"rotation": node.global_rotation,
|
||
}
|
||
|
||
|
||
func _clear_object_state(node: Node2D) -> void:
|
||
_authored.erase(node.get_instance_id())
|
||
|
||
|
||
func _restore_authored_state() -> void:
|
||
for id: int in _authored.keys():
|
||
var entry: Dictionary = _authored[id]
|
||
var node := entry.get("node") as Node2D
|
||
if node == null or not is_instance_valid(node):
|
||
_authored.erase(id)
|
||
continue
|
||
node.global_position = entry.get("position", node.global_position)
|
||
node.global_rotation = entry.get("rotation", node.global_rotation)
|
||
if node is RigidBody2D:
|
||
var body := node as RigidBody2D
|
||
body.linear_velocity = Vector2.ZERO
|
||
body.angular_velocity = 0.0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Placement
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func set_placement_mode(id: String) -> void:
|
||
_placement_id = id
|
||
for pid: String in _palette_buttons:
|
||
var btn: Button = _palette_buttons[pid]
|
||
btn.set_pressed_no_signal(pid == id)
|
||
if id == "":
|
||
_free_ghost()
|
||
else:
|
||
_spawn_ghost()
|
||
|
||
|
||
func _place_at(world_pos: Vector2) -> void:
|
||
if _snap_enabled:
|
||
world_pos = _snap_to_grid(world_pos)
|
||
var node := _spawner.spawn(_placement_id, world_pos)
|
||
if node == null:
|
||
return
|
||
if current_mode == StageMode.EDIT and node is RigidBody2D:
|
||
var body := node as RigidBody2D
|
||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||
body.freeze = true
|
||
_save_object_state(node)
|
||
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)
|
||
_refresh_status()
|
||
_spawn_ghost()
|
||
|
||
|
||
func _spawn_ghost() -> void:
|
||
_free_ghost()
|
||
if _placement_id == "":
|
||
return
|
||
var ghost := _spawner.spawn(_placement_id, Vector2.ZERO)
|
||
if ghost == null:
|
||
return
|
||
# Reparent out of World so the ghost is not selectable/counted.
|
||
if ghost.get_parent() == _world:
|
||
_world.remove_child(ghost)
|
||
_ghost_holder.add_child(ghost)
|
||
ghost.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||
if ghost is RigidBody2D:
|
||
var body := ghost as RigidBody2D
|
||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||
body.freeze = true
|
||
body.collision_layer = 0
|
||
body.collision_mask = 0
|
||
elif ghost is StaticBody2D:
|
||
var sb := ghost as StaticBody2D
|
||
sb.collision_layer = 0
|
||
sb.collision_mask = 0
|
||
elif ghost is STICKMAN_RIG:
|
||
# Freeze the ghost rig into a static standing figure: disable IK solving
|
||
# and animation so its limbs don't flex/follow anything while the ghost
|
||
# tracks the cursor.
|
||
var rig := ghost as STICKMAN_RIG
|
||
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
|
||
if skeleton != null and skeleton.modification_stack != null:
|
||
skeleton.modification_stack.enabled = false
|
||
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
|
||
if anim != null:
|
||
anim.stop()
|
||
_ghost = ghost
|
||
_update_ghost_position()
|
||
|
||
|
||
func _free_ghost() -> void:
|
||
if _ghost != null and is_instance_valid(_ghost):
|
||
_ghost.queue_free()
|
||
_ghost = null
|
||
|
||
|
||
func _update_ghost_position() -> void:
|
||
if _ghost == null or not is_instance_valid(_ghost):
|
||
return
|
||
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
|
||
if _snap_enabled:
|
||
pos = _snap_to_grid(pos)
|
||
_ghost.position = pos
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Selection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
|
||
var hit := _selection.hit_test(world_pos)
|
||
if hit != null:
|
||
if shift:
|
||
_selection.toggle_selection(hit)
|
||
if _selection.is_selected(hit):
|
||
_gizmos.begin_translate_drag(hit, world_pos)
|
||
elif _selection.is_selected(hit):
|
||
# Already selected: keep the whole selection and drag it together.
|
||
_gizmos.begin_translate_drag(hit, world_pos)
|
||
else:
|
||
_selection.select_only(hit)
|
||
_gizmos.begin_translate_drag(hit, world_pos)
|
||
else:
|
||
_box_selecting = true
|
||
_box_start = world_pos
|
||
_box_rect = Rect2(world_pos, Vector2.ZERO)
|
||
_gizmos.set_box_rect(_box_rect)
|
||
|
||
|
||
func _finish_box_select(shift: bool) -> void:
|
||
_box_selecting = false
|
||
_gizmos.set_box_rect(Rect2())
|
||
var rect := _box_rect.abs()
|
||
if rect.get_area() < MIN_BOX_AREA:
|
||
if not shift:
|
||
_selection.clear_selection()
|
||
else:
|
||
_selection.box_select(rect, shift)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deletion
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func delete_selected() -> void:
|
||
var selected := _selection.get_selected().duplicate()
|
||
if selected.is_empty():
|
||
return
|
||
var nav_changed := false
|
||
for node: Node2D in selected:
|
||
if is_instance_valid(node):
|
||
if node is TerrainBlock:
|
||
nav_changed = true
|
||
_clear_object_state(node)
|
||
node.queue_free()
|
||
_cleanup_rules_for_nodes(selected)
|
||
_selection.clear_selection()
|
||
if nav_changed:
|
||
_nav_dirty = true
|
||
_director_visuals.mark_dirty()
|
||
object_deleted.emit(selected)
|
||
_refresh_status()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Status / UI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _refresh_status() -> void:
|
||
if _status_label == null:
|
||
return
|
||
var mode_text := "EDIT" if current_mode == StageMode.EDIT else "PLAY"
|
||
var count := _world_children_selectable().size()
|
||
var sel := _selection.get_selected()
|
||
var sel_text: String
|
||
if sel.is_empty():
|
||
sel_text = "none"
|
||
elif sel.size() == 1:
|
||
sel_text = String(sel[0].name)
|
||
else:
|
||
sel_text = "%d objects" % sel.size()
|
||
var text := "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
|
||
if _pending_walk_target:
|
||
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"
|
||
|
||
|
||
func _build_ui() -> void:
|
||
var ui := CanvasLayer.new()
|
||
ui.name = "UI"
|
||
add_child(ui)
|
||
|
||
var top_bar := PanelContainer.new()
|
||
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||
top_bar.offset_bottom = 40.0
|
||
ui.add_child(top_bar)
|
||
|
||
var hbox := HBoxContainer.new()
|
||
hbox.add_theme_constant_override("separation", 8)
|
||
top_bar.add_child(hbox)
|
||
|
||
_mode_button = Button.new()
|
||
_mode_button.toggle_mode = true
|
||
_mode_button.toggled.connect(_on_mode_toggled)
|
||
hbox.add_child(_mode_button)
|
||
|
||
for id: String in _spawner.get_spawnable_ids():
|
||
var btn := Button.new()
|
||
btn.text = _spawner.get_label(id)
|
||
btn.toggle_mode = true
|
||
btn.toggled.connect(_on_palette_toggled.bind(id))
|
||
hbox.add_child(btn)
|
||
_palette_buttons[id] = btn
|
||
|
||
_direct_button = Button.new()
|
||
_direct_button.text = "Direct"
|
||
_direct_button.toggle_mode = true
|
||
_direct_button.toggled.connect(_on_direct_toggled)
|
||
hbox.add_child(_direct_button)
|
||
|
||
_grid_check = CheckBox.new()
|
||
_grid_check.text = "Grid"
|
||
_grid_check.button_pressed = _show_grid
|
||
_grid_check.toggled.connect(_on_grid_toggled)
|
||
hbox.add_child(_grid_check)
|
||
|
||
_snap_check = CheckBox.new()
|
||
_snap_check.text = "Snap"
|
||
_snap_check.button_pressed = _snap_enabled
|
||
_snap_check.toggled.connect(_on_snap_toggled)
|
||
hbox.add_child(_snap_check)
|
||
|
||
_grid_size_label = Label.new()
|
||
_grid_size_label.text = "Size"
|
||
hbox.add_child(_grid_size_label)
|
||
|
||
_grid_size_spin = SpinBox.new()
|
||
_grid_size_spin.min_value = MIN_GRID_SIZE
|
||
_grid_size_spin.max_value = MAX_GRID_SIZE
|
||
_grid_size_spin.step = 1.0
|
||
_grid_size_spin.value = _grid_size
|
||
_grid_size_spin.rounded = true
|
||
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
|
||
hbox.add_child(_grid_size_spin)
|
||
|
||
_status_label = Label.new()
|
||
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
hbox.add_child(_status_label)
|
||
|
||
_action_popup = PopupMenu.new()
|
||
_action_popup.add_item("🚶 Walk To", ACT_WALK)
|
||
_action_popup.add_item("💬 Speak", ACT_SPEAK)
|
||
_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)
|
||
_speak_edit = LineEdit.new()
|
||
_speak_edit.placeholder_text = "Say something…"
|
||
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
|
||
_speak_dialog.add_child(_speak_edit)
|
||
_speak_dialog.register_text_enter(_speak_edit)
|
||
ui.add_child(_speak_dialog)
|
||
|
||
_wait_dialog = AcceptDialog.new()
|
||
_wait_dialog.title = "Wait"
|
||
_wait_dialog.confirmed.connect(_on_wait_confirmed)
|
||
_wait_spin = SpinBox.new()
|
||
_wait_spin.min_value = 0.1
|
||
_wait_spin.max_value = 10.0
|
||
_wait_spin.step = 0.1
|
||
_wait_spin.value = 1.0
|
||
_wait_dialog.add_child(_wait_spin)
|
||
ui.add_child(_wait_dialog)
|
||
|
||
|
||
func _build_gizmo_layer() -> void:
|
||
_gizmos = STAGE_GIZMOS.new()
|
||
_gizmos.name = "GizmoLayer"
|
||
_gizmos.camera = _camera
|
||
_gizmos.transform_committed.connect(_on_transform_committed)
|
||
add_child(_gizmos)
|
||
|
||
|
||
func _build_grid_layer() -> void:
|
||
_grid = STAGE_GRID.new()
|
||
_grid.name = "GridLayer"
|
||
_grid.camera = _camera
|
||
add_child(_grid)
|
||
move_child(_grid, 0)
|
||
|
||
|
||
func _build_ghost_holder() -> void:
|
||
_ghost_holder = Node2D.new()
|
||
_ghost_holder.name = "PlacementGhost"
|
||
add_child(_ghost_holder)
|
||
|
||
|
||
func _build_director_visuals() -> void:
|
||
_director_visuals = STAGE_DIRECTOR_VISUALS.new()
|
||
_director_visuals.name = "DirectorVisualsLayer"
|
||
_director_visuals.camera = _camera
|
||
_director_visuals.world = _world
|
||
add_child(_director_visuals)
|
||
|
||
|
||
## Code-built NavigationRegion2D child of the stage (NOT World, so it is never
|
||
## selected/hit-tested). Sits at the stage origin; World is at identity so world
|
||
## coordinates equal region-local coordinates.
|
||
func _build_navigation() -> void:
|
||
_nav_region = NavigationRegion2D.new()
|
||
_nav_region.name = "NavigationRegion2D"
|
||
add_child(_nav_region)
|
||
_rebake_navigation()
|
||
|
||
|
||
## Rebuild the navigation mesh from every TerrainBlock child of World: each
|
||
## block's world-space polygon is convex-decomposed and fan-triangulated into
|
||
## one manual NavigationPolygon.
|
||
func _rebake_navigation() -> void:
|
||
if _nav_region == null or not is_instance_valid(_nav_region):
|
||
return
|
||
var verts := PackedVector2Array()
|
||
var triangles: Array[PackedInt32Array] = []
|
||
for child: Node in _world.get_children():
|
||
var block := child as TerrainBlock
|
||
if block == null:
|
||
continue
|
||
var pts := PackedVector2Array()
|
||
for p: Vector2 in block.polygon_points:
|
||
pts.append(block.transform * p)
|
||
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
|
||
if pieces.is_empty():
|
||
pieces = [pts]
|
||
for piece: PackedVector2Array in pieces:
|
||
var base: int = verts.size()
|
||
verts.append_array(piece)
|
||
for i: int in range(1, piece.size() - 1):
|
||
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
|
||
var poly := NavigationPolygon.new()
|
||
poly.set_vertices(verts)
|
||
for tri: PackedInt32Array in triangles:
|
||
poly.add_polygon(tri)
|
||
_nav_region.navigation_polygon = poly
|
||
if DEBUG_STAGE:
|
||
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Signal handlers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _on_mode_toggled(pressed: bool) -> void:
|
||
set_mode(StageMode.PLAY if pressed else StageMode.EDIT)
|
||
|
||
|
||
func _on_palette_toggled(pressed: bool, id: String) -> void:
|
||
if pressed:
|
||
_direct_mode = false
|
||
if _direct_button != null:
|
||
_direct_button.set_pressed_no_signal(false)
|
||
set_placement_mode(id)
|
||
elif _placement_id == id:
|
||
set_placement_mode("")
|
||
|
||
|
||
func _on_grid_toggled(pressed: bool) -> void:
|
||
_show_grid = pressed
|
||
_apply_grid_settings()
|
||
_save_settings()
|
||
|
||
|
||
func _on_snap_toggled(pressed: bool) -> void:
|
||
_snap_enabled = pressed
|
||
_apply_grid_settings()
|
||
_save_settings()
|
||
|
||
|
||
func _on_grid_size_changed(value: float) -> void:
|
||
_grid_size = clampf(value, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||
_apply_grid_settings()
|
||
_save_settings()
|
||
|
||
|
||
func _on_selection_changed(nodes: Array[Node2D]) -> void:
|
||
_gizmos.set_targets(nodes)
|
||
if nodes.is_empty():
|
||
object_deselected.emit()
|
||
else:
|
||
object_selected.emit(nodes)
|
||
_refresh_status()
|
||
|
||
|
||
func _on_hover_changed(node: Node2D) -> void:
|
||
_gizmos.set_hover(node)
|
||
|
||
|
||
func _on_transform_committed(nodes: Array[Node2D]) -> void:
|
||
for node: Node2D in nodes:
|
||
_save_object_state(node)
|
||
if node is TerrainBlock:
|
||
_nav_dirty = true
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Director tool (Phase 3a)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _on_direct_toggled(pressed: bool) -> void:
|
||
_direct_mode = pressed
|
||
if pressed:
|
||
set_placement_mode("")
|
||
_selection.clear_selection()
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
_refresh_status()
|
||
|
||
|
||
func _handle_direct_click(world_pos: Vector2) -> void:
|
||
if _pending_walk_target and _context_rig != null and is_instance_valid(_context_rig):
|
||
_stage_dbg("walk_to captured target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
|
||
_context_rig.queue_action({ "type": "walk_to", "target": world_pos })
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
_refresh_status()
|
||
return
|
||
var hit := _selection.hit_test(world_pos)
|
||
if hit is STICKMAN_RIG:
|
||
_context_rig = hit as StickmanRig
|
||
var mouse := get_viewport().get_mouse_position()
|
||
_action_popup.popup(Rect2i(Vector2i(mouse), Vector2i.ZERO))
|
||
|
||
|
||
func _on_action_popup_id_pressed(id: int) -> void:
|
||
if _context_rig == null or not is_instance_valid(_context_rig):
|
||
return
|
||
match id:
|
||
ACT_WALK:
|
||
_pending_walk_target = true
|
||
_refresh_status()
|
||
ACT_SPEAK:
|
||
_speak_edit.text = ""
|
||
_speak_dialog.popup_centered()
|
||
_speak_edit.grab_focus()
|
||
ACT_WAIT:
|
||
_wait_spin.value = 1.0
|
||
_wait_dialog.popup_centered()
|
||
ACT_RAGDOLL:
|
||
_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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _set_zoom(value: float) -> void:
|
||
var z := clampf(value, min_zoom, max_zoom)
|
||
var old_zoom := _camera.zoom.x
|
||
if is_equal_approx(z, old_zoom):
|
||
return
|
||
var mouse_world := _camera.get_global_mouse_position()
|
||
_camera.zoom = Vector2(z, z)
|
||
_camera.position = mouse_world - (mouse_world - _camera.position) * (old_zoom / z)
|
||
|
||
|
||
func _is_mouse_over_ui() -> bool:
|
||
return get_viewport().gui_get_hovered_control() != null
|
||
|
||
|
||
func _snap_to_grid(v: Vector2) -> Vector2:
|
||
if _grid_size <= 0.0:
|
||
return v
|
||
return Vector2(roundf(v.x / _grid_size) * _grid_size, roundf(v.y / _grid_size) * _grid_size)
|
||
|
||
|
||
func _apply_grid_settings() -> void:
|
||
if _grid != null:
|
||
_grid.grid_size = _grid_size
|
||
_grid.enabled = _show_grid
|
||
_grid.visible = _show_grid and current_mode == StageMode.EDIT
|
||
if _gizmos != null:
|
||
_gizmos.snap_size = _grid_size if _snap_enabled else 0.0
|
||
|
||
|
||
func _set_build_controls_visible(visible: bool) -> void:
|
||
for btn: Button in _palette_buttons.values():
|
||
btn.visible = visible
|
||
if _grid_check != null:
|
||
_grid_check.visible = visible
|
||
if _snap_check != null:
|
||
_snap_check.visible = visible
|
||
if _grid_size_spin != null:
|
||
_grid_size_spin.visible = visible
|
||
if _grid_size_label != null:
|
||
_grid_size_label.visible = visible
|
||
if _direct_button != null:
|
||
_direct_button.visible = visible
|
||
|
||
|
||
## Direct Node2D children of World, excluding the ragdoll body container.
|
||
func _world_children_selectable() -> Array[Node2D]:
|
||
var result: Array[Node2D] = []
|
||
for child: Node in _world.get_children():
|
||
var node := child as Node2D
|
||
if node == null:
|
||
continue
|
||
if node.name == RAGDOLL_CONTAINER_NAME:
|
||
continue
|
||
result.append(node)
|
||
return result
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Settings persistence
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _load_settings() -> void:
|
||
if not FileAccess.file_exists(SETTINGS_PATH):
|
||
return
|
||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
|
||
if file == null:
|
||
return
|
||
var json: Variant = JSON.parse_string(file.get_as_text())
|
||
file.close()
|
||
if not json is Dictionary:
|
||
return
|
||
var d := json as Dictionary
|
||
_grid_size = float(d.get("grid_size", DEFAULT_GRID_SIZE))
|
||
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||
_snap_enabled = bool(d.get("snap_to_grid", false))
|
||
_show_grid = bool(d.get("show_grid", true))
|
||
|
||
|
||
func _save_settings() -> void:
|
||
var data := {
|
||
"version": "1.0",
|
||
"grid_size": _grid_size,
|
||
"snap_to_grid": _snap_enabled,
|
||
"show_grid": _show_grid,
|
||
}
|
||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
|
||
if file:
|
||
file.store_string(JSON.stringify(data, "\t", false))
|
||
file.close()
|