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:
@@ -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)))
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user