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:
2026-10-05 15:04:09 -04:00
parent 36596b2c28
commit 3f66cc3195
19 changed files with 2088 additions and 194 deletions
+40 -17
View File
@@ -9,12 +9,17 @@ extends RefCounted
enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER }
const MOVE_TO: String = "move_to"
const WALK: String = "walk"
const RUN: String = "run"
const ACTION_TEMPLATES := {
"walk_to": {
"label": "Walk To",
MOVE_TO: {
"label": "Move To",
"icon": "🚶",
"params": [
{ "key": "target", "type": "position", "required": true },
{ "key": "style", "type": "choice", "default": WALK, "choices": [WALK, RUN] },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
@@ -86,6 +91,24 @@ static func has_type(type: String) -> bool:
return ACTION_TEMPLATES.has(type)
static func canonical_type(type: String) -> String:
return MOVE_TO if type == "walk_to" else type
static func checked_style(value: Variant) -> String:
if value == null:
return WALK
if not value is String:
return ""
var style: String = value
return style if style == WALK or style == RUN else ""
static func is_move_action(action: Dictionary) -> bool:
var type: String = String(action.get("type", ""))
return type == MOVE_TO or type == "walk_to"
static func label(type: String) -> String:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
return String(tpl.get("label", type))
@@ -118,9 +141,11 @@ static func reactive_role(action: Dictionary) -> ReactiveRole:
## params). `target_id` is the acting stickman's instance id.
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
var params: Dictionary = {}
match String(action.get("type", "")):
"walk_to":
var type: String = canonical_type(String(action.get("type", "")))
match type:
MOVE_TO:
params["target"] = action.get("target", Vector2.ZERO)
params["style"] = checked_style(action.get("style", null))
"speak":
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
if action.has(key):
@@ -133,7 +158,7 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
"throw_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
return {
"type": String(action.get("type", "")),
"type": type,
"target": target_id,
"params": params,
}
@@ -142,9 +167,10 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
## Converts a rule-action shape back into a flat queue action (drops `target`).
static func from_rule_action(rule_action: Dictionary) -> Dictionary:
var params: Dictionary = rule_action.get("params", {})
match String(rule_action.get("type", "")):
"walk_to":
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
var type: String = canonical_type(String(rule_action.get("type", "")))
match type:
MOVE_TO:
return { "type": MOVE_TO, "target": params.get("target", Vector2.ZERO), "style": checked_style(params.get("style", null)) }
"speak":
var speak: Dictionary = {"type": "speak"}
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
@@ -162,22 +188,19 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
"throw_prop":
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
_:
return { "type": String(rule_action.get("type", "")) }
return { "type": type }
## Human-readable one-line summary for both flat queue actions and rule-action
## shapes (the get() fallbacks tolerate either key layout).
static func summarize(action: Dictionary) -> String:
var type := String(action.get("type", ""))
var type := canonical_type(String(action.get("type", "")))
var params: Dictionary = action.get("params", {})
match type:
"walk_to":
# Rule actions use top-level `target` for the actor instance id and
# nested `params.target` for the waypoint. Prefer the nested value and
# type-guard legacy/malformed payloads so summaries never crash.
var raw_target: Variant = params.get("target", action.get("target", Vector2.ZERO))
var t: Vector2 = raw_target as Vector2 if raw_target is Vector2 else Vector2.ZERO
return "Walk To (%d, %d)" % [int(roundf(t.x)), int(roundf(t.y))]
MOVE_TO:
var style: String = checked_style(params.get("style", action.get("style", null)))
var style_label: String = "Run" if style == RUN else "Walk"
return "Move To · %s" % style_label
"speak":
var text := String(action.get("text", params.get("text", "")))
var dur := float(action.get("duration", params.get("duration", 2.0)))
+6 -5
View File
@@ -8,6 +8,7 @@ extends Node2D
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const WAYPOINT_RADIUS_PX := 6.0
const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0)
@@ -158,7 +159,7 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
for i: int in queue_view.size():
var entry: Dictionary = queue_view[i]
var action: Dictionary = entry.get("action", {})
if String(action.get("type", "")) == "walk_to":
if ACTION_REGISTRY.is_move_action(action):
var target: Vector2 = action.get("target", current)
var d := world_pos.distance_to(target)
if d <= best_dist:
@@ -218,7 +219,7 @@ func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
var tint: Color = _status_color(status)
var consumed: bool = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
var current: Vector2 = authored_current if _live_session and consumed else live_current if _live_session else authored_current
if String(action.get("type", "")) == "walk_to":
if ACTION_REGISTRY.is_move_action(action):
var target: Vector2 = action.get("target", current)
if not (_live_session and consumed and not has_authored_cursor):
_draw_dashed(current, target, zoom, tint)
@@ -319,7 +320,7 @@ func _collect_waypoints() -> Array[Vector2]:
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
for i: int in queue.size():
var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to":
if ACTION_REGISTRY.is_move_action(action):
var target: Vector2 = action.get("target", current)
result.append(target)
current = target
@@ -481,8 +482,8 @@ static func _trigger_verb(type: String) -> String:
static func _action_desc(action: Dictionary) -> String:
var params: Dictionary = action.get("params", {})
match String(action.get("type", "")):
"walk_to":
return "Walks"
"move_to", "walk_to":
return ACTION_REGISTRY.summarize(action)
"speak":
return "Speak '%s'" % String(params.get("text", ""))
"wait":
+20 -8
View File
@@ -199,12 +199,14 @@ static func _finite_number(value: Variant) -> bool:
return (value is float or value is int) and is_finite(float(value))
static func _encode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
var type := ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
"move_to":
if not action.get("target") is Vector2: return {}
return {"type": type, "target": vector_to_data(action["target"])}
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty(): return {}
return {"type": type, "target": vector_to_data(action["target"]), "style": style}
"speak": return _encode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
@@ -221,12 +223,14 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
var type := ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
"move_to":
if not is_vector_data(action.get("target")): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO)}
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty(): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO), "style": style}
"speak":
return _decode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
@@ -359,20 +363,28 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
if source == null and int(trigger.get("source", -1)) != -1: return {}
if target == null and int(trigger.get("target", -1)) != -1: return {}
var params: Dictionary = trigger.get("params", {})
var params: Dictionary = (trigger.get("params", {}) as Dictionary).duplicate(true)
if type == "arrived_at_waypoint":
if not params.get("waypoint_pos") is Vector2: return {}
params = {"waypoint_pos": vector_to_data(params["waypoint_pos"])}
elif type == "action_finished":
var filter: String = String(params.get("action_type", ""))
if not filter.is_empty():
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
return {"type": type, "source_id": source, "target_id": target, "params": params}
static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
var type := String(value.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source := _unmap_ref(value.get("source_id"), refs, unavailable); var target := _unmap_ref(value.get("target_id"), refs, unavailable)
if source == -2 or target == -2: return {}
var params: Dictionary = value.get("params", {})
var params: Dictionary = (value.get("params", {}) as Dictionary).duplicate(true)
if type == "arrived_at_waypoint":
if not is_vector_data(params.get("waypoint_pos")): return {}
params = {"waypoint_pos": data_to_vector(params["waypoint_pos"], Vector2.ZERO)}
elif type == "action_finished":
var filter: String = String(params.get("action_type", ""))
if not filter.is_empty():
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
return {"type": type, "source": source, "target": target, "params": params}
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
+3 -3
View File
@@ -1,6 +1,6 @@
class_name WaypointContext
extends PopupMenu
## WaypointContext - Right-click context menu for a walk_to waypoint (Phase 3c.3).
## WaypointContext - Right-click context menu for a Move To destination.
##
## Provides Edit/Delete/Insert actions for the waypoint's walk action plus a
## "Edit Trigger Rules" entry (shown only when rules target this waypoint).
@@ -17,8 +17,8 @@ const _EDIT_TRIGGER_IDX := 5 # item position of the trigger-rules entry (after
func _init() -> void:
# _init runs before the node is in the tree; item order is fixed here.
add_item("✎ Edit this Walk", EDIT_WALK)
add_item("✕ Delete this Walk", DELETE_WALK)
add_item("✎ Edit this Move", EDIT_WALK)
add_item("✕ Erase this Move", DELETE_WALK)
add_item("⬆ Insert action before", INSERT_BEFORE)
add_item("⬇ Insert action after", INSERT_AFTER)
add_separator()