Add new stage editor scripts for target capture, terrain painting, theme management, rule engine, and spatial indexing

- Implement `StageTargetCaptureSession` for managing target capture sessions with throw and set down positions.
- Create `StageTerrainPainter` for terrain drag painting and overlap classification with stroke preview.
- Introduce `StageThemeController` to handle theme values and resource loading for the Sandbox Stage.
- Develop `StageRuleEngine` for runtime event matching and collision bookkeeping.
- Add `StageSpatialIndex` for broadphase stage lookups with advisory spatial indexing.
This commit is contained in:
2026-10-10 12:13:01 -04:00
parent 1d4a71525a
commit 373f40cf75
37 changed files with 8087 additions and 4243 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,334 @@
class_name StageActionQueueController
extends RefCounted
## Owns queue/action editor presentation and the local edit session context.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
signal toast_requested(message: String)
signal confirmation_requested(title: String, message: String, callback: Callable)
signal queue_action_delete_commit_requested(rig: StickmanRig, action_id: int)
signal queue_clear_commit_requested(rig: StickmanRig)
signal action_commit_requested(action: Dictionary)
signal action_cancel_requested
signal capture_request_requested(
kind: StringName,
hint: String,
on_resolve: Callable,
on_cancel: Callable,
preview_rig: StickmanRig,
exclude_action_id: int,
use_queue_prefix: bool
)
signal speech_composition_requested(actor: StickmanRig, initial: Dictionary, reactive_options: bool, context: String)
signal speech_actor_capture_requested
var panel_rig: StickmanRig
var editor_kind: String = ""
var editor_index: int = -1
var editor_rig: StickmanRig
var editor_action_id: int = -1
var editor_target_action_id: int = -1
var editor_insert_after: bool = false
var editor_actor_id: int = -1
var pending_speech_initial: Dictionary = {}
var editor_restore_queue: bool = false
var waypoint_rig: StickmanRig
var waypoint_index: int = -1
var waypoint_action_id: int = -1
var waypoint_position: Vector2 = Vector2.ZERO
var _queue_panel: QueuePanel
var _action_editor: ActionEditor
var _rule_editor: RuleEditor
func configure(queue_panel: QueuePanel, action_editor: ActionEditor, rule_editor: RuleEditor) -> void:
_queue_panel = queue_panel
_action_editor = action_editor
_rule_editor = rule_editor
_connect_signal(_queue_panel, &"edit_requested", edit_queue_action)
_connect_signal(_queue_panel, &"delete_requested", _on_queue_delete_requested)
_connect_signal(_queue_panel, &"add_requested", add_queue_action)
_connect_signal(_queue_panel, &"clear_requested", _on_queue_clear_requested)
_connect_signal(_action_editor, &"committed", _on_action_committed)
_connect_signal(_action_editor, &"cancelled", _on_action_cancelled)
_connect_signal(_action_editor, &"target_requested", _on_target_requested)
_connect_signal(_action_editor, &"speech_requested", _on_speech_requested)
func _connect_signal(source: Object, signal_name: StringName, callback: Callable) -> void:
if source != null and source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
source.connect(signal_name, callback)
func _on_queue_delete_requested(index: int) -> void:
request_delete_action(index)
func _on_queue_clear_requested() -> void:
request_clear_queue()
func request_delete_action(index: int) -> void:
if panel_rig == null or not is_instance_valid(panel_rig):
return
var action_id: int = panel_rig.action_id_at(index)
if action_id >= 0:
confirmation_requested.emit("Delete Action", "Delete this action?", Callable(self, "_confirm_delete_action").bind(panel_rig, action_id))
func _confirm_delete_action(rig: StickmanRig, action_id: int) -> void:
queue_action_delete_commit_requested.emit(rig, action_id)
func request_clear_queue() -> void:
if panel_rig == null or not is_instance_valid(panel_rig):
return
var target_rig: StickmanRig = panel_rig
confirmation_requested.emit(
"Clear Queue",
"Remove all actions for %s?" % target_rig.name,
Callable(self, "_confirm_clear_queue").bind(target_rig)
)
func _confirm_clear_queue(rig: StickmanRig) -> void:
queue_clear_commit_requested.emit(rig)
func _on_action_committed(action: Dictionary) -> void:
action_commit_requested.emit(action)
func _on_action_cancelled() -> void:
action_cancel_requested.emit()
func _on_target_requested() -> void:
request_action_target_capture()
func _on_speech_requested(initial: Dictionary) -> void:
handle_speech_request(initial)
func handle_speech_request(initial: Dictionary) -> void:
if _action_editor != null:
_action_editor.hide()
var actor: StickmanRig = action_editor_preview_rig()
if (actor == null or not is_instance_valid(actor)) and editor_kind == "rule_add":
pending_speech_initial = initial.duplicate(true)
speech_actor_capture_requested.emit()
return
if actor == null or not is_instance_valid(actor):
toast_requested.emit("Choose a character before composing speech")
action_cancel_requested.emit()
return
speech_composition_requested.emit(actor, initial.duplicate(true), editor_kind in ["rule_add", "rule_edit"], "action_editor")
func accept_pending_speech_actor(value: Node2D) -> void:
if not value is StickmanRig:
cancel_pending_speech_actor()
return
var actor: StickmanRig = value as StickmanRig
set_editor_actor_id(int(actor.get_instance_id()))
var initial: Dictionary = pending_speech_initial.duplicate(true)
pending_speech_initial.clear()
speech_composition_requested.emit(actor, initial, true, "action_editor")
func cancel_pending_speech_actor() -> void:
pending_speech_initial.clear()
if _rule_editor != null:
_rule_editor.popup_centered()
func begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
var queue: Array[Dictionary] = rig.get_queue()
if index < 0 or index >= queue.size() or not ACTION_REGISTRY.is_move_action(queue[index]):
return
open_action_editor("queue_edit", queue[index].duplicate(true), index, rig, -1, from_panel)
func open_queue_panel(rig: StickmanRig) -> void:
panel_rig = rig
if _queue_panel == null:
return
_queue_panel.setup(rig)
_queue_panel.popup_centered()
func edit_queue_action(index: int) -> void:
if panel_rig == null or not is_instance_valid(panel_rig):
return
var queue: Array[Dictionary] = panel_rig.get_queue()
if index < 0 or index >= queue.size():
return
var action_id: int = panel_rig.action_id_at(index)
var action_status: int = panel_rig.get_action_status(action_id)
if action_status == ACTION_RUNNER.QueueStatus.COMPLETED or action_status == ACTION_RUNNER.QueueStatus.INTERRUPTED:
toast_requested.emit("Completed actions can only be deleted")
return
open_action_editor("queue_edit", queue[index].duplicate(true), index, panel_rig, -1, true)
func add_queue_action() -> void:
if panel_rig == null or not is_instance_valid(panel_rig):
return
open_action_editor("queue_add", {}, -1, panel_rig, -1, true)
func add_rule_action() -> void:
open_action_editor("rule_add", {}, -1, null, -1, false)
func edit_rule_action(index: int) -> void:
if _rule_editor == null:
return
var rule_action: Dictionary = _rule_editor.get_action(index)
if rule_action.is_empty():
return
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(rule_action)
var actor_id: int = int(rule_action.get("target", -1))
open_action_editor("rule_edit", flat, index, null, actor_id, false)
func open_action_editor(
kind: String,
flat: Dictionary,
index: int,
rig: StickmanRig,
actor_id: int,
hide_queue: bool,
target_action_id: int = -1,
insert_after: bool = false
) -> void:
editor_kind = kind
editor_index = index
editor_rig = rig
editor_action_id = rig.action_id_at(index) if kind == "queue_edit" and rig != null and is_instance_valid(rig) else -1
editor_target_action_id = target_action_id
editor_insert_after = insert_after
editor_actor_id = actor_id
editor_restore_queue = hide_queue
if hide_queue and _queue_panel != null:
_queue_panel.hide()
elif kind in ["rule_add", "rule_edit"] and _rule_editor != null:
_rule_editor.hide()
if _action_editor == null:
return
if flat.is_empty():
_action_editor.open_new(kind in ["rule_add", "rule_edit"])
else:
_action_editor.open_edit(flat, kind in ["rule_add", "rule_edit"])
func set_editor_actor_id(actor_id: int) -> void:
editor_actor_id = actor_id
func action_editor_preview_rig() -> StickmanRig:
if editor_rig != null and is_instance_valid(editor_rig):
return editor_rig
var actor: StickmanRig = instance_from_id(editor_actor_id) as StickmanRig
return actor if actor != null and is_instance_valid(actor) else null
func restore_queue_panel() -> void:
if editor_restore_queue and _queue_panel != null:
_queue_panel.refresh()
_queue_panel.popup_centered()
func cancel_action_editor() -> bool:
if _action_editor != null:
_action_editor.hide()
match editor_kind:
"queue_add", "queue_edit":
restore_queue_panel()
"rule_add", "rule_edit":
if _rule_editor != null:
_rule_editor.popup_centered()
"rule_builder_move_to":
return true
return false
func set_walk_target(position: Vector2) -> void:
if _action_editor != null:
_action_editor.set_walk_target(position)
func set_prop_target(stage_id: String) -> void:
if _action_editor != null:
_action_editor.set_prop_target(stage_id)
func set_throw_target(position: Vector2) -> void:
if _action_editor != null:
_action_editor.set_throw_target(position)
func set_down_target(position: Vector2) -> void:
if _action_editor != null:
_action_editor.set_down_target(position)
func show_action_editor() -> void:
if _action_editor != null:
_action_editor.popup_centered()
func request_action_target_capture() -> void:
if _action_editor == null:
return
_action_editor.hide()
var action_type: String = _action_editor.current_action_type()
if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop"]:
_emit_capture_request(
&"actor",
"🎯 Click the stickman who will act (Esc or RMB to cancel)",
Callable(self, "accept_action_actor"),
Callable(self, "cancel_target_capture")
)
return
match action_type:
ACTION_REGISTRY.MOVE_TO:
var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk"
_emit_capture_request(&"position", "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture"))
"pick_up_prop":
_emit_capture_request(&"prop", "🫴 Click the prop to pick up (Esc or RMB to cancel)", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture"))
"throw_prop":
_emit_capture_request(&"throw_position", "🏹 Click where to throw (Esc or RMB to cancel)", Callable(self, "set_throw_target"), Callable(self, "cancel_target_capture"))
"set_down_prop":
_emit_capture_request(&"set_down_position", "Click where to let go (Esc or RMB to cancel)", Callable(self, "set_down_target"), Callable(self, "cancel_target_capture"))
_:
_emit_capture_request(&"position", "🚩 Click the target (Esc or RMB to cancel)", Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture"))
func _emit_capture_request(kind: StringName, hint: String, on_resolve: Callable, on_cancel: Callable) -> void:
var preview_rig: StickmanRig = action_editor_preview_rig()
var use_queue_prefix: bool = editor_kind in ["queue_edit", "queue_add"]
var exclude_action_id: int = editor_action_id if editor_kind == "queue_edit" else -1
capture_request_requested.emit(kind, hint, on_resolve, on_cancel, preview_rig, exclude_action_id, use_queue_prefix)
func accept_action_actor(value: Node2D) -> void:
if not value is StickmanRig:
cancel_target_capture()
return
set_editor_actor_id(int(value.get_instance_id()))
request_action_target_capture()
func accept_prop_target(value: Node2D) -> void:
if value is PropBlock and not (value as PropBlock).stage_object_id.is_empty():
set_prop_target((value as PropBlock).stage_object_id)
else:
show_action_editor()
func cancel_target_capture() -> void:
show_action_editor()
@@ -0,0 +1 @@
uid://boninh5ktcxr
@@ -0,0 +1,223 @@
class_name StageAssetBrowser
extends Node
## Owns the stage asset selector, library scans, and thumbnail queue.
signal asset_selected_requested(entry: Dictionary)
signal asset_load_failed(kind: String, path: String)
signal selector_cancel_requested
signal refresh_requested
const ASSET_SELECTOR_SCENE := preload("res://scenes/stage/asset_selector.tscn")
const STICKMAN_LIBRARY_SCRIPT := preload("res://scripts/characters/stickman/stickman_library.gd")
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
const THUMBNAIL_CACHE_SCRIPT := preload("res://scripts/shared/thumbnails/thumbnail_cache.gd")
const STICKMAN_THUMBNAIL_SCRIPT := preload("res://scripts/shared/thumbnails/stickman_thumbnail.gd")
const PROP_THUMBNAIL_SCRIPT := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const SELECTOR_DIM_ALPHA: float = 0.5
var stickman_library: StickmanLibrary
var thumbnail_cache: ThumbnailCache
var stickman_thumbnail: StickmanThumbnail
var prop_thumbnail: PropThumbnail
var selector: AssetSelector
var selector_open: bool = false
var selector_kind: String = ""
var selector_dim: ColorRect
var browse_dialog: FileDialog
var thumbnail_queue: Array[Dictionary] = []
var thumbnail_busy: bool = false
var thumbnail_generation: int = 0
func _ready() -> void:
stickman_library = STICKMAN_LIBRARY_SCRIPT.new() as StickmanLibrary
thumbnail_cache = THUMBNAIL_CACHE_SCRIPT.new() as ThumbnailCache
stickman_thumbnail = STICKMAN_THUMBNAIL_SCRIPT.new() as StickmanThumbnail
prop_thumbnail = PROP_THUMBNAIL_SCRIPT.new() as PropThumbnail
add_child(stickman_thumbnail)
add_child(prop_thumbnail)
func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_theme: SandboxTheme) -> void:
selector_dim = ColorRect.new()
selector_dim.name = "SelectorDim"
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
selector_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
selector_dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
selector_dim.visible = false
ui_layer.add_child(selector_dim)
selector = ASSET_SELECTOR_SCENE.instantiate() as AssetSelector
selector.item_selected.connect(func(entry: Dictionary) -> void: asset_selected_requested.emit(entry))
selector.cancelled.connect(_on_selector_cancelled)
selector.popup_hide.connect(_on_selector_cancelled)
selector.browse_requested.connect(show_browse_dialog)
selector.refresh_requested.connect(func() -> void: refresh_requested.emit())
ui_layer.add_child(selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
selector.apply_font(ui_font, emoji_font)
selector.apply_theme(sandbox_theme)
browse_dialog = FileDialog.new()
browse_dialog.title = "Open .stk"
browse_dialog.access = FileDialog.ACCESS_FILESYSTEM
browse_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
browse_dialog.current_dir = ProjectSettings.globalize_path(CONTENT_PATHS.STICKMEN_DIR)
browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
browse_dialog.file_selected.connect(_on_browse_file_selected)
ui_layer.add_child(browse_dialog)
func scan_entries(kind: String) -> Array[Dictionary]:
if kind == "stickman":
return stickman_library.scan()
return PROP_LIBRARY_SCRIPT.scan()
func make_browse_entry(kind: String, path: String) -> Dictionary:
if kind == "prop":
return PROP_LIBRARY_SCRIPT.make_custom_entry(path)
return stickman_library.make_entry(path)
func open_selector(kind: String, current_stickman_path: String) -> Dictionary:
set_selector_kind(kind)
var entries: Array[Dictionary] = scan_entries(kind)
var selected_stickman_path: String = current_stickman_path
if kind == "stickman" and not entries.is_empty() and stickman_library.find_by_path(current_stickman_path).is_empty():
selected_stickman_path = String(entries[0].get("path", ""))
var auto_selection: Dictionary = entries[0] if kind == "stickman" and entries.size() == 1 else {}
if auto_selection.is_empty():
open(kind, entries)
return {
"entries": entries,
"selected_stickman_path": selected_stickman_path,
"auto_selection": auto_selection,
}
func set_selector_kind(kind: String) -> void:
selector_kind = kind
func open(kind: String, entries: Array[Dictionary]) -> void:
selector_kind = kind
selector_open = true
if selector_dim != null:
selector_dim.visible = true
if selector != null:
selector.open(kind, entries)
load_or_enqueue_thumbnails(kind, entries)
func close() -> void:
selector_open = false
selector_kind = ""
thumbnail_generation += 1
thumbnail_queue.clear()
if selector_dim != null:
selector_dim.visible = false
if selector != null:
selector.hide()
func show_browse_dialog() -> void:
if browse_dialog == null:
return
if selector_kind == "prop":
browse_dialog.title = "Open .prop"
browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
else:
browse_dialog.title = "Open .stk"
browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
browse_dialog.popup_centered()
func set_entries(entries: Array[Dictionary]) -> void:
if selector != null:
selector.set_entries(entries)
load_or_enqueue_thumbnails(selector_kind, entries)
func refresh_selector_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = scan_entries(selector_kind)
set_entries(entries)
return entries
func _on_browse_file_selected(path: String) -> void:
var entry: Dictionary = make_browse_entry(selector_kind, path)
if entry.is_empty():
asset_load_failed.emit(selector_kind, path)
return
asset_selected_requested.emit(entry)
func load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
thumbnail_queue.clear()
thumbnail_generation += 1
var generation: int = thumbnail_generation
for entry: Dictionary in entries:
if kind == "stickman":
var path: String = String(entry.get("path", ""))
var key: String = thumbnail_cache.stickman_key(path)
var png: String = thumbnail_cache.stickman_png(key)
var texture: Texture2D = thumbnail_cache.get_texture(png)
if texture != null:
if selector != null:
selector.set_thumbnail(entry, texture)
else:
thumbnail_queue.append({"kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation})
else:
var id: String = String(entry.get("id", ""))
var png: String = thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else thumbnail_cache.prop_png(id)
var texture: Texture2D = thumbnail_cache.get_texture(png)
if texture != null:
if selector != null:
selector.set_thumbnail(entry, texture)
else:
thumbnail_queue.append({"kind": kind, "entry": entry, "cache_path": png, "generation": generation})
func drain_thumbnail_queue() -> void:
if thumbnail_busy or thumbnail_queue.is_empty():
return
thumbnail_busy = true
var job: Dictionary = thumbnail_queue.pop_front()
var kind: String = String(job["kind"])
var entry: Dictionary = job["entry"]
var texture: Texture2D = null
if kind == "stickman":
var key: String = String(job["key"])
var data: Dictionary = entry["data"]
texture = await stickman_thumbnail.render(data)
if texture != null:
thumbnail_cache.store_texture(String(job.get("cache_path", thumbnail_cache.stickman_png(key))), texture)
else:
if bool(entry.get("is_custom", false)):
texture = await prop_thumbnail.render_shapes(entry.get("shapes", []) as Array[Dictionary])
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
texture = await prop_thumbnail.render(payload, preset)
if texture != null:
var fallback: String = thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else thumbnail_cache.prop_png(String(entry["id"]))
thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), texture)
thumbnail_busy = false
if selector != null and selector_open and int(job.get("generation", -1)) == thumbnail_generation and texture != null:
selector.set_thumbnail(entry, texture)
func _on_selector_cancelled() -> void:
if selector_open:
selector_cancel_requested.emit()
func get_selector() -> AssetSelector:
return selector
func get_browse_dialog() -> FileDialog:
return browse_dialog
@@ -0,0 +1 @@
uid://bpwukead70hk8
+709
View File
@@ -0,0 +1,709 @@
class_name StageChrome
extends CanvasLayer
## Shared stage presentation shell. Domain decisions are reported to SandboxStage.
const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd")
signal file_menu_requested(id: int)
signal recent_menu_requested
signal recent_scene_requested(index: int)
signal mode_toggled(pressed: bool, mode: int)
signal grid_toggled(pressed: bool)
signal snap_toggled(pressed: bool)
signal grid_size_changed(value: float)
signal palette_activated(payload: Dictionary)
signal palette_drag_started(payload: Dictionary)
signal pause_requested
signal resume_requested
signal reset_requested
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
var _stage_theme: SandboxTheme
var _current_mode: int = 0
var _grid_size: float = 15.0
var _snap_enabled: bool = false
var _show_grid: bool = true
var _accent_edit: Color = Color("#22C6FF")
var _accent_direct: Color = Color("#FFB300")
var _accent_play: Color = Color("#33DD77")
var _status_pill_font_size: int = 16
var _tooltip_font_size: int = 18
var _mode_buttons: Dictionary = {}
var _palette_buttons: Dictionary = {}
var _palette_dock: PanelContainer
var _file_menu: MenuButton
var _status_label: Label
var _status_cursor_coords: Label
var _direct_hint_label: Label
var _mode_frame: Panel
var _mode_badge: PanelContainer
var _mode_badge_label: Label
var _timestamp_badge: PanelContainer
var _timestamp_label: Label
var _pause_button: Button
var _resume_button: Button
var _reset_button: Button
var _tooltip: Label
var _grid_check: CheckBox
var _snap_check: CheckBox
var _grid_size_spin: SpinBox
var _grid_size_label: Label
var _action_cursor: ImageTexture
func configure(
stage_theme: SandboxTheme,
initial_mode: int,
grid_size: float,
snap_enabled: bool,
show_grid: bool,
accent_edit: Color,
accent_direct: Color,
accent_play: Color,
status_pill_font_size: int,
tooltip_font_size: int
) -> void:
_stage_theme = stage_theme
_current_mode = initial_mode
_grid_size = grid_size
_snap_enabled = snap_enabled
_show_grid = show_grid
_accent_edit = accent_edit
_accent_direct = accent_direct
_accent_play = accent_play
_status_pill_font_size = status_pill_font_size
_tooltip_font_size = tooltip_font_size
func build(palette_entries: Array[Dictionary]) -> void:
_build_action_cursor()
_build_mode_frame()
_build_top_bar()
_build_palette(palette_entries)
_build_status_bar()
_build_mode_badge()
_build_run_controls()
_build_timestamp_badge()
_build_action_tooltip()
func get_canvas_layer() -> CanvasLayer:
return self
func get_file_menu() -> MenuButton:
return _file_menu
func get_mode_buttons() -> Dictionary:
return _mode_buttons
func get_palette_buttons() -> Dictionary:
return _palette_buttons
func get_palette_dock() -> PanelContainer:
return _palette_dock
func get_status_label() -> Label:
return _status_label
func get_status_cursor_coords() -> Label:
return _status_cursor_coords
func get_direct_hint_label() -> Label:
return _direct_hint_label
func get_mode_frame() -> Panel:
return _mode_frame
func get_mode_badge() -> PanelContainer:
return _mode_badge
func get_mode_badge_label() -> Label:
return _mode_badge_label
func get_timestamp_badge() -> PanelContainer:
return _timestamp_badge
func get_timestamp_label() -> Label:
return _timestamp_label
func get_pause_button() -> Button:
return _pause_button
func get_resume_button() -> Button:
return _resume_button
func get_reset_button() -> Button:
return _reset_button
func get_run_controls() -> HBoxContainer:
return get_node_or_null("RunControls") as HBoxContainer
func get_tooltip() -> Label:
return _tooltip
func get_grid_check() -> CheckBox:
return _grid_check
func get_snap_check() -> CheckBox:
return _snap_check
func get_grid_size_spin() -> SpinBox:
return _grid_size_spin
func get_grid_size_label() -> Label:
return _grid_size_label
func sync_mode_buttons(mode: int, accent: Color) -> void:
_current_mode = mode
for id: int in _mode_buttons:
var button: Button = _mode_buttons[id]
var active: bool = mode == id
button.set_pressed_no_signal(active)
if active:
button.add_theme_color_override("font_color", accent)
button.add_theme_color_override("font_pressed_color", accent)
button.add_theme_color_override("font_hover_color", accent)
else:
button.remove_theme_color_override("font_color")
button.remove_theme_color_override("font_pressed_color")
button.remove_theme_color_override("font_hover_color")
func apply_mode_frame(mode: int, accent: Color) -> void:
if _mode_frame == null:
return
update_layout(mode)
_mode_frame.visible = true
var style: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel") as StyleBoxFlat
if style != null:
style.border_color = accent
func refresh_mode_badge(mode: int, accent: Color) -> void:
if _mode_badge == null or _mode_badge_label == null:
return
var text: String = "✏️ EDIT"
match mode:
1:
text = "🎬 DIRECTING"
2:
text = "▶️ SIMULATING"
_mode_badge_label.text = text
var style: StyleBoxFlat = _mode_badge.get_theme_stylebox("panel") as StyleBoxFlat
if style != null:
style.bg_color = accent
var text_color: Color = Color.BLACK if accent.get_luminance() > 0.5 else Color.WHITE
_mode_badge_label.add_theme_color_override("font_color", text_color)
_mode_badge.size = _mode_badge.get_combined_minimum_size()
func update_layout(mode: int) -> void:
if _mode_frame == null:
return
var palette_width: float = _theme_number("palette.dock_width", 116.0, 80.0, 260.0)
var left_inset: float = palette_width if mode == 0 else 0.0
_mode_frame.offset_left = left_inset
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_right = 0.0
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
if _mode_badge != null:
_mode_badge.offset_left = left_inset + 12.0
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
func set_status(text_value: String) -> void:
if _status_label != null:
_status_label.text = text_value
func set_cursor_coords(text_value: String) -> void:
if _status_cursor_coords != null:
_status_cursor_coords.text = text_value
func set_scene_title(scene_name: String, scene_path: String, dirty: bool) -> void:
if not is_inside_tree():
return
var scene_file: String = scene_path.get_file()
if scene_file.is_empty():
scene_file = "Unsaved"
get_window().title = "%s%s — %s — Stickman Studio" % [scene_name, " *" if dirty else "", scene_file]
func set_timestamp(seconds: float) -> void:
if _timestamp_label == null:
return
var safe_seconds: float = maxf(seconds, 0.0)
var minutes: int = int(floor(safe_seconds / 60.0))
var remainder: float = fmod(safe_seconds, 60.0)
var centiseconds: int = int(floor(remainder * 100.0))
_timestamp_label.text = "⏱️ %02d:%02d.%02d" % [minutes, int(centiseconds / 100), centiseconds % 100]
func set_run_controls(pause_visible: bool, resume_visible: bool, reset_visible: bool) -> void:
if _pause_button != null:
_pause_button.visible = pause_visible
if _resume_button != null:
_resume_button.visible = resume_visible
if _reset_button != null:
_reset_button.visible = reset_visible
if _timestamp_badge != null:
_timestamp_badge.visible = true
func set_action_tooltip(visible: bool, text_value: String = "") -> void:
if _tooltip == null:
return
_tooltip.visible = visible
if not visible:
return
_tooltip.text = text_value
var style: StyleBox = _tooltip.get_theme_stylebox("normal")
var extra: Vector2 = style.get_minimum_size() if style != null else Vector2.ZERO
_tooltip.size = _tooltip.get_minimum_size() + extra
var mouse: Vector2 = get_viewport().get_mouse_position()
var size: Vector2 = _tooltip.size
var position: Vector2 = mouse + Vector2(20.0, -20.0)
var viewport_size: Vector2 = get_viewport().get_visible_rect().size
if position.x + size.x > viewport_size.x:
position.x = mouse.x - size.x - 20.0
if position.y + size.y > viewport_size.y:
position.y = mouse.y - size.y - 20.0
if position.y < 0.0:
position.y = mouse.y + 20.0
_tooltip.position = position
func set_edit_controls_visible(visible: bool) -> void:
for button: Button in _palette_buttons.values():
button.visible = visible
if _palette_dock != null:
_palette_dock.visible = visible
if _grid_check != null:
_grid_check.visible = visible
if _snap_check != null:
_snap_check.visible = visible
if _grid_size_spin != null:
_grid_size_spin.visible = visible
if _grid_size_label != null:
_grid_size_label.visible = visible
func set_direct_controls_visible(visible: bool) -> void:
if _direct_hint_label != null:
_direct_hint_label.visible = visible
func is_stage_frame_position(viewport_pos: Vector2) -> bool:
if _mode_frame == null:
return false
var viewport_size: Vector2 = get_window().size
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
viewport_size = Vector2(1280.0, 720.0)
var left: float = _mode_frame.offset_left
var top: float = _mode_frame.offset_top
var right: float = maxf(left + 1.0, viewport_size.x)
var bottom: float = maxf(top + 1.0, viewport_size.y + _mode_frame.offset_bottom)
return is_point_in_stage_frame(viewport_pos, Rect2(Vector2(left, top), Vector2(right - left, bottom - top)))
func apply_cursor(mode: int, awaiting_click: bool, speech_badge_active: bool) -> void:
if awaiting_click:
apply_action_cursor()
return
Input.set_custom_mouse_cursor(null)
if speech_badge_active:
Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND)
elif mode == 2:
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
else:
Input.set_default_cursor_shape(Input.CURSOR_CROSS)
func apply_action_cursor() -> void:
if _action_cursor == null:
return
var hotspot: Vector2 = Vector2(_action_cursor.get_width() * 0.5, _action_cursor.get_height() * 0.5)
Input.set_custom_mouse_cursor(_action_cursor, Input.CURSOR_ARROW, hotspot)
func _build_mode_frame() -> void:
_mode_frame = Panel.new()
_mode_frame.name = "ModeFrame"
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_mode_frame.offset_left = _theme_number("palette.dock_width", 116.0, 80.0, 260.0)
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
var frame_style := StyleBoxFlat.new()
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
frame_style.border_color = _accent_direct
frame_style.set_border_width_all(3)
_mode_frame.add_theme_stylebox_override("panel", frame_style)
add_child(_mode_frame)
func _build_top_bar() -> void:
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = TOP_BAR_HEIGHT
add_child(top_bar)
var chrome := Control.new()
chrome.name = "Content"
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
top_bar.add_child(chrome)
var left_group := HBoxContainer.new()
left_group.name = "LeftControls"
left_group.mouse_filter = Control.MOUSE_FILTER_PASS
left_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
left_group.offset_left = 8.0
left_group.offset_top = 8.0
chrome.add_child(left_group)
var right_group := HBoxContainer.new()
right_group.name = "RightControls"
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
right_group.offset_right = -8.0
right_group.offset_top = 8.0
right_group.add_theme_constant_override("separation", 8)
chrome.add_child(right_group)
_build_file_menu(left_group)
_build_mode_switcher(chrome)
_build_grid_controls(right_group)
func _build_file_menu(parent: HBoxContainer) -> void:
_file_menu = MenuButton.new()
_file_menu.name = "FileMenu"
_file_menu.text = "File"
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
_file_menu.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.file_menu", 22, 12, 48))
var file_popup: PopupMenu = _file_menu.get_popup()
var popup_font_size: int = _theme_integer("fonts.sizes.file_menu_popup", 26, 12, 64)
file_popup.add_theme_font_size_override("font_size", popup_font_size)
file_popup.add_item("New", 0)
file_popup.add_item("Open…", 1)
var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent"
recent_popup.add_theme_font_size_override("font_size", popup_font_size)
recent_popup.id_pressed.connect(_on_recent_scene_requested)
file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent")
file_popup.add_item("Save", 2)
file_popup.add_item("Save As…", 3)
file_popup.add_item("Rename Scene…", 4)
file_popup.id_pressed.connect(_on_file_menu_requested)
file_popup.about_to_popup.connect(_on_recent_menu_requested)
parent.add_child(_file_menu)
func _build_mode_switcher(parent: Control) -> void:
var center := CenterContainer.new()
center.name = "ModeCenter"
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
center.offset_bottom = TOP_BAR_HEIGHT
parent.add_child(center)
var switcher := HBoxContainer.new()
switcher.name = "ModeSwitcher"
switcher.mouse_filter = Control.MOUSE_FILTER_PASS
switcher.add_theme_constant_override("separation", 4)
center.add_child(switcher)
var definitions: Array = [[0, "✏️ Edit"], [1, "🎬 Direct"], [2, "▶️ Play"]]
for definition: Array in definitions:
var mode: int = int(definition[0])
var button := Button.new()
button.text = String(definition[1])
button.toggle_mode = true
button.custom_minimum_size = Vector2(_theme_number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0), 48.0)
button.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.mode_switcher", 24, 12, 48))
button.toggled.connect(_on_mode_toggled.bind(mode))
switcher.add_child(button)
_mode_buttons[mode] = button
_direct_hint_label = Label.new()
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
_direct_hint_label.visible = false
_apply_ui_font(_direct_hint_label)
var right_group := parent.get_node_or_null(NodePath("RightControls")) as HBoxContainer
if right_group != null:
right_group.add_child(_direct_hint_label)
func _build_grid_controls(parent: HBoxContainer) -> void:
_grid_check = CheckBox.new()
_grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid
_grid_check.toggled.connect(_on_grid_toggled)
parent.add_child(_grid_check)
_snap_check = CheckBox.new()
_snap_check.text = "Snap"
_snap_check.button_pressed = _snap_enabled
_snap_check.toggled.connect(_on_snap_toggled)
parent.add_child(_snap_check)
_grid_size_label = Label.new()
_grid_size_label.text = "Size"
parent.add_child(_grid_size_label)
_grid_size_spin = SpinBox.new()
_grid_size_spin.min_value = 1.0
_grid_size_spin.max_value = 100.0
_grid_size_spin.step = 1.0
_grid_size_spin.value = _grid_size
_grid_size_spin.rounded = true
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
parent.add_child(_grid_size_spin)
func _build_palette(entries: Array[Dictionary]) -> void:
_palette_dock = PanelContainer.new()
_palette_dock.name = "PaletteDock"
_palette_dock.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
_palette_dock.offset_top = TOP_BAR_HEIGHT
_palette_dock.offset_bottom = -STATUS_BAR_HEIGHT
_palette_dock.offset_right = _theme_number("palette.dock_width", 116.0, 80.0, 260.0)
if _stage_theme != null:
_palette_dock.add_theme_stylebox_override("panel", _stage_theme.stylebox("palette"))
var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_palette_dock.add_child(scroll)
var cards := VBoxContainer.new()
cards.name = "PaletteCards"
cards.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(cards)
for entry: Dictionary in entries:
var card: Variant = ASSET_CARD.new()
card.configure(entry, null, _stage_theme)
card.toggle_mode = true
card.activated.connect(_on_palette_activated)
card.drag_started.connect(_on_palette_drag_started)
cards.add_child(card)
_palette_buttons[String(entry["placement_id"])] = card
add_child(_palette_dock)
func _build_status_bar() -> void:
var bottom_bar := PanelContainer.new()
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
bottom_bar.offset_top = -STATUS_BAR_HEIGHT
add_child(bottom_bar)
var status_row := HBoxContainer.new()
status_row.add_theme_constant_override("separation", 12)
bottom_bar.add_child(status_row)
_status_label = Label.new()
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_ui_font(_status_label)
status_row.add_child(_status_label)
_status_cursor_coords = Label.new()
_apply_ui_font(_status_cursor_coords)
status_row.add_child(_status_cursor_coords)
func _build_mode_badge() -> void:
_mode_badge = PanelContainer.new()
_mode_badge.name = "ModeBadge"
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
_mode_badge.offset_left = _theme_number("palette.dock_width", 116.0, 80.0, 260.0) + 12.0
var badge_style := StyleBoxFlat.new()
badge_style.bg_color = _accent_edit
badge_style.set_corner_radius_all(12)
badge_style.content_margin_left = 12.0
badge_style.content_margin_right = 12.0
badge_style.content_margin_top = 4.0
badge_style.content_margin_bottom = 4.0
_mode_badge.add_theme_stylebox_override("panel", badge_style)
_mode_badge_label = Label.new()
_mode_badge_label.add_theme_font_size_override("font_size", _status_pill_font_size)
_apply_ui_font(_mode_badge_label)
_mode_badge.add_child(_mode_badge_label)
add_child(_mode_badge)
func _build_run_controls() -> void:
var controls := HBoxContainer.new()
controls.name = "RunControls"
controls.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
controls.offset_top = TOP_BAR_HEIGHT + 8.0
controls.offset_right = -12.0
controls.add_theme_constant_override("separation", 6)
add_child(controls)
_pause_button = _make_run_button("⏸️ Pause", 0)
_resume_button = _make_run_button("▶️ Resume", 1)
_reset_button = _make_run_button("↺ Reset", 2)
controls.add_child(_pause_button)
controls.add_child(_resume_button)
controls.add_child(_reset_button)
func _make_run_button(label: String, request_kind: int) -> Button:
var button := Button.new()
button.text = label
button.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.mode_switcher", 24, 12, 48))
button.pressed.connect(_on_run_button_pressed.bind(request_kind))
return button
func _on_run_button_pressed(request_kind: int) -> void:
match request_kind:
0:
pause_requested.emit()
1:
resume_requested.emit()
2:
reset_requested.emit()
func _build_timestamp_badge() -> void:
_timestamp_badge = PanelContainer.new()
_timestamp_badge.name = "TimestampBadge"
_timestamp_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_timestamp_badge.offset_left = -220.0
_timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0
_timestamp_badge.offset_right = -12.0
_timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0
var style := StyleBoxFlat.new()
style.bg_color = _theme_color("chrome.timestamp_badge.bg_color", Color("#171B24F2"))
style.set_corner_radius_all(_theme_integer("chrome.timestamp_badge.corner_radius", 10, 0, 48))
var padding: float = float(_theme_integer("chrome.timestamp_badge.padding", 8, 0, 48))
style.content_margin_left = padding
style.content_margin_right = padding
style.content_margin_top = padding * 0.5
style.content_margin_bottom = padding * 0.5
_timestamp_badge.add_theme_stylebox_override("panel", style)
_timestamp_label = Label.new()
_timestamp_label.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.timestamp_badge", 22, 12, 48))
_timestamp_label.add_theme_color_override("font_color", _theme_color("global.text_color", Color("#F5F7FA")))
_timestamp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timestamp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_timestamp_label.custom_minimum_size = Vector2(192.0, 30.0)
_apply_ui_font(_timestamp_label)
_timestamp_badge.add_child(_timestamp_label)
add_child(_timestamp_badge)
func _build_action_tooltip() -> void:
_tooltip = Label.new()
_tooltip.name = "ActionTooltip"
_tooltip.visible = false
_tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
var style := StyleBoxFlat.new()
style.bg_color = Color(0.1, 0.1, 0.1, 0.9)
style.border_color = _accent_direct
style.set_border_width_all(1)
style.set_corner_radius_all(8)
style.content_margin_left = 10.0
style.content_margin_right = 10.0
style.content_margin_top = 4.0
style.content_margin_bottom = 4.0
_tooltip.add_theme_stylebox_override("normal", style)
_tooltip.add_theme_font_size_override("font_size", _tooltip_font_size)
_tooltip.add_theme_color_override("font_color", Color.WHITE)
_apply_ui_font(_tooltip)
add_child(_tooltip)
func _build_action_cursor() -> void:
var size: int = 24
var image: Image = Image.create(size, size, false, Image.FORMAT_RGBA8)
image.fill(Color(0.0, 0.0, 0.0, 0.0))
var color: Color = Color(1.0, 0.7, 0.1, 1.0)
var center: Vector2 = Vector2(size, size) * 0.5
var radius: float = float(size) * 0.4
for y: int in size:
for x: int in size:
var point: Vector2 = Vector2(x, y) + Vector2(0.5, 0.5)
var distance: float = point.distance_to(center)
if absf(distance - radius) <= 1.5:
image.set_pixel(x, y, color)
if absf(point.x - center.x) <= 1.0 and point.y >= center.y - radius and point.y <= center.y + radius:
image.set_pixel(x, y, color)
if absf(point.y - center.y) <= 1.0 and point.x >= center.x - radius and point.x <= center.x + radius:
image.set_pixel(x, y, color)
_action_cursor = ImageTexture.create_from_image(image)
func _apply_ui_font(control: Control) -> void:
if control == null or _stage_theme == null:
return
var font: Font = _stage_theme.font()
if font != null:
control.add_theme_font_override("font", font)
func _theme_number(key: String, fallback: float, minimum: float, maximum: float) -> float:
return fallback if _stage_theme == null else _stage_theme.number(key, fallback, minimum, maximum)
func _theme_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(key, fallback, minimum, maximum)
func _theme_color(key: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(key, fallback)
func _on_file_menu_requested(id: int) -> void:
file_menu_requested.emit(id)
func _on_recent_menu_requested() -> void:
recent_menu_requested.emit()
func _on_recent_scene_requested(index: int) -> void:
recent_scene_requested.emit(index)
func _on_mode_toggled(pressed: bool, mode: int) -> void:
mode_toggled.emit(pressed, mode)
func _on_grid_toggled(pressed: bool) -> void:
grid_toggled.emit(pressed)
func _on_snap_toggled(pressed: bool) -> void:
snap_toggled.emit(pressed)
func _on_grid_size_changed(value: float) -> void:
grid_size_changed.emit(value)
func _on_palette_activated(payload: Dictionary) -> void:
palette_activated.emit(payload)
func _on_palette_drag_started(payload: Dictionary) -> void:
palette_drag_started.emit(payload)
static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool:
return frame_rect.abs().has_point(viewport_pos)
@@ -0,0 +1 @@
uid://doh7myy4tevx4
@@ -0,0 +1,497 @@
class_name StageDirectorController
extends Node
## Owns direct-action UI state and director hint/trajectory presentation.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const STICKMAN_LOCOMOTION := preload("res://scripts/characters/stickman/stickman_locomotion.gd")
const RULE_STEP_TRIGGER_TARGET: int = StageRuleAuthoringController.Step.TRIGGER_TARGET
const RULE_STEP_ACTION_TARGET: int = StageRuleAuthoringController.Step.ACTION_TARGET
const RULE_STEP_ACTION_POSITION: int = StageRuleAuthoringController.Step.ACTION_POSITION
const RULE_STEP_IDLE: int = StageRuleAuthoringController.Step.IDLE
const DIRECT_MOVE_POPUP_SIZE: Vector2i = Vector2i(380, 172)
signal toast_requested(message: String)
signal direct_move_commit_requested(rig: StickmanRig, action: Dictionary, style: String)
signal direct_action_requested(id: int)
signal action_popup_opening
signal state_changed
var context_rig: StickmanRig
var pending_walk_target: bool = false
var direct_move_target: Vector2 = Vector2.INF
var direct_move_last_style: Dictionary = {}
var _direct_move_style_ids: Array[String] = []
var _camera: Camera2D
var _viewport: Viewport
var _spatial_index: StageSpatialIndex
var _placement_overlay: StagePlacementOverlay
var _chrome: StageChrome
var _rule_authoring: StageRuleAuthoringController
var _action_queue: StageActionQueueController
var _action_popup: PopupMenu
var _direct_move_popup: PopupPanel
var _direct_move_style_option: OptionButton
var _sandbox_theme: SandboxTheme
var _ui_font: Font
var _emoji_font: Font
func configure(
camera: Camera2D,
viewport: Viewport,
spatial_index: StageSpatialIndex,
placement_overlay: StagePlacementOverlay,
chrome: StageChrome,
rule_authoring: StageRuleAuthoringController,
action_queue: StageActionQueueController
) -> void:
_camera = camera
_viewport = viewport
_spatial_index = spatial_index
_placement_overlay = placement_overlay
_chrome = chrome
_rule_authoring = rule_authoring
_action_queue = action_queue
func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_font: Font, action_ids: Dictionary) -> void:
_sandbox_theme = sandbox_theme
_ui_font = ui_font
_emoji_font = emoji_font
_action_popup = PopupMenu.new()
_action_popup.name = "DirectActionPopup"
_action_popup.add_item("🚩 Move To", int(action_ids.get("walk", 0)))
_action_popup.add_item("💬 Speak", int(action_ids.get("speak", 1)))
_action_popup.add_item("⏳ Wait", int(action_ids.get("wait", 2)))
_action_popup.add_item("💥 Ragdoll", int(action_ids.get("ragdoll", 3)))
_action_popup.add_item("🔄 Recover", int(action_ids.get("recover", 4)))
_action_popup.add_item("🫴 Pick Up Prop…", int(action_ids.get("pick_up_prop", 9)))
_action_popup.add_item("⬇️ Drop Prop", int(action_ids.get("drop_prop", 10)))
_action_popup.add_item("🏹 Throw Prop…", int(action_ids.get("throw_prop", 11)))
_action_popup.add_item("🫳 Set Down…", int(action_ids.get("set_down_prop", 12)))
_action_popup.add_separator()
_action_popup.add_item("⚡ When...", int(action_ids.get("when", 5)))
_action_popup.add_separator()
_action_popup.add_item("📋 Edit Queue…", int(action_ids.get("edit_queue", 7)))
_action_popup.add_item("⚡ Edit Rules…", int(action_ids.get("edit_rules", 8)))
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
_action_popup.about_to_popup.connect(_on_action_popup_about_to_popup)
parent.add_child(_action_popup)
_direct_move_popup = PopupPanel.new()
_direct_move_popup.name = "DirectMovePopup"
_direct_move_popup.exclusive = true
_direct_move_popup.popup_window = true
_direct_move_popup.min_size = DIRECT_MOVE_POPUP_SIZE
if _sandbox_theme != null:
_direct_move_popup.theme = _sandbox_theme.control_theme()
parent.add_child(_direct_move_popup)
var move_margin: MarginContainer = MarginContainer.new()
move_margin.add_theme_constant_override("margin_left", 14)
move_margin.add_theme_constant_override("margin_right", 14)
move_margin.add_theme_constant_override("margin_top", 12)
move_margin.add_theme_constant_override("margin_bottom", 12)
_direct_move_popup.add_child(move_margin)
var move_column: VBoxContainer = VBoxContainer.new()
move_column.add_theme_constant_override("separation", 10)
move_margin.add_child(move_column)
var move_row: HBoxContainer = HBoxContainer.new()
move_row.add_theme_constant_override("separation", 12)
move_column.add_child(move_row)
var move_label: Label = Label.new()
move_label.text = "Movement"
_apply_ui_font(move_label)
move_label.add_theme_font_size_override("font_size", 23)
move_row.add_child(move_label)
_direct_move_style_option = OptionButton.new()
_direct_move_style_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_direct_move_style_option.custom_minimum_size = Vector2(210.0, 54.0)
_direct_move_style_option.add_theme_font_size_override("font_size", 25)
if _emoji_font != null:
_direct_move_style_option.add_theme_font_override("font", _emoji_font)
else:
_apply_ui_font(_direct_move_style_option)
move_row.add_child(_direct_move_style_option)
var move_buttons: HBoxContainer = HBoxContainer.new()
move_buttons.alignment = BoxContainer.ALIGNMENT_END
move_buttons.add_theme_constant_override("separation", 8)
move_column.add_child(move_buttons)
var move_cancel: Button = Button.new()
move_cancel.text = "Cancel"
move_cancel.custom_minimum_size = Vector2(112.0, 52.0)
move_cancel.pressed.connect(cancel_direct_move_draft)
_apply_ui_font(move_cancel)
move_cancel.add_theme_font_size_override("font_size", 22)
move_buttons.add_child(move_cancel)
var move_add: Button = Button.new()
move_add.text = "Add Move To"
move_add.custom_minimum_size = Vector2(166.0, 52.0)
move_add.pressed.connect(commit_direct_move_draft)
_apply_ui_font(move_add)
move_add.add_theme_font_size_override("font_size", 22)
move_buttons.add_child(move_add)
_direct_move_popup.popup_hide.connect(_on_direct_move_popup_hidden)
_direct_move_popup.window_input.connect(_on_direct_move_popup_input)
populate_direct_move_styles()
func _apply_ui_font(control: Control) -> void:
if control != null and _ui_font != null:
control.add_theme_font_override("font", _ui_font)
func _on_action_popup_id_pressed(id: int) -> void:
direct_action_requested.emit(id)
func _on_action_popup_about_to_popup() -> void:
action_popup_opening.emit()
func _on_direct_move_popup_hidden() -> void:
cancel_direct_move_draft()
func _on_direct_move_popup_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
cancel_direct_move_draft()
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
cancel_direct_move_draft()
func get_action_popup() -> PopupMenu:
return _action_popup
func get_direct_move_popup() -> PopupPanel:
return _direct_move_popup
func get_direct_move_style_option() -> OptionButton:
return _direct_move_style_option
func get_direct_move_style_ids() -> Array[String]:
return _direct_move_style_ids
func get_direct_move_last_style() -> Dictionary:
return direct_move_last_style
func handle_direct_click(world_pos: Vector2, hit: Node2D, target_valid: bool, hit_screen_pos: Vector2) -> void:
if pending_walk_target:
if context_rig == null or not is_instance_valid(context_rig):
cancel_direct_move_draft()
return
if not target_valid:
toast_requested.emit("Choose an open place for Move To")
return
direct_move_target = world_pos
pending_walk_target = false
show_direct_move_popup(hit_screen_pos)
state_changed.emit()
return
if direct_move_target.is_finite() or not hit is StickmanRig:
return
context_rig = hit as StickmanRig
var screen_pos: Vector2 = _world_to_screen(context_rig.global_position)
var popup_rect: Rect2i = Rect2i(Vector2i(screen_pos) + Vector2i(24, 0), Vector2i.ZERO)
if _rule_authoring != null:
_rule_authoring.set_popup_anchor(popup_rect)
if _action_popup != null:
_action_popup.popup(popup_rect)
func _world_to_screen(world_pos: Vector2) -> Vector2:
if _camera == null or _viewport == null:
return Vector2.ZERO
var viewport_size: Vector2 = _viewport.get_visible_rect().size
return (world_pos - _camera.position) * _camera.zoom + viewport_size * 0.5
func begin_walk_target() -> void:
direct_move_target = Vector2.INF
pending_walk_target = true
state_changed.emit()
func populate_direct_move_styles() -> void:
_direct_move_style_ids.clear()
if _direct_move_style_option == null:
return
_direct_move_style_option.clear()
var template: Dictionary = ACTION_REGISTRY.ACTION_TEMPLATES.get(ACTION_REGISTRY.MOVE_TO, {})
var params: Array = template.get("params", [])
for entry: Variant in params:
if not entry is Dictionary:
continue
var param: Dictionary = entry
if String(param.get("key", "")) != "style":
continue
var choices: Array = param.get("choices", [])
for raw_style: Variant in choices:
var style: String = String(raw_style)
if style.is_empty() or _direct_move_style_ids.has(style):
continue
_direct_move_style_ids.append(style)
var icon: String = "🚶 " if style == ACTION_REGISTRY.WALK else ("🏃 " if style == ACTION_REGISTRY.RUN else "")
_direct_move_style_option.add_item(icon + style.capitalize())
break
if _direct_move_style_ids.is_empty():
_direct_move_style_ids.append(ACTION_REGISTRY.WALK)
_direct_move_style_option.add_item("🚶 Walk")
func show_direct_move_popup(screen_pos: Vector2) -> void:
if _direct_move_popup == null or _direct_move_style_option == null:
cancel_direct_move_draft()
return
if context_rig == null or not is_instance_valid(context_rig):
cancel_direct_move_draft()
return
var rig_id: int = context_rig.get_instance_id()
var preferred: String = String(direct_move_last_style.get(rig_id, ACTION_REGISTRY.WALK))
var index: int = _direct_move_style_ids.find(preferred)
_direct_move_style_option.select(index if index >= 0 else 0)
var viewport_size: Vector2 = _viewport.get_visible_rect().size if _viewport != null else Vector2.ZERO
var popup_size: Vector2i = DIRECT_MOVE_POPUP_SIZE
_direct_move_popup.size = popup_size
var popup_pos: Vector2i = Vector2i(screen_pos + Vector2(18.0, 12.0))
popup_pos.x = clampi(popup_pos.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
popup_pos.y = clampi(popup_pos.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
_direct_move_popup.position = popup_pos
_direct_move_popup.popup()
_direct_move_style_option.grab_focus()
func commit_direct_move_draft() -> void:
if not direct_move_target.is_finite() or context_rig == null or not is_instance_valid(context_rig):
cancel_direct_move_draft()
return
if _direct_move_style_option == null:
return
var index: int = _direct_move_style_option.selected
if index < 0 or index >= _direct_move_style_ids.size():
return
var style: String = _direct_move_style_ids[index]
var action: Dictionary = { "type": ACTION_REGISTRY.MOVE_TO, "target": direct_move_target, "style": style }
direct_move_commit_requested.emit(context_rig, action, style)
func complete_direct_move_commit(style: String, succeeded: bool) -> void:
if not succeeded:
toast_requested.emit("Move To was not added. Try again or Cancel.")
return
if context_rig != null and is_instance_valid(context_rig):
direct_move_last_style[context_rig.get_instance_id()] = style
cancel_direct_move_draft()
toast_requested.emit("Move To · %s added" % style.capitalize())
func cancel_direct_move_draft() -> bool:
if not pending_walk_target and not direct_move_target.is_finite():
return false
pending_walk_target = false
direct_move_target = Vector2.INF
context_rig = null
if _direct_move_popup != null and _direct_move_popup.visible:
_direct_move_popup.hide()
state_changed.emit()
return true
func refresh_action_popup_items(rules: Array[Dictionary]) -> void:
if _action_popup == null:
return
var valid: bool = context_rig != null and is_instance_valid(context_rig)
_disable_popup_item(_action_popup, 7, not valid or context_rig.get_queue().is_empty())
_disable_popup_item(_action_popup, 8, not valid or not rig_has_rules(context_rig, rules))
func refresh_rig_context_items(rig: StickmanRig, popup: PopupMenu, rules: Array[Dictionary]) -> void:
if popup == null:
return
var valid: bool = rig != null and is_instance_valid(rig)
_disable_popup_item(popup, 0, not valid or rig.get_queue().is_empty())
_disable_popup_item(popup, 1, not valid or not rig_has_rules(rig, rules))
func disable_popup_item(popup: PopupMenu, id: int, disabled: bool) -> void:
_disable_popup_item(popup, id, disabled)
func rig_has_rules(rig: StickmanRig, rules: Array[Dictionary]) -> bool:
if rig == null or not is_instance_valid(rig):
return false
var id: int = rig.get_instance_id()
for rule: Dictionary in rules:
var trigger: Dictionary = rule.get("trigger", {})
if int(trigger.get("source", -1)) == id:
return true
return false
func _disable_popup_item(popup: PopupMenu, id: int, disabled: bool) -> void:
var index: int = popup.get_item_index(id)
if index >= 0:
popup.set_item_disabled(index, disabled)
func is_awaiting_click(capture_active: bool) -> bool:
if pending_walk_target or capture_active:
return true
if _rule_authoring == null:
return false
return _rule_authoring.step == RULE_STEP_TRIGGER_TARGET \
or _rule_authoring.step == RULE_STEP_ACTION_TARGET \
or _rule_authoring.step == RULE_STEP_ACTION_POSITION
func action_hint_text(capture_kind: String, capture_hint: String, set_down_cache: Dictionary, throw_cache: Dictionary) -> String:
if pending_walk_target:
return "🚩 Click where they should go"
if not capture_kind.is_empty():
if capture_kind == "set_down":
var conditional: bool = not bool(set_down_cache.get("known", false))
var valid: bool = bool(set_down_cache.get("valid", false))
if conditional:
return "Click where to let go · Prop depends on earlier actions"
if not valid:
return String(set_down_cache.get("reason", "Too far to reach"))
return "Click where to let go · Ready to set down"
if capture_kind == "throw" and not throw_cache.is_empty():
if not bool(throw_cache.get("preview_available", true)):
return "ℹ %s Click to set the throw target." % String(throw_cache.get("preview_reason", "No throw preview is available."))
var preview_hint: String = String(throw_cache.get("preview_hint", ""))
if not preview_hint.is_empty():
return "%s %s" % [capture_hint, preview_hint]
return capture_hint
if _rule_authoring == null:
return ""
match _rule_authoring.step:
RULE_STEP_TRIGGER_TARGET:
var trigger: Dictionary = _rule_authoring.builder.get("trigger", {})
match String(trigger.get("type", "")):
"arrived_at_waypoint":
return "📍 Click the waypoint"
"entered_area":
return "🎯 Click the trigger area"
"collided":
return "💥 Click the prop"
_:
return "🎯 Click the target"
RULE_STEP_ACTION_TARGET:
return "🎯 Click the stickman"
RULE_STEP_ACTION_POSITION:
var actions: Array = _rule_authoring.builder.get("actions", [])
var style: String = ACTION_REGISTRY.WALK
if not actions.is_empty():
var move_action: Dictionary = actions.back()
style = ACTION_REGISTRY.checked_style(move_action.get("params", {}).get("style", null))
return "🚩 Click where they should %s" % ("run" if style == ACTION_REGISTRY.RUN else "walk")
return ""
func action_origin(
capture_kind: String,
set_down_rig: StickmanRig,
throw_rig: StickmanRig,
throw_cache: Dictionary,
panel_rig: StickmanRig
) -> Vector2:
if capture_kind == "set_down" and set_down_rig != null and is_instance_valid(set_down_rig):
return set_down_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
if capture_kind == "throw" and throw_rig != null and is_instance_valid(throw_rig):
if not throw_cache.is_empty():
return throw_cache.get("start_position", throw_rig.global_position)
return throw_rig.global_position
if pending_walk_target:
if context_rig != null and is_instance_valid(context_rig):
return context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
return Vector2.ZERO
if not capture_kind.is_empty():
if capture_kind == "position" and _action_queue != null:
var editor_rig: StickmanRig = _action_queue.action_editor_preview_rig()
if editor_rig != null and is_instance_valid(editor_rig):
return editor_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
if panel_rig != null and is_instance_valid(panel_rig):
return panel_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
return Vector2.ZERO
if _rule_authoring != null and _rule_authoring.step != RULE_STEP_IDLE:
if _rule_authoring.context_rig != null and is_instance_valid(_rule_authoring.context_rig):
return _rule_authoring.context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
var trigger: Dictionary = _rule_authoring.builder.get("trigger", {})
var source: Object = instance_from_id(int(trigger.get("source", -1)))
if source is Node2D and is_instance_valid(source):
return (source as Node2D).global_position
return Vector2.ZERO
func is_target_valid(position: Vector2) -> bool:
if _spatial_index == null:
return true
for node: Node2D in _spatial_index.candidates_at(position):
if node is TerrainBlock and STAGE_SELECTION.get_world_aabb(node).has_point(position):
return false
return true
func update_action_overlay(
capture_kind: String,
capture_hint: String,
set_down_cache: Dictionary,
throw_cache: Dictionary,
set_down_rig: StickmanRig,
throw_rig: StickmanRig,
snap_enabled: bool,
grid_size: float
) -> void:
var awaiting: bool = is_awaiting_click(not capture_kind.is_empty())
if _placement_overlay != null:
if awaiting:
var origin: Vector2 = action_origin(capture_kind, set_down_rig, throw_rig, throw_cache, _action_queue.panel_rig if _action_queue != null else null)
var target: Vector2 = _camera.get_global_mouse_position() if _camera != null else Vector2.ZERO
if snap_enabled and (capture_kind in ["position", "throw", "set_down"] or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION)):
if grid_size > 0.0:
target = Vector2(roundf(target.x / grid_size) * grid_size, roundf(target.y / grid_size) * grid_size)
if capture_kind == "set_down":
var known: bool = bool(set_down_cache.get("known", false))
var valid: bool = bool(set_down_cache.get("valid", false))
if known:
var release_transform: Transform2D = set_down_cache.get("release_transform", Transform2D.IDENTITY)
var approach: Vector2 = set_down_cache.get("approach_position", origin)
var grip: Vector2 = set_down_cache.get("grip_position", target)
var preview_prop: PropBlock = set_down_cache.get("prop") as PropBlock
_placement_overlay.set_set_down_preview(origin, target, true, valid, preview_prop, release_transform, approach, grip)
else:
_placement_overlay.set_set_down_preview(origin, target, false, false)
elif capture_kind == "throw":
if throw_rig == null or not is_instance_valid(throw_rig):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var throw_preview: Dictionary = throw_cache
if throw_preview.is_empty() or not bool(throw_preview.get("preview_available", true)):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var trajectory_points: PackedVector2Array = throw_preview.get("trajectory_points", PackedVector2Array())
var effective_target: Vector2 = throw_preview.get("effective_target", target)
var collision_position: Vector2 = throw_preview.get("collision_position", Vector2.INF)
var target_reachable: bool = bool(throw_preview.get("target_reachable", true))
var preview_planned: bool = bool(throw_preview.get("preview_planned", false))
var preview_uncertain: bool = bool(throw_preview.get("preview_uncertain", false))
_placement_overlay.set_throw_trajectory(
throw_preview["start_position"], target, throw_preview["landing_position"], target_reachable,
trajectory_points, effective_target, collision_position, preview_planned, preview_uncertain
)
else:
_placement_overlay.set_action_trajectory(origin, target, is_target_valid(target))
elif direct_move_target.is_finite() and context_rig != null and is_instance_valid(context_rig):
var origin: Vector2 = context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
_placement_overlay.set_action_trajectory(origin, direct_move_target, true)
else:
_placement_overlay.clear_action()
if _chrome != null:
_chrome.set_action_tooltip(awaiting, action_hint_text(capture_kind, capture_hint, set_down_cache, throw_cache) if awaiting else "")
@@ -0,0 +1 @@
uid://br8nw38ybdkgr
@@ -0,0 +1,250 @@
class_name StageDocumentController
extends RefCounted
## Owns Sandbox Stage document identity, recent paths, and safe file operations.
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const CLI_SCENE_LAUNCHER := preload("res://scripts/tools/cli_scene_launcher.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene"
const RECENT_LIMIT: int = 5
const DEFAULT_SCENE_NAME: String = "Untitled"
signal open_path_requested(path: String)
signal save_path_selected(path: String)
signal rename_requested(title: String)
signal unsaved_save_requested
signal unsaved_discard_requested
signal dialog_cancelled
var current_path: String = ""
var scene_name: String = DEFAULT_SCENE_NAME
var created_unix: int = 0
var dirty: bool = false
var last_error: String = ""
var _recent_scenes: Array[String] = []
var _dialog_workflow_connected: bool = false
func configure_dialog_workflow(
open_dialog: FileDialog,
save_dialog: FileDialog,
rename_dialog: ConfirmationDialog,
name_edit: LineEdit,
unsaved_dialog: ConfirmationDialog
) -> void:
if _dialog_workflow_connected:
return
_dialog_workflow_connected = true
_connect_signal(open_dialog, &"file_selected", _on_open_path_selected)
_connect_signal(save_dialog, &"file_selected", _on_save_path_selected)
_connect_signal(save_dialog, &"canceled", _on_dialog_cancelled)
_connect_signal(rename_dialog, &"confirmed", _on_rename_confirmed.bind(name_edit))
_connect_signal(unsaved_dialog, &"confirmed", _on_unsaved_save_confirmed)
_connect_signal(unsaved_dialog, &"custom_action", _on_unsaved_custom_action)
_connect_signal(unsaved_dialog, &"canceled", _on_dialog_cancelled)
func _connect_signal(source: Object, signal_name: StringName, callback: Callable) -> void:
if source != null and source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
source.connect(signal_name, callback)
func _on_open_path_selected(path: String) -> void:
open_path_requested.emit(path)
func _on_save_path_selected(path: String) -> void:
save_path_selected.emit(path)
func _on_rename_confirmed(name_edit: LineEdit) -> void:
rename_requested.emit(name_edit.text.strip_edges() if name_edit != null else "")
func _on_unsaved_save_confirmed() -> void:
unsaved_save_requested.emit()
func _on_unsaved_custom_action(action: StringName) -> void:
if action == &"discard":
unsaved_discard_requested.emit()
func _on_dialog_cancelled() -> void:
dialog_cancelled.emit()
func mark_dirty(suppressed: bool = false) -> bool:
if suppressed:
return false
dirty = true
return true
func set_dirty(value: bool) -> void:
dirty = value
func metadata_for_snapshot(is_autosave: bool) -> Dictionary:
return {
"created_unix": created_unix if created_unix > 0 else Time.get_unix_time_from_system(),
"modified_unix": Time.get_unix_time_from_system(),
"autosave": is_autosave,
"source_path": CONTENT_PATHS.canonicalize(current_path) if is_autosave else "",
}
func save(path: String, snapshot: Dictionary, is_autosave: bool = false) -> bool:
last_error = ""
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
var result: Dictionary = STAGE_SCENE_IO.write_file_safe(canonical_path, snapshot)
if not bool(result.get("ok", false)):
last_error = String(result.get("error", "Could not save scene."))
return false
if not is_autosave:
current_path = canonical_path
if created_unix <= 0:
created_unix = Time.get_unix_time_from_system()
dirty = false
add_recent_path(canonical_path)
remove_autosave()
return true
func read(path: String) -> Dictionary:
return STAGE_SCENE_IO.read_file(CONTENT_PATHS.canonicalize(path))
func parse_cli_request(args: Array) -> Dictionary:
return CLI_SCENE_LAUNCHER.parse(args)
func accept_loaded_document(
source_path: String,
loaded_name: String,
loaded_created_unix: int,
recovery_source_path: String,
is_recovery: bool
) -> void:
var normalized_name: String = loaded_name.strip_edges()
scene_name = normalized_name if not normalized_name.is_empty() else DEFAULT_SCENE_NAME
created_unix = loaded_created_unix if loaded_created_unix > 0 else Time.get_unix_time_from_system()
current_path = CONTENT_PATHS.canonicalize(recovery_source_path) if is_recovery else CONTENT_PATHS.canonicalize(source_path)
dirty = is_recovery
func add_recent_path(path: String) -> void:
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
if canonical_path.is_empty() or canonical_path == AUTOSAVE_PATH:
return
_recent_scenes.erase(canonical_path)
_recent_scenes.push_front(canonical_path)
while _recent_scenes.size() > RECENT_LIMIT:
_recent_scenes.pop_back()
func remove_recent_path(path: String) -> void:
_recent_scenes.erase(CONTENT_PATHS.canonicalize(path))
func set_recent_paths(paths: Array[String]) -> void:
_recent_scenes.clear()
for path: String in paths:
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
if canonical_path.is_empty() or _recent_scenes.has(canonical_path):
continue
_recent_scenes.append(canonical_path)
if _recent_scenes.size() >= RECENT_LIMIT:
break
func get_recent_paths() -> Array[String]:
return _recent_scenes
func populate_recent_menu(popup: PopupMenu) -> bool:
if popup == null:
return false
popup.clear()
var existing: Array[String] = []
for path: String in _recent_scenes:
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
if FileAccess.file_exists(canonical_path) and not existing.has(canonical_path):
existing.append(canonical_path)
var pruned: bool = existing != _recent_scenes
set_recent_paths(existing)
if _recent_scenes.is_empty():
popup.add_item("(No recent scenes)", -1)
popup.set_item_disabled(0, true)
else:
for index: int in _recent_scenes.size():
popup.add_item(_recent_scenes[index], index)
return pruned
func resolve_recent_selection(index: int) -> Dictionary:
if index < 0 or index >= _recent_scenes.size():
return {"valid": false, "path": "", "missing": false}
var path: String = _recent_scenes[index]
if not FileAccess.file_exists(path):
remove_recent_path(path)
return {"valid": false, "path": path, "missing": true}
return {"valid": true, "path": path, "missing": false}
func clear_recent_paths() -> void:
_recent_scenes.clear()
func has_autosave() -> bool:
return FileAccess.file_exists(AUTOSAVE_PATH)
func inspect_autosave() -> Dictionary:
if not has_autosave():
return {"available": false, "data": {}, "error": ""}
var read_result: Dictionary = read(AUTOSAVE_PATH)
if not bool(read_result.get("ok", false)):
remove_autosave()
return {"available": false, "data": {}, "error": String(read_result.get("error", "Could not read autosave."))}
var data: Dictionary = read_result.get("data", {}) as Dictionary
var raw_metadata: Variant = data.get("metadata", {})
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
if not bool(metadata.get("autosave", false)):
remove_autosave()
return {"available": false, "data": {}, "error": ""}
var source_path: String = CONTENT_PATHS.canonicalize(String(metadata.get("source_path", "")))
if not source_path.is_empty() and FileAccess.file_exists(source_path):
var autosave_modified: int = int(metadata.get("modified_unix", 0))
if autosave_modified <= int(FileAccess.get_modified_time(source_path)):
remove_autosave()
return {"available": false, "data": {}, "error": ""}
return {"available": true, "data": data, "error": ""}
func remove_autosave() -> void:
if has_autosave():
DirAccess.remove_absolute(AUTOSAVE_PATH)
func validate_queues(world: Node2D) -> String:
if world == null:
return ""
for child: Node in world.get_children():
if not child is StickmanRig:
continue
var rig: StickmanRig = child as StickmanRig
var actions: Array[Dictionary] = rig.get_queue()
var encoded: Dictionary = STAGE_SCENE_IO.encode_queue(actions)
if (encoded.get("actions", []) as Array).size() == actions.size():
continue
for action_index: int in actions.size():
var one_action: Array[Dictionary] = [actions[action_index]]
var one_encoded: Dictionary = STAGE_SCENE_IO.encode_queue(one_action)
if (one_encoded.get("actions", []) as Array).size() == 1:
continue
var character_name: String = rig.source_stk_path.get_file().trim_suffix(".stk")
if character_name.is_empty():
character_name = String(rig.name)
return "Can't save: %s action %d (%s) can't be saved. Edit or remove it from the queue." % [character_name, action_index + 1, String(actions[action_index].get("type", "unknown"))]
return ""
@@ -0,0 +1 @@
uid://dx1wd5y3v7d1o
+343
View File
@@ -0,0 +1,343 @@
class_name StageModalViews
extends RefCounted
## Builds Sandbox Stage dialogs and editor popups without owning their decisions.
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const QUEUE_PANEL_SCENE := preload("res://scenes/editors/stage/queue_panel.tscn")
const ACTION_EDITOR_SCENE := preload("res://scenes/editors/stage/action_editor.tscn")
const SPEECH_COMPOSER_SCENE := preload("res://scenes/editors/stage/speech_composer.tscn")
const SPEECH_STYLE_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_style_toolbar.gd")
const SPEECH_SHOW_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_show_toolbar.gd")
const WAYPOINT_CONTEXT_SCRIPT := preload("res://scripts/stage/waypoint_context.gd")
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
signal object_selected(node: Node2D)
var speak_dialog: AcceptDialog
var speak_edit: LineEdit
var speak_stop_current_check: CheckBox
var wait_dialog: AcceptDialog
var wait_spin: SpinBox
var scene_open_dialog: FileDialog
var scene_save_dialog: FileDialog
var speech_save_gate_dialog: ConfirmationDialog
var rename_scene_dialog: ConfirmationDialog
var scene_name_edit: LineEdit
var missing_assets_dialog: AcceptDialog
var cli_error_dialog: AcceptDialog
var unsaved_changes_dialog: ConfirmationDialog
var rig_context_popup: PopupMenu
var waypoint_context: PopupMenu
var confirm_dialog: ConfirmationDialog
var queue_panel: QueuePanel
var action_editor: ActionEditor
var speech_composer: Control
var speech_style_toolbar: Control
var speech_show_toolbar: Control
var speech_recovery_dialog: ConfirmationDialog
var speech_destination_dialog: ConfirmationDialog
var _objects_here_popup: PopupPanel
var _speak_box: VBoxContainer
var _sandbox_theme: SandboxTheme
var _ui_font: Font
func configure(sandbox_theme: SandboxTheme, ui_font: Font) -> void:
_sandbox_theme = sandbox_theme
_ui_font = ui_font
func build_action_dialogs(parent: Node) -> void:
speak_dialog = AcceptDialog.new()
speak_dialog.title = "Speak"
_speak_box = VBoxContainer.new()
speak_dialog.add_child(_speak_box)
speak_edit = LineEdit.new()
speak_edit.placeholder_text = "Say something…"
speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
_speak_box.add_child(speak_edit)
speak_stop_current_check = CheckBox.new()
speak_stop_current_check.text = "Stop what he's doing?"
speak_stop_current_check.visible = false
_speak_box.add_child(speak_stop_current_check)
speak_dialog.register_text_enter(speak_edit)
parent.add_child(speak_dialog)
apply_dialog_theme(speak_dialog)
wait_dialog = AcceptDialog.new()
wait_dialog.title = "Wait"
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)
parent.add_child(wait_dialog)
apply_dialog_theme(wait_dialog)
func build_file_dialogs(parent: Node) -> void:
scene_open_dialog = FileDialog.new()
scene_open_dialog.name = "SceneOpenDialog"
scene_open_dialog.access = FileDialog.ACCESS_FILESYSTEM
scene_open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
scene_open_dialog.current_dir = ProjectSettings.globalize_path(CONTENT_PATHS.STAGES_DIR)
scene_open_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
parent.add_child(scene_open_dialog)
scene_save_dialog = FileDialog.new()
scene_save_dialog.name = "SceneSaveDialog"
scene_save_dialog.access = FileDialog.ACCESS_FILESYSTEM
scene_save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
scene_save_dialog.current_dir = ProjectSettings.globalize_path(CONTENT_PATHS.STAGES_DIR)
scene_save_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
parent.add_child(scene_save_dialog)
speech_save_gate_dialog = ConfirmationDialog.new()
speech_save_gate_dialog.name = "FinishSpeechBeforeSaveDialog"
speech_save_gate_dialog.title = "Finish speech before saving"
speech_save_gate_dialog.dialog_text = "Choose Done speech to finish editing before the scene is saved."
speech_save_gate_dialog.ok_button_text = "Done speech"
speech_save_gate_dialog.get_cancel_button().text = "Keep editing"
parent.add_child(speech_save_gate_dialog)
apply_dialog_theme(speech_save_gate_dialog)
rename_scene_dialog = ConfirmationDialog.new()
rename_scene_dialog.name = "RenameSceneDialog"
rename_scene_dialog.title = "Rename Scene"
rename_scene_dialog.cancel_button_text = "Cancel"
rename_scene_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
scene_name_edit = LineEdit.new()
scene_name_edit.name = "SceneNameEdit"
scene_name_edit.placeholder_text = "Scene name"
scene_name_edit.custom_minimum_size = Vector2(460.0, 0.0)
rename_scene_dialog.add_child(scene_name_edit)
parent.add_child(rename_scene_dialog)
apply_dialog_theme(rename_scene_dialog)
missing_assets_dialog = AcceptDialog.new()
missing_assets_dialog.name = "MissingAssetsDialog"
missing_assets_dialog.title = "Missing Scene Assets"
missing_assets_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
parent.add_child(missing_assets_dialog)
apply_dialog_theme(missing_assets_dialog)
cli_error_dialog = AcceptDialog.new()
cli_error_dialog.name = "CliErrorDialog"
cli_error_dialog.title = "Could Not Load Scene"
cli_error_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
parent.add_child(cli_error_dialog)
apply_dialog_theme(cli_error_dialog)
unsaved_changes_dialog = ConfirmationDialog.new()
unsaved_changes_dialog.name = "UnsavedChangesDialog"
unsaved_changes_dialog.title = "⚠️ Unsaved Changes"
unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"
unsaved_changes_dialog.ok_button_text = "💾 Save"
unsaved_changes_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
parent.add_child(unsaved_changes_dialog)
apply_dialog_theme(unsaved_changes_dialog)
func build_context_and_editor_views(
parent: Node,
font: Font,
emoji_font: Font,
font_sizes: Dictionary,
queue_size: Vector2i,
action_size: Vector2i
) -> void:
rig_context_popup = PopupMenu.new()
rig_context_popup.add_item("📋 Edit Queue…", 0)
rig_context_popup.add_item("⚡ Edit Rules…", 1)
parent.add_child(rig_context_popup)
waypoint_context = WAYPOINT_CONTEXT_SCRIPT.new() as PopupMenu
parent.add_child(waypoint_context)
confirm_dialog = ConfirmationDialog.new()
confirm_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
parent.add_child(confirm_dialog)
apply_dialog_theme(confirm_dialog)
queue_panel = QUEUE_PANEL_SCENE.instantiate() as QueuePanel
parent.add_child(queue_panel)
queue_panel.apply_font(font, emoji_font, font_sizes)
queue_panel.min_size = queue_size
action_editor = ACTION_EDITOR_SCENE.instantiate() as ActionEditor
parent.add_child(action_editor)
action_editor.apply_font(font, emoji_font, font_sizes)
action_editor.apply_theme(_sandbox_theme)
action_editor.min_size = action_size
speech_composer = SPEECH_COMPOSER_SCENE.instantiate() as Control
parent.add_child(speech_composer)
speech_style_toolbar = SPEECH_STYLE_TOOLBAR_SCRIPT.new() as Control
speech_style_toolbar.name = "SpeechStyleToolbar"
speech_style_toolbar.call("configure", _sandbox_theme)
parent.add_child(speech_style_toolbar)
speech_show_toolbar = SPEECH_SHOW_TOOLBAR_SCRIPT.new() as Control
speech_show_toolbar.name = "SpeechShowToolbar"
speech_show_toolbar.call("configure", _sandbox_theme)
parent.add_child(speech_show_toolbar)
speech_recovery_dialog = ConfirmationDialog.new()
speech_recovery_dialog.title = "Speech draft found"
speech_recovery_dialog.dialog_text = "Restore your unsaved speech draft?"
speech_recovery_dialog.get_ok_button().text = "Restore"
speech_recovery_dialog.get_cancel_button().text = "Discard"
parent.add_child(speech_recovery_dialog)
speech_destination_dialog = ConfirmationDialog.new()
speech_destination_dialog.title = "Save recovered speech"
speech_destination_dialog.dialog_text = "Add this speech to the chosen character’s action queue?"
speech_destination_dialog.get_ok_button().text = "Add to queue"
speech_destination_dialog.get_cancel_button().text = "Keep editing"
parent.add_child(speech_destination_dialog)
func show_objects_here(
parent: Node,
candidates: Array[Node2D],
selected_nodes: Array[Node2D],
viewport_size: Vector2,
pointer_position: Vector2
) -> void:
if parent == null or candidates.size() < 2:
return
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
_objects_here_popup.queue_free()
var popup: PopupPanel = PopupPanel.new()
popup.name = "ObjectsHere"
popup.exclusive = false
popup.transient = true
popup.unfocusable = false
if _sandbox_theme != null:
popup.theme = _sandbox_theme.control_theme()
parent.add_child(popup)
var margin: MarginContainer = MarginContainer.new()
for side: String in ["left", "right", "top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 8)
popup.add_child(margin)
var column: VBoxContainer = VBoxContainer.new()
column.add_theme_constant_override("separation", 4)
margin.add_child(column)
var heading: Label = Label.new()
heading.text = "Objects here · Choose an object"
if _ui_font != null:
heading.add_theme_font_override("font", _ui_font)
column.add_child(heading)
var scroll: ScrollContainer = ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.custom_minimum_size = Vector2(300.0, minf(360.0, float(candidates.size()) * 48.0))
column.add_child(scroll)
var rows: VBoxContainer = VBoxContainer.new()
rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(rows)
for node: Node2D in candidates:
var button: Button = Button.new()
var selected_mark: String = "✓ " if selected_nodes.has(node) else ""
button.text = selected_mark + _object_here_label(node)
button.custom_minimum_size = Vector2(300.0, 44.0)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.pressed.connect(_on_objects_here_selected.bind(node))
rows.add_child(button)
var popup_size: Vector2i = Vector2i(
mini(360, maxi(260, int(viewport_size.x) - 16)),
mini(420, maxi(120, int(viewport_size.y) - 16))
)
popup.size = popup_size
var position: Vector2i = Vector2i(pointer_position + Vector2(12.0, 12.0))
position.x = clampi(position.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
position.y = clampi(position.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
_objects_here_popup = popup
popup.popup_hide.connect(_on_objects_here_popup_closed.bind(popup))
popup.position = position
popup.popup()
func get_objects_here_popup() -> PopupPanel:
return _objects_here_popup
func _object_here_label(node: Node2D) -> String:
if node is PropBlock:
var prop: PropBlock = node as PropBlock
var depth: String = "Behind characters" if String(prop.stage_roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else "In front of characters"
var entry: Dictionary = PROP_LIBRARY_SCRIPT.get_entry(prop.spawn_id)
var prop_name: String = String(entry.get("name", entry.get("label", prop.spawn_id)))
return "📦 %s · %s" % [prop_name, depth]
if node is TerrainBlock:
return "🧱 %s · Terrain" % String(node.get("spawn_id"))
if node is StickmanRig:
return "🧍 %s · Stickman" % node.name
return "🎯 %s" % node.name
func _on_objects_here_selected(node: Node2D) -> void:
object_selected.emit(node)
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
_objects_here_popup.hide()
func _on_objects_here_popup_closed(popup: PopupPanel) -> void:
if _objects_here_popup == popup:
_objects_here_popup = null
if is_instance_valid(popup):
popup.queue_free()
func theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value("chrome.dialogs." + key, [])
if raw is Array and (raw as Array).size() == 2:
return Vector2i(maxi(320, int(raw[0])), maxi(180, int(raw[1])))
return fallback
## Dialogs are separate native/embedded windows, so apply local visual overrides.
func apply_dialog_theme(dialog: Window) -> void:
if dialog == null:
return
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
dialog.add_theme_font_size_override("font_size", size)
if _ui_font != null:
dialog.add_theme_font_override("font", _ui_font)
if dialog is AcceptDialog:
var message: Label = (dialog as AcceptDialog).get_label()
if message != null:
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
message.add_theme_font_size_override("font_size", message_size)
if _ui_font != null:
message.add_theme_font_override("font", _ui_font)
var ok: Button = (dialog as AcceptDialog).get_ok_button()
if ok != null:
ok.add_theme_font_size_override("font_size", size)
if _ui_font != null:
ok.add_theme_font_override("font", _ui_font)
if dialog is ConfirmationDialog:
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
if cancel != null:
cancel.add_theme_font_size_override("font_size", size)
if _ui_font != null:
cancel.add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(dialog, size)
func apply_dialog_font_to_children(node: Node, size: int) -> void:
_apply_dialog_font_to_children(node, size)
func _apply_dialog_font_to_children(node: Node, size: int) -> void:
for child: Node in node.get_children():
if child is Control:
(child as Control).add_theme_font_size_override("font_size", size)
if _ui_font != null:
(child as Control).add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(child, size)
@@ -0,0 +1 @@
uid://c1xy8k68n1abq
@@ -0,0 +1,395 @@
class_name StagePropSettingsView
extends Control
## Presentation-only selected-prop settings card for the Sandbox Stage.
const STAGE_INSPECTOR_PANEL := preload("res://scripts/shared/stage_ui/stage_inspector_panel.gd")
const STAGE_PANEL_CHOICE_GROUP := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd")
const STAGE_PANEL_TOGGLE_ROW := preload("res://scripts/shared/stage_ui/stage_panel_toggle_row.gd")
const PROP_SETTINGS_DEPTH_CUE := preload("res://scripts/editors/stage/prop_settings_depth_cue.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
signal role_choice_requested(key: String, value: String)
signal role_toggle_requested(enabled: bool, key: String)
signal close_requested
var _stage_theme: SandboxTheme
var _top_bar_height: float = 64.0
var _status_bar_height: float = 38.0
var _card: StageInspectorPanel
var _reopen_button: Button
var _fields: VBoxContainer
var _notice: Label
var _depth_group: StagePanelChoiceGroup
var _motion_group: StagePanelChoiceGroup
var _controls: Dictionary = {}
var _active_selection_key: String = ""
var _dismissed_selection_key: String = ""
func configure(stage_theme: SandboxTheme, top_bar_height: float, status_bar_height: float) -> void:
_stage_theme = stage_theme
_top_bar_height = top_bar_height
_status_bar_height = status_bar_height
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build()
if not get_viewport().size_changed.is_connected(_on_viewport_resized):
get_viewport().size_changed.connect(_on_viewport_resized)
_on_viewport_resized()
func show_prop(
prop: PropBlock,
title: String,
roles: Dictionary,
safe_rect: Rect2,
selection: Array[Node2D]
) -> void:
if prop == null or not is_instance_valid(prop):
hide_card()
return
_update_selection_key(selection)
var dismissed: bool = _active_selection_key == _dismissed_selection_key
_card.visible = not dismissed
_reopen_button.visible = dismissed
if dismissed:
return
_card.set_safe_rect(safe_rect)
_notice.visible = false
_fields.visible = true
_card.set_header(title, "📦")
_sync_roles(prop, roles)
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
_update_selection_key(selection)
var dismissed: bool = _active_selection_key == _dismissed_selection_key
_card.visible = not dismissed
_reopen_button.visible = dismissed
if dismissed:
return
_card.set_safe_rect(safe_rect)
_notice.visible = true
_fields.visible = false
_card.set_header("Props", "📦")
_card.request_fit()
func hide_card(reset_selection: bool = false) -> void:
if reset_selection and not _active_selection_key.is_empty():
_update_selection_key([])
if _card != null:
_card.visible = false
if _reopen_button != null:
_reopen_button.visible = false
func set_dismissed(dismissed: bool) -> void:
if _active_selection_key.is_empty():
return
_dismissed_selection_key = _active_selection_key if dismissed else ""
func update_layout(safe_rect: Rect2) -> void:
if _card == null:
return
_card.set_safe_rect(safe_rect)
if _reopen_button == null:
return
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
var button_width: float = minf(148.0, maxf(1.0, safe_rect.size.x - inset * 2.0))
var button_height: float = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
_reopen_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_reopen_button.custom_minimum_size.x = button_width
_reopen_button.position = Vector2(safe_rect.end.x - inset - button_width, safe_rect.position.y + inset)
_reopen_button.size = Vector2(button_width, button_height)
func get_card() -> Control:
return _card
func get_reopen_button() -> Button:
return _reopen_button
func get_fields() -> VBoxContainer:
return _fields
func get_notice() -> Label:
return _notice
func get_depth_group() -> Control:
return _depth_group
func get_motion_group() -> Control:
return _motion_group
func get_controls() -> Dictionary:
return _controls
func get_active_selection_key() -> String:
return _active_selection_key
func get_dismissed_selection_key() -> String:
return _dismissed_selection_key
func focus_card() -> void:
_focus_card()
func _build() -> void:
_card = STAGE_INSPECTOR_PANEL.new() as StageInspectorPanel
_card.name = "PropSettingsCard"
_card.visible = false
add_child(_card)
_card.apply_theme(_stage_theme)
_card.close_requested.connect(_on_card_close_requested)
var body: VBoxContainer = _card.body()
body.add_theme_constant_override("separation", 10)
_notice = Label.new()
_notice.text = "Select one prop to change its settings."
_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_notice.add_theme_font_size_override("font_size", _stage_panel_integer("type.body_size", 20, 14, 40))
_notice.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
_notice.visible = false
_apply_ui_font(_notice)
body.add_child(_notice)
_fields = VBoxContainer.new()
_fields.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
_fields.size_flags_horizontal = Control.SIZE_EXPAND_FILL
body.add_child(_fields)
_add_heading(_fields, "Where it appears")
_depth_group = _make_choice_group("depth")
_fields.add_child(_depth_group)
_add_legacy_order_cue()
_add_heading(_fields, "When PLAY starts")
_motion_group = _make_choice_group("start_motion")
_fields.add_child(_motion_group)
_add_motion_hint()
_controls["holds_other_props"] = _make_toggle_row(_fields, "holds_other_props", "Props can sit on top")
_controls["blocks_stickmen"] = _make_toggle_row(_fields, "blocks_stickmen", "Blocks stickmen")
_controls["can_pick_up"] = _make_toggle_row(_fields, "can_pick_up", "Stickmen can pick it up")
_add_release_options()
_reopen_button = Button.new()
_reopen_button.name = "ReopenPropSettings"
_reopen_button.text = "Settings"
_reopen_button.custom_minimum_size = Vector2(148.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
_reopen_button.focus_mode = Control.FOCUS_ALL
_reopen_button.visible = false
_reopen_button.pressed.connect(_on_reopen_pressed)
_card.style_action_button(_reopen_button, true)
add_child(_reopen_button)
func _add_legacy_order_cue() -> void:
var legacy_cue := Label.new()
legacy_cue.name = "LegacyOrderCue"
legacy_cue.text = "Old scene order"
legacy_cue.visible = false
legacy_cue.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
legacy_cue.add_theme_color_override("font_color", _theme_color("mode_colors.edit", Color("#22C6FF")))
_apply_ui_font(legacy_cue)
_fields.add_child(legacy_cue)
_controls["legacy_cue"] = legacy_cue
func _add_motion_hint() -> void:
var motion_hint := Label.new()
motion_hint.name = "MotionHint"
motion_hint.text = "Stays here while PLAY runs."
motion_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
motion_hint.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
motion_hint.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_apply_ui_font(motion_hint)
_fields.add_child(motion_hint)
_controls["motion_hint"] = motion_hint
func _add_release_options() -> void:
var release_section := VBoxContainer.new()
release_section.name = "AfterLettingGo"
release_section.add_theme_constant_override("separation", 8)
_fields.add_child(release_section)
_add_heading(release_section, "After letting go")
var release_intro := Label.new()
release_intro.text = "After Set Down, Drop, or Throw:"
release_intro.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
release_intro.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_apply_ui_font(release_intro)
release_section.add_child(release_intro)
_controls["released_blocks_stickmen"] = _make_toggle_row(release_section, "released_blocks_stickmen", "Can bump into stickmen")
_controls["released_bumps_other_props"] = _make_toggle_row(release_section, "released_bumps_other_props", "Can bump into other props")
_controls["release_section"] = release_section
func _add_heading(parent: VBoxContainer, text_value: String) -> void:
var label := Label.new()
label.text = text_value
label.add_theme_font_size_override("font_size", _stage_panel_integer("type.section_size", 20, 12, 40))
label.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
_apply_ui_font(label)
parent.add_child(label)
func _make_choice_group(key: String) -> StagePanelChoiceGroup:
var group := STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
group.apply_theme(_stage_theme)
if key == "depth":
var behind_cue: Control = PROP_SETTINGS_DEPTH_CUE.new()
behind_cue.call("configure", false, _stage_panel_color("prop.stickman_cue_color", Color("#F5F7FA")), _stage_panel_color("prop.prop_cue_color", Color("#B7824D")), _stage_panel_color("prop.cue_outline_color", Color("#171B24")))
var front_cue: Control = PROP_SETTINGS_DEPTH_CUE.new()
front_cue.call("configure", true, _stage_panel_color("prop.stickman_cue_color", Color("#F5F7FA")), _stage_panel_color("prop.prop_cue_color", Color("#B7824D")), _stage_panel_color("prop.cue_outline_color", Color("#171B24")))
group.add_choice(&"behind", "Behind stickmen", behind_cue)
group.add_choice(&"front", "In front of stickmen", front_cue)
else:
group.add_choice(&"still", "Stays in place")
group.add_choice(&"physics", "Can fall and move")
group.choice_selected.connect(_on_choice_selected.bind(key))
return group
func _make_toggle_row(parent: VBoxContainer, key: String, caption: String) -> StagePanelToggleRow:
var row := STAGE_PANEL_TOGGLE_ROW.new() as StagePanelToggleRow
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.configure(caption)
row.apply_theme(_stage_theme)
parent.add_child(row)
row.preferred_height_changed.connect(_on_row_height_changed)
row.toggled.connect(_on_role_toggled.bind(key))
return row
func _sync_roles(prop: PropBlock, roles: Dictionary) -> void:
var legacy_depth: bool = bool(roles.get("legacy_draw_order", false))
if legacy_depth:
_depth_group.clear_no_signal()
else:
_depth_group.select_no_signal(StringName(String(roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND))))
_motion_group.select_no_signal(StringName(String(roles.get("start_motion", PROP_STAGE_ROLES.START_STILL))))
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props"]:
var control := _controls.get(key) as StagePanelToggleRow
if control == null:
continue
control.set_checked_no_signal(bool(roles.get(key, false)))
if key == "holds_other_props":
control.set_help_text("Other props can land on this one." if bool(roles.get(key, false)) else "")
elif key == "blocks_stickmen":
control.set_help_text("Stickmen cannot walk through it." if bool(roles.get(key, false)) else "")
elif key == "can_pick_up":
control.set_help_text("")
control.set_disabled_state(not prop.has_usable_grip(), "Add a hand hold in Prop Editor first.")
var release_section := _controls.get("release_section") as Control
if release_section != null:
release_section.visible = bool(roles.get("can_pick_up", false))
var legacy_cue := _controls.get("legacy_cue") as Label
if legacy_cue != null:
legacy_cue.visible = legacy_depth
var motion_hint := _controls.get("motion_hint") as Label
if motion_hint != null:
if String(roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_STILL:
motion_hint.text = "Stays here until picked up." if bool(roles.get("can_pick_up", false)) else "Stays here while PLAY runs."
else:
motion_hint.text = "Gravity and bumps can move it."
_card.request_fit()
func _update_selection_key(selection: Array[Node2D]) -> void:
var keys: PackedStringArray = []
var instance_ids: Array[int] = []
for node: Node2D in selection:
if node != null and is_instance_valid(node):
instance_ids.append(node.get_instance_id())
instance_ids.sort()
for instance_id: int in instance_ids:
keys.append(str(instance_id))
var selection_key: String = ",".join(keys)
if selection_key == _active_selection_key:
return
_active_selection_key = selection_key
_dismissed_selection_key = ""
_card.reset_scroll()
func _on_choice_selected(id: StringName, key: String) -> void:
var value: String = String(id)
if key == "depth":
if value == "behind":
value = PROP_STAGE_ROLES.DEPTH_BEHIND
elif value == "front":
value = PROP_STAGE_ROLES.DEPTH_FRONT
else:
if value == "still":
value = PROP_STAGE_ROLES.START_STILL
elif value == "physics":
value = PROP_STAGE_ROLES.START_PHYSICS
role_choice_requested.emit(key, value)
func _on_role_toggled(enabled: bool, key: String) -> void:
role_toggle_requested.emit(enabled, key)
func _on_row_height_changed() -> void:
if _card != null:
_card.request_fit()
func _on_card_close_requested() -> void:
close_requested.emit()
func _on_reopen_pressed() -> void:
var should_focus: bool = _reopen_button.has_focus()
set_dismissed(false)
_card.visible = true
_reopen_button.visible = false
if should_focus:
call_deferred("_focus_card")
func _focus_card() -> void:
if _card != null and _card.visible:
_card.focus_first_control()
func _on_viewport_resized() -> void:
var viewport: Rect2 = get_viewport_rect()
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
var top_offset: float = minf(_top_bar_height + 116.0, maxf(12.0, viewport.size.y - 220.0))
var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - _status_bar_height - 12.0)
var safe_rect := Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))
update_layout(safe_rect)
func _stage_panel_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer("stage_panels." + key, fallback, minimum, maximum)
func _stage_panel_color(key: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color("stage_panels." + key, fallback)
func _theme_color(key: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(key, fallback)
func _apply_ui_font(control: Control) -> void:
if control != null and _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
control.add_theme_font_override("font", font)
@@ -0,0 +1 @@
uid://brp43as6806r2
@@ -0,0 +1,686 @@
class_name StageRuleAuthoringController
extends RefCounted
## Owns rule-builder interaction state and the rule authoring panels.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
const RULE_PANEL_SCENE := preload("res://scenes/editors/stage/rule_panel.tscn")
const RULE_EDITOR_SCENE := preload("res://scenes/editors/stage/rule_editor.tscn")
enum Step { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
const TRIG_ARRIVED: int = 0
const TRIG_ACTION_FINISHED: int = 1
const TRIG_SPEECH_FINISHED: int = 2
const TRIG_ENTERED_AREA: int = 3
const TRIG_COLLIDED: int = 4
const TRIG_BACK: int = 5
const TRIG_PROP_PICKED_UP: int = 6
const TRIG_PROP_DROPPED: int = 7
const TRIG_PROP_THROWN: int = 8
const RULE_MORE_ADD: int = 0
const RULE_MORE_DONE: int = 1
const RULE_ACTION_DONE: int = 6
const WAYPOINT_MATCH_EPSILON: float = 24.0
signal trigger_selected(id: int)
signal action_selected(id: int)
signal more_selected(id: int)
signal rule_panel_edit_requested(rule_id: int)
signal rule_panel_delete_requested(rule_id: int)
signal rule_panel_add_requested
signal rule_panel_clear_requested
signal rule_panel_reorder_requested(ordered_ids: Array[int])
signal rule_editor_committed(rule: Dictionary)
signal rule_editor_cancelled
signal rule_editor_trigger_target_requested(type: String)
signal rule_editor_action_add_requested
signal rule_editor_action_edit_requested(index: int)
signal toast_requested(message: String)
var step: int = Step.IDLE
var builder: Dictionary = {}
var context_rig: StickmanRig
var hint: String = ""
var editing_id: int = -1
var build_from_panel: bool = false
var editor_from_panel: bool = false
var popup_anchor: Rect2i = Rect2i()
var popup_anchor_set: bool = false
var panel_source_id: int = -1
var panel_waypoint: Vector2 = Vector2.INF
var panel_title: String = ""
var panel_filter_ids: Array[int] = []
var pending_action_flat: Dictionary = {}
var _trigger_popup: PopupMenu
var _action_popup: PopupMenu
var _more_popup: PopupMenu
var _direct_popup: PopupMenu
var _rule_panel: RulePanel
var _rule_editor: RuleEditor
var _speak_stop_current_check: CheckBox
var _viewport: Viewport
func configure(
direct_popup: PopupMenu,
speak_stop_current_check: CheckBox,
viewport: Viewport
) -> void:
_direct_popup = direct_popup
_speak_stop_current_check = speak_stop_current_check
_viewport = viewport
func build_ui(
parent: Node,
popup_ids: Dictionary,
ui_font: Font,
emoji_font: Font,
font_sizes: Dictionary,
rule_size: Vector2i
) -> void:
_trigger_popup = PopupMenu.new()
_trigger_popup.add_item("📍 Arrives at a waypoint", int(popup_ids.get("trigger_arrived", TRIG_ARRIVED)))
_trigger_popup.add_item("✅ Completes any action", int(popup_ids.get("trigger_action_finished", TRIG_ACTION_FINISHED)))
_trigger_popup.add_item("💬 Finishes speaking", int(popup_ids.get("trigger_speech_finished", TRIG_SPEECH_FINISHED)))
_trigger_popup.add_item("🎯 Enters trigger area", int(popup_ids.get("trigger_entered_area", TRIG_ENTERED_AREA)))
_trigger_popup.add_item("💥 Collides with something", int(popup_ids.get("trigger_collided", TRIG_COLLIDED)))
_trigger_popup.add_item("🫴 Picks up a prop", int(popup_ids.get("trigger_prop_picked_up", TRIG_PROP_PICKED_UP)))
_trigger_popup.add_item("⬇️ Drops a prop", int(popup_ids.get("trigger_prop_dropped", TRIG_PROP_DROPPED)))
_trigger_popup.add_item("🏹 Throws a prop", int(popup_ids.get("trigger_prop_thrown", TRIG_PROP_THROWN)))
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", int(popup_ids.get("trigger_back", TRIG_BACK)))
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
parent.add_child(_trigger_popup)
_action_popup = PopupMenu.new()
_action_popup.add_item("🚶 Move To", int(popup_ids.get("action_walk", 0)))
_action_popup.add_item("💬 Speak", int(popup_ids.get("action_speak", 1)))
_action_popup.add_item("⏳ Wait", int(popup_ids.get("action_wait", 2)))
_action_popup.add_item("💥 Ragdoll", int(popup_ids.get("action_ragdoll", 3)))
_action_popup.add_item("🔄 Recover", int(popup_ids.get("action_recover", 4)))
_action_popup.add_item("🫴 Pick Up Prop…", int(popup_ids.get("action_pick_up_prop", 9)))
_action_popup.add_item("⬇️ Drop Prop", int(popup_ids.get("action_drop_prop", 10)))
_action_popup.add_item("🏹 Throw Prop…", int(popup_ids.get("action_throw_prop", 11)))
_action_popup.add_item("🫳 Set Down…", int(popup_ids.get("action_set_down_prop", 12)))
_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
parent.add_child(_action_popup)
_more_popup = PopupMenu.new()
_more_popup.add_item("➕ Add another action", int(popup_ids.get("more_add", RULE_MORE_ADD)))
_more_popup.add_item("✅ Done", int(popup_ids.get("more_done", RULE_MORE_DONE)))
_more_popup.id_pressed.connect(_on_rule_more_popup_id_pressed)
parent.add_child(_more_popup)
_rule_panel = RULE_PANEL_SCENE.instantiate() as RulePanel
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
_rule_panel.delete_requested.connect(_on_rule_panel_delete_requested)
_rule_panel.add_requested.connect(_on_rule_panel_add_requested)
_rule_panel.clear_requested.connect(_on_rule_panel_clear_requested)
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
parent.add_child(_rule_panel)
_rule_panel.apply_font(ui_font, emoji_font, font_sizes)
_rule_panel.min_size = rule_size
_rule_editor = RULE_EDITOR_SCENE.instantiate() as RuleEditor
_rule_editor.committed.connect(_on_rule_editor_committed)
_rule_editor.cancelled.connect(_on_rule_editor_cancelled)
_rule_editor.trigger_target_requested.connect(_on_rule_editor_trigger_target_requested)
_rule_editor.action_add_requested.connect(_on_rule_editor_action_add_requested)
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
parent.add_child(_rule_editor)
_rule_editor.apply_font(ui_font, emoji_font, font_sizes)
_rule_editor.min_size = rule_size
func get_trigger_popup() -> PopupMenu:
return _trigger_popup
func get_action_popup() -> PopupMenu:
return _action_popup
func get_more_popup() -> PopupMenu:
return _more_popup
func get_rule_panel() -> RulePanel:
return _rule_panel
func get_rule_editor() -> RuleEditor:
return _rule_editor
func edit_rule_by_id(rules: Array[Dictionary], rule_id: int) -> bool:
for rule: Dictionary in rules:
if int(rule.get("id", -1)) == rule_id:
open_rule_editor_full(rule, true)
return true
return false
func begin_panel_add() -> int:
if panel_source_id < 0:
toast_requested.emit("Select a stickman first")
return -1
if _rule_panel != null:
_rule_panel.hide()
return panel_source_id
func prepare_trigger_target_capture(type: String) -> String:
if _rule_editor != null:
_rule_editor.hide()
return TRIGGER_REGISTRY.target_type(type)
func trigger_target_capture_hint(target_type: String) -> String:
match target_type:
"waypoint":
return "📍 Click the waypoint (Esc or RMB to cancel)"
"area":
return "🎯 Click the trigger area (Esc or RMB to cancel)"
"prop":
return "💥 Click the prop (Esc or RMB to cancel)"
return ""
func _on_trigger_popup_id_pressed(id: int) -> void:
trigger_selected.emit(id)
func _on_rule_action_popup_id_pressed(id: int) -> void:
action_selected.emit(id)
func _on_rule_more_popup_id_pressed(id: int) -> void:
more_selected.emit(id)
func _on_rule_panel_edit_requested(rule_id: int) -> void:
rule_panel_edit_requested.emit(rule_id)
func _on_rule_panel_delete_requested(rule_id: int) -> void:
rule_panel_delete_requested.emit(rule_id)
func _on_rule_panel_add_requested() -> void:
rule_panel_add_requested.emit()
func _on_rule_panel_clear_requested() -> void:
rule_panel_clear_requested.emit()
func _on_rule_panel_reorder_requested(ordered_ids: Array[int]) -> void:
rule_panel_reorder_requested.emit(ordered_ids)
func _on_rule_editor_committed(rule: Dictionary) -> void:
rule_editor_committed.emit(rule)
func _on_rule_editor_cancelled() -> void:
rule_editor_cancelled.emit()
func _on_rule_editor_trigger_target_requested(type: String) -> void:
rule_editor_trigger_target_requested.emit(type)
func _on_rule_editor_action_add_requested() -> void:
rule_editor_action_add_requested.emit()
func _on_rule_editor_action_edit_requested(index: int) -> void:
rule_editor_action_edit_requested.emit(index)
func mouse_popup_rect() -> Rect2i:
var mouse_position: Vector2 = _viewport.get_mouse_position() if _viewport != null else Vector2.ZERO
return Rect2i(Vector2i(mouse_position), Vector2i.ZERO)
func set_popup_anchor(rect: Rect2i) -> void:
popup_anchor = rect
popup_anchor_set = true
func clear_popup_anchor() -> void:
popup_anchor = Rect2i()
popup_anchor_set = false
func popup_anchor_rect() -> Rect2i:
if not popup_anchor_set:
set_popup_anchor(mouse_popup_rect())
return popup_anchor
func begin_rule_build(source_id: int, from_panel: bool = false) -> void:
build_from_panel = from_panel
builder = {
"trigger": { "source": source_id, "target": -1, "params": {} },
"actions": [],
}
editing_id = -1
var source: Object = instance_from_id(source_id)
context_rig = source as StickmanRig if source is StickmanRig else null
step = Step.SELECT_TRIGGER
if _trigger_popup != null:
_trigger_popup.popup(popup_anchor_rect())
func open_rule_action_popup() -> void:
sync_rule_action_done_item()
if _action_popup != null:
_action_popup.popup(popup_anchor_rect())
func sync_rule_action_done_item() -> void:
if _action_popup == null:
return
var index: int = _action_popup.get_item_index(RULE_ACTION_DONE)
var editing: bool = editing_id >= 0
if editing and index < 0:
_action_popup.add_item("✅ Done", RULE_ACTION_DONE)
elif not editing and index >= 0:
_action_popup.remove_item(index)
func open_rule_more_popup() -> void:
if _more_popup != null:
_more_popup.popup(popup_anchor_rect())
func on_trigger_selected(id: int) -> void:
var trigger: Dictionary = builder.get("trigger", {})
trigger["type"] = trigger_type_string(id)
builder["trigger"] = trigger
if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN]:
step = Step.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
hint = "Click the waypoint to trigger on (Esc or RMB to cancel)"
TRIG_ENTERED_AREA:
hint = "Click the trigger area to watch (Esc or RMB to cancel)"
TRIG_COLLIDED:
hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN:
hint = "Click the prop to watch (Esc or RMB to cancel)"
else:
step = Step.SELECT_ACTION
open_rule_action_popup()
func on_action_selected(id: int) -> bool:
if id == RULE_ACTION_DONE:
return true
var action: Dictionary = { "type": action_type_string(id), "target": -1, "params": {} }
var actions: Array = builder.get("actions", [])
actions.append(action)
builder["actions"] = actions
step = Step.ACTION_TARGET
hint = "Click the stickman who will %s (Esc or RMB to cancel)" % action_verb(id)
return false
func on_more_selected(id: int) -> bool:
match id:
RULE_MORE_ADD:
step = Step.SELECT_ACTION
open_rule_action_popup()
return false
RULE_MORE_DONE:
return true
return false
func trigger_type_string(id: int) -> String:
match id:
TRIG_ARRIVED:
return "arrived_at_waypoint"
TRIG_ACTION_FINISHED:
return "action_finished"
TRIG_SPEECH_FINISHED:
return "speech_finished"
TRIG_ENTERED_AREA:
return "entered_area"
TRIG_COLLIDED:
return "collided"
TRIG_PROP_PICKED_UP:
return "prop_picked_up"
TRIG_PROP_DROPPED:
return "prop_dropped"
TRIG_PROP_THROWN:
return "prop_thrown"
_:
return ""
func action_type_string(id: int) -> String:
match id:
0:
return ACTION_REGISTRY.MOVE_TO
1:
return "speak"
2:
return "wait"
3:
return "ragdoll"
4:
return "recover"
9:
return "pick_up_prop"
10:
return "drop_prop"
11:
return "throw_prop"
12:
return "set_down_prop"
_:
return ""
func action_verb(id: int) -> String:
match id:
0:
return "move to a place"
1:
return "speak"
2:
return "wait"
3:
return "ragdoll"
4:
return "recover"
9:
return "pick up a prop"
10:
return "drop a prop"
11:
return "throw a prop"
12:
return "set down a prop"
_:
return "act"
func set_trigger_target(target_id: int, params: Dictionary) -> void:
var trigger: Dictionary = builder.get("trigger", {})
trigger["target"] = target_id
trigger["params"] = params
builder["trigger"] = trigger
step = Step.SELECT_ACTION
func accept_trigger_waypoint(position: Vector2) -> void:
set_trigger_target(-1, { "waypoint_pos": position })
open_rule_action_popup()
func accept_trigger_area(area: TriggerArea) -> void:
if area == null or not is_instance_valid(area):
return
set_trigger_target(int(area.get_instance_id()), {})
open_rule_action_popup()
func accept_trigger_prop(prop: PropBlock) -> void:
if prop == null or not is_instance_valid(prop):
return
set_trigger_target(int(prop.get_instance_id()), {})
open_rule_action_popup()
func set_trigger_target_miss_hint(trigger_type: String) -> void:
match trigger_type:
"arrived_at_waypoint":
hint = "Click a waypoint dot (Esc or RMB to cancel)"
"entered_area":
hint = "Click a trigger area (Esc or RMB to cancel)"
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
func set_trigger_target_node(value: Node2D) -> void:
if value is TriggerArea:
accept_trigger_area(value as TriggerArea)
elif value is PropBlock:
accept_trigger_prop(value as PropBlock)
func cancel_trigger_target_capture() -> void:
if _rule_editor != null:
_rule_editor.popup_centered()
func set_rule_editor_trigger_waypoint(position: Vector2) -> void:
if _rule_editor != null:
_rule_editor.set_trigger_target(-1, { "waypoint_pos": position })
func set_rule_editor_trigger_node(value: Node2D) -> void:
if value == null or not is_instance_valid(value):
return
if _rule_editor != null:
_rule_editor.set_trigger_target(int(value.get_instance_id()), {})
func accept_rule_actor(value: Node2D) -> void:
if value == null or not is_instance_valid(value):
return
var actor_id: int = int(value.get_instance_id())
var flat: Dictionary = pending_action_flat
pending_action_flat = {}
if _rule_editor != null:
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, actor_id))
func cancel_rule_actor_capture() -> void:
if _rule_editor != null:
_rule_editor.popup_centered()
func set_last_action_target(target_id: int) -> Dictionary:
var actions: Array = builder.get("actions", [])
if actions.is_empty():
return {}
var action: Dictionary = actions.back()
action["target"] = target_id
actions[actions.size() - 1] = action
builder["actions"] = actions
return action
func set_last_action_params(params: Dictionary) -> void:
var actions: Array = builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
action["params"] = params
actions[actions.size() - 1] = action
builder["actions"] = actions
func accept_last_action_position(position: Vector2) -> void:
set_last_action_param("target", position)
open_rule_more_popup()
func accept_last_action_prop_target(prop: PropBlock) -> bool:
if prop == null or not is_instance_valid(prop):
return false
if prop.stage_object_id.is_empty():
toast_requested.emit("That prop is missing its scene ID")
return false
var actions: Array = builder.get("actions", [])
if actions.is_empty():
return false
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params["target_prop_id"] = prop.stage_object_id
params["hand"] = String(params.get("hand", "auto"))
action["params"] = params
actions[actions.size() - 1] = action
builder["actions"] = actions
open_rule_more_popup()
return true
func accept_last_action_world_target(position: Vector2) -> void:
set_last_action_param("target_position", position)
open_rule_more_popup()
func cancel_last_action_target_capture() -> void:
step = Step.ACTION_TARGET
hint = "Click the stickman who will act (Esc or RMB to cancel)"
func accept_action_params_from_dialog(params: Dictionary) -> bool:
if step != Step.PARAMS:
return false
set_last_action_params(params)
open_rule_more_popup()
return true
func set_last_action_param(key: String, value: Variant) -> void:
var actions: Array = builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params[key] = value
action["params"] = params
actions[actions.size() - 1] = action
builder["actions"] = actions
func reset_builder() -> void:
if _speak_stop_current_check != null:
_speak_stop_current_check.visible = false
_speak_stop_current_check.button_pressed = false
step = Step.IDLE
builder.clear()
context_rig = null
hint = ""
editing_id = -1
func cancel_move_to_action_editor() -> void:
var actions: Array = builder.get("actions", [])
if not actions.is_empty():
actions.pop_back()
builder["actions"] = actions
step = Step.SELECT_ACTION
if actions.is_empty():
open_rule_action_popup()
else:
open_rule_more_popup()
func set_panel_for_rig(rig: StickmanRig) -> void:
panel_source_id = rig.get_instance_id()
panel_waypoint = Vector2.INF
panel_title = "Source: %s" % rig.name
func set_panel_for_waypoint(position: Vector2) -> void:
panel_source_id = -1
panel_waypoint = position
panel_title = "Waypoint (%d, %d)" % [int(roundf(position.x)), int(roundf(position.y))]
func panel_rules(rules: Array[Dictionary]) -> Array[Dictionary]:
var filtered: Array[Dictionary] = []
for rule: Dictionary in rules:
if rule_matches_panel_filter(rule):
filtered.append(rule)
return filtered
func rule_matches_panel_filter(rule: Dictionary) -> bool:
if panel_source_id >= 0:
var trigger: Dictionary = rule.get("trigger", {})
return int(trigger.get("source", -1)) == panel_source_id
if panel_waypoint.is_finite():
var trigger: Dictionary = rule.get("trigger", {})
if String(trigger.get("type", "")) != "arrived_at_waypoint":
return false
var params: Dictionary = trigger.get("params", {})
var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF)
return waypoint.is_finite() and waypoint.distance_to(panel_waypoint) <= WAYPOINT_MATCH_EPSILON
return true
func show_rule_panel(rules: Array[Dictionary]) -> void:
panel_filter_ids.clear()
var filtered: Array[Dictionary] = panel_rules(rules)
for rule: Dictionary in filtered:
panel_filter_ids.append(int(rule.get("id", -1)))
if _rule_panel != null:
_rule_panel.show_rules(filtered, panel_title)
func refresh_rule_panel(rules: Array[Dictionary]) -> void:
if _rule_panel == null:
return
panel_filter_ids.clear()
var filtered: Array[Dictionary] = panel_rules(rules)
for rule: Dictionary in filtered:
panel_filter_ids.append(int(rule.get("id", -1)))
_rule_panel.set_rules(filtered, panel_title)
func restore_rule_build_panel(rules: Array[Dictionary]) -> bool:
if not build_from_panel:
return false
build_from_panel = false
refresh_rule_panel(rules)
if _rule_panel != null:
_rule_panel.popup_centered()
return true
func open_rule_editor_full(rule: Dictionary, from_panel: bool) -> void:
editor_from_panel = from_panel
if from_panel and _rule_panel != null:
_rule_panel.hide()
if _rule_editor != null:
_rule_editor.open_full(rule)
func open_rule_editor_consequence(rule: Dictionary) -> void:
editor_from_panel = false
if _rule_editor != null:
_rule_editor.open_consequence(rule)
func restore_rule_panel(rules: Array[Dictionary]) -> void:
if not editor_from_panel:
return
editor_from_panel = false
refresh_rule_panel(rules)
if _rule_panel != null:
_rule_panel.popup_centered()
func get_rule_editor_action(index: int) -> Dictionary:
return {} if _rule_editor == null else _rule_editor.get_action(index)
func set_rule_editor_trigger_target(target_id: int, params: Dictionary) -> void:
if _rule_editor != null:
_rule_editor.set_trigger_target(target_id, params)
func set_rule_editor_action(index: int, action: Dictionary) -> void:
if _rule_editor != null:
_rule_editor.set_action(index, action)
@@ -0,0 +1 @@
uid://btfdwp0ogfw8r
+357
View File
@@ -0,0 +1,357 @@
class_name StageRunSession
extends RefCounted
## Owns run lifecycle state and the authored transforms restored between runs.
const STICKMAN_RIG_SCRIPT := preload("res://scripts/characters/stickman/stickman_rig.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const STATE_STOPPED: int = 0
const STATE_RUNNING: int = 1
const STATE_PAUSED: int = 2
const PROP_SCALE_TRACE_ENABLED: bool = true
var run_state: int = STATE_STOPPED
var elapsed_seconds: float = 0.0
var event_dispatch_ready: bool = false
var resume_baseline_frames: int = 0
var prop_pause_states: Dictionary = {}
var paused_edit_autosave_remaining: float = -1.0
var authored_state: Dictionary = {}
var restore_frames_left: int = 0
var prop_scale_trace_previous: Dictionary = {}
var edit_physics_targets: Dictionary = {} # { RigidBody2D: Transform2D }
var edit_physics_settle_frames: int = 0
func prepare_start() -> void:
restore_frames_left = 0
elapsed_seconds = 0.0
prop_pause_states.clear()
func start_run(
world_nodes: Array[Node2D],
rule_engine: StageRuleEngine,
stage_mode_name: String,
camera_zoom: Vector2
) -> void:
run_state = STATE_RUNNING
event_dispatch_ready = true
resume_baseline_frames = 0
if rule_engine != null:
rule_engine.reset_runtime_state()
for node: Node2D in world_nodes:
if node is PropBlock:
var prop: PropBlock = node as PropBlock
trace_prop_scale(prop, "before_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom)
prop.begin_play_instance_scale()
trace_prop_scale(prop, "after_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom)
var start_role: PropBlock.RuntimeRole = PropBlock.RuntimeRole.START_STILL if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_STILL else PropBlock.RuntimeRole.START_PHYSICS
prop.apply_runtime_role(start_role)
trace_prop_scale(prop, "after_play_role_applied", stage_mode_name, "RUNNING", camera_zoom)
elif node is STICKMAN_RIG_SCRIPT:
var rig: StickmanRig = node as StickmanRig
rig.reset_simulation_pause()
rig.stop_queue()
rig.clear_reactive_actions()
rig.auto_recover = false
rig.begin_terrain_fall()
rig.start_queue()
elif node is RigidBody2D:
(node as RigidBody2D).freeze = false
func pause_run(world_nodes: Array[Node2D]) -> bool:
if run_state != STATE_RUNNING:
return false
event_dispatch_ready = false
run_state = STATE_PAUSED
resume_baseline_frames = 0
for node: Node2D in world_nodes:
if node is STICKMAN_RIG_SCRIPT:
(node as StickmanRig).pause_simulation()
elif node is RigidBody2D:
capture_prop_pause_state(node as RigidBody2D)
return true
func resume_run(world_nodes: Array[Node2D], rule_engine: StageRuleEngine) -> bool:
if run_state != STATE_PAUSED:
return false
for body_id: int in prop_pause_states.keys():
var snapshot: Dictionary = prop_pause_states[body_id]
var body: RigidBody2D = snapshot.get("body") as RigidBody2D
if body == null or not is_instance_valid(body) or body.is_queued_for_deletion():
continue
body.freeze_mode = int(snapshot.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
body.freeze = bool(snapshot.get("freeze", false))
body.linear_velocity = snapshot.get("linear_velocity", Vector2.ZERO)
body.angular_velocity = float(snapshot.get("angular_velocity", 0.0))
body.sleeping = bool(snapshot.get("sleeping", false))
prop_pause_states.clear()
for node: Node2D in world_nodes:
if node is STICKMAN_RIG_SCRIPT:
(node as StickmanRig).resume_simulation()
run_state = STATE_RUNNING
event_dispatch_ready = false
resume_baseline_frames = 2
if rule_engine != null:
rule_engine.reset_runtime_state()
return true
func begin_reset() -> void:
event_dispatch_ready = false
run_state = STATE_STOPPED
resume_baseline_frames = 0
paused_edit_autosave_remaining = -1.0
prop_pause_states.clear()
func reset_world(
world_nodes: Array[Node2D],
stage_mode_name: String,
camera_zoom: Vector2
) -> void:
for node: Node2D in world_nodes:
if node is STICKMAN_RIG_SCRIPT:
var rig: StickmanRig = node as StickmanRig
rig.reset_prop_interactions_for_scene_exit()
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
rig.clear_reactive_actions()
rig.reset_simulation_pause()
elif node is RigidBody2D:
var body: RigidBody2D = node as RigidBody2D
if node is PropBlock:
var prop: PropBlock = node as PropBlock
var authored: Dictionary = authored_state.get(prop.get_instance_id(), {})
trace_prop_scale(prop, "before_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom)
prop.set_stage_roles(authored.get("roles", prop.stage_roles))
prop.end_play_instance_scale()
prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT)
trace_prop_scale(prop, "after_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom)
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
restore_authored_state(stage_mode_name, "STOPPED", camera_zoom)
func finish_reset() -> void:
restore_frames_left = 3
elapsed_seconds = 0.0
func arm_authored_restore() -> void:
restore_frames_left = 3
func reset_elapsed() -> void:
elapsed_seconds = 0.0
func capture_prop_pause_state(body: RigidBody2D) -> Dictionary:
var snapshot: Dictionary = {
"body": body,
"transform": body.global_transform,
"linear_velocity": body.linear_velocity,
"angular_velocity": body.angular_velocity,
"sleeping": body.sleeping,
"freeze": body.freeze,
"freeze_mode": body.freeze_mode,
}
prop_pause_states[body.get_instance_id()] = snapshot
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
return snapshot
func save_object_state(node: Node2D) -> void:
var authored_rotation: float = node.global_rotation
if node is STICKMAN_RIG_SCRIPT and (node as StickmanRig).get_facing_profile() == StickmanRig.FacingProfile.LEFT:
# LEFT is a derived root reflection. Remove it before persisting the
# director-authored rotation so a load never applies the mirror twice.
authored_rotation = (node.global_transform * Transform2D.FLIP_X).get_rotation()
var entry: Dictionary = {
"node": node,
"position": node.global_position,
"rotation": authored_rotation,
}
if node is STICKMAN_RIG_SCRIPT:
entry["facing_profile"] = (node as STICKMAN_RIG_SCRIPT).get_facing_profile()
if not node is STICKMAN_RIG_SCRIPT:
entry["scale"] = node.scale
if node is PropBlock:
entry["roles"] = (node as PropBlock).stage_roles.duplicate(true)
authored_state[node.get_instance_id()] = entry
func clear_object_state(node: Node2D) -> void:
authored_state.erase(node.get_instance_id())
func restore_authored_state(stage_mode_name: String, run_state_name: String, camera_zoom: Vector2) -> void:
for id: int in authored_state.keys():
var entry: Dictionary = authored_state[id]
var node: Node2D = entry.get("node") as Node2D
if node == null or not is_instance_valid(node):
authored_state.erase(id)
continue
node.global_position = entry.get("position", node.global_position)
node.global_rotation = entry.get("rotation", node.global_rotation)
if entry.has("scale"):
if node is PropBlock:
trace_prop_scale(node as PropBlock, "before_authored_scale_write", stage_mode_name, run_state_name, camera_zoom)
node.scale = entry["scale"]
if node is PropBlock:
trace_prop_scale(node as PropBlock, "after_authored_scale_write", stage_mode_name, run_state_name, camera_zoom)
if node is STICKMAN_RIG_SCRIPT and entry.has("facing_profile"):
(node as STICKMAN_RIG_SCRIPT).set_facing_profile(int(entry["facing_profile"]))
if node is RigidBody2D:
var body: RigidBody2D = node as RigidBody2D
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.set_stage_roles(entry.get("roles", prop.stage_roles))
prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT)
func trace_changed_prop_scales(
world_children: Array[Node],
stage_mode_name: String,
run_state_name: String,
camera_zoom: Vector2
) -> void:
if not PROP_SCALE_TRACE_ENABLED:
return
var current_prop_ids: Dictionary = {}
for child: Node in world_children:
if not child is PropBlock or not is_instance_valid(child):
continue
var prop: PropBlock = child as PropBlock
var instance_id: int = prop.get_instance_id()
current_prop_ids[instance_id] = true
var snapshot: String = prop_scale_trace_signature(prop, camera_zoom)
if String(prop_scale_trace_previous.get(instance_id, "")) != snapshot:
trace_prop_scale(prop, "observed_scale_change", stage_mode_name, run_state_name, camera_zoom)
for instance_id: int in prop_scale_trace_previous.keys():
if not current_prop_ids.has(instance_id):
prop_scale_trace_previous.erase(instance_id)
func trace_prop_scale(
prop: PropBlock,
event: String,
stage_mode_name: String,
run_state_name: String,
camera_zoom: Vector2
) -> void:
if not PROP_SCALE_TRACE_ENABLED or prop == null or not is_instance_valid(prop):
return
var snapshot: String = prop_scale_trace_signature(prop, camera_zoom)
prop_scale_trace_previous[prop.get_instance_id()] = snapshot
print("[PropScaleTrace t=%dms] event=%s mode=%s run_state=%s restore_pending=%d prop=%s role=%s freeze=%s freeze_mode=%d %s" % [
Time.get_ticks_msec(),
event,
stage_mode_name,
run_state_name,
restore_frames_left,
prop.stage_object_id if not prop.stage_object_id.is_empty() else String(prop.name),
PropBlock.RuntimeRole.keys()[int(prop.runtime_role)],
str(prop.freeze),
prop.freeze_mode,
snapshot,
])
func prop_scale_trace_signature(prop: PropBlock, camera_zoom: Vector2) -> String:
var polygon: Polygon2D = prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
var outline: Line2D = prop.get_node_or_null(NodePath("Outline")) as Line2D
var visuals: Node2D = prop.get_node_or_null(NodePath("Visuals")) as Node2D
var collision_polygon: CollisionPolygon2D = prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
var polygon_local: Vector2 = polygon.scale if polygon != null else Vector2.ONE
var polygon_global_x: float = polygon.global_transform.x.length() if polygon != null else 0.0
var polygon_global_y: float = polygon.global_transform.y.length() if polygon != null else 0.0
var outline_local: Vector2 = outline.scale if outline != null else Vector2.ONE
var outline_global_x: float = outline.global_transform.x.length() if outline != null else 0.0
var outline_global_y: float = outline.global_transform.y.length() if outline != null else 0.0
var visuals_local: Vector2 = visuals.scale if visuals != null else Vector2.ONE
var visuals_global_x: float = visuals.global_transform.x.length() if visuals != null else 0.0
var visuals_global_y: float = visuals.global_transform.y.length() if visuals != null else 0.0
var collision_point: Vector2 = collision_polygon.polygon[0] if collision_polygon != null and not collision_polygon.polygon.is_empty() else Vector2.ZERO
return "root_local=%s root_global=(%.5f,%.5f) poly_local=%s poly_global=(%.5f,%.5f) outline_local=%s outline_global=(%.5f,%.5f) visuals_local=%s visuals_global=(%.5f,%.5f) collision_points=%d collision_first=%s play_scale_active=%s play_scale=%s camera_zoom=%s" % [
str(prop.scale),
prop.global_transform.x.length(),
prop.global_transform.y.length(),
str(polygon_local),
polygon_global_x,
polygon_global_y,
str(outline_local),
outline_global_x,
outline_global_y,
str(visuals_local),
visuals_global_x,
visuals_global_y,
collision_polygon.polygon.size() if collision_polygon != null else 0,
str(collision_point),
str(prop.get("_play_scale_active")),
str(prop.get("_play_instance_scale")),
str(camera_zoom),
]
func advance_elapsed(delta: float) -> float:
if run_state != STATE_RUNNING:
return -1.0
elapsed_seconds += delta
return elapsed_seconds
func consume_resume_baseline_frame() -> Dictionary:
if resume_baseline_frames <= 0:
return {"active": false, "finished": false}
resume_baseline_frames -= 1
return {"active": true, "finished": resume_baseline_frames == 0}
func advance_paused_edit_autosave(delta: float) -> bool:
if paused_edit_autosave_remaining < 0.0:
return false
paused_edit_autosave_remaining -= delta
if paused_edit_autosave_remaining > 0.0:
return false
paused_edit_autosave_remaining = -1.0
return true
func clear_edit_physics_targets() -> void:
edit_physics_targets.clear()
edit_physics_settle_frames = 0
func add_edit_physics_target(body: RigidBody2D) -> void:
edit_physics_targets[body] = body.global_transform
func set_edit_physics_settle_frames(frame_count: int) -> void:
edit_physics_settle_frames = frame_count
func sync_pending_editable_physics_transforms(is_edit_mode: bool, gizmo_dragging: bool) -> Array[RigidBody2D]:
var synced_bodies: Array[RigidBody2D] = []
if edit_physics_targets.is_empty() or not is_edit_mode:
return synced_bodies
for body: RigidBody2D in edit_physics_targets.keys().duplicate():
if body == null or not is_instance_valid(body):
edit_physics_targets.erase(body)
continue
body.global_transform = edit_physics_targets[body]
synced_bodies.append(body)
if not gizmo_dragging and edit_physics_settle_frames > 0:
edit_physics_settle_frames -= 1
if edit_physics_settle_frames == 0:
edit_physics_targets.clear()
return synced_bodies
@@ -0,0 +1 @@
uid://7mkon4j4j1pl
@@ -0,0 +1,233 @@
class_name StageSceneAdapter
extends RefCounted
## Maps live stage objects to and from the StageSceneIO document representation.
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const STICKMAN_RIG_SCRIPT := preload("res://scripts/characters/stickman/stickman_rig.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
func collect(
world: Node2D,
authored: Dictionary,
rules: Array[Dictionary],
camera_state: Dictionary,
document_metadata: Dictionary
) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for node: Node in world.get_children():
if not node is Node2D:
continue
var stage_node: Node2D = node as Node2D
var type: String = _object_type(stage_node)
if type.is_empty():
continue
counters[type] = int(counters[type]) + 1
var file_id: String = "%s_%d" % [type, int(counters[type])]
runtime_to_file[stage_node.get_instance_id()] = file_id
var authored_state: Dictionary = authored.get(stage_node.get_instance_id(), {})
var position: Vector2 = authored_state.get("position", stage_node.global_position)
var rotation_degrees: float = rad_to_deg(float(authored_state.get("rotation", stage_node.global_rotation)))
var entry: Dictionary = {
"file_id": file_id,
"type": type,
"position": STAGE_SCENE_IO.vector_to_data(position),
"rotation": rotation_degrees,
}
if type == "terrain":
entry["asset_id"] = String(stage_node.get("spawn_id"))
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
elif type == "prop" and stage_node.get_script() == MISSING_PROP_PLACEHOLDER:
var original_entry: Dictionary = stage_node.get("original_entry") as Dictionary
entry = original_entry.duplicate(true)
entry["file_id"] = file_id
entry["type"] = "prop"
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
entry["rotation"] = rotation_degrees
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
elif type == "prop":
var prop: PropBlock = stage_node as PropBlock
entry["asset_id"] = prop.spawn_id
entry["stage_object_id"] = prop.stage_object_id
entry["roles"] = prop.stage_roles.duplicate(true)
var prop_scale: Vector2 = authored_state.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
elif type == "area":
var area: TriggerArea = stage_node as TriggerArea
entry["scale"] = STAGE_SCENE_IO.vector_to_data(area.scale)
entry["size"] = STAGE_SCENE_IO.vector_to_data(area.size)
elif stage_node.get_script() == MISSING_STICKMAN_PLACEHOLDER:
var original_entry: Dictionary = stage_node.get("original_entry") as Dictionary
entry = original_entry.duplicate(true)
entry["file_id"] = file_id
entry["type"] = "stickman"
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
entry["rotation"] = rotation_degrees
else:
var rig: StickmanRig = stage_node as StickmanRig
entry["stk_path"] = rig.source_stk_path
var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile()))
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile]
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
entry["action_queue"] = queue_result["actions"]
if type == "stickman" and entry.has("stk_path"):
entry["stk_path"] = CONTENT_PATHS.canonicalize(String(entry.get("stk_path", "")))
elif type == "prop" and String(entry.get("asset_id", "")).ends_with(".prop"):
entry["asset_id"] = CONTENT_PATHS.canonicalize(String(entry.get("asset_id", "")))
objects.append(entry)
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(rules, runtime_to_file)
var metadata: Dictionary = document_metadata.get("metadata", {}) as Dictionary
var scene_name: String = String(document_metadata.get("scene_name", "Untitled"))
var camera_position: Vector2 = camera_state.get("position", Vector2.ZERO)
var camera_zoom: float = float(camera_state.get("zoom", 0.5))
return {
"version": STAGE_SCENE_IO.FILE_VERSION,
"scene_name": scene_name,
"camera": {"position": STAGE_SCENE_IO.vector_to_data(camera_position), "zoom": camera_zoom},
"objects": objects,
"event_rules": rules_result["rules"],
"metadata": metadata,
}
func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary:
var file_to_runtime: Dictionary = {}
var unavailable: Dictionary = {}
var warnings: Array[String] = []
var missing_paths: Array[String] = []
var loaded_nodes: Array[Node2D] = []
var missing_stickmen: int = 0
for raw: Variant in data.get("objects", []):
if not raw is Dictionary:
warnings.append("Malformed object")
continue
var entry: Dictionary = (raw as Dictionary).duplicate(true)
var file_id: String = String(entry.get("file_id", ""))
var type: String = String(entry.get("type", ""))
if type == "stickman" and entry.has("stk_path"):
entry["stk_path"] = CONTENT_PATHS.canonicalize(String(entry.get("stk_path", "")))
elif type == "prop" and String(entry.get("asset_id", "")).ends_with(".prop"):
entry["asset_id"] = CONTENT_PATHS.canonicalize(String(entry.get("asset_id", "")))
if file_id.is_empty() or file_to_runtime.has(file_id) or unavailable.has(file_id):
warnings.append("Duplicate or empty file id")
continue
if not STAGE_SCENE_IO.is_vector_data(entry.get("position")) or not STAGE_SCENE_IO._finite_number(entry.get("rotation", 0.0)):
warnings.append("Malformed object transform")
continue
var position: Vector2 = STAGE_SCENE_IO.data_to_vector(entry.get("position"), Vector2.ZERO)
var node: Node2D = null
match type:
"terrain":
node = spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), position)
"prop":
node = spawner.spawn_prop_asset(String(entry.get("asset_id", "")), position)
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
var prop_placeholder: Node2D = MISSING_PROP_PLACEHOLDER.new() as Node2D
prop_placeholder.configure(entry)
prop_placeholder.position = position
world.add_child(prop_placeholder)
node = prop_placeholder
"area":
node = spawner.spawn_area(position)
"stickman":
node = spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), position)
if node == null:
var placeholder: Node2D = MISSING_STICKMAN_PLACEHOLDER.new() as Node2D
placeholder.configure(entry)
placeholder.position = position
world.add_child(placeholder)
node = placeholder
unavailable[file_id] = true
missing_stickmen += 1
var missing_path: String = String(entry.get("stk_path", ""))
if not missing_paths.has(missing_path):
missing_paths.append(missing_path)
if node == null:
warnings.append("Unknown object asset")
continue
node.global_rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
if type != "stickman":
if entry.has("scale") and not STAGE_SCENE_IO.is_vector_data(entry["scale"]):
warnings.append("Malformed object scale")
world.remove_child(node)
node.queue_free()
continue
node.scale = STAGE_SCENE_IO.data_to_vector(entry.get("scale"), Vector2.ONE)
if node is TriggerArea:
var area_size: Vector2 = STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2.ZERO)
if not STAGE_SCENE_IO.is_vector_data(entry.get("size")) or area_size.x <= 0.0 or area_size.y <= 0.0:
warnings.append("Malformed trigger area")
world.remove_child(node)
node.queue_free()
continue
(node as TriggerArea).size = area_size
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.stage_object_id = String(entry.get("stage_object_id", ""))
var default_roles: Dictionary = PROP_STAGE_ROLES.new_placement(prop.has_usable_grip())
if entry.get("roles", null) is Dictionary:
prop.set_stage_roles(PROP_STAGE_ROLES.normalize(entry["roles"], default_roles))
else:
prop.set_stage_roles(PROP_STAGE_ROLES.legacy_placement(prop.has_usable_grip()))
var preset_names: Array = PropBlock.MaterialPreset.keys()
var raw_preset: Variant = entry.get("material_preset", PropBlock.MaterialPreset.NONE)
var preset_index: int = -1
if raw_preset is int or raw_preset is float:
var numeric_preset: float = float(raw_preset)
if is_finite(numeric_preset) and is_equal_approx(numeric_preset, roundf(numeric_preset)):
preset_index = int(numeric_preset)
else:
preset_index = preset_names.find(String(raw_preset))
if preset_index >= 0 and preset_index < preset_names.size():
prop.material_preset = preset_index
else:
warnings.append("Invalid prop material preset")
if node is StickmanRig:
var facing_names: Array = StickmanRig.FacingProfile.keys()
var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD")))
if facing_index >= 0:
(node as StickmanRig).set_facing_profile(facing_index)
var raw_queue: Variant = entry.get("action_queue", [])
if raw_queue is Array:
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
for warning: String in queue.get("warnings", []):
var character_name: String = String(entry.get("stk_path", "")).get_file()
if character_name.is_empty():
character_name = file_id
warnings.append("%s: %s Open this character's queue to review the skipped action." % [character_name, warning])
for action: Dictionary in queue["actions"]:
(node as StickmanRig).queue_action(action)
else:
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
loaded_nodes.append(node)
file_to_runtime[file_id] = node.get_instance_id()
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
return {
"loaded_nodes": loaded_nodes,
"file_to_runtime": file_to_runtime,
"unavailable": unavailable,
"warnings": warnings,
"missing_stickmen": missing_stickmen,
"missing_paths": missing_paths,
"decoded_rules": decoded_rules,
}
func _object_type(node: Node2D) -> String:
if node is TerrainBlock:
return "terrain"
if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER:
return "prop"
if node is STICKMAN_RIG_SCRIPT or node.get_script() == MISSING_STICKMAN_PLACEHOLDER:
return "stickman"
if node is TriggerArea:
return "area"
return ""
@@ -0,0 +1 @@
uid://cj0p4105ehlop
@@ -0,0 +1,330 @@
class_name StageSpeechDraftStore
extends RefCounted
## Owns the durable speech draft and its queue/scene recovery origin.
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
const DRAFT_PATH: String = "user://speech_composer_draft.json"
const DRAFT_DEBOUNCE: float = 0.75
const DRAFT_SCHEMA: int = 1
var _world: Node2D
var _composer: Control
var origin: Dictionary = {}
var draft_dirty: bool = false
var draft_timer: float = -1.0
func configure(world: Node2D, composer: Control) -> void:
_world = world
_composer = composer
func reset_session() -> void:
draft_timer = -1.0
draft_dirty = false
origin.clear()
func arm_draft_save() -> void:
draft_dirty = true
draft_timer = DRAFT_DEBOUNCE
func update(delta: float) -> void:
if draft_timer < 0.0:
return
draft_timer -= delta
if draft_timer <= 0.0:
draft_timer = -1.0
persist_draft_now()
func capture_origin(actor: StickmanRig, context: String, scene_path: String, scene_fingerprint: String, editor_state: Dictionary) -> Dictionary:
var result: Dictionary = {
"context": context,
"scene_path": CONTENT_PATHS.canonicalize(scene_path),
"scene_fingerprint": scene_fingerprint,
"actor_file_id": speech_actor_file_id(actor),
"actor_stk_path": CONTENT_PATHS.canonicalize(actor.source_stk_path) if actor != null and is_instance_valid(actor) else "",
"action_editor_kind": String(editor_state.get("kind", "")),
"action_editor_index": int(editor_state.get("index", -1)),
"restore_queue": bool(editor_state.get("restore_queue", false)),
"queue_index": -1,
"queue_action": {},
"insert_after": bool(editor_state.get("insert_after", false)),
}
if context != "action_editor" or actor == null or not is_instance_valid(actor):
return result
var editor_rig: StickmanRig = editor_state.get("rig") as StickmanRig
var action_id: int = int(editor_state.get("action_id", -1))
if String(editor_state.get("kind", "")) == "queue_edit" and editor_rig == actor and action_id >= 0:
var queue_index: int = actor.index_for_action_id(action_id)
result["queue_index"] = queue_index
result["queue_action"] = queue_action_wire_at(actor, queue_index)
elif String(editor_state.get("kind", "")) == "queue_insert" and editor_rig == actor:
var insert_index: int = int(editor_state.get("index", -1))
var target_action_id: int = int(editor_state.get("target_action_id", -1))
if target_action_id >= 0:
insert_index = actor.index_for_action_id(target_action_id)
result["queue_index"] = insert_index
result["queue_action"] = queue_action_wire_at(actor, insert_index)
return result
func validate_origin(origin_value: Dictionary, current_scene_path: String, current_scene_fingerprint: String) -> Dictionary:
var canonical: Dictionary = canonicalize_origin(origin_value)
if current_scene_path.is_empty() or String(canonical.get("scene_path", "")) != current_scene_path:
return {}
var scene_fingerprint_matches: bool = String(canonical.get("scene_fingerprint", "")) == current_scene_fingerprint
var actor: StickmanRig = find_speech_actor(String(canonical.get("actor_file_id", "")), String(canonical.get("actor_stk_path", "")))
if actor == null:
return {}
var context: String = String(canonical.get("context", ""))
if context == "direct":
return {"actor": actor, "context": "direct"} if scene_fingerprint_matches else {}
if context != "action_editor":
return {}
var editor_kind: String = String(canonical.get("action_editor_kind", ""))
if editor_kind == "queue_add":
return {"actor": actor, "context": "action_editor", "kind": editor_kind} if scene_fingerprint_matches else {}
if editor_kind != "queue_edit" and editor_kind != "queue_insert":
return {}
var queue_index: int = int(canonical.get("queue_index", -1))
var expected: Dictionary = canonical.get("queue_action", {})
if queue_index < 0 or expected.is_empty() or queue_action_wire_at(actor, queue_index) != expected:
return {}
if editor_kind == "queue_edit":
var action_id: int = actor.action_id_at(queue_index)
if action_id < 0 or _queue_action_consumed(actor.get_action_status(action_id)):
return {}
return {"actor": actor, "context": "action_editor", "kind": editor_kind, "queue_index": queue_index}
func speech_actor_file_id(actor: StickmanRig) -> String:
if actor == null or not is_instance_valid(actor) or _world == null:
return ""
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for child: Node in _world.get_children():
if not child is Node2D:
continue
var node: Node2D = child as Node2D
var object_type: String = speech_object_type(node)
if object_type.is_empty():
continue
counters[object_type] = int(counters[object_type]) + 1
if node == actor:
return "%s_%d" % [object_type, int(counters[object_type])]
return ""
func speech_object_type(node: Node2D) -> String:
if node is TerrainBlock:
return "terrain"
if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER:
return "prop"
if node is StickmanRig or node.get_script() == MISSING_STICKMAN_PLACEHOLDER:
return "stickman"
if node is TriggerArea:
return "area"
return ""
func find_speech_actor(file_id: String, source_path: String) -> StickmanRig:
if file_id.is_empty() or _world == null:
return null
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for child: Node in _world.get_children():
if not child is Node2D:
continue
var node: Node2D = child as Node2D
var object_type: String = speech_object_type(node)
if object_type.is_empty():
continue
counters[object_type] = int(counters[object_type]) + 1
if "%s_%d" % [object_type, int(counters[object_type])] != file_id:
continue
if not node is StickmanRig:
return null
var actor: StickmanRig = node as StickmanRig
return actor if actor.source_stk_path == source_path else null
return null
func queue_action_wire_at(actor: StickmanRig, index: int) -> Dictionary:
if actor == null or not is_instance_valid(actor) or index < 0:
return {}
var queue: Array[Dictionary] = actor.get_queue()
if index >= queue.size():
return {}
var encoded: Dictionary = STAGE_SCENE_IO.encode_queue([queue[index]])
var actions: Array = encoded.get("actions", [])
return actions[0] if not actions.is_empty() and actions[0] is Dictionary else {}
func canonicalize_origin(origin_value: Dictionary) -> Dictionary:
var canonical: Dictionary = origin_value.duplicate(true)
for key: String in ["scene_path", "actor_stk_path"]:
if canonical.has(key):
canonical[key] = CONTENT_PATHS.canonicalize(String(canonical.get(key, "")))
return canonical
func speech_action_matches_queue_edit(draft: Dictionary, actor: StickmanRig, queue_index: int) -> bool:
if actor == null or not is_instance_valid(actor):
return false
var queue: Array[Dictionary] = actor.get_queue()
if queue_index < 0 or queue_index >= queue.size() or String(queue[queue_index].get("type", "")) != "speak":
return false
var draft_payload: Dictionary = SPEECH_PAYLOAD.normalize(draft)
var saved_payload: Dictionary = SPEECH_PAYLOAD.normalize(queue[queue_index])
draft_payload.erase("legacy_fixed")
saved_payload.erase("legacy_fixed")
return draft_payload == saved_payload
func inspect_recovery(current_scene_path: String, current_scene_fingerprint: String) -> Dictionary:
if current_scene_path.is_empty() or not has_draft_file():
return {"available": false, "clear_matching_edit": false}
var document: Dictionary = read_draft_document()
var origin_value: Variant = document.get("origin", null)
if not origin_value is Dictionary:
return {"available": false, "clear_matching_edit": false}
var validated: Dictionary = validate_origin(origin_value as Dictionary, current_scene_path, current_scene_fingerprint)
if validated.is_empty():
return {"available": false, "clear_matching_edit": false}
var draft_value: Variant = document.get("draft", null)
if draft_value is Dictionary and String(validated.get("context", "")) == "action_editor" and String(validated.get("kind", "")) == "queue_edit":
if speech_action_matches_queue_edit(draft_value as Dictionary, validated["actor"] as StickmanRig, int(validated["queue_index"])):
return {"available": false, "clear_matching_edit": true}
return {"available": true, "clear_matching_edit": false}
func restore_empty_recovered_bubble_text(recovered: Dictionary, saved_action: Dictionary) -> Dictionary:
var result: Dictionary = recovered.duplicate(true)
var saved: Dictionary = SPEECH_PAYLOAD.normalize(saved_action)
var saved_bubbles: Array = saved.get("bubbles", [])
var recovered_bubbles_value: Variant = result.get("bubbles", null)
if recovered_bubbles_value is Array:
var recovered_bubbles: Array = recovered_bubbles_value
for index: int in mini(recovered_bubbles.size(), saved_bubbles.size()):
if not recovered_bubbles[index] is Dictionary or not saved_bubbles[index] is Dictionary:
continue
var bubble: Dictionary = recovered_bubbles[index]
if not String(bubble.get("text", "")).strip_edges().is_empty():
continue
var saved_text: String = String((saved_bubbles[index] as Dictionary).get("text", ""))
if saved_text.strip_edges().is_empty():
continue
bubble["text"] = saved_text
recovered_bubbles[index] = bubble
result["bubbles"] = recovered_bubbles
if not recovered_bubbles.is_empty() and recovered_bubbles[0] is Dictionary:
result["text"] = String((recovered_bubbles[0] as Dictionary).get("text", ""))
elif String(result.get("text", "")).strip_edges().is_empty():
result["text"] = String(saved.get("text", ""))
elif String(result.get("text", "")).strip_edges().is_empty():
result["text"] = String(saved.get("text", ""))
return result
func read_draft_document() -> Dictionary:
if not FileAccess.file_exists(DRAFT_PATH) and FileAccess.file_exists(DRAFT_PATH + ".bak"):
DirAccess.rename_absolute(ProjectSettings.globalize_path(DRAFT_PATH + ".bak"), ProjectSettings.globalize_path(DRAFT_PATH))
if not FileAccess.file_exists(DRAFT_PATH):
return {}
var file: FileAccess = FileAccess.open(DRAFT_PATH, FileAccess.READ)
if file == null:
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary or int((parsed as Dictionary).get("schema", -1)) != DRAFT_SCHEMA:
return {}
var document: Dictionary = (parsed as Dictionary).duplicate(true)
var origin_value: Variant = document.get("origin", {})
if origin_value is Dictionary:
document["origin"] = canonicalize_origin(origin_value as Dictionary)
return document
func speech_payload_for_json(action: Dictionary) -> Dictionary:
var payload: Dictionary = action.duplicate(true)
var placement: Dictionary = payload.get("placement", {}) if payload.get("placement", {}) is Dictionary else {}
if String(placement.get("mode", "auto")) == "manual" and placement.get("center", null) is Vector2:
placement["center"] = STAGE_SCENE_IO.vector_to_data(placement["center"])
payload["placement"] = placement
var bubbles: Array = payload.get("bubbles", [])
for index: int in bubbles.size():
if not bubbles[index] is Dictionary:
continue
var bubble: Dictionary = bubbles[index]
var bubble_placement: Variant = bubble.get("placement", null)
if bubble_placement is Dictionary:
var bubble_data: Dictionary = bubble_placement as Dictionary
var bubble_position: Variant = bubble_data.get("center", null)
if String(bubble_data.get("mode", "auto")) == "manual" and bubble_position is Vector2:
bubble_data["center"] = STAGE_SCENE_IO.vector_to_data(bubble_position)
bubbles[index] = bubble
if not bubbles.is_empty():
payload["bubbles"] = bubbles
return payload
func persist_draft_now() -> void:
if _composer == null or not _composer.visible or not draft_dirty:
return
var document: Dictionary = {
"schema": DRAFT_SCHEMA,
"modified_unix": Time.get_unix_time_from_system(),
"draft": speech_payload_for_json(_composer.call("get_draft")),
"selected_bubble": int(_composer.call("selected_bubble_index")),
"origin": canonicalize_origin(origin),
}
var tmp_path: String = DRAFT_PATH + ".tmp"
var bak_path: String = DRAFT_PATH + ".bak"
var file: FileAccess = FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
push_warning("Speech composer draft could not be written.")
return
file.store_string(JSON.stringify(document, "\t", false))
file.flush()
file.close()
var tmp_abs: String = ProjectSettings.globalize_path(tmp_path)
var path_abs: String = ProjectSettings.globalize_path(DRAFT_PATH)
var bak_abs: String = ProjectSettings.globalize_path(bak_path)
if FileAccess.file_exists(bak_path):
DirAccess.remove_absolute(bak_abs)
if FileAccess.file_exists(DRAFT_PATH) and DirAccess.rename_absolute(path_abs, bak_abs) != OK:
DirAccess.remove_absolute(tmp_abs)
push_warning("Speech composer draft backup failed; keeping the previous draft.")
return
if DirAccess.rename_absolute(tmp_abs, path_abs) != OK:
if FileAccess.file_exists(bak_path):
DirAccess.rename_absolute(bak_abs, path_abs)
push_warning("Speech composer draft replacement failed.")
return
if FileAccess.file_exists(bak_path):
DirAccess.remove_absolute(bak_abs)
draft_dirty = false
func clear_draft_file() -> void:
draft_timer = -1.0
draft_dirty = false
for path: String in [DRAFT_PATH, DRAFT_PATH + ".tmp", DRAFT_PATH + ".bak"]:
var absolute: String = ProjectSettings.globalize_path(path)
if FileAccess.file_exists(path):
DirAccess.remove_absolute(absolute)
func has_draft_file() -> bool:
return FileAccess.file_exists(DRAFT_PATH) or FileAccess.file_exists(DRAFT_PATH + ".bak")
func _queue_action_consumed(status: int) -> bool:
return status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
@@ -0,0 +1 @@
uid://bxd7eblfc2yoh
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://buutqpkalyet7
@@ -0,0 +1,129 @@
class_name StageTargetCaptureSession
extends RefCounted
## Owns a single pending world-target capture and its preview calculation.
const STICKMAN_ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION }
var kind: int = Kind.NONE
var hint: String = ""
var resolve_callback: Callable = Callable()
var cancel_callback: Callable = Callable()
var throw_preview_rig: StickmanRig
var throw_preview_prefix: Array[Dictionary] = []
var throw_preview_cache: Dictionary = {}
var set_down_capture_rig: StickmanRig
var set_down_preview_prefix: Array[Dictionary] = []
var set_down_preview_cache: Dictionary = {}
func begin(kind_value: int, hint_value: String, on_resolve: Callable, on_cancel: Callable) -> void:
kind = kind_value
hint = hint_value
resolve_callback = on_resolve
cancel_callback = on_cancel
throw_preview_rig = null
throw_preview_prefix.clear()
throw_preview_cache.clear()
set_down_capture_rig = null
set_down_preview_prefix.clear()
set_down_preview_cache.clear()
func begin_throw(hint_value: String, rig: StickmanRig, on_resolve: Callable, on_cancel: Callable, queue_prefix: Array[Dictionary]) -> void:
begin(Kind.THROW_POSITION, hint_value, on_resolve, on_cancel)
throw_preview_rig = rig if rig != null and is_instance_valid(rig) else null
throw_preview_prefix = queue_prefix.duplicate(true)
func begin_set_down(hint_value: String, rig: StickmanRig, on_resolve: Callable, on_cancel: Callable, queue_prefix: Array[Dictionary]) -> void:
begin(Kind.SET_DOWN_POSITION, hint_value, on_resolve, on_cancel)
set_down_capture_rig = rig if rig != null and is_instance_valid(rig) else null
set_down_preview_prefix = queue_prefix.duplicate(true)
func finish() -> void:
kind = Kind.NONE
hint = ""
resolve_callback = Callable()
cancel_callback = Callable()
throw_preview_rig = null
throw_preview_prefix.clear()
throw_preview_cache.clear()
set_down_capture_rig = null
set_down_preview_prefix.clear()
set_down_preview_cache.clear()
func update_preview(camera: Camera2D, snap_enabled: bool, grid_size: float) -> void:
if kind == Kind.SET_DOWN_POSITION:
if set_down_capture_rig == null or not is_instance_valid(set_down_capture_rig):
set_down_preview_cache.clear()
return
var release_target: Vector2 = _snap_to_grid(camera.get_global_mouse_position(), snap_enabled, grid_size)
set_down_preview_cache = set_down_capture_rig.preview_prop_set_down(release_target, set_down_preview_prefix)
return
if kind != Kind.THROW_POSITION or throw_preview_rig == null or not is_instance_valid(throw_preview_rig) or camera == null:
throw_preview_cache.clear()
return
var target: Vector2 = _snap_to_grid(camera.get_global_mouse_position(), snap_enabled, grid_size)
throw_preview_cache = throw_preview_rig.preview_prop_throw(target, throw_preview_prefix)
func resolve_target(world_pos: Vector2, selection: StageSelection, director_visuals: Object, snap_enabled: bool, grid_size: float) -> Dictionary:
var value: Variant = null
match kind:
Kind.WAYPOINT:
var waypoint_value: Variant = director_visuals.call("hit_test_waypoint", world_pos)
if waypoint_value is Vector2 and (waypoint_value as Vector2).is_finite():
value = waypoint_value
Kind.AREA:
var area_hit: Node2D = selection.hit_test(world_pos)
if area_hit is TriggerArea:
value = area_hit
Kind.PROP:
var prop_hit: Node2D = selection.hit_test(world_pos)
if prop_hit is PropBlock:
value = prop_hit
Kind.STICKMAN:
var stickman_hit: Node2D = selection.hit_test(world_pos)
if stickman_hit is StickmanRig:
value = stickman_hit
Kind.POSITION, Kind.THROW_POSITION:
value = _snap_to_grid(world_pos, snap_enabled, grid_size)
Kind.SET_DOWN_POSITION:
var release_position: Vector2 = _snap_to_grid(world_pos, snap_enabled, grid_size)
if release_position.is_finite() and (not bool(set_down_preview_cache.get("known", false)) or bool(set_down_preview_cache.get("valid", false))):
value = release_position
if value == null:
return {"valid": false, "value": null}
return {"valid": true, "value": value}
func throw_preview_prefix_for(rig: StickmanRig, before_action_id: int = -1) -> Array[Dictionary]:
var result: Array[Dictionary] = []
if rig == null or not is_instance_valid(rig):
return result
var queue_view: Array[Dictionary] = rig.get_queue_view()
var limit: int = queue_view.size()
if before_action_id >= 0:
limit = rig.index_for_action_id(before_action_id)
if limit < 0:
return result
for index: int in mini(limit, queue_view.size()):
var entry: Dictionary = queue_view[index]
var status: int = int(entry.get("status", STICKMAN_ACTION_RUNNER.QueueStatus.NOT_STARTED))
if status == STICKMAN_ACTION_RUNNER.QueueStatus.COMPLETED or status == STICKMAN_ACTION_RUNNER.QueueStatus.INTERRUPTED:
continue
var action: Dictionary = entry.get("action", {})
if action.is_empty():
continue
result.append({"action": action.duplicate(true), "step": index + 1, "active": status == STICKMAN_ACTION_RUNNER.QueueStatus.ACTIVE})
return result
func _snap_to_grid(position: Vector2, snap_enabled: bool, grid_size: float) -> Vector2:
if not snap_enabled or grid_size <= 0.0:
return position
return Vector2(roundf(position.x / grid_size) * grid_size, roundf(position.y / grid_size) * grid_size)
@@ -0,0 +1 @@
uid://dy7djgj73h5n7
@@ -0,0 +1,278 @@
class_name StageTerrainPainter
extends Node
## Owns terrain drag painting, overlap classification, and stroke preview ghosts.
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
const TERRAIN_BLOCK := preload("res://scripts/terrain/terrain_block.gd")
const DRAG_THRESHOLD_PX: float = 6.0
signal placement_requested(node: Node2D)
signal stroke_finished(placed_count: int)
var _spawner: StageSpawner
var _world: Node2D
var _camera: Camera2D
var _ghost_holder: Node2D
var _overlay: StagePlacementOverlay
var _spatial_index: StageSpatialIndex
var _placement_id: String = ""
var _dragging: bool = false
var _painting: bool = false
var _press_world: Vector2 = Vector2.ZERO
var _anchor_cell: Vector2i = Vector2i.ZERO
var _drag_cells: Array[Vector2i] = []
var _last_drag_cells: Array[Vector2i] = []
var _ghosts: Array[Node2D] = []
var _drag_painted: Dictionary = {}
func configure(
spawner: StageSpawner,
world: Node2D,
camera: Camera2D,
ghost_holder: Node2D,
overlay: StagePlacementOverlay,
spatial_index: StageSpatialIndex
) -> void:
_spawner = spawner
_world = world
_camera = camera
_ghost_holder = ghost_holder
_overlay = overlay
_spatial_index = spatial_index
func begin(placement_id: String, world_pos: Vector2, snap_enabled: bool, grid_size: float) -> void:
if _spawner == null or not _spawner.is_terrain_id(placement_id):
return
_placement_id = placement_id
if snap_enabled:
world_pos = _snap_to_grid(world_pos, grid_size)
_free_terrain_ghosts()
_drag_painted.clear()
_dragging = true
_painting = false
_press_world = world_pos
_anchor_cell = world_to_terrain_cell(world_pos, _placement_id)
func update(world_pos: Vector2, placement_id: String) -> void:
if not _dragging:
return
_placement_id = placement_id
if not _painting:
var zoom: float = _camera.zoom.x if _camera != null else 1.0
if (world_pos - _press_world).length() * zoom < DRAG_THRESHOLD_PX:
return
_painting = true
var target: Vector2i = world_to_terrain_cell(world_pos, _placement_id)
if Input.is_key_pressed(KEY_SHIFT):
var delta: Vector2i = target - _anchor_cell
if absi(delta.x) >= absi(delta.y):
target = Vector2i(_anchor_cell.x + delta.x, _anchor_cell.y)
else:
target = Vector2i(_anchor_cell.x, _anchor_cell.y + delta.y)
_drag_cells = bresenham_cells(_anchor_cell, target)
refresh_terrain_ghosts()
if _overlay != null:
if target != _anchor_cell:
_overlay.set_terrain_guide(terrain_cell_center(_anchor_cell, _placement_id), terrain_cell_center(target, _placement_id))
else:
_overlay.clear_terrain_guide()
func commit(placement_id: String, snap_enabled: bool, grid_size: float) -> void:
if not _dragging:
return
_placement_id = placement_id
var placed_count: int = 0
if not _painting:
var click_pos: Vector2 = _snap_to_grid(_press_world, grid_size) if snap_enabled else _press_world
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
if click_node != null:
placement_requested.emit(click_node)
placed_count += 1
for cell: Vector2i in _drag_cells:
if classify_cell(cell, _placement_id) != 1:
continue
var node: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
if node == null:
continue
placement_requested.emit(node)
placed_count += 1
_drag_painted[cell] = true
_reset_stroke()
stroke_finished.emit(placed_count)
func cancel() -> void:
_reset_stroke()
func is_dragging() -> bool:
return _dragging
func is_painting() -> bool:
return _painting
func get_drag_cells() -> Array[Vector2i]:
return _drag_cells
func get_drag_painted_cells() -> Dictionary:
return _drag_painted
func get_ghosts() -> Array[Node2D]:
return _ghosts
func clear_terrain_ghosts() -> void:
_free_terrain_ghosts()
func terrain_stride(placement_id: String) -> Vector2:
var size: Vector2 = _spawner.get_template_aabb(placement_id).size
if size.x <= 0.0001 or size.y <= 0.0001:
return Vector2(STAGE_SPAWNER.TERRAIN_GRID_SIZE, STAGE_SPAWNER.TERRAIN_GRID_SIZE)
return size
func world_to_terrain_cell(pos: Vector2, placement_id: String) -> Vector2i:
var stride: Vector2 = terrain_stride(placement_id)
return Vector2i(int(roundf(pos.x / stride.x)), int(roundf(pos.y / stride.y)))
func terrain_cell_center(cell: Vector2i, placement_id: String) -> Vector2:
var stride: Vector2 = terrain_stride(placement_id)
return Vector2(cell.x * stride.x, cell.y * stride.y)
func block_aabb(cell: Vector2i, placement_id: String) -> Rect2:
var local: Rect2 = _spawner.get_template_aabb(placement_id)
return Rect2(terrain_cell_center(cell, placement_id) + local.position, local.size)
func classify_cell(cell: Vector2i, placement_id: String) -> int:
if _drag_painted.has(cell):
return 2
var aabb: Rect2 = block_aabb(cell, placement_id)
var candidates: Array[Node2D] = _spatial_index.candidates_for_aabb(aabb)
var has_same: bool = false
for node: Node2D in candidates:
if not aabb_overlaps(aabb, STAGE_SELECTION.get_world_aabb(node)):
continue
if node is TERRAIN_BLOCK and (node as TerrainBlock).spawn_id == placement_id:
has_same = true
continue
return 3
return 2 if has_same else 1
static func aabb_overlaps(a: Rect2, b: Rect2) -> bool:
return a.position.x < b.end.x and b.position.x < a.end.x \
and a.position.y < b.end.y and b.position.y < a.end.y
static func bresenham_cells(a: Vector2i, b: Vector2i) -> Array[Vector2i]:
var cells: Array[Vector2i] = []
var x0: int = a.x
var y0: int = a.y
var x1: int = b.x
var y1: int = b.y
var dx: int = absi(x1 - x0)
var dy: int = -absi(y1 - y0)
var sx: int = 1 if x0 < x1 else -1
var sy: int = 1 if y0 < y1 else -1
var error: int = dx + dy
while true:
cells.append(Vector2i(x0, y0))
if x0 == x1 and y0 == y1:
break
var doubled_error: int = 2 * error
if doubled_error >= dy:
error += dy
x0 += sx
if doubled_error <= dx:
error += dx
y0 += sy
return cells
func refresh_terrain_ghosts() -> void:
if _drag_cells == _last_drag_cells:
return
_last_drag_cells = _drag_cells.duplicate()
_free_terrain_ghosts()
if _placement_id.is_empty() or not _spawner.is_terrain_id(_placement_id):
return
for cell: Vector2i in _drag_cells:
var state: int = classify_cell(cell, _placement_id)
var ghost: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
if ghost == null:
continue
if ghost.get_parent() == _world:
_world.remove_child(ghost)
_ghost_holder.add_child(ghost)
_configure_ghost_collision(ghost)
match state:
1:
ghost.modulate = Color(0.3, 1.0, 0.4, 0.5)
2:
ghost.modulate = Color(1.0, 1.0, 1.0, 0.15)
3:
ghost.modulate = Color(1.0, 0.3, 0.3, 0.5)
_ghosts.append(ghost)
func _free_terrain_ghosts() -> void:
for ghost: Node2D in _ghosts:
if ghost != null and is_instance_valid(ghost):
ghost.queue_free()
_ghosts.clear()
func _reset_stroke() -> void:
_dragging = false
_painting = false
_press_world = Vector2.ZERO
_drag_cells.clear()
_last_drag_cells.clear()
_drag_painted.clear()
_free_terrain_ghosts()
if _overlay != null:
_overlay.clear_terrain_guide()
func _configure_ghost_collision(ghost: Node2D) -> void:
if ghost is RigidBody2D:
var body: RigidBody2D = ghost as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.collision_layer = 0
body.collision_mask = 0
elif ghost is StaticBody2D:
var static_body: StaticBody2D = ghost as StaticBody2D
static_body.collision_layer = 0
static_body.collision_mask = 0
elif ghost is STICKMAN_RIG:
var rig: StickmanRig = ghost as StickmanRig
var skeleton: Skeleton2D = rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
if skeleton != null and skeleton.modification_stack != null:
skeleton.modification_stack.enabled = false
var animation_player: AnimationPlayer = rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
if animation_player != null:
animation_player.stop()
rig.set_collision_bodies_enabled(false)
func _snap_to_grid(pos: Vector2, grid_size: float) -> Vector2:
if grid_size <= 0.0:
return pos
return Vector2(roundf(pos.x / grid_size) * grid_size, roundf(pos.y / grid_size) * grid_size)
@@ -0,0 +1 @@
uid://dlaa2syr0pq50
@@ -0,0 +1,167 @@
class_name StageThemeController
extends RefCounted
## Owns the resolved Sandbox Stage theme values and theme resource loading.
const SANDBOX_THEME_SCRIPT := preload("res://scripts/stage/sandbox_theme.gd")
var theme: Dictionary = {}
var sandbox_theme: SandboxTheme
var grid_default: float = 15.0
var accent_edit: Color = Color("#22c6ff")
var accent_direct: Color = Color("#ffb300")
var accent_play: Color = Color("#33dd77")
var guide_line_color: Color = Color("#22c6ff")
var action_popup_font_size: int = 24
var action_popup_emoji_size: int = 22
var tooltip_font_size: int = 18
var status_pill_font_size: int = 16
var ui_font: Font
var emoji_font: Font
var ui_font_bold: Font
var ui_font_italic: Font
var queue_panel_font_size: int = 18
var rule_panel_font_size: int = 18
var action_editor_font_size: int = 18
var rule_editor_font_size: int = 18
var panel_row_font_size: int = 16
var panel_title_font_size: int = 18
var panel_title_bold: bool = true
var rule_label_bold: bool = false
var badge_bold: bool = true
var action_popup_bold: bool = false
var action_popup_italic: bool = false
var font_sizes: Dictionary = {}
func load_theme(source_path: String, minimum_grid_size: float, maximum_grid_size: float) -> void:
sandbox_theme = SANDBOX_THEME_SCRIPT.new() as SandboxTheme
sandbox_theme.load_from(source_path)
theme = {}
_build_font_sizes()
if not FileAccess.file_exists(source_path):
push_warning("SandboxStage: theme file '%s' not found; using defaults." % source_path)
return
var file: FileAccess = FileAccess.open(source_path, FileAccess.READ)
if file == null:
return
var json: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not json is Dictionary:
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
return
theme = sandbox_theme.raw_config()
var fonts_value: Variant = theme.get("fonts", {})
var fonts: Dictionary = fonts_value as Dictionary if fonts_value is Dictionary else {}
action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22))
tooltip_font_size = int(fonts.get("tooltip_font_size", 18))
status_pill_font_size = int(fonts.get("status_pill_font_size", 16))
queue_panel_font_size = int(fonts.get("queue_panel_font_size", 18))
rule_panel_font_size = int(fonts.get("rule_panel_font_size", 18))
action_editor_font_size = int(fonts.get("action_editor_font_size", 18))
rule_editor_font_size = int(fonts.get("rule_editor_font_size", 18))
panel_row_font_size = int(fonts.get("panel_row_font_size", 16))
panel_title_font_size = int(fonts.get("panel_title_font_size", 18))
panel_title_bold = bool(fonts.get("panel_title_bold", true))
rule_label_bold = bool(fonts.get("rule_label_bold", false))
badge_bold = bool(fonts.get("badge_bold", true))
ui_font = _load_font(String(fonts.get("ui_font", "")))
emoji_font = _load_font(String(fonts.get("emoji_font", "")))
ui_font_bold = _resolve_style_font(String(fonts.get("ui_font_bold", "")), ui_font, true)
ui_font_italic = _resolve_style_font(String(fonts.get("ui_font_italic", "")), ui_font, false)
action_popup_bold = false
action_popup_italic = false
var action_popup_value: Variant = fonts.get("action_popup", null)
if action_popup_value is Dictionary:
var action_popup: Dictionary = action_popup_value as Dictionary
if action_popup.has("size"):
action_popup_font_size = int(action_popup["size"])
action_popup_bold = bool(action_popup.get("bold", false))
action_popup_italic = bool(action_popup.get("italic", false))
var grid_value: Variant = theme.get("grid", {})
var grid: Dictionary = grid_value as Dictionary if grid_value is Dictionary else {}
grid_default = clampf(float(grid.get("snap_size", 15.0)), minimum_grid_size, maximum_grid_size)
var colors_value: Variant = theme.get("mode_colors", {})
var colors: Dictionary = colors_value as Dictionary if colors_value is Dictionary else {}
accent_edit = parse_color(String(colors.get("edit_accent", "")), accent_edit)
accent_direct = parse_color(String(colors.get("direct_accent", "")), accent_direct)
accent_play = parse_color(String(colors.get("play_accent", "")), accent_play)
guide_line_color = parse_color(String(colors.get("guide_line", "")), guide_line_color)
if String((json as Dictionary).get("version", "")) == SANDBOX_THEME_SCRIPT.SCHEMA_VERSION:
action_popup_font_size = sandbox_theme.integer("fonts.sizes.action_popup", action_popup_font_size, 8, 96)
action_popup_emoji_size = sandbox_theme.integer("fonts.sizes.action_popup_emoji", action_popup_emoji_size, 8, 96)
tooltip_font_size = sandbox_theme.integer("fonts.sizes.tooltip", tooltip_font_size, 8, 96)
status_pill_font_size = sandbox_theme.integer("fonts.sizes.status_pill", status_pill_font_size, 8, 96)
queue_panel_font_size = sandbox_theme.integer("fonts.sizes.queue_panel", queue_panel_font_size, 8, 96)
rule_panel_font_size = sandbox_theme.integer("fonts.sizes.rule_panel", rule_panel_font_size, 8, 96)
action_editor_font_size = sandbox_theme.integer("fonts.sizes.action_editor", action_editor_font_size, 8, 96)
rule_editor_font_size = sandbox_theme.integer("fonts.sizes.rule_editor", rule_editor_font_size, 8, 96)
panel_row_font_size = sandbox_theme.integer("fonts.sizes.panel_row", panel_row_font_size, 8, 96)
panel_title_font_size = sandbox_theme.integer("fonts.sizes.panel_title", panel_title_font_size, 8, 96)
accent_edit = sandbox_theme.color("mode_colors.edit", accent_edit)
accent_direct = sandbox_theme.color("mode_colors.direct", accent_direct)
accent_play = sandbox_theme.color("mode_colors.play", accent_play)
guide_line_color = sandbox_theme.color("mode_colors.guide", guide_line_color)
grid_default = sandbox_theme.number("grid.snap_size", grid_default, minimum_grid_size, maximum_grid_size)
_build_font_sizes()
func build_font_sizes() -> void:
_build_font_sizes()
func _build_font_sizes() -> void:
font_sizes = {
"queue_panel": queue_panel_font_size,
"rule_panel": rule_panel_font_size,
"action_editor": action_editor_font_size,
"rule_editor": rule_editor_font_size,
"panel_row": panel_row_font_size,
"panel_title": panel_title_font_size,
"panel_title_bold": panel_title_bold,
"bold_font": ui_font_bold,
"italic_font": ui_font_italic,
}
func resolve_style_font(path: String, base: Font, bold: bool) -> Font:
return _resolve_style_font(path, base, bold)
func _resolve_style_font(path: String, base: Font, bold: bool) -> Font:
if not path.is_empty():
var loaded: Font = _load_font(path)
if loaded != null:
return loaded
if base == null or not bold:
return base
var variation: FontVariation = FontVariation.new()
variation.base_font = base
variation.variation_embolden = 0.9
return variation
static func parse_color(html: String, fallback: Color) -> Color:
var value: String = html.strip_edges()
if value.begins_with("#"):
value = value.substr(1)
if value.is_empty():
return fallback
return Color.from_string(value, fallback)
func load_font(path: String) -> Font:
return _load_font(path)
func _load_font(path: String) -> Font:
if path.is_empty():
return null
if not ResourceLoader.exists(path):
push_warning("SandboxStage: theme font '%s' not found; using fallback." % path)
return null
var resource: Resource = ResourceLoader.load(path)
if resource is Font:
return resource as Font
push_warning("SandboxStage: theme font '%s' is not a Font; using fallback." % path)
return null
@@ -0,0 +1 @@
uid://bj5hbnq564akd
+242
View File
@@ -0,0 +1,242 @@
class_name StageRuleEngine
extends RefCounted
## Runtime event matching, area-entry edges, and collision contact bookkeeping.
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STICKMAN_RIG_SCRIPT := preload("res://scripts/characters/stickman/stickman_rig.gd")
const WAYPOINT_MATCH_EPSILON: float = 24.0
var _area_overlap: Dictionary = {}
var _area_previous_bounds: Dictionary = {}
var _collision_pairs: Dictionary = {}
var _suppressed_contact_pairs: Dictionary = {}
func handle_event(event: Dictionary, rules: Array[Dictionary]) -> Array[Dictionary]:
var matched_rules: Array[Dictionary] = []
for rule: Dictionary in rules:
if matches_rule(rule, event):
matched_rules.append(rule)
return matched_rules
func matches_rule(rule: Dictionary, event: Dictionary) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
var rule_type: String = String(trigger.get("type", ""))
if rule_type != String(event.get("type", "")):
return false
var source: Node2D = event.get("source") as Node2D
var target: Node2D = event.get("target") as Node2D
var source_id: int = int(trigger.get("source", -1))
var target_id: int = int(trigger.get("target", -1))
match rule_type:
"arrived_at_waypoint":
if not ids_match(source_id, source):
return false
var params: Dictionary = trigger.get("params", {})
var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF)
var position: Vector2 = event.get("position", Vector2.INF)
return position.distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON
"action_finished":
if not ids_match(source_id, source):
return false
var params: Dictionary = trigger.get("params", {})
var wanted_type: String = String(params.get("action_type", ""))
var action: Dictionary = event.get("action", {})
return wanted_type.is_empty() or ACTION_REGISTRY.canonical_type(wanted_type) == ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
"speech_finished":
return ids_match(source_id, source)
"entered_area", "collided", "prop_picked_up", "prop_dropped", "prop_thrown":
return ids_match(source_id, source) and ids_match(target_id, target)
_:
return false
func ids_match(expected_id: int, node: Node2D) -> bool:
return expected_id == -1 or (node != null and is_instance_valid(node) and node.get_instance_id() == expected_id)
func physics_tick(world_children: Array[Node2D]) -> Array[Dictionary]:
var areas: Array[TriggerArea] = []
for node: Node2D in world_children:
if node is TriggerArea:
areas.append(node as TriggerArea)
if areas.is_empty():
return []
var movables: Array[Node2D] = []
for node: Node2D in world_children:
if node is STICKMAN_RIG_SCRIPT:
var rig: StickmanRig = node as StickmanRig
if rig.state == StickmanRig.RigState.ANIMATED:
movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
movables.append(node)
var swept_bounds: Dictionary = {}
var live_ids: Dictionary = {}
for node: Node2D in movables:
var node_id: int = node.get_instance_id()
var current_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
var swept: Rect2 = current_bounds
if _area_previous_bounds.has(node_id):
swept = swept.merge((_area_previous_bounds[node_id] as Rect2).abs())
swept_bounds[node_id] = swept
_area_previous_bounds[node_id] = current_bounds
live_ids[node_id] = true
for old_id: int in _area_previous_bounds.keys().duplicate():
if not live_ids.has(old_id):
_area_previous_bounds.erase(old_id)
var events: Array[Dictionary] = []
for area: TriggerArea in areas:
var area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables:
var key: String = "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var swept: Rect2 = swept_bounds.get(node.get_instance_id(), Rect2()) as Rect2
var inside: bool = swept.intersects(area_rect, true)
var was_inside: bool = bool(_area_overlap.get(key, false))
if inside and not was_inside:
_area_overlap[key] = true
events.append({"type": "entered_area", "source": node, "target": area, "position": movable_point(node), "action": {}})
elif not inside and was_inside:
_area_overlap[key] = false
return events
func refresh_event_edge_baseline(world_children: Array[Node2D]) -> void:
var areas: Array[TriggerArea] = []
for node: Node2D in world_children:
if node is TriggerArea:
areas.append(node as TriggerArea)
var movables: Array[Node2D] = []
for node: Node2D in world_children:
if node is STICKMAN_RIG_SCRIPT and (node as StickmanRig).state == StickmanRig.RigState.ANIMATED:
movables.append(node)
elif node is PropBlock:
movables.append(node)
_area_overlap.clear()
_area_previous_bounds.clear()
for node: Node2D in movables:
var node_id: int = node.get_instance_id()
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
_area_previous_bounds[node_id] = bounds
for area: TriggerArea in areas:
var key: String = "%d:%d" % [area.get_instance_id(), node_id]
var area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
_area_overlap[key] = bounds.intersects(area_rect, true)
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
for node: Node2D in world_children:
if not node is PropBlock:
continue
var prop: PropBlock = node as PropBlock
for other: Node in prop.get_colliding_bodies():
if other == null or not is_instance_valid(other):
continue
_suppressed_contact_pairs[contact_key(prop, other)] = true
if not other.has_meta("stickman_rig"):
continue
var rig: StickmanRig = other.get_meta("stickman_rig") as StickmanRig
if rig == null or not is_instance_valid(rig):
continue
var key: String = collision_key(rig, prop)
var pair: Dictionary = _collision_pairs.get(key, {"rig_id": rig.get_instance_id(), "prop_id": prop.get_instance_id(), "bodies": {}})
var body_ids: Dictionary = pair.get("bodies", {})
body_ids[other.get_instance_id()] = true
pair["bodies"] = body_ids
_collision_pairs[key] = pair
func register_contact(prop: PropBlock, other: Node) -> bool:
var key: String = contact_key(prop, other)
if _suppressed_contact_pairs.has(key):
return false
_suppressed_contact_pairs[key] = true
return true
func register_rig_body_contact(rig: StickmanRig, prop: PropBlock, body: Node) -> Dictionary:
var key: String = collision_key(rig, prop)
var pair: Dictionary = _collision_pairs.get(key, {
"rig_id": rig.get_instance_id(),
"prop_id": prop.get_instance_id(),
"bodies": {},
})
var bodies: Dictionary = pair.get("bodies", {})
var body_id: int = body.get_instance_id()
if bodies.has(body_id):
return {"accepted": false, "first_contact": false}
var first_contact: bool = bodies.is_empty()
bodies[body_id] = true
pair["bodies"] = bodies
_collision_pairs[key] = pair
return {"accepted": true, "first_contact": first_contact}
func unregister_contact(prop: PropBlock, other: Node) -> void:
_suppressed_contact_pairs.erase(contact_key(prop, other))
func unregister_rig_body_contact(rig: StickmanRig, prop: PropBlock, body: Node) -> void:
var key: String = collision_key(rig, prop)
var pair: Dictionary = _collision_pairs.get(key, {})
if pair.is_empty():
return
var bodies: Dictionary = pair.get("bodies", {})
bodies.erase(body.get_instance_id())
if bodies.is_empty():
_collision_pairs.erase(key)
else:
pair["bodies"] = bodies
_collision_pairs[key] = pair
func purge_collision_pairs_for_ids(ids: Array[int]) -> void:
for key: String in _collision_pairs.keys().duplicate():
var pair: Dictionary = _collision_pairs[key]
if ids.has(int(pair.get("rig_id", -1))) or ids.has(int(pair.get("prop_id", -1))):
_collision_pairs.erase(key)
func reset_runtime_state() -> void:
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
func movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG_SCRIPT:
return node.global_position - StickmanLocomotion.FOOT_OFFSET
return node.global_position
func movable_overlaps_area(node: Node2D, area_rect: Rect2) -> bool:
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
if bounds.size.is_zero_approx():
return area_rect.has_point(movable_point(node))
return area_rect.intersects(bounds, true)
func get_area_overlap() -> Dictionary:
return _area_overlap
func get_area_previous_bounds() -> Dictionary:
return _area_previous_bounds
func get_collision_pairs() -> Dictionary:
return _collision_pairs
func get_suppressed_contact_pairs() -> Dictionary:
return _suppressed_contact_pairs
func contact_key(prop: PropBlock, other: Node) -> String:
return "%d:%d" % [prop.get_instance_id(), other.get_instance_id()]
func collision_key(rig: StickmanRig, prop: PropBlock) -> String:
return "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
+1
View File
@@ -0,0 +1 @@
uid://cbdfvuscs7b47
+82
View File
@@ -0,0 +1,82 @@
class_name StageSpatialIndex
extends RefCounted
## Advisory spatial index for broadphase stage lookups.
##
## This index never decides collision or selection. It keeps the existing
## 16-pixel AABB rasterization, including its treatment of edge cells.
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
var _cells: Dictionary = {}
func rebuild(nodes: Array[Node2D]) -> void:
clear()
for node: Node2D in nodes:
add(node)
func add(node: Node2D) -> void:
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
return
var aabb: Rect2 = STAGE_SELECTION.get_world_aabb(node)
for cell: Vector2i in rasterize_aabb_to_cells(aabb):
var cell_nodes: Array = _cells.get(cell, [])
if not cell_nodes.has(node):
cell_nodes.append(node)
_cells[cell] = cell_nodes
func clear() -> void:
_cells.clear()
func candidates_for_aabb(aabb: Rect2) -> Array[Node2D]:
var candidates: Array[Node2D] = []
var seen: Dictionary = {}
for cell: Vector2i in rasterize_aabb_to_cells(aabb):
for value: Variant in _cells.get(cell, []):
var node: Node2D = value as Node2D
if node == null or not is_instance_valid(node):
continue
var instance_id: int = node.get_instance_id()
if seen.has(instance_id):
continue
seen[instance_id] = true
candidates.append(node)
return candidates
func candidates_at(world_pos: Vector2) -> Array[Node2D]:
var candidates: Array[Node2D] = []
var cell: Vector2i = world_to_cell(world_pos)
for value: Variant in _cells.get(cell, []):
var node: Node2D = value as Node2D
if node != null and is_instance_valid(node):
candidates.append(node)
return candidates
func get_cells() -> Dictionary:
return _cells
static func world_to_cell(pos: Vector2) -> Vector2i:
var cell_size: float = STAGE_SPAWNER.TERRAIN_GRID_SIZE
return Vector2i(int(roundf(pos.x / cell_size)), int(roundf(pos.y / cell_size)))
static func rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]:
var cells: Array[Vector2i] = []
if aabb.size.x <= 0.0 or aabb.size.y <= 0.0:
return cells
var cell_size: float = STAGE_SPAWNER.TERRAIN_GRID_SIZE
var min_x: int = int(floorf(aabb.position.x / cell_size))
var max_x: int = int(floorf((aabb.end.x - 0.0001) / cell_size))
var min_y: int = int(floorf(aabb.position.y / cell_size))
var max_y: int = int(floorf((aabb.end.y - 0.0001) / cell_size))
for y: int in range(min_y, max_y + 1):
for x: int in range(min_x, max_x + 1):
cells.append(Vector2i(x, y))
return cells
+1
View File
@@ -0,0 +1 @@
uid://b6wxhc8fh5o15