Add Phase 3a Core Director Functionality

- Introduced `PHASE_3a_CORE_DIRECTOR.md` detailing the core functionality for directors, including navigation, action queue, UI, waypoint visualization, and action execution.
- Implemented `StageDirectorVisuals` for drawing stickman action queues in edit mode, including waypoints and action badges.
- Created `SpeechBubble` class for displaying speech bubbles above stickmen, with customizable text and styling.
This commit is contained in:
2026-08-30 00:29:30 -04:00
parent badff571f0
commit bf11a5fab5
14 changed files with 2030 additions and 23 deletions
+235 -4
View File
@@ -18,6 +18,7 @@ const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
const STAGE_GRID := preload("res://scripts/stage_grid.gd")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -48,6 +49,21 @@ const DEFAULT_GRID_SIZE := 15.0
const MIN_GRID_SIZE := 1.0
const MAX_GRID_SIZE := 100.0
## Director action-popup item ids (Phase 3a).
const ACT_WALK := 0
const ACT_SPEAK := 1
const ACT_WAIT := 2
const ACT_RAGDOLL := 3
const ACT_RECOVER := 4
## Debug gate for the Phase 3a stage trace. Ship OFF.
const DEBUG_STAGE := false
## Prints a `[stage] `-prefixed message only when DEBUG_STAGE is on.
func _stage_dbg(msg: String) -> void:
if DEBUG_STAGE:
print("[stage] ", msg)
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
@@ -105,6 +121,24 @@ var _authored: Dictionary = {}
## net for engines where a same-frame freeze + teleport does not stick).
var _restore_frames_left: int = 0
# ---------------------------------------------------------------------------
# Director state (Phase 3a)
# ---------------------------------------------------------------------------
var _direct_mode: bool = false
var _direct_button: Button = null
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _wait_dialog: AcceptDialog = null
var _wait_spin: SpinBox = null
var _director_visuals = null # StageDirectorVisuals (preloaded)
var _nav_region: NavigationRegion2D = null
var _nav_dirty: bool = true
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -120,6 +154,8 @@ func _ready() -> void:
_build_grid_layer()
_build_gizmo_layer()
_build_director_visuals()
_build_navigation()
_build_ghost_holder()
_build_ui()
_apply_grid_settings()
@@ -127,6 +163,9 @@ func _ready() -> void:
func _process(_delta: float) -> void:
if _nav_dirty:
_nav_dirty = false
_rebake_navigation()
if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT:
_update_ghost_position()
@@ -166,7 +205,14 @@ func _unhandled_key_input(event: InputEvent) -> void:
if current_mode == StageMode.EDIT:
delete_selected()
KEY_ESCAPE:
if _placement_id != "":
if _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_refresh_status()
elif _direct_mode:
_direct_mode = false
_direct_button.set_pressed_no_signal(false)
elif _placement_id != "":
set_placement_mode("")
elif not _selection.get_selected().is_empty():
_selection.clear_selection()
@@ -191,6 +237,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
if current_mode != StageMode.EDIT:
return
var world_pos := _camera.get_global_mouse_position()
if _direct_mode:
if mb.pressed:
_handle_direct_click(world_pos)
return
if mb.pressed:
if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE:
_gizmos.begin_drag(world_pos)
@@ -238,10 +288,12 @@ func set_mode(mode: StageMode) -> void:
func _enter_edit_mode() -> void:
# Snap stickmen straight back to their standing pose/position (no stand-up
# tween glide).
# tween glide), stopping any director queue first.
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
(node as STICKMAN_RIG).snap_to_standing()
var rig := node as STICKMAN_RIG
rig.stop_queue()
rig.snap_to_standing()
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
# any object that may not be in the authored map. freeze_mode is set BEFORE
# freeze so the body freezes directly as kinematic, never via the static
@@ -256,6 +308,7 @@ func _enter_edit_mode() -> void:
# so re-assert the authored transform on the next few physics frames.
_restore_frames_left = 3
_gizmos.set_enabled(true)
_director_visuals.set_enabled(true)
_apply_grid_settings()
_set_build_controls_visible(true)
@@ -264,16 +317,27 @@ func _enter_play_mode() -> void:
_gizmos.set_enabled(false)
_selection.clear_selection()
set_placement_mode("")
if _action_popup != null:
_action_popup.hide()
_pending_walk_target = false
_context_rig = null
_direct_mode = false
if _direct_button != null:
_direct_button.set_pressed_no_signal(false)
_director_visuals.set_enabled(false)
_apply_grid_settings()
_set_build_controls_visible(false)
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
(node as RigidBody2D).freeze = false
var rig_count := 0
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.auto_recover = false
rig.set_ragdoll(true)
rig.start_queue()
rig_count += 1
_stage_dbg("PLAY rigs=%d" % rig_count)
func _save_object_state(node: Node2D) -> void:
@@ -328,6 +392,12 @@ func _place_at(world_pos: Vector2) -> void:
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
_save_object_state(node)
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.queue_changed.connect(_director_visuals.mark_dirty)
_director_visuals.mark_dirty()
if node is TerrainBlock:
_nav_dirty = true
object_placed.emit(node)
_refresh_status()
_spawn_ghost()
@@ -426,11 +496,17 @@ func delete_selected() -> void:
var selected := _selection.get_selected().duplicate()
if selected.is_empty():
return
var nav_changed := false
for node: Node2D in selected:
if is_instance_valid(node):
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node)
node.queue_free()
_selection.clear_selection()
if nav_changed:
_nav_dirty = true
_director_visuals.mark_dirty()
object_deleted.emit(selected)
_refresh_status()
@@ -452,6 +528,8 @@ func _refresh_status() -> void:
else:
sel_text = "%d objects" % sel.size()
_status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
if _pending_walk_target:
_status_label.text += " | Click stage for walk target (Esc to cancel)"
if _mode_button != null:
_mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY)
_mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit"
@@ -484,6 +562,12 @@ func _build_ui() -> void:
hbox.add_child(btn)
_palette_buttons[id] = btn
_direct_button = Button.new()
_direct_button.text = "Direct"
_direct_button.toggle_mode = true
_direct_button.toggled.connect(_on_direct_toggled)
hbox.add_child(_direct_button)
_grid_check = CheckBox.new()
_grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid
@@ -514,6 +598,36 @@ func _build_ui() -> void:
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
hbox.add_child(_status_label)
_action_popup = PopupMenu.new()
_action_popup.add_item("🚶 Walk To", ACT_WALK)
_action_popup.add_item("💬 Speak", ACT_SPEAK)
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
ui.add_child(_action_popup)
_speak_dialog = AcceptDialog.new()
_speak_dialog.title = "Speak"
_speak_dialog.confirmed.connect(_on_speak_confirmed)
_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_dialog.register_text_enter(_speak_edit)
ui.add_child(_speak_dialog)
_wait_dialog = AcceptDialog.new()
_wait_dialog.title = "Wait"
_wait_dialog.confirmed.connect(_on_wait_confirmed)
_wait_spin = SpinBox.new()
_wait_spin.min_value = 0.1
_wait_spin.max_value = 10.0
_wait_spin.step = 0.1
_wait_spin.value = 1.0
_wait_dialog.add_child(_wait_spin)
ui.add_child(_wait_dialog)
func _build_gizmo_layer() -> void:
_gizmos = STAGE_GIZMOS.new()
@@ -536,6 +650,56 @@ func _build_ghost_holder() -> void:
_ghost_holder.name = "PlacementGhost"
add_child(_ghost_holder)
func _build_director_visuals() -> void:
_director_visuals = STAGE_DIRECTOR_VISUALS.new()
_director_visuals.name = "DirectorVisualsLayer"
_director_visuals.camera = _camera
_director_visuals.world = _world
add_child(_director_visuals)
## Code-built NavigationRegion2D child of the stage (NOT World, so it is never
## selected/hit-tested). Sits at the stage origin; World is at identity so world
## coordinates equal region-local coordinates.
func _build_navigation() -> void:
_nav_region = NavigationRegion2D.new()
_nav_region.name = "NavigationRegion2D"
add_child(_nav_region)
_rebake_navigation()
## Rebuild the navigation mesh from every TerrainBlock child of World: each
## block's world-space polygon is convex-decomposed and fan-triangulated into
## one manual NavigationPolygon.
func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region):
return
var verts := PackedVector2Array()
var triangles: Array[PackedInt32Array] = []
for child: Node in _world.get_children():
var block := child as TerrainBlock
if block == null:
continue
var pts := PackedVector2Array()
for p: Vector2 in block.polygon_points:
pts.append(block.transform * p)
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
if pieces.is_empty():
pieces = [pts]
for piece: PackedVector2Array in pieces:
var base: int = verts.size()
verts.append_array(piece)
for i: int in range(1, piece.size() - 1):
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
var poly := NavigationPolygon.new()
poly.set_vertices(verts)
for tri: PackedInt32Array in triangles:
poly.add_polygon(tri)
_nav_region.navigation_polygon = poly
if DEBUG_STAGE:
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
# ---------------------------------------------------------------------------
# Signal handlers
# ---------------------------------------------------------------------------
@@ -546,6 +710,9 @@ func _on_mode_toggled(pressed: bool) -> void:
func _on_palette_toggled(pressed: bool, id: String) -> void:
if pressed:
_direct_mode = false
if _direct_button != null:
_direct_button.set_pressed_no_signal(false)
set_placement_mode(id)
elif _placement_id == id:
set_placement_mode("")
@@ -585,6 +752,68 @@ func _on_hover_changed(node: Node2D) -> void:
func _on_transform_committed(nodes: Array[Node2D]) -> void:
for node: Node2D in nodes:
_save_object_state(node)
if node is TerrainBlock:
_nav_dirty = true
# ---------------------------------------------------------------------------
# Director tool (Phase 3a)
# ---------------------------------------------------------------------------
func _on_direct_toggled(pressed: bool) -> void:
_direct_mode = pressed
if pressed:
set_placement_mode("")
_selection.clear_selection()
_pending_walk_target = false
_context_rig = null
_refresh_status()
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 })
_pending_walk_target = false
_context_rig = null
_refresh_status()
return
var hit := _selection.hit_test(world_pos)
if hit is STICKMAN_RIG:
_context_rig = hit as StickmanRig
var mouse := get_viewport().get_mouse_position()
_action_popup.popup(Rect2i(Vector2i(mouse), Vector2i.ZERO))
func _on_action_popup_id_pressed(id: int) -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
match id:
ACT_WALK:
_pending_walk_target = true
_refresh_status()
ACT_SPEAK:
_speak_edit.text = ""
_speak_dialog.popup_centered()
_speak_edit.grab_focus()
ACT_WAIT:
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
ACT_RAGDOLL:
_context_rig.queue_action({ "type": "ragdoll" })
ACT_RECOVER:
_context_rig.queue_action({ "type": "recover" })
func _on_speak_confirmed() -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
func _on_wait_confirmed() -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
# ---------------------------------------------------------------------------
# Helpers
@@ -630,6 +859,8 @@ func _set_build_controls_visible(visible: bool) -> void:
_grid_size_spin.visible = visible
if _grid_size_label != null:
_grid_size_label.visible = visible
if _direct_button != null:
_direct_button.visible = visible
## Direct Node2D children of World, excluding the ragdoll body container.
+129
View File
@@ -0,0 +1,129 @@
class_name StageDirectorVisuals
extends Node2D
## StageDirectorVisuals - Edit-mode director overlay (Phase 3a).
##
## Draws each stickman's action queue as walk-to waypoint dots with dashed
## connectors plus small badges for speak/wait/ragdoll/recover, with order
## numbers. Pure drawing; no hit-testing. Visible in EDIT, hidden in PLAY.
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const WAYPOINT_RADIUS_PX := 6.0
const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0)
const WAYPOINT_OUTLINE := Color.WHITE
const DASH_COLOR := Color(1.0, 1.0, 1.0, 0.6)
const DASH_WIDTH_PX := 2.0
const DASH_LENGTH_PX := 6.0
const DASH_GAP_PX := 4.0
const NUMBER_COLOR := Color.WHITE
const NUMBER_FONT_SIZE_PX := 16.0
const ICON_COLOR := Color(1.0, 1.0, 1.0, 0.9)
const ICON_SIZE_PX := 12.0
const BADGE_STACK_STEP := Vector2(0.0, -28.0)
var camera: Camera2D = null
var world: Node2D = null
var enabled: bool = true
var _dirty: bool = true
func set_enabled(value: bool) -> void:
enabled = value
visible = value
queue_redraw()
func mark_dirty() -> void:
_dirty = true
func _process(_delta: float) -> void:
if _dirty:
_dirty = false
queue_redraw()
func _zoom() -> float:
if camera != null and is_instance_valid(camera):
return maxf(camera.zoom.x, 0.0001)
return 1.0
func _collect_rigs() -> Array[StickmanRig]:
var result: Array[StickmanRig] = []
if world == null or not is_instance_valid(world):
return result
for child: Node in world.get_children():
if child is STICKMAN_RIG:
result.append(child as StickmanRig)
return result
func _draw() -> void:
if not enabled:
return
var zoom := _zoom()
for rig: StickmanRig in _collect_rigs():
_draw_rig_queue(rig, zoom)
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
var queue := rig.get_queue()
if queue.is_empty():
return
# Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root).
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET
var stack := 0
for i: int in queue.size():
var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to":
var target: Vector2 = action.get("target", current)
_draw_dashed(current, target, zoom)
_draw_waypoint(target, zoom, str(i + 1))
current = target
stack = 0
else:
var anchor := current + BADGE_STACK_STEP * float(stack) / zoom
_draw_badge(anchor, String(action.get("type", "")), zoom, str(i + 1))
stack += 1
func _draw_dashed(from: Vector2, to: Vector2, zoom: float) -> void:
var dash := DASH_LENGTH_PX / zoom
var gap := DASH_GAP_PX / zoom
var dir := from.direction_to(to)
var total := from.distance_to(to)
var dist := 0.0
while dist < total:
var start := from + dir * dist
var len := minf(dash, total - dist)
draw_line(start, start + dir * len, DASH_COLOR, DASH_WIDTH_PX / zoom, true)
dist += dash + gap
func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
var radius := WAYPOINT_RADIUS_PX / zoom
draw_circle(pos, radius, WAYPOINT_COLOR)
draw_arc(pos, radius, 0.0, TAU, 32, WAYPOINT_OUTLINE, 2.0 / zoom, true)
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
draw_string(ThemeDB.fallback_font, pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(NUMBER_FONT_SIZE_PX / zoom), NUMBER_COLOR)
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) -> void:
var s := ICON_SIZE_PX / zoom
match type:
"speak":
var bw := s * 1.6
var bh := s * 1.1
draw_rect(Rect2(anchor - Vector2(bw, bh) * 0.5, Vector2(bw, bh)), ICON_COLOR, true)
draw_colored_polygon(PackedVector2Array([
anchor + Vector2(-s * 0.25, bh * 0.5),
anchor + Vector2(s * 0.25, bh * 0.5),
anchor + Vector2(0.0, bh * 0.5 + s * 0.5),
]), ICON_COLOR)
"wait":
draw_arc(anchor, s, 0.0, TAU, 32, ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(0.0, -s * 0.7), ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(s * 0.5, 0.0), ICON_COLOR, 2.0 / zoom, true)
"ragdoll":
draw_line(anchor + Vector2(-s, -s) * 0.5, anchor + Vector2(s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor + Vector2(s, -s) * 0.5, anchor + Vector2(-s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
"recover":
var base := anchor + Vector2(0.0, s * 0.6)
var tip := anchor + Vector2(0.0, -s * 0.6)
draw_line(base, tip, ICON_COLOR, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
_draw_number(anchor + Vector2(s, -s), number, zoom)
+1
View File
@@ -0,0 +1 @@
uid://dsmvfj2hn3h3p
+422 -9
View File
@@ -25,6 +25,12 @@ enum BendDirection { NORMAL, INVERTED }
## IK targets to the standing pose before returning to ANIMATED.
enum RigState { ANIMATED, RAGDOLL, RECOVERING }
## Director action-runner execution state (Phase 3a).
enum RunnerState { IDLE, EXECUTING }
## Which kind of queued action is currently executing (Phase 3a).
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@@ -142,6 +148,33 @@ const IK_TARGET_PATHS: Dictionary = {
"Right_Leg": "IK_Targets/Right_Leg",
}
# ---------------------------------------------------------------------------
# Director / navigation constants (Phase 3a)
# ---------------------------------------------------------------------------
## Feet -> root translation (a ground point -> the hip/root position).
const FOOT_OFFSET := Vector2(0.0, -385.0)
## The navigation agent sits at the feet, on the nav mesh.
const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0)
## Fallback arrival distance (px) to the root destination.
const ARRIVE_DISTANCE := 8.0
const NAV_PATH_DESIRED_DISTANCE := 8.0
const NAV_TARGET_DESIRED_DISTANCE := 12.0
## Rig-local anchor for the speech bubble, above the head.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
if DEBUG_WALK:
print("[walk] ", msg)
## Preloaded (not a class_name type) so this script compiles even when the
## editor's global class cache is stale.
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
## Ragdoll body definitions, ordered parent-before-child. `node_path` is
## Skeleton2D-relative for bones and rig-root-relative for the head visual.
## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
@@ -216,6 +249,10 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [
## ragdoll stays down until request_recovery() is called manually.
@export var auto_recover: bool = true
@export_group("Director")
## Walk speed (px/s) used by walk_to when no per-action speed is given.
@export var walk_speed: float = 300.0
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
@@ -231,6 +268,17 @@ signal bend_flag_changed(joint: String, flipped: bool)
## the RigState enum value.
signal state_changed(new_state: int)
# ---------------------------------------------------------------------------
# Director signals (Phase 3a)
# ---------------------------------------------------------------------------
signal arrived # walk_to reached its destination
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
signal queue_changed # any queue mutation
signal speech_finished # bubble auto-hid after speak()
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
@@ -265,6 +313,38 @@ var _stabilize_timer: float = 0.0
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
var _stand_up_tween: Tween = null
# ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a)
# ---------------------------------------------------------------------------
var action_queue: Array[Dictionary] = []
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1
var _stop_requested: bool = false
var _nav_agent: NavigationAgent2D = null
## NavigationAgent2D is a plain Node (not Node2D): it has no `position`; it
## derives its agent position from its parent Node2D's global position. This
## anchor sits at the feet (NAV_AGENT_LOCAL_POS) so the agent paths from
## ground level.
var _nav_anchor: Node2D = null
var _walking: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line)
var _walk_done: bool = false
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 _phase_timer: float = 0.0
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -305,6 +385,25 @@ func _ready() -> void:
# setters only stored values).
_nodes_ready = true
_apply_profile()
# Build the navigation agent at the feet (Phase 3a). It shares the default
# navigation map/layer with the stage's NavigationRegion2D.
# NavigationAgent2D is a plain Node: it has no `position`; it derives its
# agent position from its parent Node2D's global position, so we anchor it
# under a Node2D placed at the feet.
_nav_anchor = Node2D.new()
_nav_anchor.name = "NavigationAgentAnchor"
_nav_anchor.position = NAV_AGENT_LOCAL_POS
add_child(_nav_anchor)
_nav_agent = NavigationAgent2D.new()
_nav_agent.name = "NavigationAgent2D"
_nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE
_nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE
_nav_agent.path_max_distance = 100.0
_nav_agent.max_speed = walk_speed
_nav_agent.avoidance_enabled = false
_nav_anchor.add_child(_nav_agent)
_prev_global_pos = global_position
_prev_global_rot = global_rotation
@@ -312,6 +411,9 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_update_walking(delta)
_update_speech(delta)
_update_runner(delta)
func _track_momentum(delta: float) -> void:
@@ -340,6 +442,9 @@ func _update_rest_detection(delta: float) -> void:
_rest_timer = 0.0
_stabilize_timer = 0.0
return
# Publish rest regardless of auto_recover (the director runner polls it).
if not _ragdoll_at_rest:
_ragdoll_at_rest = true
if not auto_recover:
_rest_timer = 0.0
return
@@ -446,15 +551,7 @@ func snap_to_standing() -> void:
_destroy_ragdoll()
else:
_cancel_recovery()
# Set the IK targets directly to the standing pose (no tween).
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
_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:
_skeleton.modification_stack.enabled = true
@@ -465,6 +562,18 @@ func snap_to_standing() -> void:
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 marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.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.
@@ -570,6 +679,8 @@ func _enter_ragdoll() -> void:
if _skeleton == null or _body_container == null:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return
_cancel_walking()
_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
# stack is still enabled (disabling it first would revert the bones to the
@@ -911,3 +1022,305 @@ func _destroy_ragdoll() -> void:
_ragdoll_root.queue_free()
_ragdoll_root = null
_ragdoll_bodies.clear()
# ---------------------------------------------------------------------------
# Navigation / walking (Phase 3a)
# ---------------------------------------------------------------------------
## Start walking so the feet land at `target` (world/ground space). `speed <= 0`
## uses walk_speed. No-op (push_warning) unless state == ANIMATED.
func walk_to(target: Vector2, speed: float = -1.0) -> void:
if state != RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
return
_walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else walk_speed
_nav_agent.max_speed = _walk_speed_current
_nav_agent.target_position = target
_walk_dbg("walk_to target=(%.1f, %.1f) speed=%.1f root=(%.1f, %.1f) feet=(%.1f, %.1f)" % [
target.x, target.y, _walk_speed_current,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
])
var dx := target.x - global_position.x
var anim_name: String
if dx < -0.5:
set_facing_profile(FacingProfile.LEFT)
anim_name = "walk_left"
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
anim_name = "walk_right"
else:
anim_name = "walk_right"
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
_anim_player.play(anim_name)
_walking = true
_walk_done = false
func is_walking() -> bool:
return _walking
func _update_walking(delta: float) -> void:
if not _walking:
return
if state != RigState.ANIMATED:
_cancel_walking()
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
# otherwise end the walk after a single move_toward step (~5 px).
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
_walk_dbg("sync pending")
return
# Ask the agent for its next waypoint FIRST. This forces the agent's internal
# path update (_update_navigation), which re-queries the map whenever the
# stored path is empty (set_target_position resets it via _request_repath).
# The read-only get_current_navigation_path() accessor alone never triggers a
# repath, so checking it directly would leave the path empty forever.
var next_feet := _nav_agent.get_next_path_position()
var root_target: Vector2
if _nav_agent.is_target_reachable():
# NAV branch: the target lies on the mesh — follow the path to it.
_walk_mode = "nav"
if _nav_agent.is_navigation_finished():
_finish_walk("finished")
return
root_target = next_feet + FOOT_OFFSET
else:
# DIRECT branch: an off-mesh waypoint is now a normal, supported case —
# steer straight at the clicked point, ignoring the nav mesh.
if _walk_mode != "direct":
_walk_dbg("off-mesh waypoint: switching to direct steering")
_walk_mode = "direct"
root_target = _walk_target_feet + FOOT_OFFSET
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE:
_finish_walk("arrive")
return
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
Engine.get_physics_frames(),
_current_index,
_walk_mode,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
_walk_target_feet.x, _walk_target_feet.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
str(_nav_agent.is_navigation_finished()),
str(_nav_agent.is_target_reachable()),
_nav_agent.get_final_position().x, _nav_agent.get_final_position().y,
_nav_agent.get_current_navigation_path().size(),
next_feet.x, next_feet.y,
NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()),
])
func _finish_walk(reason: String = "") -> void:
if DEBUG_WALK:
var map_iter := -1
if _nav_agent != null:
map_iter = NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map())
_walk_dbg("finish reason=%s root=(%.1f, %.1f) dist_to_target=%.1f map_iter=%d" % [
reason,
global_position.x, global_position.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
map_iter,
])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_restore_standing_markers()
_walk_done = true
_walking = false
arrived.emit()
func _cancel_walking() -> void:
_walk_dbg("cancel (state=%s)" % RigState.keys()[state])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_walk_done = false
# ---------------------------------------------------------------------------
# Speech (Phase 3a)
# ---------------------------------------------------------------------------
## 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:
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)
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()
func _hide_speech() -> void:
_speech_active = false
_speech_time_left = 0.0
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
# ---------------------------------------------------------------------------
# Action queue (Phase 3a)
# ---------------------------------------------------------------------------
func queue_action(action: Dictionary) -> void:
action_queue.append(action)
queue_changed.emit()
func clear_queue() -> void:
action_queue.clear()
queue_changed.emit()
func get_queue() -> Array[Dictionary]:
return action_queue.duplicate()
func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
push_warning("StickmanRig: remove_action index %d out of range." % index)
return
action_queue.remove_at(index)
queue_changed.emit()
func insert_action(index: int, action: Dictionary) -> void:
action_queue.insert(clampi(index, 0, action_queue.size()), action)
queue_changed.emit()
func queue_size() -> int:
return action_queue.size()
# ---------------------------------------------------------------------------
# Runner state machine (Phase 3a)
# ---------------------------------------------------------------------------
## Start running the queue. Empty queue emits queue_finished immediately.
func start_queue() -> void:
if _runner_state == RunnerState.EXECUTING:
return
if DEBUG_WALK:
print("[runner] start_queue size=%d" % action_queue.size())
if action_queue.is_empty():
queue_finished.emit()
return
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_current_index = -1
_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()
_hide_speech()
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
func is_queue_running() -> bool:
return _runner_state == RunnerState.EXECUTING
func is_ragdoll_at_rest() -> bool:
return _ragdoll_at_rest
func _update_runner(delta: float) -> void:
if _runner_state != RunnerState.EXECUTING:
return
match _action_phase:
ActionPhase.NONE:
_advance_to_next_action()
ActionPhase.WALKING:
if _walk_done:
_finish_action()
ActionPhase.SPEAKING:
if not _speech_active:
_finish_action()
ActionPhase.WAITING:
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_action()
ActionPhase.RAGDOLLING:
if is_ragdoll_at_rest():
_finish_action()
ActionPhase.RECOVERING:
if state == RigState.ANIMATED:
_finish_action()
func _advance_to_next_action() -> void:
_current_index += 1
if _current_index >= action_queue.size():
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
queue_finished.emit()
return
var action := action_queue[_current_index]
if DEBUG_WALK:
print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))])
action_started.emit(action, _current_index)
_begin_action(action)
func _begin_action(action: Dictionary) -> void:
match String(action.get("type", "")):
"walk_to":
_action_phase = ActionPhase.WALKING
_walk_done = false
walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0)))
if not _walking:
# walk_to self-guarded (e.g. not ANIMATED): complete immediately
# so the runner never hangs.
_walk_done = true
"speak":
_action_phase = ActionPhase.SPEAKING
speak(String(action.get("text", "")), float(action.get("duration", 2.0)))
"wait":
_phase_timer = float(action.get("duration", 0.0))
_action_phase = ActionPhase.WAITING
"ragdoll":
_action_phase = ActionPhase.RAGDOLLING
set_ragdoll(true)
"recover":
_action_phase = ActionPhase.RECOVERING
request_recovery()
_:
push_warning("StickmanRig: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
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)
_action_phase = ActionPhase.NONE
+50
View File
@@ -0,0 +1,50 @@
class_name SpeechBubble
extends Node2D
## SpeechBubble - World-space speech bubble drawn in _draw() (Phase 3a).
##
## A child of the rig root at a fixed upward offset (SPEECH_BUBBLE_OFFSET), so
## it follows the figure and scales with the camera. Pure drawing, no
## hit-testing. Hidden by default.
const FONT_SIZE := 28
const PADDING := Vector2(14.0, 10.0)
const TAIL_HEIGHT := 12.0
const TAIL_WIDTH := 16.0
const MAX_WIDTH := 320.0
const BG_COLOR := Color(1.0, 1.0, 1.0, 0.95)
const BORDER_COLOR := Color(0.0, 0.0, 0.0, 0.6)
const TEXT_COLOR := Color(0.0, 0.0, 0.0, 1.0)
var _text: String = ""
var _bg_style: StyleBoxFlat
func _init() -> void:
visible = false
_bg_style = StyleBoxFlat.new()
_bg_style.bg_color = BG_COLOR
_bg_style.border_color = BORDER_COLOR
_bg_style.set_border_width_all(2)
_bg_style.set_corner_radius_all(8)
func show_text(text: String) -> void:
_text = text
visible = true
queue_redraw()
func hide_bubble() -> void:
visible = false
func _draw() -> void:
if _text.is_empty():
return
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(_text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE)
var box_size := text_size + PADDING * 2.0
var box := Rect2(Vector2(-box_size.x * 0.5, -TAIL_HEIGHT - box_size.y), box_size)
draw_style_box(_bg_style, box)
draw_colored_polygon(PackedVector2Array([
Vector2(-TAIL_WIDTH * 0.5, -TAIL_HEIGHT),
Vector2(TAIL_WIDTH * 0.5, -TAIL_HEIGHT),
Vector2(0.0, 0.0),
]), BG_COLOR)
draw_string(font, box.position + PADDING, _text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE, TEXT_COLOR)
+1
View File
@@ -0,0 +1 @@
uid://b22w8sfbhd0y8