feat: Implement Phase 16 - Move To: Walk and Run
- Introduced a new action type `move_to` allowing children to direct a stickman to a destination using either Walk or Run styles. - Updated the ActionEditor UI to include Walk/Run buttons for selecting movement styles. - Ensured compatibility with existing `walk_to` actions by converting them to the new `move_to` format during loading. - Defined clear success criteria for the implementation, including distinct movement animations and behavior for both Walk and Run. - Established a comprehensive verification contract to ensure functionality across various terrains and scenarios.
This commit is contained in:
@@ -4,9 +4,8 @@ extends PopupPanel
|
||||
##
|
||||
## Used for adding a new action and for editing an existing one (queue or rule).
|
||||
## The type dropdown and the param fields are generated from ActionRegistry, so
|
||||
## new action types appear automatically. For `walk_to`, the target position is
|
||||
## captured on the stage: the editor emits `target_requested()` and the stage
|
||||
## hides the editor, captures a click, then calls `set_walk_target()`.
|
||||
## new action types appear automatically. A Move To destination is captured on
|
||||
## the stage while its Walk/Run draft remains in this editor.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
|
||||
@@ -23,6 +22,13 @@ var _text_edit: LineEdit = null
|
||||
var _duration_spin: SpinBox = null
|
||||
var _target_label: Label = null
|
||||
var _set_target_btn: Button = null
|
||||
var _walk_style_btn: Button = null
|
||||
var _run_style_btn: Button = null
|
||||
var _move_style: String = ACTION_REGISTRY.WALK
|
||||
var _move_style_group: ButtonGroup = null
|
||||
var _direct_accent_color: Color = Color("#FFB300")
|
||||
var _surface_color: Color = Color("#252B38")
|
||||
var _border_color: Color = Color("#FFFFFF33")
|
||||
var _prop_target_label: Label = null
|
||||
var _set_prop_target_btn: Button = null
|
||||
var _hand_option: OptionButton = null
|
||||
@@ -53,18 +59,19 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
cancelled.emit()
|
||||
|
||||
|
||||
## Opens the editor in "add" mode (empty, default type walk_to).
|
||||
## Opens the editor in "add" mode (empty, default Move To/Walk).
|
||||
func open_new(show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_mode = "new"
|
||||
_initial = {}
|
||||
_has_target = false
|
||||
_target_pos = Vector2.ZERO
|
||||
_move_style = ACTION_REGISTRY.WALK
|
||||
_prop_target_id = ""
|
||||
_has_throw_target = false
|
||||
_throw_target_pos = Vector2.ZERO
|
||||
_select_type("walk_to")
|
||||
_rebuild_params("walk_to")
|
||||
_select_type(ACTION_REGISTRY.MOVE_TO)
|
||||
_rebuild_params(ACTION_REGISTRY.MOVE_TO)
|
||||
title = "Add Action"
|
||||
popup_centered()
|
||||
|
||||
@@ -74,7 +81,7 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_mode = "edit"
|
||||
_initial = action.duplicate(true)
|
||||
var type := String(action.get("type", "walk_to"))
|
||||
var type := ACTION_REGISTRY.canonical_type(String(action.get("type", ACTION_REGISTRY.MOVE_TO)))
|
||||
if type == "speak":
|
||||
speech_requested.emit(_initial.duplicate(true))
|
||||
return
|
||||
@@ -83,6 +90,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
||||
_prop_target_id = String(action.get("target_prop_id", ""))
|
||||
_has_throw_target = action.has("target_position") and action["target_position"] is Vector2
|
||||
_throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO
|
||||
_move_style = ACTION_REGISTRY.checked_style(action.get("style", null))
|
||||
if _move_style.is_empty():
|
||||
_move_style = ACTION_REGISTRY.WALK
|
||||
_select_type(type)
|
||||
_rebuild_params(type)
|
||||
title = "Edit Action"
|
||||
@@ -114,6 +124,10 @@ func current_action_type() -> String:
|
||||
return _current_type()
|
||||
|
||||
|
||||
func current_move_style() -> String:
|
||||
return _move_style
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
@@ -149,11 +163,13 @@ func _build_ui() -> void:
|
||||
|
||||
var cancel := Button.new()
|
||||
cancel.text = "Cancel"
|
||||
cancel.custom_minimum_size.y = 44.0
|
||||
cancel.pressed.connect(func() -> void: cancelled.emit())
|
||||
buttons.add_child(cancel)
|
||||
|
||||
var ok := Button.new()
|
||||
ok.text = "OK"
|
||||
ok.text = "Done"
|
||||
ok.custom_minimum_size.y = 44.0
|
||||
ok.pressed.connect(_on_ok_pressed)
|
||||
buttons.add_child(ok)
|
||||
|
||||
@@ -169,7 +185,12 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
_apply_font_recursive(self, _base_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
if sandbox_theme != null:
|
||||
self.theme = sandbox_theme.control_theme()
|
||||
_direct_accent_color = sandbox_theme.color("mode_colors.direct", _direct_accent_color)
|
||||
_surface_color = sandbox_theme.color("global.surface_color", _surface_color)
|
||||
_border_color = sandbox_theme.color("global.border_color", _border_color)
|
||||
_sync_move_style_buttons()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
@@ -201,7 +222,7 @@ func _current_type() -> String:
|
||||
var types := ACTION_REGISTRY.types()
|
||||
var idx := _type_option.selected
|
||||
if idx < 0 or idx >= types.size():
|
||||
return "walk_to"
|
||||
return ACTION_REGISTRY.MOVE_TO
|
||||
return types[idx]
|
||||
|
||||
|
||||
@@ -224,16 +245,30 @@ func _rebuild_params(type: String) -> void:
|
||||
_set_prop_target_btn = null
|
||||
_hand_option = null
|
||||
_stop_current_check = null
|
||||
_walk_style_btn = null
|
||||
_run_style_btn = null
|
||||
|
||||
match type:
|
||||
"walk_to":
|
||||
"move_to":
|
||||
var move_label := Label.new()
|
||||
move_label.text = "How should they move?"
|
||||
_params_box.add_child(move_label)
|
||||
var style_row := HBoxContainer.new()
|
||||
style_row.add_theme_constant_override("separation", 8)
|
||||
_params_box.add_child(style_row)
|
||||
_move_style_group = ButtonGroup.new()
|
||||
_walk_style_btn = _make_move_style_button("🚶 Walk", ACTION_REGISTRY.WALK)
|
||||
_run_style_btn = _make_move_style_button("🏃 Run", ACTION_REGISTRY.RUN)
|
||||
style_row.add_child(_walk_style_btn)
|
||||
style_row.add_child(_run_style_btn)
|
||||
_sync_move_style_buttons()
|
||||
var trow := HBoxContainer.new()
|
||||
_params_box.add_child(trow)
|
||||
_target_label = Label.new()
|
||||
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
trow.add_child(_target_label)
|
||||
_set_target_btn = Button.new()
|
||||
_set_target_btn.text = "🎯 Click target…"
|
||||
_set_target_btn.text = "🚩 Choose a Place"
|
||||
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
||||
trow.add_child(_set_target_btn)
|
||||
_update_walk_target_label()
|
||||
@@ -300,9 +335,71 @@ func _update_walk_target_label() -> void:
|
||||
if _target_label == null:
|
||||
return
|
||||
if _has_target:
|
||||
_target_label.text = "Target: (%d, %d)" % [int(roundf(_target_pos.x)), int(roundf(_target_pos.y))]
|
||||
_target_label.text = "Place chosen"
|
||||
if _set_target_btn != null:
|
||||
_set_target_btn.text = "🚩 Change Place"
|
||||
else:
|
||||
_target_label.text = "Target: not set"
|
||||
_target_label.text = "Place to go:"
|
||||
if _set_target_btn != null:
|
||||
_set_target_btn.text = "🚩 Choose a Place"
|
||||
|
||||
|
||||
func _make_move_style_button(label: String, style: String) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = label
|
||||
button.toggle_mode = true
|
||||
button.button_group = _move_style_group
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.custom_minimum_size = Vector2(44.0, 44.0)
|
||||
button.focus_mode = Control.FOCUS_ALL
|
||||
button.pressed.connect(_on_move_style_pressed.bind(style))
|
||||
return button
|
||||
|
||||
|
||||
func _on_move_style_pressed(style: String) -> void:
|
||||
_move_style = style
|
||||
_sync_move_style_buttons()
|
||||
|
||||
|
||||
func _sync_move_style_buttons() -> void:
|
||||
for pair: Array in [[_walk_style_btn, ACTION_REGISTRY.WALK, "🚶 Walk"], [_run_style_btn, ACTION_REGISTRY.RUN, "🏃 Run"]]:
|
||||
var button: Button = pair[0] as Button
|
||||
if button == null:
|
||||
continue
|
||||
var style: String = String(pair[1])
|
||||
var selected: bool = _move_style == style
|
||||
button.set_pressed_no_signal(selected)
|
||||
button.text = String(pair[2]) + (" ✓" if selected else "")
|
||||
button.add_theme_stylebox_override("normal", _move_style_stylebox(selected, false))
|
||||
button.add_theme_stylebox_override("pressed", _move_style_stylebox(selected, false))
|
||||
button.add_theme_stylebox_override("hover", _move_style_stylebox(selected, true))
|
||||
button.add_theme_stylebox_override("focus", _move_style_focus_stylebox())
|
||||
button.add_theme_color_override("font_color", Color("#161616") if selected else Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color("#161616") if selected else Color.WHITE)
|
||||
|
||||
|
||||
func _move_style_stylebox(selected: bool, hovered: bool) -> StyleBoxFlat:
|
||||
var stylebox := StyleBoxFlat.new()
|
||||
stylebox.bg_color = _direct_accent_color if selected else _surface_color
|
||||
if hovered:
|
||||
stylebox.bg_color = stylebox.bg_color.lightened(0.12)
|
||||
stylebox.border_color = Color("#FFF4CF") if selected else (_direct_accent_color if hovered else _border_color)
|
||||
stylebox.set_border_width_all(3 if selected else 1)
|
||||
stylebox.set_corner_radius_all(8)
|
||||
stylebox.content_margin_left = 8.0
|
||||
stylebox.content_margin_right = 8.0
|
||||
stylebox.content_margin_top = 6.0
|
||||
stylebox.content_margin_bottom = 6.0
|
||||
return stylebox
|
||||
|
||||
|
||||
func _move_style_focus_stylebox() -> StyleBoxFlat:
|
||||
var stylebox := StyleBoxFlat.new()
|
||||
stylebox.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
||||
stylebox.border_color = Color.WHITE
|
||||
stylebox.set_border_width_all(2)
|
||||
stylebox.set_corner_radius_all(8)
|
||||
return stylebox
|
||||
|
||||
|
||||
func _update_prop_target_label() -> void:
|
||||
@@ -323,12 +420,13 @@ func _on_ok_pressed() -> void:
|
||||
var type := _current_type()
|
||||
var action: Dictionary = { "type": type }
|
||||
match type:
|
||||
"walk_to":
|
||||
"move_to":
|
||||
if not _has_target:
|
||||
# Ask the stage to capture the target before committing.
|
||||
target_requested.emit()
|
||||
return
|
||||
action["target"] = _target_pos
|
||||
action["style"] = _move_style
|
||||
"pick_up_prop":
|
||||
if _prop_target_id.is_empty():
|
||||
target_requested.emit()
|
||||
|
||||
@@ -301,6 +301,7 @@ func _refresh_trigger() -> void:
|
||||
|
||||
|
||||
func _select_action_type(want: String) -> void:
|
||||
want = ACTION_REGISTRY.canonical_type(want)
|
||||
if want.is_empty():
|
||||
_action_type_option.select(0)
|
||||
return
|
||||
|
||||
@@ -83,6 +83,7 @@ const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
const SELECTOR_DIM_ALPHA := 0.5
|
||||
const TOP_BAR_HEIGHT: float = 64.0
|
||||
const STATUS_BAR_HEIGHT: float = 38.0
|
||||
const DIRECT_MOVE_POPUP_SIZE := Vector2i(380, 172)
|
||||
|
||||
const SETTINGS_PATH := "user://sandbox_settings.json"
|
||||
const THEME_PATH := "res://sandbox_theme.json"
|
||||
@@ -291,6 +292,12 @@ var _edit_physics_settle_frames: int = 0
|
||||
var _action_popup: PopupMenu = null
|
||||
var _context_rig: StickmanRig = null
|
||||
var _pending_walk_target: bool = false
|
||||
var _direct_move_popup: PopupPanel = null
|
||||
var _direct_move_style_option: OptionButton = null
|
||||
var _direct_move_style_ids: Array[String] = []
|
||||
var _direct_move_target: Vector2 = Vector2.INF
|
||||
## Instance IDs keep this preference local to the current open stage scene.
|
||||
var _direct_move_last_style: Dictionary = {}
|
||||
var _next_stage_prop_serial: int = 0
|
||||
var _speak_dialog: AcceptDialog = null
|
||||
var _speak_edit: LineEdit = null
|
||||
@@ -458,12 +465,6 @@ var _capture_throw_preview_rig: StickmanRig = null
|
||||
var _capture_throw_preview_prefix: Array[Dictionary] = []
|
||||
var _throw_preview_cache: Dictionary = {}
|
||||
|
||||
## Walk-edit state (Phase 3c.3): re-placing an existing walk_to target.
|
||||
var _walk_edit_rig: StickmanRig = null
|
||||
var _walk_edit_index: int = -1
|
||||
var _walk_edit_action_id: int = -1
|
||||
var _walk_edit_from_panel: bool = false
|
||||
|
||||
## ActionEditor context for queue/rule add/edit flows.
|
||||
var _action_editor_kind: String = ""
|
||||
var _action_editor_index: int = -1
|
||||
@@ -1326,10 +1327,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
elif _rule_step != RuleStep.IDLE:
|
||||
_cancel_rule_build()
|
||||
elif _pending_walk_target:
|
||||
_pending_walk_target = false
|
||||
_context_rig = null
|
||||
_apply_cursor()
|
||||
_refresh_status()
|
||||
_cancel_direct_move_draft()
|
||||
elif _direct_move_target.is_finite():
|
||||
_cancel_direct_move_draft()
|
||||
elif _terrain_dragging:
|
||||
_cancel_terrain_drag()
|
||||
elif _selector_open:
|
||||
@@ -1387,6 +1387,8 @@ func _space_should_pass_to_gui() -> bool:
|
||||
return true
|
||||
if _selector_open or (_queue_panel != null and _queue_panel.visible) or (_rule_panel != null and _rule_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible):
|
||||
return true
|
||||
if _direct_move_popup != null and _direct_move_popup.visible:
|
||||
return true
|
||||
for popup: PopupMenu in [_action_popup, _trigger_popup, _rule_action_popup, _rule_more_popup, _waypoint_context, _rig_context_popup]:
|
||||
if popup != null and popup.visible:
|
||||
return true
|
||||
@@ -1421,6 +1423,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||||
return
|
||||
if current_mode == StageMode.PLAY:
|
||||
return
|
||||
if _direct_move_target.is_finite():
|
||||
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
|
||||
_cancel_direct_move_draft()
|
||||
return
|
||||
if mb.button_index == MOUSE_BUTTON_RIGHT:
|
||||
if mb.pressed:
|
||||
if _capture_kind != CaptureKind.NONE:
|
||||
@@ -1428,10 +1434,7 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||||
elif _rule_step != RuleStep.IDLE:
|
||||
_cancel_rule_build()
|
||||
elif _pending_walk_target:
|
||||
_pending_walk_target = false
|
||||
_context_rig = null
|
||||
_apply_cursor()
|
||||
_refresh_status()
|
||||
_cancel_direct_move_draft()
|
||||
else:
|
||||
_handle_right_click()
|
||||
return
|
||||
@@ -1569,7 +1572,7 @@ func _clear_director_pending() -> void:
|
||||
if _speech_composer != null and _speech_composer.visible:
|
||||
_finish_speech_composition(false)
|
||||
_clear_popup_anchor()
|
||||
_pending_walk_target = false
|
||||
_cancel_direct_move_draft()
|
||||
_context_rig = null
|
||||
if _capture_kind != CaptureKind.NONE:
|
||||
_cancel_capture()
|
||||
@@ -1777,7 +1780,7 @@ func _finish_resume_baseline() -> void:
|
||||
|
||||
|
||||
func _pause_toggle_blocked() -> bool:
|
||||
if _capture_kind != CaptureKind.NONE or _rule_step != RuleStep.IDLE or _pending_walk_target or _gizmos.is_dragging() or _selector_open:
|
||||
if _capture_kind != CaptureKind.NONE or _rule_step != RuleStep.IDLE or _pending_walk_target or _direct_move_target.is_finite() or _gizmos.is_dragging() or _selector_open:
|
||||
return true
|
||||
var focus: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
|
||||
@@ -2482,6 +2485,7 @@ func _reset_scene_content() -> void:
|
||||
_free_ghost(); _free_terrain_ghosts(); _end_terrain_drag()
|
||||
_selection.clear_selection(); _selection.clear_hover(); _authored.clear(); _edit_physics_targets.clear(); _event_rules.clear(); _next_rule_id = 0
|
||||
_clear_director_pending(); _reset_rule_builder(); _nav_dirty = false
|
||||
_direct_move_last_style.clear()
|
||||
for child: Node in _world.get_children():
|
||||
_world.remove_child(child)
|
||||
child.queue_free()
|
||||
@@ -2695,6 +2699,8 @@ func _check_autosave_recovery() -> void:
|
||||
|
||||
|
||||
func _handle_cli_startup(args: Array) -> void:
|
||||
if OS.get_environment("STICKMAN_WALK_DEBUG") == "1":
|
||||
print("[WalkDebug] CLI_STARTUP args=%s" % [args])
|
||||
var result: Dictionary = CliSceneLauncher.parse(args)
|
||||
if not String(result.get("error", "")).is_empty():
|
||||
_show_cli_error_dialog(String(result["error"]))
|
||||
@@ -2753,7 +2759,9 @@ func _refresh_status() -> void:
|
||||
sel_text = "%d objects" % sel.size()
|
||||
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
|
||||
if _pending_walk_target:
|
||||
text += " | Click stage for walk target (Esc or RMB to cancel)"
|
||||
text += " | Click stage to choose a Move To place (Esc or RMB to cancel)"
|
||||
elif _direct_move_target.is_finite():
|
||||
text += " | Choose Movement, then Add Move To (Esc or RMB to cancel)"
|
||||
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
|
||||
text += " | " + _rule_hint
|
||||
if not _toast_text.is_empty():
|
||||
@@ -3239,7 +3247,7 @@ func _build_ui() -> void:
|
||||
_set_timestamp(0.0)
|
||||
|
||||
_action_popup = PopupMenu.new()
|
||||
_action_popup.add_item("🚶 Walk To", ACT_WALK)
|
||||
_action_popup.add_item("🚩 Move To", ACT_WALK)
|
||||
_action_popup.add_item("💬 Speak", ACT_SPEAK)
|
||||
_action_popup.add_item("⏳ Wait", ACT_WAIT)
|
||||
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
|
||||
@@ -3256,6 +3264,62 @@ func _build_ui() -> void:
|
||||
_action_popup.about_to_popup.connect(_refresh_action_popup_items)
|
||||
ui.add_child(_action_popup)
|
||||
|
||||
_direct_move_popup = PopupPanel.new()
|
||||
_direct_move_popup.name = "DirectMovePopup"
|
||||
_direct_move_popup.exclusive = true
|
||||
_direct_move_popup.popup_window = true
|
||||
_direct_move_popup.min_size = DIRECT_MOVE_POPUP_SIZE
|
||||
if _sandbox_theme != null:
|
||||
_direct_move_popup.theme = _sandbox_theme.control_theme()
|
||||
ui.add_child(_direct_move_popup)
|
||||
var move_margin := MarginContainer.new()
|
||||
move_margin.add_theme_constant_override("margin_left", 14)
|
||||
move_margin.add_theme_constant_override("margin_right", 14)
|
||||
move_margin.add_theme_constant_override("margin_top", 12)
|
||||
move_margin.add_theme_constant_override("margin_bottom", 12)
|
||||
_direct_move_popup.add_child(move_margin)
|
||||
var move_column := VBoxContainer.new()
|
||||
move_column.add_theme_constant_override("separation", 10)
|
||||
move_margin.add_child(move_column)
|
||||
var move_row := HBoxContainer.new()
|
||||
move_row.add_theme_constant_override("separation", 12)
|
||||
move_column.add_child(move_row)
|
||||
var move_label := Label.new()
|
||||
move_label.text = "Movement"
|
||||
_apply_ui_font(move_label)
|
||||
move_label.add_theme_font_size_override("font_size", 23)
|
||||
move_row.add_child(move_label)
|
||||
_direct_move_style_option = OptionButton.new()
|
||||
_direct_move_style_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_direct_move_style_option.custom_minimum_size = Vector2(210.0, 54.0)
|
||||
_direct_move_style_option.add_theme_font_size_override("font_size", 25)
|
||||
if _emoji_font != null:
|
||||
_direct_move_style_option.add_theme_font_override("font", _emoji_font)
|
||||
else:
|
||||
_apply_ui_font(_direct_move_style_option)
|
||||
move_row.add_child(_direct_move_style_option)
|
||||
var move_buttons := HBoxContainer.new()
|
||||
move_buttons.alignment = BoxContainer.ALIGNMENT_END
|
||||
move_buttons.add_theme_constant_override("separation", 8)
|
||||
move_column.add_child(move_buttons)
|
||||
var move_cancel := Button.new()
|
||||
move_cancel.text = "Cancel"
|
||||
move_cancel.custom_minimum_size = Vector2(112.0, 52.0)
|
||||
move_cancel.pressed.connect(_cancel_direct_move_draft)
|
||||
_apply_ui_font(move_cancel)
|
||||
move_cancel.add_theme_font_size_override("font_size", 22)
|
||||
move_buttons.add_child(move_cancel)
|
||||
var move_add := Button.new()
|
||||
move_add.text = "Add Move To"
|
||||
move_add.custom_minimum_size = Vector2(166.0, 52.0)
|
||||
move_add.pressed.connect(_commit_direct_move_draft)
|
||||
_apply_ui_font(move_add)
|
||||
move_add.add_theme_font_size_override("font_size", 22)
|
||||
move_buttons.add_child(move_add)
|
||||
_direct_move_popup.popup_hide.connect(_cancel_direct_move_draft)
|
||||
_direct_move_popup.window_input.connect(_on_direct_move_popup_input)
|
||||
_populate_direct_move_styles()
|
||||
|
||||
_trigger_popup = PopupMenu.new()
|
||||
_trigger_popup.add_item("📍 Arrives at a waypoint", TRIG_ARRIVED)
|
||||
_trigger_popup.add_item("✅ Completes any action", TRIG_ACTION_FINISHED)
|
||||
@@ -3271,7 +3335,7 @@ func _build_ui() -> void:
|
||||
ui.add_child(_trigger_popup)
|
||||
|
||||
_rule_action_popup = PopupMenu.new()
|
||||
_rule_action_popup.add_item("🚶 Walk To", ACT_WALK)
|
||||
_rule_action_popup.add_item("🚶 Move 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)
|
||||
@@ -3463,6 +3527,7 @@ func _build_ui() -> void:
|
||||
_action_editor.speech_requested.connect(_on_action_editor_speech_requested)
|
||||
ui.add_child(_action_editor)
|
||||
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||||
_action_editor.apply_theme(_sandbox_theme)
|
||||
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
|
||||
|
||||
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor
|
||||
@@ -4082,14 +4147,22 @@ func _sync_editable_physics_transform(node: Node2D) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 })
|
||||
if _pending_walk_target:
|
||||
if _context_rig == null or not is_instance_valid(_context_rig):
|
||||
_cancel_direct_move_draft()
|
||||
return
|
||||
if not _is_target_valid(world_pos):
|
||||
_show_toast("Choose an open place for Move To")
|
||||
return
|
||||
_stage_dbg("move_to staged target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
|
||||
_direct_move_target = world_pos
|
||||
_pending_walk_target = false
|
||||
_context_rig = null
|
||||
_show_direct_move_popup(_world_to_screen(world_pos))
|
||||
_apply_cursor()
|
||||
_refresh_status()
|
||||
return
|
||||
if _direct_move_target.is_finite():
|
||||
return
|
||||
var hit := _selection.hit_test(world_pos)
|
||||
if hit is STICKMAN_RIG:
|
||||
_context_rig = hit as StickmanRig
|
||||
@@ -4099,6 +4172,87 @@ func _handle_direct_click(world_pos: Vector2) -> void:
|
||||
_action_popup.popup(rect)
|
||||
|
||||
|
||||
func _populate_direct_move_styles() -> void:
|
||||
_direct_move_style_ids.clear()
|
||||
_direct_move_style_option.clear()
|
||||
var template: Dictionary = ACTION_REGISTRY.ACTION_TEMPLATES.get(ACTION_REGISTRY.MOVE_TO, {})
|
||||
var params: Array = template.get("params", [])
|
||||
for entry: Variant in params:
|
||||
if not entry is Dictionary:
|
||||
continue
|
||||
var param: Dictionary = entry
|
||||
if String(param.get("key", "")) != "style":
|
||||
continue
|
||||
var choices: Array = param.get("choices", [])
|
||||
for raw_style: Variant in choices:
|
||||
var style := String(raw_style)
|
||||
if style.is_empty() or _direct_move_style_ids.has(style):
|
||||
continue
|
||||
_direct_move_style_ids.append(style)
|
||||
var icon := "🚶 " if style == ACTION_REGISTRY.WALK else ("🏃 " if style == ACTION_REGISTRY.RUN else "")
|
||||
_direct_move_style_option.add_item(icon + style.capitalize())
|
||||
break
|
||||
if _direct_move_style_ids.is_empty():
|
||||
_direct_move_style_ids.append(ACTION_REGISTRY.WALK)
|
||||
_direct_move_style_option.add_item("🚶 Walk")
|
||||
|
||||
|
||||
func _show_direct_move_popup(screen_pos: Vector2) -> void:
|
||||
if _direct_move_popup == null or _direct_move_style_option == null:
|
||||
_cancel_direct_move_draft()
|
||||
return
|
||||
var rig_id: int = _context_rig.get_instance_id()
|
||||
var preferred: String = String(_direct_move_last_style.get(rig_id, ACTION_REGISTRY.WALK))
|
||||
var index: int = _direct_move_style_ids.find(preferred)
|
||||
_direct_move_style_option.select(index if index >= 0 else 0)
|
||||
var viewport_size: Vector2 = get_viewport_rect().size
|
||||
var popup_size := DIRECT_MOVE_POPUP_SIZE
|
||||
_direct_move_popup.size = popup_size
|
||||
var popup_pos := Vector2i(screen_pos + Vector2(18.0, 12.0))
|
||||
popup_pos.x = clampi(popup_pos.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
|
||||
popup_pos.y = clampi(popup_pos.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
|
||||
_direct_move_popup.position = popup_pos
|
||||
_direct_move_popup.popup()
|
||||
_direct_move_style_option.grab_focus()
|
||||
|
||||
|
||||
func _commit_direct_move_draft() -> void:
|
||||
if not _direct_move_target.is_finite() or _context_rig == null or not is_instance_valid(_context_rig):
|
||||
_cancel_direct_move_draft()
|
||||
return
|
||||
var index: int = _direct_move_style_option.selected
|
||||
if index < 0 or index >= _direct_move_style_ids.size():
|
||||
return
|
||||
var style: String = _direct_move_style_ids[index]
|
||||
var rig_id: int = _context_rig.get_instance_id()
|
||||
var action: Dictionary = {"type": ACTION_REGISTRY.MOVE_TO, "target": _direct_move_target, "style": style}
|
||||
if not _queue_action_and_confirm(_context_rig, action):
|
||||
_show_toast("Move To was not added. Try again or Cancel.")
|
||||
return
|
||||
_direct_move_last_style[rig_id] = style
|
||||
_cancel_direct_move_draft()
|
||||
_show_toast("Move To · %s added" % style.capitalize())
|
||||
|
||||
|
||||
func _cancel_direct_move_draft() -> void:
|
||||
if not _pending_walk_target and not _direct_move_target.is_finite():
|
||||
return
|
||||
_pending_walk_target = false
|
||||
_direct_move_target = Vector2.INF
|
||||
_context_rig = null
|
||||
if _direct_move_popup != null and _direct_move_popup.visible:
|
||||
_direct_move_popup.hide()
|
||||
_apply_cursor()
|
||||
_refresh_status()
|
||||
|
||||
|
||||
func _on_direct_move_popup_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
||||
_cancel_direct_move_draft()
|
||||
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_cancel_direct_move_draft()
|
||||
|
||||
|
||||
## Re-enables/disables the "📋 Edit Queue…" / "⚡ Edit Rules…" items in the Direct
|
||||
## action popup based on the context rig's queue/rules (entry-point gating, spec §2
|
||||
## decision 8). Runs on about_to_popup.
|
||||
@@ -4146,6 +4300,7 @@ func _on_action_popup_id_pressed(id: int) -> void:
|
||||
return
|
||||
match id:
|
||||
ACT_WALK:
|
||||
_direct_move_target = Vector2.INF
|
||||
_pending_walk_target = true
|
||||
_apply_cursor()
|
||||
_refresh_status()
|
||||
@@ -4214,7 +4369,7 @@ func _is_awaiting_click() -> bool:
|
||||
|
||||
func _action_hint_text() -> String:
|
||||
if _pending_walk_target:
|
||||
return "🚩 Click to set walk target"
|
||||
return "🚩 Click where they should go"
|
||||
if _capture_kind != CaptureKind.NONE:
|
||||
if _capture_kind == CaptureKind.THROW_POSITION and not _throw_preview_cache.is_empty():
|
||||
if not bool(_throw_preview_cache.get("preview_available", true)):
|
||||
@@ -4238,7 +4393,12 @@ func _action_hint_text() -> String:
|
||||
RuleStep.ACTION_TARGET:
|
||||
return "🎯 Click the stickman"
|
||||
RuleStep.ACTION_POSITION:
|
||||
return "🚩 Click where to walk"
|
||||
var actions: Array = _rule_builder.get("actions", [])
|
||||
var style: String = ACTION_REGISTRY.WALK
|
||||
if not actions.is_empty():
|
||||
var move_action: Dictionary = actions.back()
|
||||
style = ACTION_REGISTRY.checked_style(move_action.get("params", {}).get("style", null))
|
||||
return "🚩 Click where they should %s" % ("run" if style == ACTION_REGISTRY.RUN else "walk")
|
||||
return ""
|
||||
|
||||
|
||||
@@ -4252,8 +4412,10 @@ func _action_origin() -> Vector2:
|
||||
return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
return Vector2.ZERO
|
||||
if _capture_kind != CaptureKind.NONE:
|
||||
if _walk_edit_rig != null and is_instance_valid(_walk_edit_rig):
|
||||
return _walk_edit_rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
if _capture_kind == CaptureKind.POSITION:
|
||||
var editor_rig: StickmanRig = _action_editor_preview_rig()
|
||||
if editor_rig != null and is_instance_valid(editor_rig):
|
||||
return editor_rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
if _panel_rig != null and is_instance_valid(_panel_rig):
|
||||
return _panel_rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
return Vector2.ZERO
|
||||
@@ -4281,7 +4443,7 @@ func _update_action_overlay() -> void:
|
||||
if awaiting:
|
||||
var origin := _action_origin()
|
||||
var target := _camera.get_global_mouse_position()
|
||||
if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.THROW_POSITION):
|
||||
if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.POSITION or _capture_kind == CaptureKind.THROW_POSITION):
|
||||
target = _snap_to_grid(target)
|
||||
if _capture_kind == CaptureKind.THROW_POSITION:
|
||||
if _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
|
||||
@@ -4312,6 +4474,9 @@ func _update_action_overlay() -> void:
|
||||
)
|
||||
else:
|
||||
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
|
||||
elif _direct_move_target.is_finite() and _context_rig != null and is_instance_valid(_context_rig):
|
||||
var origin: Vector2 = _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
_placement_overlay.set_action_trajectory(origin, _direct_move_target, true)
|
||||
else:
|
||||
_placement_overlay.clear_action()
|
||||
if _tooltip != null:
|
||||
@@ -4501,7 +4666,7 @@ func _trigger_type_string(id: int) -> String:
|
||||
func _action_type_string(id: int) -> String:
|
||||
match id:
|
||||
ACT_WALK:
|
||||
return "walk_to"
|
||||
return ACTION_REGISTRY.MOVE_TO
|
||||
ACT_SPEAK:
|
||||
return "speak"
|
||||
ACT_WAIT:
|
||||
@@ -4523,7 +4688,7 @@ func _action_type_string(id: int) -> String:
|
||||
func _action_verb(id: int) -> String:
|
||||
match id:
|
||||
ACT_WALK:
|
||||
return "walk"
|
||||
return "move to a place"
|
||||
ACT_SPEAK:
|
||||
return "speak"
|
||||
ACT_WAIT:
|
||||
@@ -4601,10 +4766,8 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
|
||||
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 or RMB to cancel)" % String(hit.name)
|
||||
_refresh_status()
|
||||
"move_to":
|
||||
_open_action_editor("rule_builder_move_to", {}, -1, null, int((hit as StickmanRig).get_instance_id()), false)
|
||||
"speak":
|
||||
_rule_step = RuleStep.PARAMS
|
||||
_open_speech_composer(hit as StickmanRig, {"type": "speak", "text": "", "duration": 2.0}, true, "rule_builder")
|
||||
@@ -4911,7 +5074,7 @@ func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
|
||||
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", ""))
|
||||
return want.is_empty() or ACTION_REGISTRY.canonical_type(want) == ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
|
||||
"speech_finished":
|
||||
return _ids_match(source_id, source)
|
||||
"entered_area":
|
||||
@@ -4948,21 +5111,7 @@ func _execute_rule_actions(rule: Dictionary) -> void:
|
||||
|
||||
## 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":
|
||||
return ACTION_REGISTRY.from_rule_action(a)
|
||||
"wait":
|
||||
out["duration"] = float(params.get("duration", 0.0))
|
||||
"pick_up_prop":
|
||||
out["target_prop_id"] = String(params.get("target_prop_id", ""))
|
||||
out["hand"] = String(params.get("hand", "auto"))
|
||||
"throw_prop":
|
||||
out["target_position"] = params.get("target_position", Vector2.ZERO)
|
||||
return out
|
||||
return ACTION_REGISTRY.from_rule_action(a)
|
||||
|
||||
|
||||
## Geometric overlap: TriggerArea children vs ANIMATED stickmen + unfrozen props.
|
||||
@@ -5093,7 +5242,7 @@ func _on_waypoint_context_id_pressed(id: int) -> void:
|
||||
_begin_walk_edit(rig, index, false)
|
||||
WaypointContext.DELETE_WALK:
|
||||
rig.remove_action(index)
|
||||
_show_toast("Walk deleted")
|
||||
_show_toast("Move erased")
|
||||
WaypointContext.INSERT_BEFORE:
|
||||
if _queue_action_consumed(status) or status == StickmanActionRunner.QueueStatus.ACTIVE:
|
||||
_show_toast("Insert after the active action")
|
||||
@@ -5234,54 +5383,10 @@ func _resolve_capture(world_pos: Vector2) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
|
||||
if index < 0 or index >= rig.queue_size():
|
||||
var queue: Array[Dictionary] = rig.get_queue()
|
||||
if index < 0 or index >= queue.size() or not ACTION_REGISTRY.is_move_action(queue[index]):
|
||||
return
|
||||
_walk_edit_rig = rig
|
||||
_walk_edit_index = index
|
||||
_walk_edit_action_id = rig.action_id_at(index)
|
||||
_walk_edit_from_panel = from_panel
|
||||
var queue := rig.get_queue()
|
||||
var pos: Vector2 = queue[index].get("target", rig.global_position - StickmanLocomotion.FOOT_OFFSET)
|
||||
_director_visuals.set_edit_waypoint(pos)
|
||||
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc or RMB to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
|
||||
|
||||
|
||||
func _on_walk_edit_captured(value: Vector2) -> void:
|
||||
var rig := _walk_edit_rig
|
||||
var action_id: int = _walk_edit_action_id
|
||||
var from_panel := _walk_edit_from_panel
|
||||
_walk_edit_rig = null
|
||||
_walk_edit_index = -1
|
||||
_walk_edit_action_id = -1
|
||||
_walk_edit_from_panel = false
|
||||
if _director_visuals != null:
|
||||
_director_visuals.clear_edit_waypoint()
|
||||
if rig != null and is_instance_valid(rig):
|
||||
var index: int = rig.index_for_action_id(action_id)
|
||||
var status: int = rig.get_action_status(action_id)
|
||||
var queue: Array[Dictionary] = rig.get_queue()
|
||||
if index >= 0 and index < queue.size() and not _queue_action_consumed(status):
|
||||
var action: Dictionary = queue[index]
|
||||
action["target"] = value
|
||||
if rig.replace_action(index, action):
|
||||
_show_toast("Waypoint moved")
|
||||
else:
|
||||
_show_toast("That action changed while the waypoint was open")
|
||||
if from_panel and _queue_panel != null:
|
||||
_queue_panel.refresh()
|
||||
_queue_panel.popup_centered()
|
||||
|
||||
|
||||
func _cb_walk_edit_cancel() -> void:
|
||||
var from_panel := _walk_edit_from_panel
|
||||
_walk_edit_rig = null
|
||||
_walk_edit_index = -1
|
||||
_walk_edit_action_id = -1
|
||||
_walk_edit_from_panel = false
|
||||
if _director_visuals != null:
|
||||
_director_visuals.clear_edit_waypoint()
|
||||
if from_panel and _queue_panel != null:
|
||||
_queue_panel.popup_centered()
|
||||
_open_action_editor("queue_edit", queue[index].duplicate(true), index, rig, -1, from_panel)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -5304,11 +5409,7 @@ func _on_queue_panel_edit_requested(index: int) -> void:
|
||||
_show_toast("Completed actions can only be deleted")
|
||||
return
|
||||
var action: Dictionary = queue[index]
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
_queue_panel.hide()
|
||||
_begin_walk_edit(_panel_rig, index, true)
|
||||
else:
|
||||
_open_action_editor("queue_edit", action.duplicate(true), index, _panel_rig, -1, true)
|
||||
_open_action_editor("queue_edit", action.duplicate(true), index, _panel_rig, -1, true)
|
||||
|
||||
|
||||
func _on_queue_panel_delete_requested(index: int) -> void:
|
||||
@@ -6395,6 +6496,21 @@ func _on_action_editor_committed(flat: Dictionary) -> bool:
|
||||
if _rule_editor != null and actor_id >= 0:
|
||||
_rule_editor.set_action(_action_editor_index, ACTION_REGISTRY.to_rule_action(flat, actor_id))
|
||||
succeeded = true
|
||||
"rule_builder_move_to":
|
||||
var actions: Array = _rule_builder.get("actions", [])
|
||||
if not actions.is_empty() and _action_editor_actor_id >= 0:
|
||||
var rule_action: Dictionary = actions.back()
|
||||
rule_action["type"] = ACTION_REGISTRY.MOVE_TO
|
||||
rule_action["target"] = _action_editor_actor_id
|
||||
rule_action["params"] = {
|
||||
"target": flat.get("target", Vector2.ZERO),
|
||||
"style": ACTION_REGISTRY.checked_style(flat.get("style", null)),
|
||||
}
|
||||
actions[actions.size() - 1] = rule_action
|
||||
_rule_builder["actions"] = actions
|
||||
_rule_step = RuleStep.SELECT_ACTION
|
||||
_open_rule_more_popup()
|
||||
succeeded = true
|
||||
if not succeeded:
|
||||
return false
|
||||
_action_editor.hide()
|
||||
@@ -6426,6 +6542,16 @@ func _on_action_editor_cancelled() -> void:
|
||||
"rule_add", "rule_edit":
|
||||
if _rule_editor != null:
|
||||
_rule_editor.popup_centered()
|
||||
"rule_builder_move_to":
|
||||
var actions: Array = _rule_builder.get("actions", [])
|
||||
if not actions.is_empty():
|
||||
actions.pop_back()
|
||||
_rule_builder["actions"] = actions
|
||||
_rule_step = RuleStep.SELECT_ACTION
|
||||
if actions.is_empty():
|
||||
_open_rule_action_popup()
|
||||
else:
|
||||
_open_rule_more_popup()
|
||||
_:
|
||||
pass
|
||||
|
||||
@@ -6433,7 +6559,7 @@ func _on_action_editor_cancelled() -> void:
|
||||
func _on_action_editor_target_requested() -> void:
|
||||
_action_editor.hide()
|
||||
var action_type := _action_editor.current_action_type()
|
||||
if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == "pick_up_prop" or action_type == "throw_prop"):
|
||||
if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "throw_prop"):
|
||||
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_editor_actor_for_target, _cb_editor_actor_capture_cancel)
|
||||
return
|
||||
_begin_editor_action_target_capture()
|
||||
@@ -6441,6 +6567,9 @@ func _on_action_editor_target_requested() -> void:
|
||||
|
||||
func _begin_editor_action_target_capture() -> void:
|
||||
match _action_editor.current_action_type():
|
||||
"move_to":
|
||||
var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk"
|
||||
_begin_capture(CaptureKind.POSITION, "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||||
"pick_up_prop":
|
||||
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
|
||||
"throw_prop":
|
||||
@@ -6458,7 +6587,7 @@ func _begin_editor_action_target_capture() -> void:
|
||||
queue_prefix
|
||||
)
|
||||
_:
|
||||
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||||
_begin_capture(CaptureKind.POSITION, "🚩 Click the target (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||||
|
||||
|
||||
func _cb_editor_walk_target(value: Vector2) -> void:
|
||||
|
||||
Reference in New Issue
Block a user