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:
+235
-4
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsmvfj2hn3h3p
|
||||
+422
-9
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b22w8sfbhd0y8
|
||||
Reference in New Issue
Block a user