Add Phase 4 Final Bugs Specification and Tests
- Introduced a new architecture specification for Phase 4 final bugs, detailing scope, decisions, runtime data contracts, action roles, execution lanes, speech ownership, and signal flow. - Implemented changes in `scripts/action_registry.gd`, `scripts/action_editor.gd`, `scripts/sandbox_stage.gd`, `scripts/stage_gizmos.gd`, `scripts/stickman_factory.gd`, and `scripts/stickman_rig.gd` to address identified bugs. - Created a new test suite `tests/test_phase4_final_bugs.gd` to validate the implementation, covering action roles, grid snapping, prop manipulation, reactive runtime lane, spawn pose preservation, and more. - Added a unique identifier for the test file to ensure proper tracking and management.
This commit is contained in:
@@ -22,6 +22,8 @@ var _text_edit: LineEdit = null
|
||||
var _duration_spin: SpinBox = null
|
||||
var _target_label: Label = null
|
||||
var _set_target_btn: Button = null
|
||||
var _stop_current_check: CheckBox = null
|
||||
var _show_reactive_options: bool = false
|
||||
var _has_target: bool = false
|
||||
var _target_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
@@ -45,7 +47,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
|
||||
## Opens the editor in "add" mode (empty, default type walk_to).
|
||||
func open_new() -> void:
|
||||
func open_new(show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_mode = "new"
|
||||
_initial = {}
|
||||
_has_target = false
|
||||
@@ -57,7 +60,8 @@ func open_new() -> void:
|
||||
|
||||
|
||||
## Opens the editor pre-filled from a flat queue action.
|
||||
func open_edit(action: Dictionary) -> void:
|
||||
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"))
|
||||
@@ -177,6 +181,7 @@ func _rebuild_params(type: String) -> void:
|
||||
_duration_spin = null
|
||||
_target_label = null
|
||||
_set_target_btn = null
|
||||
_stop_current_check = null
|
||||
|
||||
match type:
|
||||
"walk_to":
|
||||
@@ -203,6 +208,11 @@ func _rebuild_params(type: String) -> void:
|
||||
_params_box.add_child(dlabel)
|
||||
_duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0)))
|
||||
_params_box.add_child(_duration_spin)
|
||||
if _show_reactive_options and ACTION_REGISTRY.is_overlay_eligible(type):
|
||||
_stop_current_check = CheckBox.new()
|
||||
_stop_current_check.text = "Stop what he's doing?"
|
||||
_stop_current_check.button_pressed = bool(_initial.get("stop_current", false))
|
||||
_params_box.add_child(_stop_current_check)
|
||||
"wait":
|
||||
var wlabel := Label.new()
|
||||
wlabel.text = "Duration (s):"
|
||||
@@ -247,6 +257,8 @@ func _on_ok_pressed() -> void:
|
||||
"speak":
|
||||
action["text"] = _text_edit.text if _text_edit != null else ""
|
||||
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
|
||||
if _show_reactive_options and _stop_current_check != null:
|
||||
action["stop_current"] = _stop_current_check.button_pressed
|
||||
"wait":
|
||||
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
|
||||
committed.emit(action)
|
||||
|
||||
@@ -7,6 +7,8 @@ extends RefCounted
|
||||
## the ActionEditor and the panels generate their UI from this registry, so no
|
||||
## other code changes are required.
|
||||
|
||||
enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER }
|
||||
|
||||
const ACTION_TEMPLATES := {
|
||||
"walk_to": {
|
||||
"label": "Walk To",
|
||||
@@ -14,6 +16,7 @@ const ACTION_TEMPLATES := {
|
||||
"params": [
|
||||
{ "key": "target", "type": "position", "required": true },
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
"speak": {
|
||||
"label": "Speak",
|
||||
@@ -22,6 +25,7 @@ const ACTION_TEMPLATES := {
|
||||
{ "key": "text", "type": "text", "required": true },
|
||||
{ "key": "duration", "type": "float", "default": 2.0 },
|
||||
],
|
||||
"reactive_role": ReactiveRole.OVERLAY, "overlay_eligible": true,
|
||||
},
|
||||
"wait": {
|
||||
"label": "Wait",
|
||||
@@ -29,16 +33,19 @@ const ACTION_TEMPLATES := {
|
||||
"params": [
|
||||
{ "key": "duration", "type": "float", "required": true },
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
"ragdoll": {
|
||||
"label": "Ragdoll",
|
||||
"icon": "💥",
|
||||
"params": [],
|
||||
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
||||
},
|
||||
"recover": {
|
||||
"label": "Recover",
|
||||
"icon": "🔄",
|
||||
"params": [],
|
||||
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -66,6 +73,21 @@ static func icon(type: String) -> String:
|
||||
return String(tpl.get("icon", ""))
|
||||
|
||||
|
||||
static func is_overlay_eligible(type: String) -> bool:
|
||||
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||
return bool(tpl.get("overlay_eligible", false))
|
||||
|
||||
|
||||
static func reactive_role(action: Dictionary) -> ReactiveRole:
|
||||
var type: String = String(action.get("type", ""))
|
||||
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||
var role: ReactiveRole = int(tpl.get("reactive_role", ReactiveRole.SEQUENTIAL))
|
||||
var params: Dictionary = action.get("params", {})
|
||||
if type == "speak" and bool(params.get("stop_current", action.get("stop_current", false))):
|
||||
return ReactiveRole.SEQUENTIAL
|
||||
return role
|
||||
|
||||
|
||||
## Converts a flat queue action into a rule-action shape (adds `target` + nests
|
||||
## params). `target_id` is the acting stickman's instance id.
|
||||
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
||||
@@ -76,6 +98,8 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
||||
"speak":
|
||||
params["text"] = String(action.get("text", ""))
|
||||
params["duration"] = float(action.get("duration", 2.0))
|
||||
if bool(action.get("stop_current", false)):
|
||||
params["stop_current"] = true
|
||||
"wait":
|
||||
params["duration"] = float(action.get("duration", 0.0))
|
||||
return {
|
||||
@@ -92,11 +116,14 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
|
||||
"walk_to":
|
||||
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
|
||||
"speak":
|
||||
return {
|
||||
var speak: Dictionary = {
|
||||
"type": "speak",
|
||||
"text": String(params.get("text", "")),
|
||||
"duration": float(params.get("duration", 2.0)),
|
||||
}
|
||||
if bool(params.get("stop_current", false)):
|
||||
speak["stop_current"] = true
|
||||
return speak
|
||||
"wait":
|
||||
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
|
||||
_:
|
||||
@@ -110,12 +137,17 @@ static func summarize(action: Dictionary) -> String:
|
||||
var params: Dictionary = action.get("params", {})
|
||||
match type:
|
||||
"walk_to":
|
||||
var t: Vector2 = action.get("target", params.get("target", Vector2.ZERO))
|
||||
# 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))]
|
||||
"speak":
|
||||
var text := String(action.get("text", params.get("text", "")))
|
||||
var dur := float(action.get("duration", params.get("duration", 2.0)))
|
||||
return "Speak \"%s\" (%ss)" % [text, _fmt_duration(dur)]
|
||||
var suffix: String = " — stops current" if bool(params.get("stop_current", action.get("stop_current", false))) else ""
|
||||
return "Speak \"%s\" (%ss)%s" % [text, _fmt_duration(dur), suffix]
|
||||
"wait":
|
||||
var wd := float(action.get("duration", params.get("duration", 0.0)))
|
||||
return "Wait %ss" % _fmt_duration(wd)
|
||||
|
||||
+122
-10
@@ -188,6 +188,9 @@ var _ghost_holder: Node2D = null
|
||||
|
||||
## Phase 4b terrain drag-painting state.
|
||||
var _terrain_dragging: bool = false
|
||||
const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0
|
||||
var _terrain_press_world: Vector2 = Vector2.ZERO
|
||||
var _terrain_painting: bool = false
|
||||
var _terrain_anchor_cell: Vector2i = Vector2i.ZERO
|
||||
var _terrain_target_cell: Vector2i = Vector2i.ZERO
|
||||
var _drag_cells: Array[Vector2i] = []
|
||||
@@ -210,6 +213,8 @@ var _authored: Dictionary = {}
|
||||
## Physics frames remaining before re-asserting the authored restore (a safety
|
||||
## net for engines where a same-frame freeze + teleport does not stick).
|
||||
var _restore_frames_left: int = 0
|
||||
var _edit_physics_targets: Dictionary = {} # { RigidBody2D: Transform2D }
|
||||
var _edit_physics_settle_frames: int = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Director state (Phase 3a)
|
||||
@@ -220,6 +225,7 @@ var _context_rig: StickmanRig = null
|
||||
var _pending_walk_target: bool = false
|
||||
var _speak_dialog: AcceptDialog = null
|
||||
var _speak_edit: LineEdit = null
|
||||
var _speak_stop_current_check: CheckBox = null
|
||||
var _wait_dialog: AcceptDialog = null
|
||||
var _wait_spin: SpinBox = null
|
||||
var _director_visuals = null # StageDirectorVisuals (preloaded)
|
||||
@@ -414,6 +420,7 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_sync_pending_editable_physics_transforms()
|
||||
if _restore_frames_left > 0:
|
||||
_restore_frames_left -= 1
|
||||
_restore_authored_state()
|
||||
@@ -495,7 +502,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||||
return
|
||||
if mb.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
var world_pos := _camera.get_global_mouse_position()
|
||||
# Use the position carried by this exact event. Polling the global cursor
|
||||
# here can lag behind an unhandled press/release, leaving the gizmo with an
|
||||
# unchanged drag origin even though the pointer has moved.
|
||||
var world_pos := _viewport_to_world(mb.position)
|
||||
# Rule-builder click routing is highest priority (Phase 4).
|
||||
if _rule_step != RuleStep.IDLE:
|
||||
if mb.pressed:
|
||||
@@ -548,16 +558,22 @@ func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
||||
return
|
||||
if current_mode == StageMode.PLAY:
|
||||
return
|
||||
var world_pos := _camera.get_global_mouse_position()
|
||||
var world_pos := _viewport_to_world(mm.position)
|
||||
if _gizmos.is_dragging():
|
||||
_gizmos.drag_to(world_pos)
|
||||
_director_visuals.mark_dirty()
|
||||
elif _terrain_dragging:
|
||||
_update_terrain_drag(world_pos)
|
||||
elif _box_selecting:
|
||||
_box_rect = Rect2(_box_start, world_pos - _box_start)
|
||||
_gizmos.set_box_rect(_box_rect)
|
||||
elif not _is_mouse_over_ui() and current_mode == StageMode.EDIT:
|
||||
_selection.update_hover(world_pos)
|
||||
|
||||
|
||||
func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
|
||||
return get_viewport().get_canvas_transform().affine_inverse() * viewport_pos
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mode management
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -657,6 +673,8 @@ func _enter_direct_mode() -> void:
|
||||
|
||||
|
||||
func _enter_play_mode() -> void:
|
||||
_edit_physics_targets.clear()
|
||||
_edit_physics_settle_frames = 0
|
||||
_gizmos.set_enabled(false)
|
||||
_selection.clear_selection()
|
||||
set_placement_mode("")
|
||||
@@ -817,7 +835,7 @@ func _update_ghost_position() -> void:
|
||||
return
|
||||
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
|
||||
if _is_terrain_placement():
|
||||
pos = _terrain_cell_center(_world_to_terrain_cell(pos))
|
||||
pos = _snap_to_grid(pos) if _snap_enabled else pos
|
||||
elif _snap_enabled:
|
||||
pos = _snap_to_grid(pos)
|
||||
_ghost.position = pos
|
||||
@@ -868,14 +886,20 @@ func _begin_terrain_drag(world_pos: Vector2) -> void:
|
||||
_free_ghost()
|
||||
_drag_painted.clear()
|
||||
_terrain_dragging = true
|
||||
_terrain_painting = false
|
||||
_terrain_press_world = world_pos
|
||||
_terrain_anchor_cell = _world_to_terrain_cell(world_pos)
|
||||
_terrain_target_cell = _terrain_anchor_cell
|
||||
_update_terrain_drag(world_pos)
|
||||
|
||||
|
||||
func _update_terrain_drag(world_pos: Vector2) -> void:
|
||||
if not _terrain_dragging:
|
||||
return
|
||||
if not _terrain_painting:
|
||||
var zoom: float = _camera.zoom.x if _camera != null else 1.0
|
||||
if (world_pos - _terrain_press_world).length() * zoom < TERRAIN_DRAG_THRESHOLD_PX:
|
||||
return
|
||||
_terrain_painting = true
|
||||
var target := _world_to_terrain_cell(world_pos)
|
||||
if Input.is_key_pressed(KEY_SHIFT):
|
||||
var d := target - _terrain_anchor_cell
|
||||
@@ -989,6 +1013,13 @@ func _free_terrain_ghosts() -> void:
|
||||
|
||||
func _commit_terrain_drag() -> void:
|
||||
var placed: Array[Node2D] = []
|
||||
if not _terrain_painting:
|
||||
var click_pos: Vector2 = _snap_to_grid(_terrain_press_world) if _snap_enabled else _terrain_press_world
|
||||
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
|
||||
if click_node != null:
|
||||
_finalize_placed_node(click_node)
|
||||
object_placed.emit(click_node)
|
||||
placed.append(click_node)
|
||||
for cell: Vector2i in _drag_cells:
|
||||
if _classify_cell(cell) != 1:
|
||||
continue
|
||||
@@ -1012,6 +1043,8 @@ func _cancel_terrain_drag() -> void:
|
||||
|
||||
func _end_terrain_drag() -> void:
|
||||
_terrain_dragging = false
|
||||
_terrain_painting = false
|
||||
_terrain_press_world = Vector2.ZERO
|
||||
_drag_cells.clear()
|
||||
_last_drag_cells.clear()
|
||||
_drag_painted.clear()
|
||||
@@ -1461,10 +1494,16 @@ func _build_ui() -> void:
|
||||
_speak_dialog = AcceptDialog.new()
|
||||
_speak_dialog.title = "Speak"
|
||||
_speak_dialog.confirmed.connect(_on_speak_confirmed)
|
||||
var speak_box := VBoxContainer.new()
|
||||
_speak_dialog.add_child(speak_box)
|
||||
_speak_edit = LineEdit.new()
|
||||
_speak_edit.placeholder_text = "Say something…"
|
||||
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
|
||||
_speak_dialog.add_child(_speak_edit)
|
||||
speak_box.add_child(_speak_edit)
|
||||
_speak_stop_current_check = CheckBox.new()
|
||||
_speak_stop_current_check.text = "Stop what he's doing?"
|
||||
_speak_stop_current_check.visible = false
|
||||
speak_box.add_child(_speak_stop_current_check)
|
||||
_speak_dialog.register_text_enter(_speak_edit)
|
||||
ui.add_child(_speak_dialog)
|
||||
|
||||
@@ -1568,6 +1607,8 @@ func _build_gizmo_layer() -> void:
|
||||
_gizmos.name = "GizmoLayer"
|
||||
_gizmos.camera = _camera
|
||||
_gizmos.transform_committed.connect(_on_transform_committed)
|
||||
_gizmos.transform_started.connect(_on_gizmo_transform_started)
|
||||
_gizmos.transform_updated.connect(_on_gizmo_transform_updated)
|
||||
add_child(_gizmos)
|
||||
|
||||
|
||||
@@ -1817,11 +1858,59 @@ func _on_hover_changed(node: Node2D) -> void:
|
||||
|
||||
func _on_transform_committed(nodes: Array[Node2D]) -> void:
|
||||
for node: Node2D in nodes:
|
||||
if node is RigidBody2D:
|
||||
_edit_physics_targets[node] = node.global_transform
|
||||
_sync_editable_physics_transform(node)
|
||||
_save_object_state(node)
|
||||
if node is TerrainBlock:
|
||||
_nav_dirty = true
|
||||
_rebuild_grid_cells()
|
||||
_director_visuals.mark_dirty()
|
||||
_edit_physics_settle_frames = 3
|
||||
|
||||
|
||||
func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void:
|
||||
_restore_frames_left = 0
|
||||
_edit_physics_targets.clear()
|
||||
_edit_physics_settle_frames = 0
|
||||
|
||||
|
||||
func _on_gizmo_transform_updated(nodes: Array[Node2D]) -> void:
|
||||
for node: Node2D in nodes:
|
||||
if node is RigidBody2D:
|
||||
_edit_physics_targets[node] = node.global_transform
|
||||
_sync_editable_physics_transform(node)
|
||||
|
||||
|
||||
func _sync_pending_editable_physics_transforms() -> void:
|
||||
if _edit_physics_targets.is_empty() or current_mode != StageMode.EDIT:
|
||||
return
|
||||
for node: RigidBody2D in _edit_physics_targets.keys().duplicate():
|
||||
if node == null or not is_instance_valid(node):
|
||||
_edit_physics_targets.erase(node)
|
||||
continue
|
||||
node.global_transform = _edit_physics_targets[node]
|
||||
_sync_editable_physics_transform(node)
|
||||
if not _gizmos.is_dragging() and _edit_physics_settle_frames > 0:
|
||||
_edit_physics_settle_frames -= 1
|
||||
if _edit_physics_settle_frames == 0:
|
||||
_edit_physics_targets.clear()
|
||||
|
||||
|
||||
func _sync_editable_physics_transform(node: Node2D) -> void:
|
||||
if not (node is RigidBody2D):
|
||||
return
|
||||
var body := node as RigidBody2D
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
body.linear_velocity = Vector2.ZERO
|
||||
body.angular_velocity = 0.0
|
||||
var target_transform: Transform2D = body.global_transform
|
||||
var rid := body.get_rid()
|
||||
if rid.is_valid():
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, target_transform)
|
||||
body.global_transform = target_transform
|
||||
body.reset_physics_interpolation()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Director tool (Phase 3a)
|
||||
@@ -1897,6 +1986,8 @@ func _on_action_popup_id_pressed(id: int) -> void:
|
||||
_refresh_status()
|
||||
ACT_SPEAK:
|
||||
_speak_edit.text = ""
|
||||
_speak_stop_current_check.visible = false
|
||||
_speak_stop_current_check.button_pressed = false
|
||||
_speak_dialog.popup_centered()
|
||||
_speak_edit.grab_focus()
|
||||
ACT_WAIT:
|
||||
@@ -2020,7 +2111,7 @@ func _update_tooltip_position() -> void:
|
||||
|
||||
func _on_speak_confirmed() -> void:
|
||||
if _rule_step == RuleStep.PARAMS:
|
||||
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0 })
|
||||
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0, "stop_current": _speak_stop_current_check.button_pressed })
|
||||
_open_rule_more_popup()
|
||||
return
|
||||
if _context_rig == null or not is_instance_valid(_context_rig):
|
||||
@@ -2264,6 +2355,8 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
|
||||
"speak":
|
||||
_rule_step = RuleStep.PARAMS
|
||||
_speak_edit.text = ""
|
||||
_speak_stop_current_check.visible = true
|
||||
_speak_stop_current_check.button_pressed = false
|
||||
_speak_dialog.popup_centered()
|
||||
_speak_edit.grab_focus()
|
||||
"wait":
|
||||
@@ -2345,6 +2438,9 @@ func _cancel_rule_build() -> void:
|
||||
|
||||
|
||||
func _reset_rule_builder() -> void:
|
||||
if _speak_stop_current_check != null:
|
||||
_speak_stop_current_check.visible = false
|
||||
_speak_stop_current_check.button_pressed = false
|
||||
_rule_step = RuleStep.IDLE
|
||||
_rule_builder = {}
|
||||
_rule_context_rig = null
|
||||
@@ -2409,6 +2505,11 @@ func _on_rig_speech_finished(rig: StickmanRig) -> void:
|
||||
|
||||
|
||||
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void:
|
||||
# Physics contacts can be delivered one frame after PLAY is stopped. Never
|
||||
# recreate a flinch or dispatch a reactive rule in EDIT/DIRECT after the
|
||||
# authoritative snap_to_standing() cleanup has run.
|
||||
if current_mode != StageMode.PLAY:
|
||||
return
|
||||
if other == null or not is_instance_valid(other):
|
||||
return
|
||||
if bool(other.get_meta("stickman_ragdoll_body", false)):
|
||||
@@ -2521,11 +2622,21 @@ func _ids_match(a: int, b: Node2D) -> bool:
|
||||
|
||||
|
||||
func _execute_rule_actions(rule: Dictionary) -> void:
|
||||
var batches: Dictionary = {}
|
||||
for a: Dictionary in rule.get("actions", []):
|
||||
var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig
|
||||
if rig == null or not is_instance_valid(rig):
|
||||
continue
|
||||
rig.enqueue_reactive([_action_for_rig(a)])
|
||||
var id: int = rig.get_instance_id()
|
||||
if not batches.has(id):
|
||||
batches[id] = { "rig": rig, "actions": [] }
|
||||
(batches[id]["actions"] as Array).append(_action_for_rig(a))
|
||||
for batch: Dictionary in batches.values():
|
||||
var target_rig := batch.get("rig") as StickmanRig
|
||||
if target_rig != null and is_instance_valid(target_rig):
|
||||
var batch_actions: Array[Dictionary] = []
|
||||
batch_actions.assign(batch.get("actions", []))
|
||||
target_rig.enqueue_reactive(batch_actions)
|
||||
|
||||
|
||||
## Converts a rule action into the queue-action shape the runner understands.
|
||||
@@ -2538,6 +2649,7 @@ func _action_for_rig(a: Dictionary) -> Dictionary:
|
||||
"speak":
|
||||
out["text"] = String(params.get("text", ""))
|
||||
out["duration"] = float(params.get("duration", 2.0))
|
||||
out["stop_current"] = bool(params.get("stop_current", false))
|
||||
"wait":
|
||||
out["duration"] = float(params.get("duration", 0.0))
|
||||
return out
|
||||
@@ -3082,9 +3194,9 @@ func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: Stickm
|
||||
elif (kind == "rule_add" or kind == "rule_edit") and _rule_editor != null:
|
||||
_rule_editor.hide()
|
||||
if flat.is_empty():
|
||||
_action_editor.open_new()
|
||||
_action_editor.open_new(kind == "rule_add" or kind == "rule_edit")
|
||||
else:
|
||||
_action_editor.open_edit(flat)
|
||||
_action_editor.open_edit(flat, kind == "rule_add" or kind == "rule_edit")
|
||||
|
||||
|
||||
func _on_action_editor_committed(flat: Dictionary) -> void:
|
||||
@@ -3204,7 +3316,7 @@ func _apply_grid_settings() -> void:
|
||||
_grid.enabled = _show_grid
|
||||
_grid.visible = _show_grid and current_mode == StageMode.EDIT
|
||||
if _gizmos != null:
|
||||
_gizmos.snap_size = _grid_size if _snap_enabled else 0.0
|
||||
_gizmos.set_snap_size(_grid_size if _snap_enabled else 0.0)
|
||||
|
||||
|
||||
func _set_edit_controls_visible(visible: bool) -> void:
|
||||
|
||||
+26
-6
@@ -22,6 +22,8 @@ enum Handle { NONE, TRANSLATE, ROTATE }
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
signal transform_committed(nodes: Array[Node2D])
|
||||
signal transform_started(nodes: Array[Node2D])
|
||||
signal transform_updated(nodes: Array[Node2D])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style constants (screen-space px; divided by zoom for world-space drawing)
|
||||
@@ -53,6 +55,8 @@ var _hovered: Node2D = null
|
||||
var _dragging: Handle = Handle.NONE
|
||||
var _drag_last: Vector2 = Vector2.ZERO
|
||||
var _drag_anchor: Node2D = null
|
||||
var _translate_drag_origin: Vector2 = Vector2.ZERO
|
||||
var _translate_start_positions: Dictionary = {}
|
||||
var _rotate_start_rotation: float = 0.0
|
||||
var _rotate_start_angle: float = 0.0
|
||||
var _rotate_center: Vector2 = Vector2.ZERO
|
||||
@@ -78,6 +82,10 @@ func set_enabled(enabled: bool) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_snap_size(size: float) -> void:
|
||||
snap_size = maxf(size, 0.0)
|
||||
|
||||
|
||||
func set_box_rect(rect: Rect2) -> void:
|
||||
_box_rect = rect
|
||||
queue_redraw()
|
||||
@@ -105,31 +113,39 @@ func begin_drag(world_pos: Vector2) -> void:
|
||||
_rotate_center = STAGE_SELECTION.get_world_aabb(_targets[0]).get_center()
|
||||
_rotate_start_rotation = _targets[0].global_rotation
|
||||
_rotate_start_angle = (world_pos - _rotate_center).angle()
|
||||
transform_started.emit(_targets.duplicate())
|
||||
|
||||
|
||||
## Begins a translate drag anchored on `node` (used when an object body is
|
||||
## clicked). The drag moves the whole current selection (`_targets`); if `node`
|
||||
## is not currently selected, the drag targets just `node`.
|
||||
func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void:
|
||||
if node == null or not is_instance_valid(node):
|
||||
return
|
||||
_drag_anchor = node
|
||||
if not _targets.has(node):
|
||||
_targets = [node]
|
||||
_dragging = Handle.TRANSLATE
|
||||
_drag_last = world_pos
|
||||
_translate_drag_origin = world_pos
|
||||
_translate_start_positions.clear()
|
||||
for target: Node2D in _targets:
|
||||
if target != null and is_instance_valid(target):
|
||||
_translate_start_positions[target] = target.global_position
|
||||
transform_started.emit(_targets.duplicate())
|
||||
|
||||
|
||||
func drag_to(world_pos: Vector2) -> void:
|
||||
if _dragging == Handle.NONE or _targets.is_empty():
|
||||
return
|
||||
var delta := world_pos - _drag_last
|
||||
if _dragging == Handle.TRANSLATE:
|
||||
var actual := delta
|
||||
var actual := world_pos - _translate_drag_origin
|
||||
if snap_size > 0.0 and _drag_anchor != null and is_instance_valid(_drag_anchor):
|
||||
var old := _drag_anchor.global_position
|
||||
actual = _snap(old + delta) - old
|
||||
var anchor_start: Vector2 = _translate_start_positions.get(_drag_anchor, _drag_anchor.global_position)
|
||||
actual = _snap(anchor_start + actual) - anchor_start
|
||||
for node: Node2D in _targets:
|
||||
if is_instance_valid(node):
|
||||
node.global_position += actual
|
||||
if is_instance_valid(node) and _translate_start_positions.has(node):
|
||||
node.global_position = (_translate_start_positions[node] as Vector2) + actual
|
||||
elif _dragging == Handle.ROTATE:
|
||||
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var target := _targets[0]
|
||||
@@ -140,6 +156,7 @@ func drag_to(world_pos: Vector2) -> void:
|
||||
new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
|
||||
target.global_rotation = new_rotation
|
||||
_drag_last = world_pos
|
||||
transform_updated.emit(_targets.duplicate())
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -148,6 +165,7 @@ func end_drag() -> void:
|
||||
transform_committed.emit(_targets.duplicate())
|
||||
_dragging = Handle.NONE
|
||||
_drag_anchor = null
|
||||
_translate_start_positions.clear()
|
||||
|
||||
|
||||
func is_dragging() -> bool:
|
||||
@@ -194,4 +212,6 @@ func _rotate_ring_radius(rect: Rect2) -> float:
|
||||
|
||||
|
||||
func _snap(v: Vector2) -> Vector2:
|
||||
if snap_size <= 0.0:
|
||||
return v
|
||||
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
|
||||
|
||||
@@ -37,6 +37,7 @@ static func load_stk(path: String) -> Dictionary:
|
||||
static func spawn_from_data(stk_data: Dictionary) -> StickmanRig:
|
||||
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||
StkRigAdapter.apply(stk_data, rig)
|
||||
rig.initialize_spawn_pose_from_skeleton()
|
||||
return rig
|
||||
|
||||
|
||||
|
||||
+261
-46
@@ -8,6 +8,8 @@ extends Node2D
|
||||
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
|
||||
## reordering run only at runtime (_ready + setters on a live instance).
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -455,6 +457,7 @@ var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (
|
||||
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
|
||||
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
|
||||
var _stand_up_tween: Tween = null
|
||||
var _stand_up_generation: int = 0
|
||||
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
|
||||
var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up
|
||||
var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target
|
||||
@@ -466,6 +469,8 @@ var _collision_lengths: Dictionary = {}
|
||||
var _collision_visuals: Dictionary = {}
|
||||
var _reaction_bones: Dictionary = {}
|
||||
var _impact_reactions: Dictionary = {}
|
||||
var _spawn_pose: Dictionary = {}
|
||||
var _spawn_limb_rotations: Dictionary = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Director / navigation state (Phase 3a)
|
||||
@@ -476,6 +481,9 @@ var action_queue: Array[Dictionary] = []
|
||||
var _runner_state: RunnerState = RunnerState.IDLE
|
||||
var _action_phase: ActionPhase = ActionPhase.NONE
|
||||
var _current_index: int = -1
|
||||
var _run_queue: Array[Dictionary] = []
|
||||
var _current_run_item: Dictionary = {}
|
||||
var _run_cursor: int = 0
|
||||
var _stop_requested: bool = false
|
||||
|
||||
var _nav_agent: NavigationAgent2D = null
|
||||
@@ -499,6 +507,8 @@ var _ragdoll_at_rest: bool = false
|
||||
var _speech_bubble = null # SpeechBubble (preloaded script)
|
||||
var _speech_active: bool = false
|
||||
var _speech_time_left: float = 0.0
|
||||
var _primary_speech: Dictionary = {}
|
||||
var _overlay_speech: Dictionary = {}
|
||||
|
||||
var _phase_timer: float = 0.0
|
||||
|
||||
@@ -737,6 +747,7 @@ func snap_to_standing() -> void:
|
||||
elif state == RigState.RECOVERING:
|
||||
_cancel_recovery()
|
||||
_clear_impact_reactions(true)
|
||||
_hide_speech()
|
||||
_restore_standing_markers()
|
||||
# Re-show the kinematic puppet and re-enable IK.
|
||||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||||
@@ -747,28 +758,93 @@ func snap_to_standing() -> void:
|
||||
else:
|
||||
_restore_head_lookat_state()
|
||||
_recovery_bones.clear()
|
||||
_rest_timer = 0.0
|
||||
_stabilize_timer = 0.0
|
||||
_ragdoll_at_rest = false
|
||||
_captured_pose.clear()
|
||||
_captured_landing_center = Vector2.ZERO
|
||||
_captured_ground_y = 0.0
|
||||
_pre_ragdoll_state.clear()
|
||||
_probe_top_y = 0.0
|
||||
_probe_bottom_y = 0.0
|
||||
_probe_exclude.clear()
|
||||
_set_collision_bodies_enabled(true)
|
||||
|
||||
if _body_container != null and is_instance_valid(_body_container):
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
state = RigState.ANIMATED
|
||||
_apply_idle_limb_solver_mode(_spawn_limb_rotations)
|
||||
if previous_state != RigState.ANIMATED:
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
|
||||
func _restore_standing_markers() -> void:
|
||||
for marker_name: String in STAND_POSE:
|
||||
var pose: Dictionary = _spawn_pose if not _spawn_pose.is_empty() else STAND_POSE
|
||||
for marker_name: String in pose:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var target: Dictionary = STAND_POSE[marker_name]
|
||||
var target: Dictionary = pose[marker_name]
|
||||
marker.position = target.get("pos", marker.position)
|
||||
if marker_name == "Torso":
|
||||
marker.rotation = target.get("rot", marker.rotation)
|
||||
|
||||
|
||||
## Captures adapted skeleton endpoints as the initial IK marker pose. Called by
|
||||
## StickmanFactory before the instance enters the tree; safe for factory bypasses.
|
||||
func initialize_spawn_pose_from_skeleton() -> void:
|
||||
var skeleton := get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
||||
if skeleton == null:
|
||||
push_warning("StickmanRig: cannot initialize spawn pose; missing Skeleton2D.")
|
||||
return
|
||||
var pose: Dictionary = {}
|
||||
var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||||
if torso != null:
|
||||
pose["Torso"] = { "pos": to_local(torso.global_position), "rot": torso.rotation }
|
||||
var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||||
if head != null:
|
||||
var head_tip: Vector2 = head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
|
||||
pose["Head"] = { "pos": to_local(head_tip), "rot": 0.0 }
|
||||
var paths: Dictionary = {
|
||||
"Left_Hand": "Torso/LeftUpperArm/LeftLowerArm",
|
||||
"Right_Hand": "Torso/RightUpperArm/RightLowerArm",
|
||||
"Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg",
|
||||
"Right_Leg": "Torso/RightUpperLeg/RightLowerLeg",
|
||||
}
|
||||
for marker_name: String in paths:
|
||||
var bone := skeleton.get_node_or_null(NodePath(String(paths[marker_name]))) as Bone2D
|
||||
if bone == null:
|
||||
push_warning("StickmanRig: missing spawn-pose bone '%s'." % String(paths[marker_name]))
|
||||
continue
|
||||
var endpoint: Vector2 = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||||
pose[marker_name] = { "pos": to_local(endpoint), "rot": 0.0 }
|
||||
if pose.size() == IK_TARGET_PATHS.size():
|
||||
# The targets must be written before the rig enters the tree; merely
|
||||
# caching them would let the modification stack solve once against the
|
||||
# adapter's generic targets and change the placed silhouette.
|
||||
for marker_name: String in pose:
|
||||
var marker := get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
|
||||
if marker == null:
|
||||
push_warning("StickmanRig: missing spawn-pose marker '%s'." % marker_name)
|
||||
return
|
||||
var target: Dictionary = pose[marker_name]
|
||||
marker.position = target.get("pos", marker.position)
|
||||
marker.rotation = target.get("rot", marker.rotation)
|
||||
_spawn_pose = pose.duplicate(true)
|
||||
_spawn_limb_rotations.clear()
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
var pair: Array = LIMB_BONE_PATHS[joint]
|
||||
var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D
|
||||
var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D
|
||||
if upper != null and lower != null:
|
||||
_spawn_limb_rotations[joint] = {
|
||||
"upper": upper.rotation,
|
||||
"lower": lower.rotation,
|
||||
}
|
||||
|
||||
|
||||
## Applies the same velocity delta to every ragdoll body via a mass-scaled
|
||||
## central impulse, preserving the ragdoll's internal structure. No-op outside
|
||||
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
|
||||
@@ -1063,10 +1139,48 @@ func _apply_profile() -> void:
|
||||
# scale. An X-mirror does not affect depth (draw order), so
|
||||
# Z_ORDER_BY_PROFILE is unchanged.
|
||||
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
|
||||
_sync_speech_bubble_mirror()
|
||||
_apply_head_lookat_mirror_mode()
|
||||
_apply_idle_limb_solver_mode()
|
||||
facing_profile_changed.emit(int(facing_profile))
|
||||
|
||||
|
||||
## Godot's TwoBoneIK solver is not mirror-invariant under a negative X scale.
|
||||
## While a LEFT-facing walk is active the animation must keep driving IK, but
|
||||
## once the walk stops an enabled solver can settle into a pose that never
|
||||
## existed in the clip. Pin the limbs to the exact frame captured at stop.
|
||||
func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void:
|
||||
var pin_mirrored_idle := facing_profile == FacingProfile.LEFT \
|
||||
and state == RigState.ANIMATED and not _walking
|
||||
var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations()
|
||||
_set_limb_mods_enabled(not pin_mirrored_idle)
|
||||
if not pin_mirrored_idle:
|
||||
return
|
||||
for joint: String in source:
|
||||
var bones: Dictionary = _limb_bones.get(joint, {})
|
||||
var saved: Dictionary = source[joint]
|
||||
var upper := bones.get("upper") as Bone2D
|
||||
var lower := bones.get("lower") as Bone2D
|
||||
if upper != null and is_instance_valid(upper):
|
||||
upper.rotation = float(saved.get("upper", upper.rotation))
|
||||
if lower != null and is_instance_valid(lower):
|
||||
lower.rotation = float(saved.get("lower", lower.rotation))
|
||||
|
||||
|
||||
func _capture_limb_rotations() -> Dictionary:
|
||||
var rotations: Dictionary = {}
|
||||
for joint: String in _limb_bones:
|
||||
var bones: Dictionary = _limb_bones[joint]
|
||||
var upper := bones.get("upper") as Bone2D
|
||||
var lower := bones.get("lower") as Bone2D
|
||||
if upper != null and lower != null and is_instance_valid(upper) and is_instance_valid(lower):
|
||||
rotations[joint] = {
|
||||
"upper": upper.rotation,
|
||||
"lower": lower.rotation,
|
||||
}
|
||||
return rotations
|
||||
|
||||
|
||||
## The SkeletonModification2DLookAt that aims the Head bone is NOT
|
||||
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
|
||||
## root's negative X scale reflects the head-bone frame, and the LookAt writes a
|
||||
@@ -1195,7 +1309,9 @@ func _enter_ragdoll() -> void:
|
||||
_capture_pre_ragdoll_state()
|
||||
_impact_reactions.clear()
|
||||
_set_collision_bodies_enabled(false)
|
||||
_cancel_walking()
|
||||
# Preserve the live solved pose for the instant ragdoll handoff. A normal
|
||||
# queue cancellation restores the idle markers/limbs, which would pop here.
|
||||
_cancel_walking(false)
|
||||
_ragdoll_at_rest = false
|
||||
# Instant handoff: stop the player without resetting it (keep_state) and
|
||||
# build the ragdoll from the CURRENT solved bone positions while the IK
|
||||
@@ -1335,6 +1451,7 @@ func _reanchor_root_to_landing() -> void:
|
||||
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
|
||||
## clean slate.
|
||||
func _cancel_recovery() -> void:
|
||||
_stand_up_generation += 1
|
||||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.kill()
|
||||
_stand_up_tween = null
|
||||
@@ -1700,6 +1817,8 @@ func _get_ik_marker(name: String) -> Marker2D:
|
||||
## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only
|
||||
## starts the marker tween and appends one bone-rotation tween per entry.
|
||||
func _play_stand_up() -> void:
|
||||
_stand_up_generation += 1
|
||||
var generation: int = _stand_up_generation
|
||||
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
|
||||
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
|
||||
if _stand_up_tween != null:
|
||||
@@ -1708,7 +1827,7 @@ func _play_stand_up() -> void:
|
||||
if bone == null or not is_instance_valid(bone):
|
||||
continue
|
||||
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
|
||||
_stand_up_tween.finished.connect(_on_stand_up_finished)
|
||||
_stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation))
|
||||
|
||||
|
||||
## Tweens the 6 IK markers from their current (captured) values to the target
|
||||
@@ -1730,7 +1849,10 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
|
||||
|
||||
|
||||
## Stand-up tween complete: settle into ANIMATED.
|
||||
func _on_stand_up_finished() -> void:
|
||||
func _on_stand_up_finished(generation: int) -> void:
|
||||
if generation != _stand_up_generation or state != RigState.RECOVERING:
|
||||
return
|
||||
var recovered_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
|
||||
# Restore the pre-ragdoll facing profile + per-joint bend flags so the
|
||||
# re-enabled IK holds the exact pre-ragdoll pose.
|
||||
set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile))))
|
||||
@@ -1748,6 +1870,7 @@ func _on_stand_up_finished() -> void:
|
||||
_body_container.modulate.a = 1.0
|
||||
_set_collision_bodies_enabled(true)
|
||||
state = RigState.ANIMATED
|
||||
_apply_idle_limb_solver_mode(recovered_limb_rotations)
|
||||
state_changed.emit(int(state))
|
||||
|
||||
|
||||
@@ -2017,6 +2140,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
|
||||
set_facing_profile(FacingProfile.LEFT)
|
||||
elif dx > 0.5:
|
||||
set_facing_profile(FacingProfile.RIGHT)
|
||||
# LEFT idle pins the captured limb pose because mirrored TwoBoneIK is not
|
||||
# stable. Re-enable the solvers before the walk animation starts driving.
|
||||
_set_limb_mods_enabled(true)
|
||||
# Always play the canonical walk_right clip. For LEFT the rig root is
|
||||
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
|
||||
# walk_left is no longer used at runtime.
|
||||
@@ -2041,7 +2167,7 @@ func _update_walking(delta: float) -> void:
|
||||
if not _walking:
|
||||
return
|
||||
if state != RigState.ANIMATED:
|
||||
_cancel_walking()
|
||||
_cancel_walking(false)
|
||||
return
|
||||
# Defer all nav reads until the map has actually synchronized. An unsynced
|
||||
# agent reports an empty, finished path (map iteration id == 0), which would
|
||||
@@ -2123,9 +2249,8 @@ func _update_walking(delta: float) -> void:
|
||||
])
|
||||
|
||||
|
||||
## Re-asserts the standing pose one extra physics frame after a walk ends, so
|
||||
## any residual walk-animation body bob (a ±12.5 px torso keyframe) is not left
|
||||
## on the markers when the animation stop and marker restore race.
|
||||
## Legacy deferred marker reset hook. Current walk completion deliberately
|
||||
## leaves this at zero because the final animation frame must remain visible.
|
||||
func _settle_walk_markers() -> void:
|
||||
if _walk_settle_frames > 0:
|
||||
_walk_settle_frames -= 1
|
||||
@@ -2143,19 +2268,24 @@ func _finish_walk(reason: String = "") -> void:
|
||||
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
|
||||
map_iter,
|
||||
])
|
||||
# Freeze on the exact solved animation frame at arrival. LEFT cannot leave
|
||||
# TwoBoneIK running under the root mirror, so capture the bone rotations
|
||||
# before stopping and pin that same frame after disabling the solvers.
|
||||
var final_limb_rotations: Dictionary = _capture_limb_rotations()
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
_restore_standing_markers()
|
||||
_walk_settle_frames = 1
|
||||
_walk_done = true
|
||||
_anim_player.stop(true)
|
||||
_walking = false
|
||||
_apply_idle_limb_solver_mode(final_limb_rotations)
|
||||
_walk_settle_frames = 0
|
||||
_walk_done = true
|
||||
arrived.emit(_walk_target_feet)
|
||||
|
||||
|
||||
func _cancel_walking() -> void:
|
||||
func _cancel_walking(restore_idle_pose: bool = true) -> void:
|
||||
_walk_dbg("cancel (state=%s)" % RigState.keys()[state])
|
||||
var current_limb_rotations: Dictionary = _capture_limb_rotations()
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
_anim_player.stop(true)
|
||||
if _nav_agent != null and _nav_anchor != null:
|
||||
_nav_agent.target_position = _nav_anchor.global_position
|
||||
_walking = false
|
||||
@@ -2163,6 +2293,8 @@ func _cancel_walking() -> void:
|
||||
_walk_mode_latched = false
|
||||
_walk_latch_probe_frames = 0
|
||||
_walk_settle_frames = 0
|
||||
if restore_idle_pose:
|
||||
_apply_idle_limb_solver_mode(current_limb_rotations)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2172,32 +2304,65 @@ func _cancel_walking() -> void:
|
||||
## Show the speech bubble with `text` for `duration` seconds; auto-hides and
|
||||
## emits speech_finished. Lazily creates the SpeechBubble child on first use.
|
||||
func speak(text: String, duration: float) -> void:
|
||||
_primary_speech = { "text": text, "time_left": maxf(duration, 0.0) }
|
||||
_speech_active = true
|
||||
_speech_time_left = maxf(duration, 0.0)
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func _speak_overlay(text: String, duration: float) -> void:
|
||||
_overlay_speech = { "text": text, "time_left": maxf(duration, 0.0) }
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func _show_visible_speech() -> void:
|
||||
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
|
||||
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new()
|
||||
_speech_bubble.name = "SpeechBubble"
|
||||
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
|
||||
add_child(_speech_bubble)
|
||||
_speech_bubble.show_text(text)
|
||||
_speech_active = true
|
||||
_speech_time_left = maxf(duration, 0.0)
|
||||
_sync_speech_bubble_mirror()
|
||||
var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
|
||||
if not visible_speech.is_empty():
|
||||
_speech_bubble.show_text(String(visible_speech.get("text", "")))
|
||||
|
||||
|
||||
func _update_speech(delta: float) -> void:
|
||||
if not _speech_active:
|
||||
return
|
||||
_speech_time_left -= delta
|
||||
if _speech_time_left <= 0.0:
|
||||
_hide_speech()
|
||||
speech_finished.emit()
|
||||
var visible_owner_changed := false
|
||||
if not _primary_speech.is_empty():
|
||||
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
|
||||
_speech_time_left = float(_primary_speech["time_left"])
|
||||
if _speech_time_left <= 0.0:
|
||||
_primary_speech.clear()
|
||||
_speech_active = false
|
||||
speech_finished.emit()
|
||||
if not _overlay_speech.is_empty():
|
||||
_overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta
|
||||
if float(_overlay_speech["time_left"]) <= 0.0:
|
||||
_overlay_speech.clear()
|
||||
visible_owner_changed = true
|
||||
speech_finished.emit()
|
||||
if _overlay_speech.is_empty() and _primary_speech.is_empty():
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
elif visible_owner_changed and _overlay_speech.is_empty():
|
||||
_show_visible_speech()
|
||||
|
||||
|
||||
func _hide_speech() -> void:
|
||||
_speech_active = false
|
||||
_speech_time_left = 0.0
|
||||
_primary_speech.clear()
|
||||
_overlay_speech.clear()
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
|
||||
func _sync_speech_bubble_mirror() -> void:
|
||||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2.ONE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action queue (Phase 3a)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2239,26 +2404,52 @@ func queue_size() -> int:
|
||||
func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
if actions.is_empty():
|
||||
return
|
||||
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
|
||||
for i: int in action_queue.size():
|
||||
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
|
||||
_run_cursor = 0
|
||||
var primary: Array[Dictionary] = []
|
||||
for action: Dictionary in actions:
|
||||
action["reactive"] = true
|
||||
var start := action_queue.size()
|
||||
action_queue.append_array(actions)
|
||||
queue_changed.emit()
|
||||
if _runner_state == RunnerState.IDLE:
|
||||
# Resume at the first appended action (not at index 0), so any queued
|
||||
# sequential actions are skipped over rather than replayed.
|
||||
_current_index = start - 1
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
_stop_requested = false
|
||||
var role: int = ACTION_REGISTRY.reactive_role(action)
|
||||
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
|
||||
_speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
|
||||
continue
|
||||
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
|
||||
_cancel_current_primary()
|
||||
_overlay_speech.clear()
|
||||
_hide_speech()
|
||||
var kept: Array[Dictionary] = []
|
||||
for item: Dictionary in _run_queue.slice(_run_cursor):
|
||||
if not bool(item.get("reactive", false)):
|
||||
kept.append(item)
|
||||
_run_queue = kept
|
||||
_run_cursor = 0
|
||||
primary.clear()
|
||||
primary.append(action.duplicate(true))
|
||||
else:
|
||||
primary.append(action.duplicate(true))
|
||||
if primary.is_empty():
|
||||
return
|
||||
_cancel_current_primary()
|
||||
var injected: Array[Dictionary] = []
|
||||
for action: Dictionary in primary:
|
||||
injected.append({ "action": action, "queue_index": -1, "reactive": true })
|
||||
_run_queue = injected + _run_queue.slice(_run_cursor)
|
||||
_run_cursor = 0
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
|
||||
|
||||
## Drops rule-injected ("reactive") actions from the queue, restoring the
|
||||
## authored sequential queue after a Play session. No-op while the runner is
|
||||
## executing (callers invoke it on mode exit, after stop_queue()).
|
||||
func clear_reactive_actions() -> void:
|
||||
if _runner_state == RunnerState.EXECUTING:
|
||||
return
|
||||
var runtime_kept: Array[Dictionary] = []
|
||||
for item: Dictionary in _run_queue:
|
||||
if not bool(item.get("reactive", false)):
|
||||
runtime_kept.append(item)
|
||||
_run_queue = runtime_kept
|
||||
_run_cursor = mini(_run_cursor, _run_queue.size())
|
||||
var kept: Array[Dictionary] = []
|
||||
for action: Dictionary in action_queue:
|
||||
if not bool(action.get("reactive", false)):
|
||||
@@ -2284,14 +2475,21 @@ func start_queue() -> void:
|
||||
_runner_state = RunnerState.EXECUTING
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
_run_queue.clear()
|
||||
for i: int in action_queue.size():
|
||||
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
|
||||
_run_cursor = 0
|
||||
_current_run_item.clear()
|
||||
_stop_requested = false
|
||||
|
||||
|
||||
## Abort the current action and return to IDLE. Does not emit queue_finished.
|
||||
func stop_queue() -> void:
|
||||
_stop_requested = true
|
||||
_cancel_walking()
|
||||
_cancel_current_primary()
|
||||
_hide_speech()
|
||||
_run_queue.clear()
|
||||
_current_run_item.clear()
|
||||
_runner_state = RunnerState.IDLE
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
@@ -2315,7 +2513,7 @@ func _update_runner(delta: float) -> void:
|
||||
if _walk_done:
|
||||
_finish_action()
|
||||
ActionPhase.SPEAKING:
|
||||
if not _speech_active:
|
||||
if _primary_speech.is_empty():
|
||||
_finish_action()
|
||||
ActionPhase.WAITING:
|
||||
_phase_timer -= delta
|
||||
@@ -2330,14 +2528,16 @@ func _update_runner(delta: float) -> void:
|
||||
|
||||
|
||||
func _advance_to_next_action() -> void:
|
||||
_current_index += 1
|
||||
if _current_index >= action_queue.size():
|
||||
if _run_cursor >= _run_queue.size():
|
||||
_runner_state = RunnerState.IDLE
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_index = -1
|
||||
queue_finished.emit()
|
||||
return
|
||||
var action := action_queue[_current_index]
|
||||
_current_run_item = _run_queue[_run_cursor]
|
||||
_run_cursor += 1
|
||||
var action: Dictionary = _current_run_item.get("action", {})
|
||||
_current_index = int(_current_run_item.get("queue_index", -1))
|
||||
if DEBUG_WALK:
|
||||
print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))])
|
||||
action_started.emit(action, _current_index)
|
||||
@@ -2372,8 +2572,23 @@ func _begin_action(action: Dictionary) -> void:
|
||||
|
||||
|
||||
func _finish_action() -> void:
|
||||
if DEBUG_WALK and _current_index >= 0 and _current_index < action_queue.size():
|
||||
print("[runner] finish idx=%d type=%s" % [_current_index, String(action_queue[_current_index].get("type", ""))])
|
||||
if _current_index >= 0 and _current_index < action_queue.size():
|
||||
action_finished.emit(action_queue[_current_index], _current_index)
|
||||
var finished: Dictionary = _current_run_item.get("action", {})
|
||||
var index: int = int(_current_run_item.get("queue_index", -1))
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
if not finished.is_empty():
|
||||
action_finished.emit(finished, index)
|
||||
|
||||
|
||||
func _cancel_current_primary() -> void:
|
||||
_cancel_walking()
|
||||
_primary_speech.clear()
|
||||
_speech_active = false
|
||||
_speech_time_left = 0.0
|
||||
_phase_timer = 0.0
|
||||
_action_phase = ActionPhase.NONE
|
||||
_current_run_item.clear()
|
||||
_current_index = -1
|
||||
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||||
_speech_bubble.hide_bubble()
|
||||
|
||||
Reference in New Issue
Block a user