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
@@ -76,6 +76,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 64 | **Legacy scene draw order can interleave props and characters** — the new two-choice depth control cannot exactly encode an older prop between two characters. | Low | Planned | Preserve `World` order with a persisted `legacy_draw_order` marker and z-index zero until the child explicitly picks Behind or In front. Show an Old scene order cue in the card; selecting a depth applies that choice relative to every character. See `docs/specs/phase_17a_prop_roles.md`. (2026-10-05) |
| 65 | **Large custom prop switches need state parity with native buttons** — a drawn 64 × 34 px switch inside a full-row `Button` replaces the stock small `CheckButton` icon. | Low | Planned | The proposed `PropSettingsToggleRow` should keep native focus, keyboard activation, disabled state, and one `toggled` emission per press; its child labels must ignore pointer input. A compact depth cue lives inside each choice button, while the stage remains the live preview. See `docs/specs/prop_settings_card_refinement.md`. (2026-10-09) |
| 66 | **Stage selection panels duplicate layout and visual rules** — the prop card owns its own shell sizing, choice styles, and `stage_prop_settings` tokens, making later inspector cards prone to drift. | Low | Planned | Promote the current prop card to a small shared Stage inspector shell, choice group, and toggle row with `stage_panels` theme tokens. Keep domain handlers in `SandboxStage`; preserve the existing prop role schema. During migration, resolve old prop tokens as fallback only temporarily and remove duplicate reads once the prop card uses the shared controls. See `docs/specs/stage_panel_ui_system.md`. (2026-10-09) |
| 67 | **SandboxStage controller is too large for comfortable Godot IDE editing** — stage UI, terrain painting, document workflow, runtime rules, and speech coordination shared one roughly 7,600-line script. | High | In Progress | Approved slices 1–16 extracted reusable stage components and reduced `sandbox_stage.gd` to 5,009 lines (about 34%); the below-3,800-line target remains unmet by 1,209 lines. Root input/selection, authored queue/rule writes, recovery application, and save/commit ordering remain coordinated there. Root `--check-only` parse passes; behavior and in-editor responsiveness checks are pending. See `docs/specs/sandbox_stage_composition_refactor.md`. (2026-10-10) |
---
@@ -157,5 +158,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-10-05 | Phase 17a prop roles and Set Down architecture specification written for final user sign-off (`docs/specs/phase_17a_prop_roles.md`). Retain `PropBlock` as a stable `RigidBody2D` with authored-start/held/released profiles; make Set Down a world release target with terrain-grounded reach and physics settling; use explicit contact-pair policy and a grounded walking sweep. Legacy scenes migrate with dynamic prop behavior and a saved draw-order marker. Logged #62–#64. No implementation code changed. |
| 2026-10-09 | Proposed Prop settings card refinement written for user approval (`docs/specs/prop_settings_card_refinement.md`): full-width 64 × 34 px switches, explicit selected choice styling, plain-language copy, nearby help, compact scrollable card, and small front/behind cues. Logged #65 for custom control state parity. No implementation code changed. |
| 2026-10-09 | Proposed Stage selection panel UI system drafted for user approval (`docs/specs/stage_panel_ui_system.md`). The current prop card is the reference; a shared shell, choice group, toggle row, and theme tokens support later Stage inspectors. Logged #66 for duplicated panel presentation. No implementation code changed. |
| 2026-10-10 | Sandbox Stage composition refactor specified for developer handoff (`docs/specs/sandbox_stage_composition_refactor.md`). Defines state ownership, explicit component APIs, compatibility facades, and an ordered migration beginning with spatial indexing and terrain painting. Logged #67; implementation followed in the entries below. |
| 2026-10-10 | Sandbox Stage composition refactor slices 1–16 implemented (`docs/specs/sandbox_stage_composition_refactor.md`): extracted spatial indexing, terrain painting, stage chrome and views, document/scene adapters, run/director/rule/action/speech sessions, asset browsing, theme loading, and target capture. `sandbox_stage.gd` is 5,009 lines, about 34% smaller than the approximate 7,600-line baseline; the below-3,800-line target remains open. Godot 4.7.1 root `--check-only` parse passes. No behavior tests or in-editor checks were run; #67 remains In Progress. |
_End of Log_
@@ -0,0 +1,141 @@
# Sandbox Stage composition refactor
## Status and goal
Architecture specification for a behavior-preserving refactor of `scripts/editors/stage/sandbox_stage.gd`. The script is roughly 7,600 physical lines and combines scene orchestration with terrain painting, stage UI construction, document workflow, rule dispatch, and speech editing. The goal is to make the Godot script editor practical to use by moving coherent state and behavior into smaller scripts. This is an internal refactor: the stage's visible behavior, `.scene` format, and launch path stay the same.
**Implementation status (2026-10-10):** Approved extraction slices 1–16 are implemented. The root is 5,009 lines, down from the approximate 7,600-line baseline (about 34%); the below-3,800-line target remains unmet by 1,209 lines. A focused Godot 4.7.1 `--check-only` parse of the root passes and loads its component scripts. Behavior and in-editor responsiveness checks remain pending. Further reduction would require revisiting the coordinator boundary for world input/selection, scene recovery application, or save and commit transactions.
Implement this in the ordered slices below. Each slice must leave the stage usable and independently reviewable. Do not move methods merely to reduce line count: move the state they own, and remove the old implementation after the replacement is wired.
## Existing boundaries and constraints
- `SandboxStage` is the `Node2D` root of `scenes/stage/sandbox_stage.tscn`; its `Camera2D` and `World` children are stable. Keep `SandboxStage` as the scene script and public stage facade.
- Existing helpers already own spawning, selection, gizmos, grid drawing, director visuals, placement overlay, action/trigger registries, and JSON schema conversion. Extend these boundaries rather than duplicating their logic.
- `StageSceneIO` remains the `.scene` schema authority. Do not change `FILE_VERSION`, serialized fields, migration, or validation as part of this refactor. Route persisted paths through `ContentPaths.canonicalize` as today.
- The shared Stage inspector controls and `stage_panels` theme tokens remain the basis for selected-object cards. The prop settings presentation may move, but its role writes, dirty marking, and contact reconciliation remain coordinated by the stage.
- Current tests and tools call some underscored stage methods and inspect fields. Preserve compatibility through small forwarding methods or references during this refactor; remove a facade only in a separately approved API cleanup.
- The working tree may contain unrelated edits to `sandbox_stage.gd`. Preserve them. Do not use a wholesale file replacement or revert.
## Composition rules
1. The root owns `current_mode`, run-state transitions, the `World` and camera lifecycle, input priority, authored object state, selection, and the final decision to mutate the scene. `_ready`, `_process`, `_physics_process`, `_input`, and `_unhandled_input` should become dispatchers with explicit component calls.
2. A component owns its private state and exposes a small typed API. Give it references it actually needs in `configure(...)` or construction. It must not discover the stage with `get_parent()` or call arbitrary stage methods through a stored `SandboxStage` reference.
3. Components report intents or results with signals or typed return values. The root handles cross-domain effects such as `_finalize_placed_node`, `_mark_scene_dirty`, navigation invalidation, queue/rule commits, and mode changes. Avoid generic `Callable` bags and duplicated writable state.
4. Use `RefCounted` for data and calculation services without a tree lifecycle; use `Node` or `Control` only for components that need `_process`, input, children, popups, or rendering. The root creates and owns tree components in `_ready` or through a small scene child. Keep the dependency direction root -> component -> existing lower-level helpers. Avoid circular script preloads and new autoloads.
5. One owner per fact. When the root needs a state for input priority, ask the component (for example, `is_dragging()` or `is_open()`) instead of mirroring a boolean. Keep compatibility accessors only for concrete existing callers; if a caller currently mutates an exposed value (the terrain tests seed `_drag_painted` directly), temporarily expose the component-owned value rather than keeping a second copy, and record that exception for later API cleanup.
6. Scene input order is a contract. Speech UI and stage bubble input have first refusal, then palette dragging, camera gestures, placement/director capture, and world selection. A component's `handle_input(event) -> bool` reports consumption; the root alone calls `get_viewport().set_input_as_handled()` at the same points as today.
## Component boundaries
| Component and location | Owns | Root-facing contract | Explicitly outside its ownership |
| --- | --- | --- | --- |
| `StageSpatialIndex`, `scripts/stage/stage_spatial_index.gd` (`RefCounted`) | Advisory 16 px cell dictionary, AABB rasterization, rebuild/add/clear, candidate lookup | `rebuild(nodes)`, `add(node)`, `clear()`, `candidates_for_aabb(aabb)`, `candidates_at(world_pos)` | Collision authority, terrain painting, selection decisions |
| `StageTerrainPainter`, `scripts/editors/stage/stage_terrain_painter.gd` (`Node`) | Terrain drag start/threshold, cell path, overlap classification, painted-cell suppression, multiple ghosts and guide | `configure(spawner, world, camera, ghost_holder, overlay, spatial_index)`, `begin(...)`, `update(...)`, `commit(...)`, `cancel()`, `is_dragging()`; emit `placement_requested(node)` for each created block and `stroke_finished(placed_count)` once | Authored state, dirty flag, navigation rebuild, `object_placed` stage signal, single cursor ghost |
| `StageChrome`, `scripts/editors/stage/stage_chrome.gd` (`CanvasLayer`) | Top/status bars, mode controls, menus/dialog presentation, cursor/status visuals, theme application and layout | `configure(theme, initial_state)`, `set_mode(...)`, `set_status(...)`, `set_scene_title(...)`, `set_run_state(...)`; typed signals for user requests. Root may retain references to old controls through forwarding accessors during migration | Stage mode transitions, save/load decisions, world mutation |
| `StagePropSettingsView`, `scripts/editors/stage/stage_prop_settings_view.gd` (`Control`) | Selected prop card construction, layout, choice/toggle presentation and dismissal UI | `show_prop(prop, roles, safe_rect)`, `hide_card()`, `set_dismissed(...)`; signals `role_choice_requested`, `role_toggle_requested`, `close_requested` | Writing `PropBlock` roles, contact reconciliation, dirty flag. Use existing `StageInspectorPanel`, `StagePanelChoiceGroup`, `StagePanelToggleRow`, and theme tokens |
| `StageDocumentController`, `scripts/editors/stage/stage_document_controller.gd` (`RefCounted` or `Node` if timer needed) | Current path/name/created time, dirty/recent list, autosave path and recovery bookkeeping, file read/write and CLI file request coordination | `save(path, snapshot, autosave) -> bool`, `read(path) -> Dictionary`, `mark_dirty()`, `accept_loaded_document(...)`, `recent_paths()`; signals for errors/recovery prompts | Building or applying world snapshots, `StageSceneIO` schema implementation, speech save gate decisions |
| `StageSceneAdapter`, `scripts/editors/stage/stage_scene_adapter.gd` (`RefCounted`) | Mapping live objects and authored transforms to a scene dictionary; reconstructing objects and runtime IDs from a validated scene dictionary | `collect(world, authored, rules, camera_state, document_metadata) -> Dictionary`; `apply(data, world, spawner) -> Dictionary` with loaded objects, ID map, warnings and missing paths | Opening files, recent list, UI dialogs, mode switching, dirty state. Stage performs post-load signal wiring/finalization and rule/view refresh |
| `StageRuleEngine`, `scripts/stage/stage_rule_engine.gd` (`RefCounted` or `Node`) | Runtime event matching, area entry edges, collision pair suppression and reset/baseline bookkeeping | `handle_event(event, rules)`, `physics_tick(rules, ...)`, `reset_runtime_state()`; signal `rule_triggered(rule)` | Rule-builder UI, authored rule storage/edits, action queue mutation. Root executes matched consequences |
| `StageSpeechSession`, `scripts/editors/stage/stage_speech_session.gd` (`Node`) | Active composer/preview/toolbar session, bubble pointer routing, temporary popups | `open(actor, initial, context)`, `handle_input(event) -> bool`, `update(delta)`, `close(...)`, `is_open()`, `is_save_blocked()`; signals for commit/cancel, target capture and save-gate resolution | Draft file persistence and recovery identity, final queue/rule write, scene save, generic camera controls, mode transition |
| `StageSpeechDraftStore`, `scripts/editors/stage/stage_speech_draft_store.gd` (`RefCounted`) | Speech draft debounce and atomic file persistence, serialized payload conversion, canonical recovery origin and actor/queue identity validation | `configure(world, composer)`, `arm_draft_save()`, `update(delta)`, `capture_origin(...)`, `validate_origin(...)`, `read_draft_document()`, `persist_draft_now()`, `clear_draft_file()` | Preview/input routing, final queue/rule write, scene save decision, mode transition |
These contracts describe ownership, not a demand for one giant constructor. Small parameter objects are acceptable where they make call sites clearer, provided state still has one owner. The developer may split `StageChrome` and `StageSpeechSession` further if either remains unwieldy; keep each split aligned with a distinct state lifecycle.
## Ordered implementation slices
### 1. Spatial index and terrain painter
Extract `_world_to_grid_cell`, `_rasterize_aabb_to_cells`, `_rebuild_grid_cells`, `_add_node_to_grid_cells`, and all reads of `_grid_cells` into `StageSpatialIndex`. The index is advisory only. Its queries must return the same candidates and use the current edge-cell treatment. Director target validity currently reads this index, so route that read through its API before moving the painter.
Extract `_terrain_stride` through `_end_terrain_drag`, including `_bresenham_cells`, `_classify_cell`, and the multiple-ghost state, into `StageTerrainPainter`. Preserve the current single-click behavior, drag threshold in screen pixels, Shift axis constraint, three classification states, strict interior overlap, snap behavior, guide/ghost visuals, and duplicate-cell suppression. The stage receives each created node, finalizes it once, emits its existing `object_placed` signal once, then marks dirty/rebuilds navigation/index once per completed nonempty stroke. Cancellation must free ghosts and guide and rearm the single cursor ghost exactly as today. Keep old stage method names as small forwarding methods where callers need them.
### 2. Stage presentation
Move `_build_ui` and visual chrome update/style methods into `StageChrome` in sections, while the root connects its signals to existing handlers. Move the prop settings card into `StagePropSettingsView` using the shared Stage panel system. Keep the present control order, focus, popup anchoring, sizing, theme values, and mode-specific visibility. The root remains the only caller that changes modes or roles. Retain explicit references or forwarding accessors for tools that inspect `_file_menu`, `_selector_dim`, `_palette_buttons`, or other existing controls until those callers are migrated separately.
### 3. Document workflow and scene adapter
Separate the current `_collect_scene_data` / `_apply_scene_data` mapping from file workflow. `StageSceneAdapter` must preserve missing stickman/prop placeholders, stable file/runtime ID mapping, authored transforms, prop roles/material/depth, queues/rules, camera, warning messages, and post-load hooks. The root controls the reset/apply transaction: validate before destructive reset, suppress dirty during reconstruction, then rebuild the index/navigation and refresh visuals once. `StageDocumentController` owns file paths, recents, autosave/recovery metadata, safe writes via `StageSceneIO`, and CLI requests. Keep the existing speech-save gate before normal scene writes. Keep old `SandboxStage` save/load/collect/apply methods as facades for callers.
### 4. Runtime rule engine
Move event matching, edge tracking, and contact pair cleanup into `StageRuleEngine`. The root supplies its current authored rules to each dispatch rather than giving the engine a second mutable rule list; this also keeps existing callers that assign `_event_rules` directly working. The root controls whether PLAY dispatch is enabled. Reset all runtime pairs and area baselines on the same mode transitions as now, including pause/resume behavior. Emit a matched rule in stored order; the root keeps `_execute_rule_actions` and its existing queue semantics. Preserve rule IDs, source/target matching, waypoint tolerance, visible-AABB area detection, and one event per contact/entry until separation.
### 5. Speech session
Move speech state and handlers in two steps: first draft/recovery/origin logic, then preview/toolbar/pointer routing. `SpeechComposer` remains the authority for draft payloads; `StageSpeechSession` coordinates the stage-side session and preview without writing an authored action. Root handlers for `committed` apply one action to the existing direct/queue/rule destination. Preserve input suppression while composing, camera navigation where currently allowed, bubble overlap picker, placement, token selection, toolbar focus, draft recovery, the explicit Done/Cancel boundary, and deferred scene save. The root calls session input before ordinary stage input, preserving Escape and pointer priority.
### 6. Final root cleanup
Remove obsolete duplicate state and helpers after all callers use component APIs. Keep `SandboxStage` readable as a coordinator: lifecycle, top-level input dispatch, mode transitions, object finalization, cross-domain event handlers, and compatibility facades. Aim to reduce the root by more than half without creating another monolithic script; review any new component approaching roughly 1,000 lines for a natural second boundary. Do not change the root scene path or class name.
### 7. Director and authoring workflow consolidation (approved continuation)
The first extraction pass leaves the root at roughly 6,200 lines because DIRECT actions, target-capture presentation, queue/action editing, and authored rule-building still live together. Continue the refactor with these bounded components:
| Component and location | Owns | Root-facing contract | Explicitly outside its ownership |
| --- | --- | --- | --- |
| `StageDirectorController`, `scripts/editors/stage/stage_director_controller.gd` (`Node`) | Direct-action popups and context menus, direct-move draft/style state, director hint text and overlay presentation inputs | `configure(...)`, `build_ui(...)`, `handle_direct_click(world_pos)`, `update_presentation(...)`; signals for queue-action, speech, capture, panel, toast, and status requests | Queue mutation, rule mutation, scene dirty state, mode transitions, world hit-testing priority |
| `StageRuleAuthoringController`, `scripts/editors/stage/stage_rule_authoring_controller.gd` (`RefCounted`) | Rule-builder draft/step state, trigger/action chooser presentation, RulePanel and RuleEditor presentation and local filters; target-result and parameter callbacks that update its draft | Typed begin/click/edit APIs that accept current rules or identifiers; emit explicit capture/editor requests; expose read-only state needed by director hints | Persistent `_event_rules` storage, rule IDs, world hit-testing, save gate, rule execution, authored rule commit/reorder/delete/clear, dirty marking |
| `StageActionQueueController`, `scripts/editors/stage/stage_action_queue_controller.gd` (`RefCounted`) | QueuePanel and ActionEditor presentation, action-editor context, reorder/edit workflow state, action parameter UI, queue delete/clear confirmation prompts, and action target request details | Typed `open_queue`, `open_editor`, and result APIs; signals for confirmation presentation, action commit, target capture, speech composition, and panel requests | `StickmanRig` queue writes, authored-world state, target hit-testing/capture ownership, scene save and dirty decisions |
The components may be split further by lifecycle if one approaches 1,000 lines. The root remains the sole owner of the authored rule array and action-queue writes. Components may return proposed values or emit typed requests; they must not retain a copied mutable rules/queue collection. Pass the current rules to a method when a view needs them. Keep stage input ordering in `SandboxStage`: it decides which workflow receives a world click, translates the event position, and applies the resulting request. Preserve current test-facing underscored methods and state through forwarding facades while callers are migrated.
The continuation target is a root below roughly half of the original ~7,600-line script, without transferring world mutation, mode ownership, or input priority to a component. This addendum authorizes the additional component files and the focused root wiring needed to reach that boundary; it does not authorize behavior or serialization changes.
### 8. Run lifecycle and authored snapshot (approved continuation)
`StageRunSession`, `scripts/editors/stage/stage_run_session.gd` (`RefCounted`), owns the mutable run-state fields, paused rigid-body snapshots, authored transform/role snapshots, deferred restore count, prop-scale trace history, paused-edit autosave countdown, and editable rigid-body transform targets. It receives the current selectable world nodes and `StageRuleEngine` through typed method arguments; it does not retain a stage or world reference. The root keeps mode transition ordering, timestamp/run-control presentation, rule-edge baseline coordination, contact reconciliation, signals, dirty marking, navigation work, and final scene reset ordering. Existing underscored state fields and authored-state methods stay as forwarding facades for current callers. The component may apply the run lifecycle operations directly to the supplied nodes, while the root remains responsible for cross-domain effects and the final transition decision.
This continuation narrows the original composition rule that listed authored object state as root-owned: the authored snapshot data now has one owner in `StageRunSession`, while `SandboxStage` remains its compatibility facade and the owner of the mode/world transaction. No scene fields, run timing, input order, or simulation semantics change.
### 9. Asset selector and thumbnail queue (approved continuation)
`StageAssetBrowser`, `scripts/editors/stage/stage_asset_browser.gd` (`Node`), owns stickman/prop library scans, selector open/refresh coordination, browse entry validation and dialog presentation, selector generation state, thumbnail cache/render helpers, and the asynchronous thumbnail queue. It receives the existing UI layer, fonts, and theme through `build_ui(...)`; it has no `SandboxStage` reference. It emits typed asset-selection, load-error, cancellation, and refresh requests. The root remains responsible for choosing the placement asset through `StageSpawner`, changing placement mode, and applying the refresh fallback selection. Existing underscored selector/library/cache fields and thumbnail queue methods remain forwarding facades while callers are migrated.
### 10. Speech draft persistence split (approved continuation)
`StageSpeechDraftStore`, `scripts/editors/stage/stage_speech_draft_store.gd` (`RefCounted`), owns the durable recovery draft lifecycle that previously enlarged `StageSpeechSession`: debounce state, atomic `.tmp`/`.bak` replacement, payload conversion, recovery availability checks, recovered bubble-text preparation, scene path origin, and actor/queue identity validation. It receives only `World` and the active composer in `configure(...)`, and does not retain `SandboxStage`. `StageSpeechSession` keeps typed forwarding methods and owns live composer, preview, pointer, and toolbar state. The root computes the live scene fingerprint, applies recovered editor/session state, and keeps save-gate and final action-commit decisions in `SandboxStage`.
### 11. Modal construction and presentation
`StageModalViews`, `scripts/editors/stage/stage_modal_views.gd` (`RefCounted`), owns creation and shared sizing/theme application for the stage's modal dialogs, authoring popups, and the overlapping-object picker. `SandboxStage` supplies the existing layer/theme/font inputs, keeps its dialog/control references as compatibility facades, and handles the selected object after the view emits it. The component does not hold a `SandboxStage` reference and does not decide save/load, mode, or authored-state actions.
### 12. Theme value loading
`StageThemeController`, `scripts/editors/stage/stage_theme_controller.gd` (`RefCounted`), owns theme file parsing, font resolution, color/font-size configuration, and theme defaults. Root theme fields remain forwarding properties for current callers. `SandboxStage` remains responsible for applying the resolved values to live stage visuals and controls.
### 13. Target capture and document workflow details
`StageTargetCaptureSession`, `scripts/editors/stage/stage_target_capture_session.gd` (`RefCounted`), owns capture kind/callback state and throw/set-down preview state and resolution helpers. `SandboxStage` retains stage input priority, cursor/status presentation, and final callback dispatch; legacy capture fields and method names remain forwarding facades.
`StageDocumentController` also owns recent-menu population/selection bookkeeping, scene-dialog signal wiring for open/save/rename/unsaved requests, and scene-save queue validation. It emits typed workflow requests; the root performs scene apply, rename dirty marking, pending-file-action decisions, confirmation execution, recent settings persistence, and the speech save gate before normal writes. Queue validation observes the supplied world and preserves the existing save error message and order semantics.
### 14. Authoring callback ownership refinement
`StageRuleAuthoringController` handles draft-only callbacks for rule target results, action parameters, and RuleEditor targets. `SandboxStage` still performs world hit-tests, starts/stops capture, and owns all persistent rule array changes, final commits, save-gate decisions, and dirty marking. Existing underscored root callbacks remain forwarding facades.
`StageActionQueueController` connects QueuePanel and ActionEditor UI signals internally, builds queue delete/clear confirmation requests, and resolves action-editor presentation state and target-capture requests. The root presents confirmation through the shared dialog and performs each confirmed queue write. For target capture, the root retains event priority and capture ownership; the controller supplies typed kind/hint/callback requests and receives the final target to update the editor.
`StageSpeechSession` connects the composer, preview, picker, placement/reset, and toolbar signals internally and emits its existing toast requests. The root keeps stage input routing and the final speech commit/save-gate boundary. Redundant private root forwarding callbacks with no project or test callsites may be removed once direct component wiring is verified; keep the root camera bridge, speech-open/save-gate access, cursor integration, final commit/cancel handlers, and any test-facing facades.
### 15. Remove superseded component callback wrappers
After the internal signal connections are in place, remove private root callbacks that only forward speech preview/picker/toolbar/placement signals, prop-card viewport/reopen signals, or ActionEditor target callbacks into their owning components. `StageSpeechSession` owns its UI signal routing; `StagePropSettingsView` owns reopen and viewport layout; and `StageActionQueueController` owns ActionEditor target and speech request handling. `SandboxStage` retains generic capture initiation/result handling, camera input routing, live speech cursor and save-gate checks, final speech commit/cancel decisions, prop role writes, and test-facing queue/rule facades. Remove only handlers with no connection or caller after checking project and test sources.
### 16. Move remaining view and recovery preparation
`StageModalViews` now builds the overlapping-object picker and emits the selected `Node2D`; the root validates that it still belongs to `World` before changing selection. `StageAssetBrowser` owns selector scanning/open/refresh setup and browse-file entry validation, while root placement-asset choice and fallback selection remain stage decisions. `StageSpeechDraftStore` computes whether a recovered draft is valid for the supplied scene identity and prepares empty recovered bubble text from a saved queue action. The root still computes the current scene fingerprint, applies recovered editor state, and decides when a matching draft is cleared.
## Acceptance criteria
- Stage opens through `scenes/stage/sandbox_stage.tscn`, and CLI scene playback still loads/plays a `.scene` with the same errors for missing paths.
- EDIT placement, terrain click/drag, selection, prop settings, DIRECT queues/rules/captures, PLAY/pause/resume/reset, and speech composition behave as before. Input and cancellation priorities are unchanged.
- Saving and loading an existing scene yields the same schema, canonical paths, authored transforms, object order, placeholders, action queues, rules, metadata, and recovery behavior. No save migration is introduced.
- Each extracted component has one documented owner for its mutable state. The root does not keep a second mutable copy. Component-to-root communication uses typed APIs/signals, with no parent traversal or circular dependency.
- Existing stage-facing methods and fields used by current callers continue to work through facades during this refactor. Godot parses every changed script after each slice. The developer should perform focused manual stage checks for the slice before moving on; the final in-editor responsiveness must be judged in the Godot IDE, since splitting source files alone does not guarantee a specific performance gain.
## Handoff notes
Slices 1–16 have been delivered. The root retains world input priority, hit-testing and selection decisions, authored rule/queue writes, scene recovery application, and save/commit decisions to preserve ordering and existing stage-facing APIs. The original size target has not been reached; a further pass should first agree on whether any of those coordinator boundaries or compatibility facades may move. This specification authorizes architectural refactoring only; defer visual redesign, new features, rule semantics changes, and serialization changes.
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