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.