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.
|
||||
|
||||
Reference in New Issue
Block a user