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
@@ -385,9 +385,20 @@ func _advance_to_next_action() -> void:
_current_index = int(_current_run_item.get("queue_index", -1))
_current_action_id = int(_current_run_item.get("action_id", -1))
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
var action: Dictionary = _current_run_item.get("action", {})
var action: Dictionary = _current_run_item.get("action", {}).duplicate(true)
var legacy_speed_override: float = -1.0
if ACTION_REGISTRY.is_move_action(action):
var raw_type: String = String(action.get("type", ""))
var raw_speed: Variant = action.get("speed", -1.0)
if raw_type == "walk_to" and (raw_speed is int or raw_speed is float) and is_finite(float(raw_speed)) and float(raw_speed) > 0.0:
legacy_speed_override = float(raw_speed)
action["type"] = ACTION_REGISTRY.MOVE_TO
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
action["style"] = style if not style.is_empty() else ACTION_REGISTRY.WALK
action.erase("speed")
_current_run_item["action"] = action
var action_type := String(action.get("type", ""))
var requires_animated := action_type == "walk_to" or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop"
var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop"
if requires_animated and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
@@ -403,14 +414,17 @@ func _advance_to_next_action() -> void:
_current_index = -1
return
action_started.emit(action, _current_index)
_begin_action(action)
_begin_action(action, legacy_speed_override)
func _begin_action(action: Dictionary) -> void:
func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> void:
match String(action.get("type", "")):
"walk_to":
"move_to":
_action_phase = ActionPhase.WALKING
if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))):
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty():
style = ACTION_REGISTRY.WALK
if _locomotion == null or not _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override):
_finish_action()
"speak":
if bool(action.get("keep_acting", false)):
@@ -3,6 +3,8 @@ extends Node
## Owns navigation, walking/gait, terrain following, animated falls, and landings.
## StickmanRig schedules this component and exposes its public facade.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
const TERRAIN_PROBE_UP: float = 96.0
@@ -47,6 +49,19 @@ const NAV_PATH_DESIRED_DISTANCE: float = 8.0
const NAV_TARGET_DESIRED_DISTANCE: float = 12.0
const LATCH_PROBE_MAX_FRAMES: int = 6
const WALK_CYCLE_DURATION: float = 0.8
const RUN_CYCLE_DURATION: float = 0.75
const RUN_RIGHT_CONTACT_START: float = 0.0
const RUN_LEFT_CONTACT_START: float = 0.375
const RUN_CONTACT_DURATION: float = 0.1875
const RUN_CONTACT_EDGE_FADE: float = 0.03125
const ARRIVAL_SETTLE_DURATION: float = 0.15
const LEFT_STANDING_ANIMATION: StringName = &"walk_left"
const RIGHT_STANDING_ANIMATION: StringName = &"walk_right"
const MOVE_STYLE_DATA: Dictionary = {
"walk": {"clip": "walk_right", "pace": 300.0, "cycle": WALK_CYCLE_DURATION, "run": false},
"run": {"clip": "run_right", "pace": 500.0, "cycle": RUN_CYCLE_DURATION, "run": true},
}
const MOVE_MARKER_NAMES: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]
const HEAD_COLLISION_RADIUS: float = 90.0
const TORSO_COLLISION_RADIUS: float = 20.0
@@ -106,12 +121,22 @@ var _terrain_paused_walk_animation: bool = false
var _nav_agent: NavigationAgent2D = null
var _nav_anchor: Node2D = null
var _walking: bool = false
var _settling: bool = false
var _settle_waiting_for_ground: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _active_move_style: String = "walk"
var _active_animation_name: StringName = &"walk_right"
var _active_cycle_duration: float = WALK_CYCLE_DURATION
var _walk_mode: String = "nav"
var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
var _walk_settle_elapsed: float = 0.0
var _walk_settle_start_pose: Dictionary = {}
var _walk_settle_end_pose: Dictionary = {}
var _walk_debug_settle_frames: int = 0
var _walk_debug_progress_elapsed: float = 0.0
var _arrival_emitted: bool = false
var _walk_done: bool = false
var _leg_base_markers: Dictionary = {}
var _last_grounded_walk_velocity_x: float = 0.0
@@ -309,27 +334,65 @@ func tick_movement(delta: float, _rig_state: int) -> void:
if terrain_following_enabled and terrain_motion == TerrainMotion.GROUNDED:
_last_grounded_walk_velocity_x = 0.0
_update_walking(delta)
_settle_walk_markers(delta)
_update_terrain_following(delta)
_settle_walk_markers()
_resume_settle_after_terrain()
if _walk_debug_enabled() and _walking:
_walk_debug_progress_elapsed += delta
if _walk_debug_progress_elapsed >= 1.0:
_walk_debug_progress_elapsed = 0.0
_debug_walk_pose("RUN_PROGRESS terrain=%s" % TerrainMotion.keys()[int(terrain_motion)])
func tick_post_skeleton(delta: float, _rig_state: int) -> void:
if _rig != null:
apply_impact_reactions(delta)
sync_collision_bodies()
if _walk_debug_settle_frames > 0:
_walk_debug_settle_frames -= 1
if _walk_debug_settle_frames == 0:
_debug_walk_pose("SETTLED")
func walk_to(target: Vector2, speed: float = -1.0) -> bool:
return move_to(target, "walk", speed)
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> bool:
if _walk_debug_enabled():
print("[WalkDebug] MOVE_TO style=%s target=%s root=%s" % [style, target, _rig.global_position if _rig != null else Vector2.ZERO])
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
push_warning("StickmanRig: Move To ignored; not ANIMATED.")
return false
if _rig._is_action_queue_blocked():
push_warning("StickmanRig: walk_to ignored during landing impact.")
push_warning("StickmanRig: Move To ignored during landing impact.")
return false
if _nav_agent == null or _nav_anchor == null:
push_warning("StickmanLocomotion: walk_to ignored; navigation is unavailable.")
push_warning("StickmanLocomotion: Move To ignored; navigation is unavailable.")
return false
var checked_style: String = style if MOVE_STYLE_DATA.has(style) else ACTION_REGISTRY.WALK
var style_data: Dictionary = MOVE_STYLE_DATA[checked_style]
_active_move_style = checked_style
_active_animation_name = StringName(style_data.get("clip", "walk_right"))
_active_cycle_duration = float(style_data.get("cycle", WALK_CYCLE_DURATION))
_walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else _rig.walk_speed
var default_pace: float = _rig.walk_speed if checked_style == "walk" else float(style_data.get("pace", 500.0))
_walk_speed_current = speed_override if speed_override > 0.0 else default_pace
_nav_agent.max_speed = _walk_speed_current
_walk_done = false
_arrival_emitted = false
_settling = false
_settle_waiting_for_ground = false
_walk_settle_elapsed = 0.0
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
_reset_foot_contacts()
var target_root: Vector2 = target + FOOT_OFFSET
if (target - (_rig.global_position - FOOT_OFFSET)).length() <= ARRIVE_DISTANCE:
_rig.global_position = target_root
_walking = false
_walk_done = true
_arrival_emitted = true
arrived.emit(_walk_target_feet)
return true
_nav_agent.target_position = target
var dx: float = target.x - _rig.global_position.x
if dx < -0.5:
@@ -337,14 +400,12 @@ func walk_to(target: Vector2, speed: float = -1.0) -> bool:
elif dx > 0.5:
_rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
_rig._set_limb_mods_enabled(true)
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation("walk_right"):
_rig._anim_player.play("walk_right")
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation(_active_animation_name):
_rig._anim_player.play(_active_animation_name)
_walking = true
_walk_done = false
_walk_mode = "nav"
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
return true
func cancel_walk(restore_idle_pose: bool = true) -> void:
@@ -352,19 +413,24 @@ func cancel_walk(restore_idle_pose: bool = true) -> void:
return
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
_rig._anim_player.stop(true)
_rig._anim_player.stop(false)
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_settling = false
_settle_waiting_for_ground = false
_walk_done = false
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
_walk_settle_elapsed = 0.0
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
_reset_foot_contacts()
if restore_idle_pose:
_rig._apply_idle_limb_solver_mode(current_limb_rotations)
func is_walking() -> bool:
return _walking
return _walking or _settling
func ensure_grounded_walk_animation() -> void:
@@ -372,7 +438,7 @@ func ensure_grounded_walk_animation() -> void:
return
if terrain_motion != TerrainMotion.GROUNDED or blocks_walking():
return
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation("walk_right"):
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation(_active_animation_name):
return
if _terrain_paused_walk_animation:
if _rig._anim_player.is_playing():
@@ -380,7 +446,7 @@ func ensure_grounded_walk_animation() -> void:
else:
_resume_walk_animation()
elif not _rig._anim_player.is_playing():
_rig._anim_player.play("walk_right")
_rig._anim_player.play(_active_animation_name)
func get_foot_offset() -> Vector2:
return FOOT_OFFSET
@@ -439,21 +505,213 @@ func _update_walking(delta: float) -> void:
_rig.global_position.x = final_root.x
_finish_walk()
func _settle_walk_markers() -> void:
if _walk_settle_frames > 0:
_walk_settle_frames -= 1
_rig._restore_standing_markers()
func _settle_walk_markers(delta: float) -> void:
if not _settling or _rig == null:
return
if _rig.state != StickmanRig.RigState.ANIMATED:
cancel_walk(false)
return
if terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED:
_settle_waiting_for_ground = true
return
_walk_settle_elapsed = minf(_walk_settle_elapsed + delta, ARRIVAL_SETTLE_DURATION)
var weight: float = smoothstep(0.0, ARRIVAL_SETTLE_DURATION, _walk_settle_elapsed)
_apply_walk_settle_pose(weight)
if _walk_settle_elapsed >= ARRIVAL_SETTLE_DURATION:
_settling = false
_settle_waiting_for_ground = false
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
_walk_debug_settle_frames = 2 if _walk_debug_enabled() else 0
_walk_done = true
func _finish_walk() -> void:
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if not _walking or _arrival_emitted:
return
# Capture the evaluated cycle before stopping it so the settle can blend from
# the actual run pose rather than from the animation reset values.
_walk_settle_start_pose = _capture_walk_marker_pose()
_debug_walk_pose("ARRIVAL_RUN_POSE")
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
# Keep the evaluated run pose for the first frame of the settle blend.
_rig._anim_player.stop(true)
_walking = false
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
_walk_settle_frames = 0
_walk_done = true
_settling = true
_settle_waiting_for_ground = false
_walk_settle_elapsed = 0.0
_arrival_emitted = true
_walk_done = false
_restore_walk_standing_pose()
_walk_settle_end_pose = _capture_walk_marker_pose()
_apply_walk_settle_pose(0.0)
if _walk_debug_enabled():
print("[WalkDebug] SETTLE_TARGETS start=%s end=%s" % [_walk_settle_start_pose, _walk_settle_end_pose])
_debug_walk_pose("SETTLE_BEGIN")
arrived.emit(_walk_target_feet)
func _capture_walk_marker_pose() -> Dictionary:
var pose: Dictionary = {}
for marker_name: String in MOVE_MARKER_NAMES:
var marker: Marker2D = _rig._get_ik_marker(marker_name)
if marker == null:
continue
pose[marker_name] = {"position": marker.position, "rotation": marker.rotation}
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
if base != null:
pose["base:" + leg_name] = {"position": base.position, "rotation": base.rotation}
return pose
func _apply_walk_settle_pose(weight: float) -> void:
for pose_key: String in _walk_settle_end_pose:
var marker: Marker2D
if pose_key.begins_with("base:"):
marker = _leg_base_markers.get(pose_key.trim_prefix("base:")) as Marker2D
else:
marker = _rig._get_ik_marker(pose_key)
var start: Dictionary = _walk_settle_start_pose.get(pose_key, {})
var finish: Dictionary = _walk_settle_end_pose.get(pose_key, {})
if marker == null or start.is_empty() or finish.is_empty():
continue
marker.position = (start["position"] as Vector2).lerp(finish["position"] as Vector2, weight)
marker.rotation = lerpf(float(start["rotation"]), float(finish["rotation"]), weight)
func _restore_walk_standing_pose() -> void:
_rig._restore_standing_markers()
var standing_pose: Dictionary = {}
var standing_animation: StringName = &""
if _rig.facing_profile == StickmanRig.FacingProfile.LEFT:
standing_animation = LEFT_STANDING_ANIMATION
elif _rig.facing_profile == StickmanRig.FacingProfile.RIGHT:
standing_animation = RIGHT_STANDING_ANIMATION
if not standing_animation.is_empty():
standing_pose = _sample_animation_first_frame(standing_animation)
_apply_walk_marker_pose(standing_pose)
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
var target: Marker2D = _rig._get_ik_marker(leg_name)
if base == null or target == null:
continue
var base_key := "base:" + leg_name
if standing_pose.has(base_key):
# The baked walk clip keys the animated base target. Seed the IK target
# from the same frame so it also works while terrain following is off.
target.position = base.position
else:
base.position = target.position
func _sample_animation_first_frame(animation_name: StringName) -> Dictionary:
var sampled: Dictionary = {}
if _rig == null or _rig._anim_player == null or not _rig._anim_player.has_animation(animation_name):
return sampled
var animation: Animation = _rig._anim_player.get_animation(animation_name)
for track_idx: int in animation.get_track_count():
var track_path := String(animation.track_get_path(track_idx))
var separator_idx := track_path.rfind(":")
if separator_idx < 0:
continue
var property_name := track_path.substr(separator_idx + 1)
if property_name != "position" and property_name != "rotation":
continue
var node_path := track_path.substr(0, separator_idx)
var pose_key := ""
if node_path.begins_with("IK_Targets/"):
pose_key = node_path.get_file()
elif node_path.begins_with("AnimationTargets/") and node_path.get_file().ends_with("_Base"):
pose_key = "base:" + node_path.get_file().trim_suffix("_Base")
if pose_key.is_empty():
continue
var key_count := animation.track_get_key_count(track_idx)
if key_count == 0:
continue
var first_key_idx := 0
for key_idx: int in range(1, key_count):
if animation.track_get_key_time(track_idx, key_idx) < animation.track_get_key_time(track_idx, first_key_idx):
first_key_idx = key_idx
var key_value: Variant = animation.track_get_key_value(track_idx, first_key_idx)
if not sampled.has(pose_key):
sampled[pose_key] = {}
var properties: Dictionary = sampled[pose_key]
properties[property_name] = key_value
return sampled
func _apply_walk_marker_pose(pose: Dictionary) -> void:
for pose_key: String in pose:
var marker: Marker2D
if pose_key.begins_with("base:"):
marker = _leg_base_markers.get(pose_key.trim_prefix("base:")) as Marker2D
else:
marker = _rig._get_ik_marker(pose_key)
if marker == null:
continue
var properties: Dictionary = pose[pose_key]
if properties.get("position") is Vector2:
marker.position = properties["position"]
if properties.get("rotation") is float or properties.get("rotation") is int:
marker.rotation = float(properties["rotation"])
func _resume_settle_after_terrain() -> void:
if not _settling or not _settle_waiting_for_ground or terrain_motion != TerrainMotion.GROUNDED:
return
_walk_settle_start_pose = _capture_walk_marker_pose()
_restore_walk_standing_pose()
_walk_settle_end_pose = _capture_walk_marker_pose()
_apply_walk_settle_pose(0.0)
_walk_settle_elapsed = 0.0
_settle_waiting_for_ground = false
if _walk_debug_enabled():
print("[WalkDebug] SETTLE_RESUMED start=%s end=%s" % [_walk_settle_start_pose, _walk_settle_end_pose])
func _walk_debug_enabled() -> bool:
return OS.get_environment("STICKMAN_WALK_DEBUG") == "1"
func _debug_walk_pose(label: String) -> void:
if not _walk_debug_enabled() or _rig == null:
return
var markers: Dictionary = {}
for marker_name: String in MOVE_MARKER_NAMES:
var marker: Marker2D = _rig._get_ik_marker(marker_name)
if marker != null:
markers[marker_name] = marker.position
var bases: Dictionary = {}
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
if base != null:
bases[leg_name] = base.position
var bend_flags: Dictionary = {}
for joint: String in ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]:
bend_flags[joint] = _rig.get_joint_bend_flipped(joint)
var tips: Dictionary = {}
var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
if skeleton != null:
var bone_paths: Dictionary = {
"Left_Upper_Arm": "Torso/LeftUpperArm",
"Left_Lower_Arm": "Torso/LeftUpperArm/LeftLowerArm",
"Right_Upper_Arm": "Torso/RightUpperArm",
"Right_Lower_Arm": "Torso/RightUpperArm/RightLowerArm",
"Left_Upper_Leg": "Torso/LeftUpperLeg",
"Left_Lower_Leg": "Torso/LeftUpperLeg/LeftLowerLeg",
"Right_Upper_Leg": "Torso/RightUpperLeg",
"Right_Lower_Leg": "Torso/RightUpperLeg/RightLowerLeg",
}
for bone_name: String in bone_paths:
var bone := skeleton.get_node_or_null(NodePath(String(bone_paths[bone_name]))) as Bone2D
if bone == null:
continue
var tip_world := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
tips[bone_name] = _rig.to_local(tip_world)
print("[WalkDebug] %s facing=%d bends=%s root=%s markers=%s leg_bases=%s limb_rotations=%s limb_tips=%s" % [label, int(_rig.facing_profile), bend_flags, _rig.global_position, markers, bases, _rig._capture_limb_rotations(), tips])
func begin_terrain_fall() -> void:
if _rig == null:
return
@@ -562,10 +820,24 @@ func probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Arra
func _stance_weight(leg_name: String) -> float:
if not _walking or _rig._anim_player == null:
return 1.0
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), _active_cycle_duration)
if _active_move_style == ACTION_REGISTRY.RUN:
var contact_start: float = RUN_RIGHT_CONTACT_START if leg_name == "Right_Leg" else RUN_LEFT_CONTACT_START
return _run_contact_weight(phase, contact_start)
var right_weight: float = 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
func _run_contact_weight(phase: float, contact_start: float) -> float:
var cycle_phase: float = fposmod(phase, RUN_CYCLE_DURATION)
var contact_phase: float = fposmod(cycle_phase - contact_start, RUN_CYCLE_DURATION)
if contact_phase >= RUN_CONTACT_DURATION:
return 0.0
var fade: float = minf(RUN_CONTACT_EDGE_FADE, RUN_CONTACT_DURATION * 0.5)
var fade_in: float = smoothstep(0.0, fade, contact_phase)
var fade_out: float = 1.0 - smoothstep(RUN_CONTACT_DURATION - fade, RUN_CONTACT_DURATION, contact_phase)
return clampf(fade_in * fade_out, 0.0, 1.0)
func _update_terrain_following(delta: float) -> void:
if not terrain_following_enabled or _rig.state != StickmanRig.RigState.ANIMATED or _leg_base_markers.size() != 2:
terrain_pelvis_offset = 0.0
@@ -3,6 +3,7 @@ extends Node
## Owns one-stickman/one-hand prop interaction state during Sandbox PLAY.
const ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
enum Hand { LEFT, RIGHT }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING }
@@ -243,8 +244,10 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
if bool(entry.get("active", false)):
uncertain = true
var action_type := String(action.get("type", ""))
if ACTION_REGISTRY.is_move_action(action):
action_type = ACTION_REGISTRY.MOVE_TO
match action_type:
"walk_to":
"move_to":
var feet_target: Variant = action.get("target", Vector2.INF)
if feet_target is Vector2 and (feet_target as Vector2).is_finite() and _locomotion != null:
var previous_root := root_position
+13 -4
View File
@@ -54,8 +54,10 @@ const BEND_JOINT_BONE_PATHS: Dictionary = {
}
## flip_bend_direction value per facing profile, keyed by bend-joint name.
## Side-facing profiles share a branch pairing. FORWARD keeps its camera-facing
## pairing, which separates the limbs when the character faces the viewer.
const PROFILE_FLAGS: Dictionary = {
FacingProfile.LEFT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.LEFT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
}
@@ -92,6 +94,7 @@ const Z_ORDER_BY_PROFILE: Dictionary = {
}
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
## Upper + lower Bone2D paths (Skeleton2D-relative), retained for mirrored
## idle-pose preservation and legacy test access.
@@ -790,7 +793,13 @@ func _set_limb_mods_enabled(enabled: bool) -> void:
## uses walk_speed. No-op (push_warning) unless state == ANIMATED.
func walk_to(target: Vector2, speed: float = -1.0) -> void:
if _locomotion_component != null:
_locomotion_component.walk_to(target, speed)
_locomotion_component.move_to(target, ACTION_REGISTRY.WALK, speed)
## Start a style-aware Move To action. Walk remains the compatibility default.
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> void:
if _locomotion_component != null:
_locomotion_component.move_to(target, style, speed_override)
func is_walking() -> bool:
@@ -865,7 +874,7 @@ func remove_action(index: int) -> void:
func insert_action(index: int, action: Dictionary) -> void:
if _action_runner_component != null:
var normalized: Dictionary = _with_recover_barrier(action)
if is_ragdolled() and String(normalized.get("type", "")) == "walk_to":
if is_ragdolled() and ACTION_REGISTRY.is_move_action(normalized):
var queue: Array[Dictionary] = get_queue()
var at: int = clampi(index, 0, queue.size())
var has_recover_barrier: bool = false
@@ -885,7 +894,7 @@ func insert_action(index: int, action: Dictionary) -> void:
func replace_action(index: int, action: Dictionary) -> bool:
if _action_runner_component == null:
return false
if is_ragdolled() and String(action.get("type", "")) == "walk_to":
if is_ragdolled() and ACTION_REGISTRY.is_move_action(action):
var queue: Array[Dictionary] = get_queue()
var has_recover_barrier: bool = false
if index > 0 and index < queue.size() and String(queue[index - 1].get("type", "")) == "recover":
+113 -15
View File
@@ -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()
+1
View File
@@ -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
+234 -105
View File
@@ -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:
+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()
+57 -4
View File
@@ -1,7 +1,7 @@
@tool
extends EditorScript
## Unified animation generator: bakes walk_left, walk_right and stand_up into
## Unified animation generator: bakes walk_left, walk_right, run_right and stand_up into
## the open scene's AnimationPlayer. Supersedes the old create_walk.gd.
##
## Note: runtime ragdoll recovery does NOT play the baked stand_up — StickmanRig
@@ -10,6 +10,7 @@ extends EditorScript
## baked stand_up is kept as an authored reference / manual-play animation.
const STAND_UP_DURATION := 2.0
const RUN_ANIMATION_DURATION := 0.75
## IK target marker node paths (relative to the rig root), keyed by marker name.
const POSE_PATHS: Dictionary = {
@@ -58,9 +59,11 @@ func _run() -> void:
push_error("EditorScript: AnimationPlayer node not found under root.")
return
_generate_walk_animation(anim_player, "walk_right", false)
_generate_walk_animation(anim_player, "walk_left", true)
_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
_generate_run_in_place_animation(anim_player, "run_in_place", false)
_generate_run_in_place_animation(anim_player, "run_right", false)
#_generate_walk_animation(anim_player, "walk_right", false)
#_generate_walk_animation(anim_player, "walk_left", true)
#_generate_pose_animation(anim_player, "stand_up", STAND_UP_DURATION, POSE_DOWN, POSE_STANDING)
func _generate_walk_animation(anim_player: AnimationPlayer, anim_name: String, flip_x: bool) -> void:
@@ -132,3 +135,53 @@ func _add_animation(anim_player: AnimationPlayer, anim_name: String, anim: Anima
lib.add_animation(anim_name, anim)
print("Successfully generated '%s' animation!" % anim_name)
func _generate_run_in_place_animation(anim_player: AnimationPlayer, anim_name: String, flip_x: bool = false) -> void:
var anim = Animation.new()
anim.length = RUN_ANIMATION_DURATION
anim.loop_mode = Animation.LOOP_LINEAR
var dir_mult: float = -1.0 if flip_x else 1.0
# Contact, down, break, and up poses with arms counter-swinging against the legs.
var raw_tracks = {
"IK_Targets/Torso:position": [
Vector2(0, 15), Vector2(0, -30), Vector2(0, 15), Vector2(0, -30), Vector2(0, 15)
],
"IK_Targets/Torso:rotation": [0.30, 0.36, 0.28, 0.20, 0.30],
"IK_Targets/Head:position": [
Vector2(100, -605), Vector2(100, -642), Vector2(100, -605), Vector2(100, -642), Vector2(100, -605)
],
"AnimationTargets/Right_Leg_Base:position": [
Vector2(80, 390), Vector2(-120, 290), Vector2(-80, 260), Vector2(110, 240), Vector2(80, 390)
],
"AnimationTargets/Left_Leg_Base:position": [
Vector2(-80, 260), Vector2(110, 240), Vector2(80, 390), Vector2(-120, 290), Vector2(-80, 260)
],
"IK_Targets/Right_Hand:position": [
Vector2(-250, -205), Vector2(-167, -40), Vector2(35, 0), Vector2(237, -40),
Vector2(320, -225), Vector2(237, -35), Vector2(35, 15), Vector2(-167, -35), Vector2(-250, -205)
],
"IK_Targets/Left_Hand:position": [
Vector2(320, -225), Vector2(237, -30), Vector2(55, 15), Vector2(-167, -35),
Vector2(-250, -205), Vector2(-167, -40), Vector2(55, 0), Vector2(237, -40), Vector2(320, -225)
]
}
var times = [0.0, 0.1875, 0.375, 0.5625, RUN_ANIMATION_DURATION]
var arm_times = [0.0, 0.09375, 0.1875, 0.28125, 0.375, 0.46875, 0.5625, 0.65625, RUN_ANIMATION_DURATION]
for path in raw_tracks:
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_CUBIC)
anim.track_set_interpolation_loop_wrap(track_idx, true)
var key_times: Array = arm_times if path.ends_with("_Hand:position") else times
for i in range(raw_tracks[path].size()):
var value: Variant = raw_tracks[path][i]
if value is Vector2:
value = Vector2(value.x * dir_mult, value.y)
anim.track_insert_key(track_idx, key_times[i], value)
_add_animation(anim_player, anim_name, anim)