From 373f40cf75dd39225407424c540ada7fe2ef0ad0 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Sat, 10 Oct 2026 12:13:01 -0400 Subject: [PATCH] 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. --- .../tech_debt_and_optimizations.md | 3 + .../sandbox_stage_composition_refactor.md | 141 + scripts/editors/stage/sandbox_stage.gd | 5877 +++++------------ .../stage/stage_action_queue_controller.gd | 334 + .../stage_action_queue_controller.gd.uid | 1 + scripts/editors/stage/stage_asset_browser.gd | 223 + .../editors/stage/stage_asset_browser.gd.uid | 1 + scripts/editors/stage/stage_chrome.gd | 709 ++ scripts/editors/stage/stage_chrome.gd.uid | 1 + .../stage/stage_director_controller.gd | 497 ++ .../stage/stage_director_controller.gd.uid | 1 + .../stage/stage_document_controller.gd | 250 + .../stage/stage_document_controller.gd.uid | 1 + scripts/editors/stage/stage_modal_views.gd | 343 + .../editors/stage/stage_modal_views.gd.uid | 1 + .../editors/stage/stage_prop_settings_view.gd | 395 ++ .../stage/stage_prop_settings_view.gd.uid | 1 + .../stage/stage_rule_authoring_controller.gd | 686 ++ .../stage_rule_authoring_controller.gd.uid | 1 + scripts/editors/stage/stage_run_session.gd | 357 + .../editors/stage/stage_run_session.gd.uid | 1 + scripts/editors/stage/stage_scene_adapter.gd | 233 + .../editors/stage/stage_scene_adapter.gd.uid | 1 + .../editors/stage/stage_speech_draft_store.gd | 330 + .../stage/stage_speech_draft_store.gd.uid | 1 + scripts/editors/stage/stage_speech_session.gd | 1037 +++ .../editors/stage/stage_speech_session.gd.uid | 1 + .../stage/stage_target_capture_session.gd | 129 + .../stage/stage_target_capture_session.gd.uid | 1 + .../editors/stage/stage_terrain_painter.gd | 278 + .../stage/stage_terrain_painter.gd.uid | 1 + .../editors/stage/stage_theme_controller.gd | 167 + .../stage/stage_theme_controller.gd.uid | 1 + scripts/stage/stage_rule_engine.gd | 242 + scripts/stage/stage_rule_engine.gd.uid | 1 + scripts/stage/stage_spatial_index.gd | 82 + scripts/stage/stage_spatial_index.gd.uid | 1 + 37 files changed, 8087 insertions(+), 4243 deletions(-) create mode 100644 docs/specs/sandbox_stage_composition_refactor.md create mode 100644 scripts/editors/stage/stage_action_queue_controller.gd create mode 100644 scripts/editors/stage/stage_action_queue_controller.gd.uid create mode 100644 scripts/editors/stage/stage_asset_browser.gd create mode 100644 scripts/editors/stage/stage_asset_browser.gd.uid create mode 100644 scripts/editors/stage/stage_chrome.gd create mode 100644 scripts/editors/stage/stage_chrome.gd.uid create mode 100644 scripts/editors/stage/stage_director_controller.gd create mode 100644 scripts/editors/stage/stage_director_controller.gd.uid create mode 100644 scripts/editors/stage/stage_document_controller.gd create mode 100644 scripts/editors/stage/stage_document_controller.gd.uid create mode 100644 scripts/editors/stage/stage_modal_views.gd create mode 100644 scripts/editors/stage/stage_modal_views.gd.uid create mode 100644 scripts/editors/stage/stage_prop_settings_view.gd create mode 100644 scripts/editors/stage/stage_prop_settings_view.gd.uid create mode 100644 scripts/editors/stage/stage_rule_authoring_controller.gd create mode 100644 scripts/editors/stage/stage_rule_authoring_controller.gd.uid create mode 100644 scripts/editors/stage/stage_run_session.gd create mode 100644 scripts/editors/stage/stage_run_session.gd.uid create mode 100644 scripts/editors/stage/stage_scene_adapter.gd create mode 100644 scripts/editors/stage/stage_scene_adapter.gd.uid create mode 100644 scripts/editors/stage/stage_speech_draft_store.gd create mode 100644 scripts/editors/stage/stage_speech_draft_store.gd.uid create mode 100644 scripts/editors/stage/stage_speech_session.gd create mode 100644 scripts/editors/stage/stage_speech_session.gd.uid create mode 100644 scripts/editors/stage/stage_target_capture_session.gd create mode 100644 scripts/editors/stage/stage_target_capture_session.gd.uid create mode 100644 scripts/editors/stage/stage_terrain_painter.gd create mode 100644 scripts/editors/stage/stage_terrain_painter.gd.uid create mode 100644 scripts/editors/stage/stage_theme_controller.gd create mode 100644 scripts/editors/stage/stage_theme_controller.gd.uid create mode 100644 scripts/stage/stage_rule_engine.gd create mode 100644 scripts/stage/stage_rule_engine.gd.uid create mode 100644 scripts/stage/stage_spatial_index.gd create mode 100644 scripts/stage/stage_spatial_index.gd.uid diff --git a/docs/architecture/tech_debt_and_optimizations.md b/docs/architecture/tech_debt_and_optimizations.md index b12944f..5478247 100644 --- a/docs/architecture/tech_debt_and_optimizations.md +++ b/docs/architecture/tech_debt_and_optimizations.md @@ -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_ diff --git a/docs/specs/sandbox_stage_composition_refactor.md b/docs/specs/sandbox_stage_composition_refactor.md new file mode 100644 index 0000000..31d0156 --- /dev/null +++ b/docs/specs/sandbox_stage_composition_refactor.md @@ -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. diff --git a/scripts/editors/stage/sandbox_stage.gd b/scripts/editors/stage/sandbox_stage.gd index 3322c75..bd041fa 100644 --- a/scripts/editors/stage/sandbox_stage.gd +++ b/scripts/editors/stage/sandbox_stage.gd @@ -29,11 +29,23 @@ const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd") const STICKMAN_THUMBNAIL := preload("res://scripts/shared/thumbnails/stickman_thumbnail.gd") const PROP_THUMBNAIL := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd") const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") -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 STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd") +const STAGE_SPATIAL_INDEX := preload("res://scripts/stage/stage_spatial_index.gd") +const STAGE_TERRAIN_PAINTER := preload("res://scripts/editors/stage/stage_terrain_painter.gd") +const STAGE_CHROME_SCRIPT := preload("res://scripts/editors/stage/stage_chrome.gd") +const STAGE_PROP_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/stage/stage_prop_settings_view.gd") +const STAGE_SCENE_ADAPTER_SCRIPT := preload("res://scripts/editors/stage/stage_scene_adapter.gd") +const STAGE_DOCUMENT_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_document_controller.gd") +const STAGE_RULE_ENGINE_SCRIPT := preload("res://scripts/stage/stage_rule_engine.gd") +const STAGE_SPEECH_SESSION_SCRIPT := preload("res://scripts/editors/stage/stage_speech_session.gd") +const STAGE_RULE_AUTHORING_SCRIPT := preload("res://scripts/editors/stage/stage_rule_authoring_controller.gd") +const STAGE_ACTION_QUEUE_SCRIPT := preload("res://scripts/editors/stage/stage_action_queue_controller.gd") +const STAGE_DIRECTOR_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_director_controller.gd") +const STAGE_RUN_SESSION_SCRIPT := preload("res://scripts/editors/stage/stage_run_session.gd") +const STAGE_ASSET_BROWSER_SCRIPT := preload("res://scripts/editors/stage/stage_asset_browser.gd") +const STAGE_MODAL_VIEWS_SCRIPT := preload("res://scripts/editors/stage/stage_modal_views.gd") +const STAGE_THEME_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_theme_controller.gd") +const STAGE_TARGET_CAPTURE_SCRIPT := preload("res://scripts/editors/stage/stage_target_capture_session.gd") # Phase 3c editor tools. const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") @@ -89,19 +101,19 @@ const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer" const SELECTOR_DIM_ALPHA := 0.5 const TOP_BAR_HEIGHT: float = 64.0 const STATUS_BAR_HEIGHT: float = 38.0 -const DIRECT_MOVE_POPUP_SIZE := Vector2i(380, 172) +const DIRECT_MOVE_POPUP_SIZE: Vector2i = STAGE_DIRECTOR_CONTROLLER_SCRIPT.DIRECT_MOVE_POPUP_SIZE const SETTINGS_PATH := "user://sandbox_settings.json" const THEME_PATH := "res://sandbox_theme.json" const DEFAULT_GRID_SIZE := 15.0 const MIN_GRID_SIZE := 1.0 const MAX_GRID_SIZE := 100.0 -const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene" -const SPEECH_DRAFT_PATH: String = "user://speech_composer_draft.json" -const SPEECH_DRAFT_DEBOUNCE: float = 0.75 -const SPEECH_DRAFT_SCHEMA: int = 1 -const SPEECH_BUBBLE_DRAG_THRESHOLD: float = 8.0 -const RECENT_SCENE_LIMIT: int = 5 +const AUTOSAVE_PATH: String = STAGE_DOCUMENT_CONTROLLER_SCRIPT.AUTOSAVE_PATH +const SPEECH_DRAFT_PATH: String = STAGE_SPEECH_SESSION_SCRIPT.DRAFT_PATH +const SPEECH_DRAFT_DEBOUNCE: float = STAGE_SPEECH_SESSION_SCRIPT.DRAFT_DEBOUNCE +const SPEECH_DRAFT_SCHEMA: int = STAGE_SPEECH_SESSION_SCRIPT.DRAFT_SCHEMA +const SPEECH_BUBBLE_DRAG_THRESHOLD: float = STAGE_SPEECH_SESSION_SCRIPT.BUBBLE_DRAG_THRESHOLD +const RECENT_SCENE_LIMIT: int = STAGE_DOCUMENT_CONTROLLER_SCRIPT.RECENT_LIMIT const DEFAULT_SCENE_NAME: String = "Untitled" const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0) const DEFAULT_CAMERA_ZOOM: float = 0.5 @@ -117,23 +129,23 @@ const ACT_RECOVER := 4 ## Phase 4 rule-builder item ids. ACT_WHEN appends to the action popup; the ## TRIG_* ids drive the trigger sub-menu. const ACT_WHEN := 5 -const TRIG_ARRIVED := 0 -const TRIG_ACTION_FINISHED := 1 -const TRIG_SPEECH_FINISHED := 2 -const TRIG_ENTERED_AREA := 3 -const TRIG_COLLIDED := 4 -const TRIG_BACK := 5 -const TRIG_PROP_PICKED_UP := 6 -const TRIG_PROP_DROPPED := 7 -const TRIG_PROP_THROWN := 8 +const TRIG_ARRIVED: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_ARRIVED +const TRIG_ACTION_FINISHED: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_ACTION_FINISHED +const TRIG_SPEECH_FINISHED: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_SPEECH_FINISHED +const TRIG_ENTERED_AREA: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_ENTERED_AREA +const TRIG_COLLIDED: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_COLLIDED +const TRIG_BACK: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_BACK +const TRIG_PROP_PICKED_UP: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_PROP_PICKED_UP +const TRIG_PROP_DROPPED: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_PROP_DROPPED +const TRIG_PROP_THROWN: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_PROP_THROWN ## Phase 4 "add another / done" popup item ids. -const RULE_MORE_ADD := 0 -const RULE_MORE_DONE := 1 +const RULE_MORE_ADD: int = STAGE_RULE_AUTHORING_SCRIPT.RULE_MORE_ADD +const RULE_MORE_DONE: int = STAGE_RULE_AUTHORING_SCRIPT.RULE_MORE_DONE ## Phase 4 "done" item id for the rule-action popup (edit mode only). Distinct ## from ACT_WALK..ACT_RECOVER (0..4) so it never collides with an action id. -const RULE_ACTION_DONE := 6 +const RULE_ACTION_DONE: int = STAGE_RULE_AUTHORING_SCRIPT.RULE_ACTION_DONE ## Phase 3c action-popup item ids ("Edit Queue…" / "Edit Rules…"). const ACT_EDIT_QUEUE := 7 @@ -149,7 +161,7 @@ const RIG_CTX_EDIT_RULES := 1 ## Max distance (px) between an arrival event position and a rule's waypoint for ## the arrived_at_waypoint trigger to match. -const WAYPOINT_MATCH_EPSILON := 24.0 +const WAYPOINT_MATCH_EPSILON: float = STAGE_RULE_AUTHORING_SCRIPT.WAYPOINT_MATCH_EPSILON ## Debug gate for the Phase 3a stage trace. Ship OFF. const DEBUG_STAGE := false @@ -178,80 +190,236 @@ func _stage_dbg(msg: String) -> void: # --------------------------------------------------------------------------- var current_mode: StageMode = StageMode.EDIT -var _run_state: RunState = RunState.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 _suppressed_contact_pairs: Dictionary = {} -var _paused_edit_autosave_remaining: float = -1.0 +var _run_session: StageRunSession +var _run_state: int: + get: + return RunState.STOPPED if _run_session == null else _run_session.run_state + set(value): + if _run_session != null: + _run_session.run_state = value +var _elapsed_seconds: float: + get: + return 0.0 if _run_session == null else _run_session.elapsed_seconds + set(value): + if _run_session != null: + _run_session.elapsed_seconds = value +var _event_dispatch_ready: bool: + get: + return false if _run_session == null else _run_session.event_dispatch_ready + set(value): + if _run_session != null: + _run_session.event_dispatch_ready = value +var _resume_baseline_frames: int: + get: + return 0 if _run_session == null else _run_session.resume_baseline_frames + set(value): + if _run_session != null: + _run_session.resume_baseline_frames = value +var _prop_pause_states: Dictionary: + get: + return {} if _run_session == null else _run_session.prop_pause_states +var _paused_edit_autosave_remaining: float: + get: + return -1.0 if _run_session == null else _run_session.paused_edit_autosave_remaining + set(value): + if _run_session != null: + _run_session.paused_edit_autosave_remaining = value +var _suppressed_contact_pairs: Dictionary: + get: + return {} if _rule_engine == null else _rule_engine.get_suppressed_contact_pairs() var _spawner: StageSpawner var _selection: StageSelection var _gizmos: StageGizmos var _grid: StageGrid +var _spatial_index: StageSpatialIndex +var _terrain_painter: StageTerrainPainter +var _rule_engine: StageRuleEngine +var _rule_authoring: StageRuleAuthoringController +var _action_queue_controller: StageActionQueueController +var _speech_session: StageSpeechSession +var _asset_browser: StageAssetBrowser +var _modal_views: StageModalViews +var _chrome: StageChrome +var _prop_settings_view: StagePropSettingsView var _placement_overlay = null # StagePlacementOverlay (preloaded) var _placement_id: String = "" -var _stickman_library: StickmanLibrary -var _thumbnail_cache: ThumbnailCache -var _stickman_thumb: StickmanThumbnail -var _prop_thumb: PropThumbnail -var _selector: AssetSelector = null -var _selector_open: bool = false -var _selector_kind: String = "" -var _selector_dim: ColorRect = null -var _browse_dialog: FileDialog = null -var _thumbnail_queue: Array[Dictionary] = [] -var _thumbnail_busy: bool = false -var _thumbnail_generation: int = 0 +var _stickman_library: StickmanLibrary: + get: + return null if _asset_browser == null else _asset_browser.stickman_library +var _thumbnail_cache: ThumbnailCache: + get: + return null if _asset_browser == null else _asset_browser.thumbnail_cache +var _stickman_thumb: StickmanThumbnail: + get: + return null if _asset_browser == null else _asset_browser.stickman_thumbnail +var _prop_thumb: PropThumbnail: + get: + return null if _asset_browser == null else _asset_browser.prop_thumbnail +var _selector: AssetSelector: + get: + return null if _asset_browser == null else _asset_browser.get_selector() +var _selector_open: bool: + get: + return false if _asset_browser == null else _asset_browser.selector_open +var _selector_kind: String: + get: + return "" if _asset_browser == null else _asset_browser.selector_kind +var _selector_dim: ColorRect: + get: + return null if _asset_browser == null else _asset_browser.selector_dim +var _browse_dialog: FileDialog: + get: + return null if _asset_browser == null else _asset_browser.get_browse_dialog() +var _thumbnail_queue: Array[Dictionary]: + get: + return [] if _asset_browser == null else _asset_browser.thumbnail_queue +var _thumbnail_busy: bool: + get: + return false if _asset_browser == null else _asset_browser.thumbnail_busy + set(value): + if _asset_browser != null: + _asset_browser.thumbnail_busy = value +var _thumbnail_generation: int: + get: + return 0 if _asset_browser == null else _asset_browser.thumbnail_generation -var _mode_buttons: Dictionary = {} # int (StageMode) -> Button -var _palette_buttons: Dictionary = {} -var _palette_dock: PanelContainer = null -var _prop_settings_card: Control = null -var _prop_settings_reopen_button: Button = null -var _prop_settings_fields: VBoxContainer = null -var _prop_settings_notice: Label = null -var _prop_settings_depth_group: Control = null -var _prop_settings_motion_group: Control = null -var _prop_settings_controls: Dictionary = {} -var _prop_settings_active_key: String = "" -var _prop_settings_dismissed_key: String = "" -var _objects_here_popup: PopupPanel = null +var _mode_buttons: Dictionary: + get: + return {} if _chrome == null else _chrome.get_mode_buttons() +var _palette_buttons: Dictionary: + get: + return {} if _chrome == null else _chrome.get_palette_buttons() +var _palette_dock: PanelContainer: + get: + return null if _chrome == null else _chrome.get_palette_dock() +var _prop_settings_card: Control: + get: + return null if _prop_settings_view == null else _prop_settings_view.get_card() +var _prop_settings_reopen_button: Button: + get: + return null if _prop_settings_view == null else _prop_settings_view.get_reopen_button() +var _prop_settings_fields: VBoxContainer: + get: + return null if _prop_settings_view == null else _prop_settings_view.get_fields() +var _prop_settings_notice: Label: + get: + return null if _prop_settings_view == null else _prop_settings_view.get_notice() +var _prop_settings_depth_group: Control: + get: + return null if _prop_settings_view == null else _prop_settings_view.get_depth_group() +var _prop_settings_motion_group: Control: + get: + return null if _prop_settings_view == null else _prop_settings_view.get_motion_group() +var _prop_settings_controls: Dictionary: + get: + return {} if _prop_settings_view == null else _prop_settings_view.get_controls() +var _prop_settings_active_key: String: + get: + return "" if _prop_settings_view == null else _prop_settings_view.get_active_selection_key() +var _prop_settings_dismissed_key: String: + get: + return "" if _prop_settings_view == null else _prop_settings_view.get_dismissed_selection_key() +var _objects_here_popup: PopupPanel: + get: + return null if _modal_views == null else _modal_views.get_objects_here_popup() var _palette_drag_payload: Dictionary = {} var _palette_drag_previous_id: String = "" -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 = null -var _timestamp_label: Label = null -var _run_controls: HBoxContainer = null -var _pause_button: Button = null -var _resume_button: Button = null -var _reset_button: Button = null -var _tooltip: Label -var _grid_check: CheckBox -var _snap_check: CheckBox -var _grid_size_spin: SpinBox -var _grid_size_label: Label +var _status_label: Label: + get: + return null if _chrome == null else _chrome.get_status_label() +var _status_cursor_coords: Label: + get: + return null if _chrome == null else _chrome.get_status_cursor_coords() +var _direct_hint_label: Label: + get: + return null if _chrome == null else _chrome.get_direct_hint_label() +var _mode_frame: Panel: + get: + return null if _chrome == null else _chrome.get_mode_frame() +var _mode_badge: PanelContainer: + get: + return null if _chrome == null else _chrome.get_mode_badge() +var _mode_badge_label: Label: + get: + return null if _chrome == null else _chrome.get_mode_badge_label() +var _timestamp_badge: PanelContainer: + get: + return null if _chrome == null else _chrome.get_timestamp_badge() +var _timestamp_label: Label: + get: + return null if _chrome == null else _chrome.get_timestamp_label() +var _run_controls: HBoxContainer: + get: + return null if _chrome == null else _chrome.get_run_controls() +var _pause_button: Button: + get: + return null if _chrome == null else _chrome.get_pause_button() +var _resume_button: Button: + get: + return null if _chrome == null else _chrome.get_resume_button() +var _reset_button: Button: + get: + return null if _chrome == null else _chrome.get_reset_button() +var _tooltip: Label: + get: + return null if _chrome == null else _chrome.get_tooltip() +var _grid_check: CheckBox: + get: + return null if _chrome == null else _chrome.get_grid_check() +var _snap_check: CheckBox: + get: + return null if _chrome == null else _chrome.get_snap_check() +var _grid_size_spin: SpinBox: + get: + return null if _chrome == null else _chrome.get_grid_size_spin() +var _grid_size_label: Label: + get: + return null if _chrome == null else _chrome.get_grid_size_label() var _grid_size: float = DEFAULT_GRID_SIZE var _snap_enabled: bool = false var _show_grid: bool = true -var _current_scene_path: String = "" -var _scene_name: String = DEFAULT_SCENE_NAME -var _scene_created_unix: int = 0 -var _dirty: bool = false +var _document: StageDocumentController +var _scene_adapter: StageSceneAdapter +var _current_scene_path: String: + get: + return "" if _document == null else _document.current_path + set(value): + if _document != null: + _document.current_path = CONTENT_PATHS.canonicalize(value) +var _scene_name: String: + get: + return DEFAULT_SCENE_NAME if _document == null else _document.scene_name + set(value): + if _document != null: + _document.scene_name = value +var _scene_created_unix: int: + get: + return 0 if _document == null else _document.created_unix + set(value): + if _document != null: + _document.created_unix = value +var _dirty: bool: + get: + return false if _document == null else _document.dirty + set(value): + if _document != null: + _document.set_dirty(value) var _suppress_dirty: bool = false -var _recent_scenes: Array[String] = [] +var _recent_scenes: Array[String]: + get: + return [] if _document == null else _document.get_recent_paths() + set(value): + if _document != null: + _document.set_recent_paths(value) var _pending_file_action: PendingFileAction = PendingFileAction.NONE var _pending_open_path: String = "" -var _file_menu: MenuButton = null +var _file_menu: MenuButton: + get: + return null if _chrome == null else _chrome.get_file_menu() var _scene_open_dialog: FileDialog = null var _scene_save_dialog: FileDialog = null var _unsaved_changes_dialog: ConfirmationDialog = null @@ -274,49 +442,102 @@ var _ghost: Node2D = null var _ghost_holder: Node2D = null ## Phase 4b terrain drag-painting state. -var _terrain_dragging: bool = false const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0 -var _terrain_press_world: Vector2 = Vector2.ZERO -var _terrain_painting: bool = false -var _terrain_anchor_cell: Vector2i = Vector2i.ZERO -var _terrain_target_cell: Vector2i = Vector2i.ZERO -var _drag_cells: Array[Vector2i] = [] -var _last_drag_cells: Array[Vector2i] = [] -var _ghost_array: Array[Node2D] = [] -## Block cells already stamped during the CURRENT drag (in-drag self-overlap -## skip, 3-state Case B). -var _drag_painted: Dictionary = {} - -## Grid spatial dictionary: cell (Vector2i, TERRAIN_GRID_SIZE) -> Array[Node2D] -## whose world AABB overlaps that cell. Advisory only (3-state tint + skip); -## never authoritative for physics. -var _grid_cells: Dictionary = {} +## Compatibility views retained for existing stage tools and tests. +var _terrain_dragging: bool: + get: + return _terrain_painter != null and _terrain_painter.is_dragging() +var _terrain_painting: bool: + get: + return _terrain_painter != null and _terrain_painter.is_painting() +var _drag_cells: Array[Vector2i]: + get: + if _terrain_painter == null: + return [] + return _terrain_painter.get_drag_cells() +var _drag_painted: Dictionary: + get: + if _terrain_painter == null: + return {} + return _terrain_painter.get_drag_painted_cells() +var _ghost_array: Array[Node2D]: + get: + if _terrain_painter == null: + return [] + return _terrain_painter.get_ghosts() +var _grid_cells: Dictionary: + get: + if _spatial_index == null: + return {} + return _spatial_index.get_cells() ## Authored object state (instance id -> {node, position, rotation}), updated on ## every place/move/rotate and restored when returning to EDIT. -var _authored: Dictionary = {} +var _authored: Dictionary: + get: + return {} if _run_session == null else _run_session.authored_state ## Physics frames remaining before re-asserting the authored restore (a safety ## net for engines where a same-frame freeze + teleport does not stick). -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 +var _restore_frames_left: int: + get: + return 0 if _run_session == null else _run_session.restore_frames_left + set(value): + if _run_session != null: + _run_session.restore_frames_left = value +var _prop_scale_trace_previous: Dictionary: + get: + return {} if _run_session == null else _run_session.prop_scale_trace_previous +var _edit_physics_targets: Dictionary: + get: + return {} if _run_session == null else _run_session.edit_physics_targets +var _edit_physics_settle_frames: int: + get: + return 0 if _run_session == null else _run_session.edit_physics_settle_frames + set(value): + if _run_session != null: + _run_session.edit_physics_settle_frames = value # --------------------------------------------------------------------------- # Director state (Phase 3a) # --------------------------------------------------------------------------- -var _action_popup: PopupMenu = null -var _context_rig: StickmanRig = null -var _pending_walk_target: bool = false -var _direct_move_popup: PopupPanel = null -var _direct_move_style_option: OptionButton = null -var _direct_move_style_ids: Array[String] = [] -var _direct_move_target: Vector2 = Vector2.INF +var _director_controller: StageDirectorController +var _action_popup: PopupMenu: + get: + return null if _director_controller == null else _director_controller.get_action_popup() +var _context_rig: StickmanRig: + get: + return null if _director_controller == null else _director_controller.context_rig + set(value): + if _director_controller != null: + _director_controller.context_rig = value +var _pending_walk_target: bool: + get: + return false if _director_controller == null else _director_controller.pending_walk_target + set(value): + if _director_controller != null: + _director_controller.pending_walk_target = value +var _direct_move_popup: PopupPanel: + get: + return null if _director_controller == null else _director_controller.get_direct_move_popup() +var _direct_move_style_option: OptionButton: + get: + return null if _director_controller == null else _director_controller.get_direct_move_style_option() +var _direct_move_style_ids: Array[String]: + get: + return [] if _director_controller == null else _director_controller.get_direct_move_style_ids() +var _direct_move_target: Vector2: + get: + return Vector2.INF if _director_controller == null else _director_controller.direct_move_target + set(value): + if _director_controller != null: + _director_controller.direct_move_target = value ## Instance IDs keep this preference local to the current open stage scene. -var _direct_move_last_style: Dictionary = {} +var _direct_move_last_style: Dictionary: + get: + return {} if _director_controller == null else _director_controller.get_direct_move_last_style() var _next_stage_prop_serial: int = 0 var _speak_dialog: AcceptDialog = null var _speak_edit: LineEdit = null @@ -332,39 +553,143 @@ var _nav_dirty: bool = true # Theme / cursor state (Phase 4b) # --------------------------------------------------------------------------- -## Parsed sandbox_theme.json (or {} if missing/malformed). -var _theme: Dictionary = {} -var _sandbox_theme = null -var _theme_grid_default: float = DEFAULT_GRID_SIZE -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 = null -var _emoji_font: Font = null +var _theme_controller: StageThemeController = STAGE_THEME_CONTROLLER_SCRIPT.new() as StageThemeController +var _theme: Dictionary: + get: + return _theme_controller.theme + set(value): + _theme_controller.theme = value +var _sandbox_theme: SandboxTheme: + get: + return _theme_controller.sandbox_theme + set(value): + _theme_controller.sandbox_theme = value +var _theme_grid_default: float: + get: + return _theme_controller.grid_default + set(value): + _theme_controller.grid_default = value +var _accent_edit: Color: + get: + return _theme_controller.accent_edit + set(value): + _theme_controller.accent_edit = value +var _accent_direct: Color: + get: + return _theme_controller.accent_direct + set(value): + _theme_controller.accent_direct = value +var _accent_play: Color: + get: + return _theme_controller.accent_play + set(value): + _theme_controller.accent_play = value +var _guide_line_color: Color: + get: + return _theme_controller.guide_line_color + set(value): + _theme_controller.guide_line_color = value +var _action_popup_font_size: int: + get: + return _theme_controller.action_popup_font_size + set(value): + _theme_controller.action_popup_font_size = value +var _action_popup_emoji_size: int: + get: + return _theme_controller.action_popup_emoji_size + set(value): + _theme_controller.action_popup_emoji_size = value +var _tooltip_font_size: int: + get: + return _theme_controller.tooltip_font_size + set(value): + _theme_controller.tooltip_font_size = value +var _status_pill_font_size: int: + get: + return _theme_controller.status_pill_font_size + set(value): + _theme_controller.status_pill_font_size = value +var _ui_font: Font: + get: + return _theme_controller.ui_font + set(value): + _theme_controller.ui_font = value +var _emoji_font: Font: + get: + return _theme_controller.emoji_font + set(value): + _theme_controller.emoji_font = value +var _ui_font_bold: Font: + get: + return _theme_controller.ui_font_bold + set(value): + _theme_controller.ui_font_bold = value +var _ui_font_italic: Font: + get: + return _theme_controller.ui_font_italic + set(value): + _theme_controller.ui_font_italic = value +var _queue_panel_font_size: int: + get: + return _theme_controller.queue_panel_font_size + set(value): + _theme_controller.queue_panel_font_size = value +var _rule_panel_font_size: int: + get: + return _theme_controller.rule_panel_font_size + set(value): + _theme_controller.rule_panel_font_size = value +var _action_editor_font_size: int: + get: + return _theme_controller.action_editor_font_size + set(value): + _theme_controller.action_editor_font_size = value +var _rule_editor_font_size: int: + get: + return _theme_controller.rule_editor_font_size + set(value): + _theme_controller.rule_editor_font_size = value +var _panel_row_font_size: int: + get: + return _theme_controller.panel_row_font_size + set(value): + _theme_controller.panel_row_font_size = value +var _panel_title_font_size: int: + get: + return _theme_controller.panel_title_font_size + set(value): + _theme_controller.panel_title_font_size = value +var _panel_title_bold: bool: + get: + return _theme_controller.panel_title_bold + set(value): + _theme_controller.panel_title_bold = value +var _rule_label_bold: bool: + get: + return _theme_controller.rule_label_bold + set(value): + _theme_controller.rule_label_bold = value +var _badge_bold: bool: + get: + return _theme_controller.badge_bold + set(value): + _theme_controller.badge_bold = value +var _action_popup_bold: bool: + get: + return _theme_controller.action_popup_bold + set(value): + _theme_controller.action_popup_bold = value +var _action_popup_italic: bool: + get: + return _theme_controller.action_popup_italic + set(value): + _theme_controller.action_popup_italic = value +var _font_sizes: Dictionary: + get: + return _theme_controller.font_sizes + set(value): + _theme_controller.font_sizes = value -## Phase 3c style fonts/sizes/flags (sandbox_theme.json §fonts). Bold/italic are -## resolved from ui_font_bold/ui_font_italic (fallback to a FontVariation / ui_font). -var _ui_font_bold: Font = null -var _ui_font_italic: Font = null -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 = {} - -var _action_cursor: ImageTexture = null var _last_awaiting_click: bool = false # --------------------------------------------------------------------------- @@ -375,33 +700,78 @@ var _last_awaiting_click: bool = false var _event_rules: Array[Dictionary] = [] var _next_rule_id: int = 0 -## Rule-builder state machine. -var _rule_step: RuleStep = RuleStep.IDLE -var _rule_builder: Dictionary = {} -var _rule_context_rig: StickmanRig = null -var _rule_hint: String = "" -var _rule_editing_id: int = -1 - -## True when the rule builder was launched from the Rule Panel's "Add Rule" -## (restore the panel on finalize/cancel). -var _rule_build_from_panel: bool = false - -## Session popup anchor: the first context menu of a rule session records its -## screen rect; all child popups in that session reuse it. Cleared on confirm -## or cancel so the next session re-records from a fresh click. -var _popup_anchor: Rect2i = Rect2i() -var _popup_anchor_set: bool = false +## Compatibility facades for rule-authoring state now owned by its controller. +var _rule_step: int: + get: + return 0 if _rule_authoring == null else _rule_authoring.step + set(value): + if _rule_authoring != null: + _rule_authoring.step = value +var _rule_builder: Dictionary: + get: + return {} if _rule_authoring == null else _rule_authoring.builder + set(value): + if _rule_authoring != null: + _rule_authoring.builder = value +var _rule_context_rig: StickmanRig: + get: + return null if _rule_authoring == null else _rule_authoring.context_rig + set(value): + if _rule_authoring != null: + _rule_authoring.context_rig = value +var _rule_hint: String: + get: + return "" if _rule_authoring == null else _rule_authoring.hint + set(value): + if _rule_authoring != null: + _rule_authoring.hint = value +var _rule_editing_id: int: + get: + return -1 if _rule_authoring == null else _rule_authoring.editing_id + set(value): + if _rule_authoring != null: + _rule_authoring.editing_id = value +var _rule_build_from_panel: bool: + get: + return false if _rule_authoring == null else _rule_authoring.build_from_panel + set(value): + if _rule_authoring != null: + _rule_authoring.build_from_panel = value +var _popup_anchor: Rect2i: + get: + return Rect2i() if _rule_authoring == null else _rule_authoring.popup_anchor + set(value): + if _rule_authoring != null: + _rule_authoring.popup_anchor = value +var _popup_anchor_set: bool: + get: + return false if _rule_authoring == null else _rule_authoring.popup_anchor_set + set(value): + if _rule_authoring != null: + _rule_authoring.popup_anchor_set = value ## Rule-builder popups (built in _build_ui). -var _trigger_popup: PopupMenu = null -var _rule_action_popup: PopupMenu = null -var _rule_more_popup: PopupMenu = null +var _trigger_popup: PopupMenu: + get: + return null if _rule_authoring == null else _rule_authoring.get_trigger_popup() +var _rule_action_popup: PopupMenu: + get: + return null if _rule_authoring == null else _rule_authoring.get_action_popup() +var _rule_more_popup: PopupMenu: + get: + return null if _rule_authoring == null else _rule_authoring.get_more_popup() ## Edge-trigger bookkeeping for the geometric engine, keyed ":" ## and ":". -var _area_overlap: Dictionary = {} -var _area_previous_bounds: Dictionary = {} -var _collision_pairs: Dictionary = {} +var _area_overlap: Dictionary: + get: + return {} if _rule_engine == null else _rule_engine.get_area_overlap() +var _area_previous_bounds: Dictionary: + get: + return {} if _rule_engine == null else _rule_engine.get_area_previous_bounds() +var _collision_pairs: Dictionary: + get: + return {} if _rule_engine == null else _rule_engine.get_collision_pairs() ## Lightweight toast text + countdown (cleared in _process on expiry). var _toast_text: String = "" @@ -413,34 +783,148 @@ var _toast_timer: float = 0.0 ## Editor popups / panels (instantiated from their scenes in _build_ui). var _queue_panel: QueuePanel = null -var _rule_panel: RulePanel = null +var _rule_panel: RulePanel: + get: + return null if _rule_authoring == null else _rule_authoring.get_rule_panel() var _action_editor: ActionEditor = null -var _rule_editor: RuleEditor = null +var _rule_editor: RuleEditor: + get: + return null if _rule_authoring == null else _rule_authoring.get_rule_editor() var _speech_composer: Control = null var _speech_style_toolbar: Control = null var _speech_show_toolbar: Control = null -var _speech_preview: SpeechBubble = null -var _speech_actor: StickmanRig = null -var _speech_session_kind: String = "" -var _speech_placing: bool = false -var _speech_placement_bubble_index: int = 0 -var _speech_bubble_dragging: bool = false -var _speech_bubble_pointer_pending: bool = false -var _speech_bubble_pointer_moved: bool = false -var _speech_bubble_pointer_on_badge: bool = false -var _speech_bubble_pointer_start_screen: Vector2 = Vector2.ZERO -var _speech_bubble_pointer_start_world: Vector2 = Vector2.ZERO -var _speech_bubble_pointer_hits: Array[int] = [] -var _speech_bubble_pointer_index: int = -1 -var _speech_bubble_pointer_sequence: int = 0 -var _speech_bubble_word_clicked: bool = false -var _speech_badge_cursor_active: bool = false -var _speech_bubble_picker: PopupPanel = null -var _speech_token_picker: PopupPanel = null -var _pending_speech_initial: Dictionary = {} -var _speech_origin: Dictionary = {} -var _speech_draft_dirty: bool = false -var _speech_draft_timer: float = -1.0 +var _speech_preview: SpeechBubble: + get: + return null if _speech_session == null else _speech_session.preview + set(value): + if _speech_session != null: + _speech_session.preview = value +var _speech_actor: StickmanRig: + get: + return null if _speech_session == null else _speech_session.actor + set(value): + if _speech_session != null: + _speech_session.actor = value +var _speech_session_kind: String: + get: + return "" if _speech_session == null else _speech_session.session_kind + set(value): + if _speech_session != null: + _speech_session.session_kind = value +var _speech_placing: bool: + get: + return false if _speech_session == null else _speech_session.placing + set(value): + if _speech_session != null: + _speech_session.placing = value +var _speech_placement_bubble_index: int: + get: + return 0 if _speech_session == null else _speech_session.placement_bubble_index + set(value): + if _speech_session != null: + _speech_session.placement_bubble_index = value +var _speech_bubble_dragging: bool: + get: + return false if _speech_session == null else _speech_session.bubble_dragging + set(value): + if _speech_session != null: + _speech_session.bubble_dragging = value +var _speech_bubble_pointer_pending: bool: + get: + return false if _speech_session == null else _speech_session.bubble_pointer_pending + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_pending = value +var _speech_bubble_pointer_moved: bool: + get: + return false if _speech_session == null else _speech_session.bubble_pointer_moved + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_moved = value +var _speech_bubble_pointer_on_badge: bool: + get: + return false if _speech_session == null else _speech_session.bubble_pointer_on_badge + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_on_badge = value +var _speech_bubble_pointer_start_screen: Vector2: + get: + return Vector2.ZERO if _speech_session == null else _speech_session.bubble_pointer_start_screen + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_start_screen = value +var _speech_bubble_pointer_start_world: Vector2: + get: + return Vector2.ZERO if _speech_session == null else _speech_session.bubble_pointer_start_world + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_start_world = value +var _speech_bubble_pointer_hits: Array[int]: + get: + return [] if _speech_session == null else _speech_session.bubble_pointer_hits + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_hits = value +var _speech_bubble_pointer_index: int: + get: + return -1 if _speech_session == null else _speech_session.bubble_pointer_index + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_index = value +var _speech_bubble_pointer_sequence: int: + get: + return 0 if _speech_session == null else _speech_session.bubble_pointer_sequence + set(value): + if _speech_session != null: + _speech_session.bubble_pointer_sequence = value +var _speech_bubble_word_clicked: bool: + get: + return false if _speech_session == null else _speech_session.bubble_word_clicked + set(value): + if _speech_session != null: + _speech_session.bubble_word_clicked = value +var _speech_badge_cursor_active: bool: + get: + return false if _speech_session == null else _speech_session.badge_cursor_active + set(value): + if _speech_session != null: + _speech_session.badge_cursor_active = value +var _speech_bubble_picker: PopupPanel: + get: + return null if _speech_session == null else _speech_session.bubble_picker + set(value): + if _speech_session != null: + _speech_session.bubble_picker = value +var _speech_token_picker: PopupPanel: + get: + return null if _speech_session == null else _speech_session.token_picker + set(value): + if _speech_session != null: + _speech_session.token_picker = value +var _pending_speech_initial: Dictionary: + get: + return {} if _action_queue_controller == null else _action_queue_controller.pending_speech_initial + set(value): + if _action_queue_controller != null: + _action_queue_controller.pending_speech_initial = value +var _speech_origin: Dictionary: + get: + return {} if _speech_session == null else _speech_session.origin + set(value): + if _speech_session != null: + _speech_session.origin = value +var _speech_draft_dirty: bool: + get: + return false if _speech_session == null else _speech_session.draft_dirty + set(value): + if _speech_session != null: + _speech_session.draft_dirty = value +var _speech_draft_timer: float: + get: + return -1.0 if _speech_session == null else _speech_session.draft_timer + set(value): + if _speech_session != null: + _speech_session.draft_timer = value var _speech_recovery_dialog: ConfirmationDialog = null var _speech_destination_dialog: ConfirmationDialog = null var _speech_save_gate_dialog: ConfirmationDialog = null @@ -461,46 +945,152 @@ var _confirm_dialog: ConfirmationDialog = null var _confirm_callback: Callable = Callable() ## The stickman the panels/editors currently operate on. -var _panel_rig: StickmanRig = null +var _panel_rig: StickmanRig: + get: + return null if _action_queue_controller == null else _action_queue_controller.panel_rig + set(value): + if _action_queue_controller != null: + _action_queue_controller.panel_rig = value ## Waypoint right-click context (which rig/queue index/position was clicked). -var _ctx_waypoint_rig: StickmanRig = null -var _ctx_waypoint_index: int = -1 -var _ctx_waypoint_action_id: int = -1 -var _ctx_waypoint_pos: Vector2 = Vector2.ZERO +var _ctx_waypoint_rig: StickmanRig: + get: + return null if _action_queue_controller == null else _action_queue_controller.waypoint_rig + set(value): + if _action_queue_controller != null: + _action_queue_controller.waypoint_rig = value +var _ctx_waypoint_index: int: + get: + return -1 if _action_queue_controller == null else _action_queue_controller.waypoint_index + set(value): + if _action_queue_controller != null: + _action_queue_controller.waypoint_index = value +var _ctx_waypoint_action_id: int: + get: + return -1 if _action_queue_controller == null else _action_queue_controller.waypoint_action_id + set(value): + if _action_queue_controller != null: + _action_queue_controller.waypoint_action_id = value +var _ctx_waypoint_pos: Vector2: + get: + return Vector2.ZERO if _action_queue_controller == null else _action_queue_controller.waypoint_position + set(value): + if _action_queue_controller != null: + _action_queue_controller.waypoint_position = value -## Rule-panel filter: source id (>= 0) OR waypoint (finite); never both. -var _rule_panel_source_id: int = -1 -var _rule_panel_waypoint: Vector2 = Vector2.INF -var _rule_panel_title: String = "" -var _rule_panel_filter_ids: Array[int] = [] +## Rule-panel state compatibility facades; rule storage stays in SandboxStage. +var _rule_panel_source_id: int: + get: + return -1 if _rule_authoring == null else _rule_authoring.panel_source_id + set(value): + if _rule_authoring != null: + _rule_authoring.panel_source_id = value +var _rule_panel_waypoint: Vector2: + get: + return Vector2.INF if _rule_authoring == null else _rule_authoring.panel_waypoint + set(value): + if _rule_authoring != null: + _rule_authoring.panel_waypoint = value +var _rule_panel_title: String: + get: + return "" if _rule_authoring == null else _rule_authoring.panel_title + set(value): + if _rule_authoring != null: + _rule_authoring.panel_title = value +var _rule_panel_filter_ids: Array[int]: + get: + return [] if _rule_authoring == null else _rule_authoring.panel_filter_ids + set(value): + if _rule_authoring != null: + _rule_authoring.panel_filter_ids = value ## Unified target capture for editor popups. -var _capture_kind: CaptureKind = CaptureKind.NONE -var _capture_hint: String = "" -var _capture_callback: Callable = Callable() -var _capture_cancel: Callable = Callable() -var _capture_throw_preview_rig: StickmanRig = null -var _capture_throw_preview_prefix: Array[Dictionary] = [] -var _throw_preview_cache: Dictionary = {} -var _set_down_capture_rig: StickmanRig = null -var _set_down_preview_prefix: Array[Dictionary] = [] -var _set_down_preview_cache: Dictionary = {} +var _target_capture: StageTargetCaptureSession = STAGE_TARGET_CAPTURE_SCRIPT.new() as StageTargetCaptureSession +var _capture_kind: CaptureKind: + get: + return _target_capture.kind + set(value): + _target_capture.kind = int(value) +var _capture_hint: String: + get: + return _target_capture.hint + set(value): + _target_capture.hint = value +var _capture_callback: Callable: + get: + return _target_capture.resolve_callback + set(value): + _target_capture.resolve_callback = value +var _capture_cancel: Callable: + get: + return _target_capture.cancel_callback + set(value): + _target_capture.cancel_callback = value var _set_down_cancel_button: Button = null -## ActionEditor context for queue/rule add/edit flows. -var _action_editor_kind: String = "" -var _action_editor_index: int = -1 -var _action_editor_rig: StickmanRig = null -var _action_editor_action_id: int = -1 -var _action_editor_target_action_id: int = -1 -var _action_editor_insert_after: bool = false -var _action_editor_actor_id: int = -1 -var _action_editor_restore_queue: bool = false -var _pending_rule_action_flat: Dictionary = {} +## ActionEditor context compatibility facades; the queue controller owns it. +var _action_editor_kind: String: + get: + return "" if _action_queue_controller == null else _action_queue_controller.editor_kind + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_kind = value +var _action_editor_index: int: + get: + return -1 if _action_queue_controller == null else _action_queue_controller.editor_index + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_index = value +var _action_editor_rig: StickmanRig: + get: + return null if _action_queue_controller == null else _action_queue_controller.editor_rig + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_rig = value +var _action_editor_action_id: int: + get: + return -1 if _action_queue_controller == null else _action_queue_controller.editor_action_id + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_action_id = value +var _action_editor_target_action_id: int: + get: + return -1 if _action_queue_controller == null else _action_queue_controller.editor_target_action_id + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_target_action_id = value +var _action_editor_insert_after: bool: + get: + return false if _action_queue_controller == null else _action_queue_controller.editor_insert_after + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_insert_after = value +var _action_editor_actor_id: int: + get: + return -1 if _action_queue_controller == null else _action_queue_controller.editor_actor_id + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_actor_id = value +var _action_editor_restore_queue: bool: + get: + return false if _action_queue_controller == null else _action_queue_controller.editor_restore_queue + set(value): + if _action_queue_controller != null: + _action_queue_controller.editor_restore_queue = value +var _pending_rule_action_flat: Dictionary: + get: + return {} if _rule_authoring == null else _rule_authoring.pending_action_flat + set(value): + if _rule_authoring != null: + _rule_authoring.pending_action_flat = value -## Rule editor came from the rule panel (restore it on close). -var _rule_editor_from_panel: bool = false +## Compatibility facade for the rule editor's panel restore state. +var _rule_editor_from_panel: bool: + get: + return false if _rule_authoring == null else _rule_authoring.editor_from_panel + set(value): + if _rule_authoring != null: + _rule_authoring.editor_from_panel = value # --------------------------------------------------------------------------- # Lifecycle @@ -508,31 +1098,83 @@ var _rule_editor_from_panel: bool = false func _ready() -> void: get_tree().auto_accept_quit = false - print("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") _camera.make_current() + _run_session = STAGE_RUN_SESSION_SCRIPT.new() as StageRunSession + _document = STAGE_DOCUMENT_CONTROLLER_SCRIPT.new() as StageDocumentController + _document.open_path_requested.connect(_on_document_open_path_requested) + _document.save_path_selected.connect(_on_scene_save_path_selected) + _document.rename_requested.connect(_on_document_rename_requested) + _document.unsaved_save_requested.connect(_on_unsaved_save) + _document.unsaved_discard_requested.connect(_on_unsaved_discard) + _document.dialog_cancelled.connect(_on_document_dialog_cancelled) + _scene_adapter = STAGE_SCENE_ADAPTER_SCRIPT.new() as StageSceneAdapter + _rule_engine = STAGE_RULE_ENGINE_SCRIPT.new() as StageRuleEngine + _rule_authoring = STAGE_RULE_AUTHORING_SCRIPT.new() as StageRuleAuthoringController + _rule_authoring.toast_requested.connect(_show_toast) + _rule_authoring.trigger_selected.connect(_on_trigger_popup_id_pressed) + _rule_authoring.action_selected.connect(_on_rule_action_popup_id_pressed) + _rule_authoring.more_selected.connect(_on_rule_more_id_pressed) + _rule_authoring.rule_panel_edit_requested.connect(_on_rule_panel_edit_requested) + _rule_authoring.rule_panel_delete_requested.connect(_on_rule_panel_delete_requested) + _rule_authoring.rule_panel_add_requested.connect(_on_rule_panel_add_requested) + _rule_authoring.rule_panel_clear_requested.connect(_on_rule_panel_clear_requested) + _rule_authoring.rule_panel_reorder_requested.connect(_on_rule_panel_reorder_requested) + _rule_authoring.rule_editor_committed.connect(_on_rule_editor_committed) + _rule_authoring.rule_editor_cancelled.connect(_on_rule_editor_cancelled) + _rule_authoring.rule_editor_trigger_target_requested.connect(_on_rule_editor_trigger_target_requested) + _rule_authoring.rule_editor_action_add_requested.connect(_on_rule_editor_action_add_requested) + _rule_authoring.rule_editor_action_edit_requested.connect(_on_rule_editor_action_edit_requested) + _action_queue_controller = STAGE_ACTION_QUEUE_SCRIPT.new() as StageActionQueueController + _action_queue_controller.toast_requested.connect(_show_toast) + _action_queue_controller.confirmation_requested.connect(_on_action_queue_confirmation_requested) + _action_queue_controller.queue_action_delete_commit_requested.connect(_commit_queue_action_delete) + _action_queue_controller.queue_clear_commit_requested.connect(_commit_queue_clear) + _action_queue_controller.action_commit_requested.connect(_on_action_editor_committed) + _action_queue_controller.action_cancel_requested.connect(_on_action_editor_cancelled) + _action_queue_controller.capture_request_requested.connect(_on_action_editor_capture_request) + _action_queue_controller.speech_composition_requested.connect(_open_speech_composer) + _action_queue_controller.speech_actor_capture_requested.connect(_begin_action_editor_speech_actor_capture) + _director_controller = STAGE_DIRECTOR_CONTROLLER_SCRIPT.new() as StageDirectorController + add_child(_director_controller) + _director_controller.toast_requested.connect(_show_toast) + _director_controller.direct_action_requested.connect(_on_action_popup_id_pressed) + _director_controller.action_popup_opening.connect(_refresh_action_popup_items) + _director_controller.state_changed.connect(_on_director_state_changed) + _director_controller.direct_move_commit_requested.connect(_on_direct_move_commit_requested) + _speech_session = STAGE_SPEECH_SESSION_SCRIPT.new() as StageSpeechSession + add_child(_speech_session) _load_theme() _grid_size = _theme_grid_default _load_settings() _spawner = STAGE_SPAWNER.new(_world) - _stickman_library = STICKMAN_LIBRARY.new() - _thumbnail_cache = THUMBNAIL_CACHE.new() - _stickman_thumb = STICKMAN_THUMBNAIL.new() - _prop_thumb = PROP_THUMBNAIL.new() - add_child(_stickman_thumb) - add_child(_prop_thumb) + _spatial_index = STAGE_SPATIAL_INDEX.new() + _asset_browser = STAGE_ASSET_BROWSER_SCRIPT.new() as StageAssetBrowser + add_child(_asset_browser) _selection = STAGE_SELECTION.new(_world, _camera) _selection.selection_changed.connect(_on_selection_changed) _selection.hover_changed.connect(_on_hover_changed) - _build_action_cursor() _build_grid_layer() _build_gizmo_layer() _build_director_visuals() _build_navigation() _build_ghost_holder() _build_placement_overlay() + _terrain_painter = STAGE_TERRAIN_PAINTER.new() + _terrain_painter.configure(_spawner, _world, _camera, _ghost_holder, _placement_overlay as StagePlacementOverlay, _spatial_index) + _terrain_painter.placement_requested.connect(_on_terrain_placement_requested) + _terrain_painter.stroke_finished.connect(_on_terrain_stroke_finished) + add_child(_terrain_painter) _build_ui() + _rule_authoring.configure(_action_popup, _speak_stop_current_check, get_viewport()) + _action_queue_controller.configure(_queue_panel, _action_editor, _rule_editor) + _speech_session.configure(_world, _speech_composer, _speech_style_toolbar, _speech_show_toolbar, _camera, get_viewport(), _chrome.get_canvas_layer()) + _speech_session.camera_input_requested.connect(_on_speech_camera_input_requested) + _speech_session.camera_pan_cancel_requested.connect(_on_speech_camera_pan_cancel_requested) + _speech_session.toast_requested.connect(_show_toast) + _speech_session.commit_requested.connect(_on_speech_committed) + _speech_session.cancel_requested.connect(_on_speech_cancelled) _apply_theme_to_visuals() _rebuild_grid_cells() _apply_grid_settings() @@ -543,6 +1185,7 @@ func _ready() -> void: _set_timestamp(0.0) _sync_run_controls() _apply_cursor() + _refresh_status() _update_scene_chrome() _handle_cli_startup(OS.get_cmdline_user_args()) @@ -551,11 +1194,8 @@ func _ready() -> void: func _process(delta: float) -> void: _update_speech_composition() - if _speech_draft_timer >= 0.0: - _speech_draft_timer -= delta - if _speech_draft_timer <= 0.0: - _speech_draft_timer = -1.0 - _persist_speech_draft_now() + if _speech_session != null: + _speech_session.update(delta) _drain_thumbnail_queue() if _nav_dirty: _nav_dirty = false @@ -574,19 +1214,16 @@ func _process(delta: float) -> void: if _toast_timer <= 0.0: _toast_text = "" _refresh_status() - if _paused_edit_autosave_remaining >= 0.0: - _paused_edit_autosave_remaining -= delta - if _paused_edit_autosave_remaining <= 0.0: - _paused_edit_autosave_remaining = -1.0 - if not _save_scene_to_path(AUTOSAVE_PATH, true): - _dirty = true + if _run_session != null and _run_session.advance_paused_edit_autosave(delta): + if not _save_scene_to_path(AUTOSAVE_PATH, true): + _dirty = true func _physics_process(delta: float) -> void: _update_throw_preview_cache() - if _run_state == RunState.RUNNING: - _elapsed_seconds += delta - _set_timestamp(_elapsed_seconds) + var elapsed_timestamp: float = _run_session.advance_elapsed(delta) if _run_session != null else -1.0 + if elapsed_timestamp >= 0.0: + _set_timestamp(elapsed_timestamp) _sync_pending_editable_physics_transforms() if _restore_frames_left > 0: if current_mode == StageMode.PLAY or _run_state != RunState.STOPPED: @@ -596,83 +1233,31 @@ func _physics_process(delta: float) -> void: _restore_frames_left -= 1 _restore_authored_state() _trace_changed_prop_scales() - if _resume_baseline_frames > 0: + if _run_session != null and _resume_baseline_frames > 0: + var baseline_result: Dictionary = _run_session.consume_resume_baseline_frame() _refresh_event_edge_baseline() - _resume_baseline_frames -= 1 - if _resume_baseline_frames == 0: + if bool(baseline_result.get("finished", false)): call_deferred("_finish_resume_baseline") if current_mode == StageMode.PLAY and _event_dispatch_ready and _run_state == RunState.RUNNING: _update_area_entry() func _update_throw_preview_cache() -> void: - if _capture_kind == CaptureKind.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 := _camera.get_global_mouse_position() - if _snap_enabled: - release_target = _snap_to_grid(release_target) - _set_down_preview_cache = _set_down_capture_rig.preview_prop_set_down(release_target, _set_down_preview_prefix) - return - if _capture_kind != CaptureKind.THROW_POSITION or _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig): - _throw_preview_cache.clear() - return - if _camera == null: - _throw_preview_cache.clear() - return - var target := _camera.get_global_mouse_position() - if _snap_enabled: - target = _snap_to_grid(target) - _throw_preview_cache = _capture_throw_preview_rig.preview_prop_throw(target, _capture_throw_preview_prefix) - + _target_capture.update_preview(_camera, _snap_enabled, _grid_size) # --------------------------------------------------------------------------- # Input # --------------------------------------------------------------------------- func _input(event: InputEvent) -> void: - if _speech_composer_is_open() and bool(_speech_composer.call("is_message_dialog_open")): - if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE: - _speech_composer.call("close_message_dialog") - get_viewport().set_input_as_handled() - return - if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE and _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")): - _speech_style_toolbar.call("close_chooser") - get_viewport().set_input_as_handled() - return + if _speech_session != null: + _speech_session.set_camera_panning(_panning) + if _speech_session.handle_input(event): + if _speech_session.should_mark_input_handled(): + get_viewport().set_input_as_handled() + return if event is InputEventKey and (event as InputEventKey).keycode == KEY_SPACE: _handle_space_key_event(event as InputEventKey) return - if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")) and _is_speech_pointer_press(event): - var chooser_point: Vector2 = _speech_event_position(event) - if not bool(_speech_style_toolbar.call("chooser_contains_viewport_point", chooser_point)): - _speech_style_toolbar.call("close_chooser") - if _speech_composer_owns_event(event): - _panning = false - if _speech_bubble_pointer_pending: - _cancel_speech_bubble_pointer() - return - if _route_speech_composer_stage_input(event): - return - if _speech_bubble_pointer_pending: - if event is InputEventMouseMotion: - _update_speech_bubble_pointer((event as InputEventMouseMotion).position) - return - if event is InputEventScreenDrag: - _update_speech_bubble_pointer((event as InputEventScreenDrag).position) - return - var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed - var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed - if mouse_release or touch_release: - if _speech_bubble_dragging: - if _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.end_bubble_drag() - _speech_bubble_dragging = false - call_deferred("_clear_speech_bubble_pointer", _speech_bubble_pointer_sequence) - else: - var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position - call_deferred("_resolve_speech_bubble_pointer_click", _speech_bubble_pointer_sequence, release_position) - return if not _palette_drag_payload.is_empty(): if event is InputEventMouseMotion: _update_palette_drag((event as InputEventMouseMotion).position) @@ -695,161 +1280,36 @@ func _input(event: InputEvent) -> void: _handle_mouse_button(event) elif event is InputEventMouseMotion: _handle_mouse_motion(event) + if _speech_session != null: + _speech_session.set_camera_panning(_panning) -func _speech_composer_owns_event(event: InputEvent) -> bool: - if _speech_composer == null or not _speech_composer.visible or not _speech_composer.has_method("contains_viewport_point"): - return false - var point: Vector2 = Vector2(-1.0, -1.0) - if event is InputEventMouse: - point = (event as InputEventMouse).position - elif event is InputEventGesture: - point = (event as InputEventGesture).position - elif event is InputEventScreenTouch: - point = (event as InputEventScreenTouch).position - elif event is InputEventScreenDrag: - point = (event as InputEventScreenDrag).position - if point.x < 0.0 or point.y < 0.0: - return false - if bool(_speech_composer.call("contains_viewport_point", point)): - return true - if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("contains_viewport_point", point)): - return true - if _speech_show_toolbar != null and bool(_speech_show_toolbar.call("contains_viewport_point", point)): - return true - if _speech_token_picker != null and is_instance_valid(_speech_token_picker) and _speech_token_picker.visible: - return Rect2(_speech_token_picker.position, Vector2(_speech_token_picker.size)).has_point(point) - return false +func _on_speech_camera_input_requested(event: InputEvent) -> void: + if event is InputEventMagnifyGesture: + _set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor) + elif event is InputEventPanGesture: + _camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x + elif event is InputEventMouseButton: + _handle_mouse_button(event as InputEventMouseButton) + elif event is InputEventMouseMotion: + _handle_mouse_motion(event as InputEventMouseMotion) + if _speech_session != null: + _speech_session.set_camera_panning(_panning) -func _is_speech_pointer_press(event: InputEvent) -> bool: - return event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and (event as InputEventMouseButton).pressed or event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed - - -func _speech_event_position(event: InputEvent) -> Vector2: - if event is InputEventMouse: - return (event as InputEventMouse).position - if event is InputEventScreenTouch: - return (event as InputEventScreenTouch).position - if event is InputEventGesture: - return (event as InputEventGesture).position - return Vector2(-1.0, -1.0) +func _on_speech_camera_pan_cancel_requested() -> void: + _panning = false func _speech_composer_is_open() -> bool: - return _speech_composer != null and is_instance_valid(_speech_composer) and _speech_composer.visible - - -func _speech_stage_viewport_rect() -> Rect2: - var viewport_size: Vector2 = get_viewport_rect().size - return Rect2(Vector2(12.0, 72.0), Vector2(maxf(1.0, viewport_size.x - 24.0), maxf(1.0, viewport_size.y - 120.0))) - - -func _route_speech_composer_stage_input(event: InputEvent) -> bool: - if not _speech_composer_is_open(): - return false - if _speech_bubble_pointer_pending: - if event is InputEventMouseMotion: - _update_speech_bubble_pointer((event as InputEventMouseMotion).position) - return true - if event is InputEventScreenDrag: - _update_speech_bubble_pointer((event as InputEventScreenDrag).position) - return true - var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed - var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed - if mouse_release or touch_release: - if _speech_bubble_dragging: - if _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.end_bubble_drag() - _speech_bubble_dragging = false - call_deferred("_clear_speech_bubble_pointer", _speech_bubble_pointer_sequence) - else: - var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position - call_deferred("_resolve_speech_bubble_pointer_click", _speech_bubble_pointer_sequence, release_position) - return true - var point: Vector2 = _speech_event_position(event) - if point.x < 0.0 or point.y < 0.0 or not _speech_stage_viewport_rect().has_point(point): - return false - if event is InputEventMagnifyGesture: - _set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor) - get_viewport().set_input_as_handled() - return true - if event is InputEventPanGesture: - _camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x - get_viewport().set_input_as_handled() - return true - if event is InputEventMouseButton: - var mouse_button: InputEventMouseButton = event as InputEventMouseButton - if mouse_button.button_index == MOUSE_BUTTON_MIDDLE or mouse_button.button_index == MOUSE_BUTTON_WHEEL_UP or mouse_button.button_index == MOUSE_BUTTON_WHEEL_DOWN: - _handle_mouse_button(mouse_button) - get_viewport().set_input_as_handled() - return true - if mouse_button.button_index == MOUSE_BUTTON_LEFT: - if mouse_button.pressed: - if _speech_placing: - return false - _begin_speech_bubble_pointer(mouse_button.position) - if not _speech_bubble_pointer_pending: - get_viewport().set_input_as_handled() - return true - get_viewport().set_input_as_handled() - return true - if mouse_button.button_index == MOUSE_BUTTON_RIGHT and mouse_button.pressed and _speech_placing: - return false - get_viewport().set_input_as_handled() - return true - if event is InputEventMouseMotion: - if _panning: - _handle_mouse_motion(event as InputEventMouseMotion) - get_viewport().set_input_as_handled() - return true - if event is InputEventScreenTouch: - var touch: InputEventScreenTouch = event as InputEventScreenTouch - if touch.pressed: - if _speech_placing: - return false - _begin_speech_bubble_pointer(touch.position) - if not _speech_bubble_pointer_pending: - get_viewport().set_input_as_handled() - return true - get_viewport().set_input_as_handled() - return true - if event is InputEventScreenDrag: - get_viewport().set_input_as_handled() - return true - return false - - -func _begin_speech_bubble_pointer(screen_position: Vector2) -> void: - if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null or not _speech_composer.visible: - return - if not _speech_stage_viewport_rect().has_point(screen_position): - return - var pointer_world: Vector2 = _viewport_to_world(screen_position) - var badge_index: int = _speech_preview.hit_test_order_badge(pointer_world) - var hits: Array[int] = [] - if badge_index >= 0: - hits.append(badge_index) - else: - hits = _speech_preview.hit_test_bubbles(pointer_world) - _speech_bubble_pointer_sequence += 1 - _speech_bubble_pointer_pending = true - _speech_bubble_pointer_moved = false - _speech_bubble_pointer_on_badge = badge_index >= 0 - _speech_bubble_pointer_start_screen = screen_position - _speech_bubble_pointer_start_world = _viewport_to_world(screen_position) - _speech_bubble_pointer_hits = hits - _speech_bubble_pointer_index = hits[0] if not hits.is_empty() else -1 - _speech_bubble_word_clicked = false - _speech_bubble_dragging = false + return false if _speech_session == null else _speech_session.is_open() func _update_speech_badge_cursor() -> void: - var should_point: bool = false - if _speech_composer_is_open() and not _speech_placing and not _is_awaiting_click() and not _is_mouse_over_ui(): - var mouse_position: Vector2 = get_viewport().get_mouse_position() - if _speech_stage_viewport_rect().has_point(mouse_position) and _speech_preview != null and is_instance_valid(_speech_preview): - should_point = _speech_preview.hit_test_order_badge(_viewport_to_world(mouse_position)) >= 0 + if _speech_session == null: + return + var mouse_position: Vector2 = get_viewport().get_mouse_position() + var should_point: bool = _speech_session.should_point_badge(mouse_position, not _is_awaiting_click() and not _is_mouse_over_ui()) if should_point == _speech_badge_cursor_active: return _speech_badge_cursor_active = should_point @@ -857,465 +1317,11 @@ func _update_speech_badge_cursor() -> void: Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND) else: _apply_cursor() - - -func _update_speech_bubble_pointer(screen_position: Vector2) -> void: - if _speech_preview == null or not is_instance_valid(_speech_preview): - _cancel_speech_bubble_pointer() - return - if not _speech_bubble_dragging: - if screen_position.distance_to(_speech_bubble_pointer_start_screen) < SPEECH_BUBBLE_DRAG_THRESHOLD: - return - _speech_bubble_pointer_moved = true - if _speech_bubble_pointer_on_badge: - return - if _speech_bubble_pointer_hits.is_empty(): - return - var drag_bubble_index: int = _speech_preview.hit_test_bubble_for_drag(_speech_bubble_pointer_start_world) - if drag_bubble_index < 0: - drag_bubble_index = _speech_bubble_pointer_index - _select_speech_bubble_from_stage(drag_bubble_index) - _speech_bubble_dragging = _speech_preview.begin_bubble_drag(_speech_bubble_pointer_start_world, drag_bubble_index) - if not _speech_bubble_dragging: - return - _speech_preview.update_bubble_drag(_viewport_to_world(screen_position)) - - -func _resolve_speech_bubble_pointer_click(sequence: int, screen_position: Vector2) -> void: - if not _speech_bubble_pointer_pending or sequence != _speech_bubble_pointer_sequence: - return - if not _speech_bubble_pointer_moved and not _speech_bubble_word_clicked: - if _speech_bubble_pointer_hits.is_empty(): - if _speech_composer != null and _speech_composer.visible: - _speech_composer.call("clear_stage_focus") - _clear_speech_bubble_pointer(sequence) - return - _select_speech_bubble_from_stage(_speech_bubble_pointer_index) - if _speech_bubble_pointer_hits.size() > 1: - _show_speech_bubble_picker(_speech_bubble_pointer_hits, screen_position) - _clear_speech_bubble_pointer(sequence) - - -func _clear_speech_bubble_pointer(sequence: int) -> void: - if sequence != _speech_bubble_pointer_sequence: - return - _speech_bubble_pointer_pending = false - _speech_bubble_pointer_moved = false - _speech_bubble_pointer_on_badge = false - _speech_bubble_pointer_hits.clear() - _speech_bubble_pointer_index = -1 - _speech_bubble_word_clicked = false - - -func _cancel_speech_bubble_pointer() -> void: - if _speech_bubble_dragging and _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.end_bubble_drag() - _speech_bubble_dragging = false - _speech_bubble_pointer_pending = false - _speech_bubble_pointer_moved = false - _speech_bubble_pointer_on_badge = false - _speech_bubble_pointer_hits.clear() - _speech_bubble_pointer_index = -1 - _speech_bubble_word_clicked = false - _speech_bubble_pointer_sequence += 1 - - -func _select_speech_bubble_from_stage(index: int) -> void: - if _speech_composer == null or not _speech_composer.visible: - return - if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")): - _speech_style_toolbar.call("close_chooser") - _speech_composer.call("select_bubble_from_stage", index) - if _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.set_selected_bubble_index(index) - _speech_preview.set_selected_token(-1, -1, -1) - _show_speech_style_toolbar() - - -func _on_speech_preview_word_selected(bubble_index: int, start: int, end: int) -> void: - _on_speech_preview_token_selected(bubble_index, start, end) - - -func _on_speech_preview_token_selected(bubble_index: int, start: int, end: int) -> void: - if _speech_bubble_pointer_pending: - _speech_bubble_word_clicked = true - if _speech_composer == null or not _speech_composer.visible: - return - if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")): - _speech_style_toolbar.call("close_chooser") - _speech_composer.call("select_bubble_token_from_stage", bubble_index, start, end) - if _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.set_selected_bubble_index(bubble_index) - _speech_preview.set_selected_token(bubble_index, start, end) - _show_speech_style_toolbar() - - -func _show_speech_bubble_picker(hits: Array[int], screen_position: Vector2) -> void: - if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker): - _speech_bubble_picker.hide() - var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer - if ui_layer == null or _speech_composer == null: - return - var picker := PopupPanel.new() - picker.name = "SpeechBubblePicker" - picker.exclusive = false - picker.transient = true - picker.unfocusable = false - ui_layer.add_child(picker) - var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 10) - margin.add_theme_constant_override("margin_right", 10) - margin.add_theme_constant_override("margin_top", 8) - margin.add_theme_constant_override("margin_bottom", 8) - picker.add_child(margin) - var column := VBoxContainer.new() - column.add_theme_constant_override("separation", 6) - margin.add_child(column) - var title := Label.new() - title.text = "Choose a bubble" - title.add_theme_font_size_override("font_size", 18) - column.add_child(title) - for index: int in hits: - var bubble_label: String = _speech_bubble_picker_label(index) - var choice := Button.new() - choice.text = bubble_label - choice.custom_minimum_size = Vector2(240.0, 44.0) - choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL - choice.pressed.connect(_on_speech_bubble_picker_selected.bind(index)) - column.add_child(choice) - var viewport_size: Vector2 = get_viewport_rect().size - var popup_width: int = mini(280, maxi(220, int(viewport_size.x) - 16)) - var popup_height: int = mini(80 + hits.size() * 50, maxi(100, int(viewport_size.y) - 16)) - picker.size = Vector2i(popup_width, popup_height) - var popup_position := Vector2i(screen_position + Vector2(12.0, 12.0)) - popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8)) - popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8)) - _speech_bubble_picker = picker - picker.popup_hide.connect(_on_speech_bubble_picker_closed.bind(picker)) - picker.position = popup_position - picker.popup() - - -func _speech_bubble_picker_label(index: int) -> String: - var bubbles: Array = [] - if _speech_composer != null and _speech_composer.has_method("get_draft"): - var draft: Dictionary = _speech_composer.call("get_draft") - bubbles = draft.get("bubbles", []) - if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: - return "Bubble %d · Empty" % (index + 1) - var text: String = String((bubbles[index] as Dictionary).get("text", "")).replace("\n", " ").strip_edges() - if text.is_empty(): - text = "Empty" - elif text.length() > 20: - text = text.substr(0, 19) + "…" - return "Bubble %d · %s" % [index + 1, text] - - -func _on_speech_bubble_picker_selected(index: int) -> void: - _select_speech_bubble_from_stage(index) - if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker): - _speech_bubble_picker.hide() - - -func _on_speech_bubble_picker_closed(picker: PopupPanel) -> void: - if _speech_bubble_picker == picker: - _speech_bubble_picker = null - if is_instance_valid(picker): - picker.queue_free() - - -func _on_speech_preview_line_tokens_requested(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void: - if _speech_bubble_pointer_pending: - _speech_bubble_word_clicked = true - _show_speech_token_picker(bubble_index, spans, viewport_position) - - -func _on_speech_preview_text_tap_detected(_bubble_index: int) -> void: - if _speech_bubble_pointer_pending: - _speech_bubble_word_clicked = true - - -func _show_speech_token_picker(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void: - if spans.is_empty() or _speech_composer == null: - return - if _speech_token_picker != null and is_instance_valid(_speech_token_picker): - _speech_token_picker.hide() - var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer - if ui_layer == null: - return - var picker: PopupPanel = PopupPanel.new() - picker.name = "SpeechTokenPicker" - picker.exclusive = false - picker.transient = true - picker.unfocusable = false - ui_layer.add_child(picker) - var margin: MarginContainer = MarginContainer.new() - margin.add_theme_constant_override("margin_left", 10) - margin.add_theme_constant_override("margin_right", 10) - margin.add_theme_constant_override("margin_top", 8) - margin.add_theme_constant_override("margin_bottom", 8) - picker.add_child(margin) - var column: VBoxContainer = VBoxContainer.new() - column.add_theme_constant_override("separation", 6) - margin.add_child(column) - var title: Label = Label.new() - title.text = "Choose a word or mark" - title.add_theme_font_size_override("font_size", 18) - column.add_child(title) - var scroll: ScrollContainer = ScrollContainer.new() - scroll.custom_minimum_size = Vector2(300.0, minf(320.0, float(spans.size()) * 54.0)) - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - column.add_child(scroll) - var choices: HFlowContainer = HFlowContainer.new() - choices.add_theme_constant_override("h_separation", 5) - choices.add_theme_constant_override("v_separation", 5) - scroll.add_child(choices) - var draft: Dictionary = _speech_composer.call("get_draft") - var bubbles: Array = draft.get("bubbles", []) - var text: String = String((bubbles[bubble_index] as Dictionary).get("text", "")) if bubble_index >= 0 and bubble_index < bubbles.size() and bubbles[bubble_index] is Dictionary else "" - for span: Vector2i in spans: - if span.x < 0 or span.y <= span.x or span.y > text.length(): - continue - var token: String = text.substr(span.x, span.y - span.x) - var choice: Button = Button.new() - choice.text = token - choice.custom_minimum_size = Vector2(maxf(52.0, minf(160.0, 22.0 + float(token.length()) * 14.0)), 48.0) - choice.pressed.connect(_on_speech_token_picker_selected.bind(bubble_index, span.x, span.y)) - choices.add_child(choice) - var viewport_size: Vector2 = get_viewport_rect().size - var popup_width: int = mini(360, maxi(220, int(viewport_size.x) - 16)) - var popup_height: int = mini(420, maxi(130, int(viewport_size.y) - 16)) - picker.size = Vector2i(popup_width, popup_height) - var popup_position: Vector2i = Vector2i(viewport_position + Vector2(12.0, 12.0)) - popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8)) - popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8)) - _speech_token_picker = picker - picker.popup_hide.connect(_on_speech_token_picker_closed.bind(picker)) - picker.position = popup_position - picker.popup() - - -func _on_speech_token_picker_selected(bubble_index: int, start: int, end: int) -> void: - _on_speech_preview_token_selected(bubble_index, start, end) - if _speech_token_picker != null and is_instance_valid(_speech_token_picker): - _speech_token_picker.hide() - - -func _on_speech_token_picker_closed(picker: PopupPanel) -> void: - if _speech_token_picker == picker: - _speech_token_picker = null - if is_instance_valid(picker): - picker.queue_free() - - -func _on_speech_style_chooser_open_requested(kind: StringName) -> void: - if _speech_composer == null or _speech_style_toolbar == null: - return - if bool(_speech_composer.call("is_message_dialog_open")): - _speech_composer.call("close_message_dialog") - _speech_composer.call("set_style_category", kind) - _speech_style_toolbar.call("set_context", _speech_composer.call("style_context")) - _speech_style_toolbar.call("present_chooser", kind) - - -func _on_speech_style_choice_selected(kind: StringName, value: String) -> void: - if _speech_composer == null: - return - _speech_composer.call("apply_style_choice", kind, value) - _speech_style_toolbar.call("set_context", _speech_composer.call("style_context")) - - -func _on_speech_style_sequence_action_requested(action: StringName) -> void: - if _speech_composer == null or not _speech_composer.visible: - return - match action: - &"earlier": _speech_composer.call("move_selected_bubble", -1) - &"later": _speech_composer.call("move_selected_bubble", 1) - &"add": - _speech_composer.call("add_bubble_after_selected") - call_deferred("_resolve_added_speech_bubble_collision") - &"remove": _speech_composer.call("remove_selected_bubble") - &"undo_remove": _speech_composer.call("undo_remove_bubble") - _refresh_speech_style_toolbar() - - -func _resolve_added_speech_bubble_collision() -> void: - if not _speech_composer_is_open() or _speech_preview == null or not is_instance_valid(_speech_preview): - return - var bubble_index: int = int(_speech_composer.call("selected_bubble_index")) - var suggestion: Dictionary = _speech_preview.suggest_added_bubble_center(bubble_index) - if not bool(suggestion.get("collided", false)): - return - var center: Variant = suggestion.get("center", null) - if center is Vector2: - _speech_composer.call("set_bubble_placement_preview", bubble_index, center) - - -func _on_speech_done_requested() -> void: - if _speech_composer != null and _speech_composer.visible: - _speech_composer.call("request_done_speech") - - -func _on_speech_cancel_requested() -> void: - if _speech_composer != null and _speech_composer.visible: - _speech_composer.call("request_cancel_speech") - - -func _on_speech_validation_failed(message: String, bubble_index: int) -> void: - _show_toast(message) - if bubble_index >= 0 and _speech_composer != null and _speech_composer.visible: - _speech_composer.call("open_message_dialog") - _refresh_speech_style_toolbar() - - -func _on_speech_show_choice_selected(key: StringName, value: Variant) -> void: - if _speech_composer == null or not _speech_composer.visible: - return - var action: Dictionary = _speech_composer.call("get_preview_action") - if key == &"connect_bubbles" and bool(value) and String(action.get("bubble_layout", "stack")) == "same_spot": - if _speech_preview == null or not is_instance_valid(_speech_preview): - return - var bubbles: Array = action.get("bubbles", []) - var centers: Array = _speech_preview.bubble_layout_centers(&"stack") - if bubbles.size() < 2 or centers.size() != bubbles.size(): - return - _speech_composer.call("apply_bubble_layout", centers, "stack") - _speech_composer.call("choose_speech_show", key, value) - _speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled")) - _refresh_speech_style_toolbar() - - -func _on_speech_layout_preset_requested(preset: StringName) -> void: - if _speech_composer == null or not _speech_composer.visible or _speech_preview == null or not is_instance_valid(_speech_preview): - return - var centers: Array = _speech_preview.bubble_layout_centers(preset) - var action: Dictionary = _speech_composer.call("get_preview_action") - var bubbles: Array = action.get("bubbles", []) - if centers.is_empty() or centers.size() != bubbles.size(): - return - _speech_composer.call("apply_bubble_layout", centers, String(preset)) - _speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled")) - _refresh_speech_style_toolbar() - - -func _on_speech_stage_focus_changed(bubble_index: int) -> void: - if _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.set_selected_bubble_index(bubble_index) - if bubble_index < 0: - _speech_preview.set_selected_token(-1, -1, -1) - _refresh_speech_style_toolbar() - - -func _on_speech_style_message_requested(opened: bool) -> void: - if _speech_composer == null or _speech_style_toolbar == null: - return - if opened: - _speech_style_toolbar.call("close_chooser") - _speech_composer.call("open_message_dialog") - else: - _speech_composer.call("close_message_dialog") - _refresh_speech_style_toolbar() - - -func _on_speech_message_dialog_closed() -> void: - if _speech_style_toolbar != null: - _speech_style_toolbar.call("set_message_dialog_open", false) - _show_speech_style_toolbar() - - -func _on_speech_style_done_requested() -> void: - if _speech_composer != null and bool(_speech_composer.call("is_message_dialog_open")): - _speech_composer.call("close_message_dialog") - if _speech_composer != null: - _speech_composer.call("clear_stage_focus") - - -func _show_speech_style_toolbar() -> void: - _refresh_speech_style_toolbar() - - -func _on_speech_style_context_changed(context: Dictionary) -> void: - if _speech_style_toolbar != null: - _speech_style_toolbar.call("set_context", context) - if _speech_preview != null and is_instance_valid(_speech_preview): - var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {} - _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) - var focus_index: int = int(_speech_composer.call("stage_focus_bubble_index")) - _speech_preview.set_selected_bubble_index(focus_index) - _speech_preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1))) - _refresh_speech_style_toolbar() - - -func _refresh_speech_style_toolbar() -> void: - if _speech_style_toolbar == null or _speech_show_toolbar == null or _speech_composer == null or _speech_preview == null or not is_instance_valid(_speech_preview) or not _speech_composer.visible: - if _speech_style_toolbar != null: - _speech_style_toolbar.hide() - if _speech_show_toolbar != null: - _speech_show_toolbar.hide() - _sync_speech_badge_avoidance() - return - var preview_state: String = String(_speech_composer.call("preview_state_name")) - var unavailable: bool = _speech_placing or _speech_bubble_dragging or preview_state != "static" or not _speech_preview.visible - var stage_focus_index: int = int(_speech_composer.call("stage_focus_bubble_index")) - if stage_focus_index < 0: - _speech_style_toolbar.hide() - _speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled")) - _speech_show_toolbar.call("set_busy", unavailable) - if unavailable: - _speech_show_toolbar.hide() - _sync_speech_badge_avoidance() - return - var group_rect: Rect2 = _speech_preview.group_frames_viewport_rect() - _sync_speech_badge_avoidance() - _speech_show_toolbar.call("set_anchor_rect", group_rect, _speech_stage_viewport_rect()) - return - _speech_show_toolbar.hide() - _speech_style_toolbar.call("set_busy", unavailable) - if unavailable: - _speech_style_toolbar.hide() - _sync_speech_badge_avoidance() - return - var context: Dictionary = _speech_composer.call("style_context") - _speech_style_toolbar.call("set_context", context) - var bubble_index: int = int(context.get("bubble_index", -1)) - var frame_rect: Rect2 = _speech_preview.selected_frame_viewport_rect(bubble_index) - _sync_speech_badge_avoidance() - _speech_style_toolbar.call("set_anchor_rect", frame_rect, _speech_stage_viewport_rect()) - _speech_composer.call("set_message_dialog_anchor", frame_rect, _speech_stage_viewport_rect(), _speech_style_toolbar.call("toolbar_viewport_rect")) - - -func _sync_speech_badge_avoidance() -> void: - var badge_rects: Array[Rect2] = [] - if _speech_preview != null and is_instance_valid(_speech_preview): - badge_rects = _speech_preview.order_badge_viewport_rects() - if _speech_show_toolbar != null and is_instance_valid(_speech_show_toolbar): - _speech_show_toolbar.call("set_badge_avoidance_rects", badge_rects) - if _speech_style_toolbar != null and is_instance_valid(_speech_style_toolbar): - _speech_style_toolbar.call("set_badge_avoidance_rects", badge_rects) - - func _unhandled_input(event: InputEvent) -> void: - if _speech_composer_is_open() and bool(_speech_composer.call("is_message_dialog_open")): - get_viewport().set_input_as_handled() + if _speech_session != null and _speech_session.handle_unhandled_input(event): + if _speech_session.should_mark_input_handled(): + get_viewport().set_input_as_handled() return - if _speech_bubble_pointer_pending: - var bubble_mouse_event: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT - var bubble_touch_event: bool = event is InputEventScreenTouch - if bubble_mouse_event or bubble_touch_event: - get_viewport().set_input_as_handled() - return - if _speech_composer_is_open(): - var speech_point: Vector2 = _speech_event_position(event) - var in_speech_stage: bool = speech_point.x >= 0.0 and speech_point.y >= 0.0 and _speech_stage_viewport_rect().has_point(speech_point) - var placement_press: bool = false - if _speech_placing and event is InputEventMouseButton: - var mouse_button: InputEventMouseButton = event as InputEventMouseButton - placement_press = mouse_button.pressed and (mouse_button.button_index == MOUSE_BUTTON_LEFT or mouse_button.button_index == MOUSE_BUTTON_RIGHT) - elif _speech_placing and event is InputEventScreenTouch: - placement_press = (event as InputEventScreenTouch).pressed - if in_speech_stage and not placement_press and (event is InputEventMouseButton or event is InputEventMouseMotion or event is InputEventScreenTouch or event is InputEventScreenDrag or event is InputEventGesture): - get_viewport().set_input_as_handled() - return if event is InputEventMouseButton: _handle_world_click(event) elif event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed and _speech_placing: @@ -1323,18 +1329,8 @@ func _unhandled_input(event: InputEvent) -> void: func _place_speech_bubble_at(viewport_position: Vector2) -> void: - if not _speech_placing or _speech_actor == null or not is_instance_valid(_speech_actor) or _speech_composer == null: - return - if _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.set_placement_ghost(false) - var local_center: Vector2 = _speech_actor.to_local(_viewport_to_world(viewport_position)) - if _speech_placement_bubble_index < 0: - _speech_composer.call("set_placement_preview", local_center) - else: - _speech_composer.call("set_bubble_placement_preview", _speech_placement_bubble_index, local_center) - _speech_placing = false - _speech_composer.call("set_placement_pending", false) - _speech_placement_bubble_index = 0 + if _speech_session != null: + _speech_session.place_bubble_at(viewport_position) func _unhandled_key_input(event: InputEvent) -> void: @@ -1354,9 +1350,8 @@ func _unhandled_key_input(event: InputEvent) -> void: delete_selected() KEY_ESCAPE: if _speech_placing: - _speech_placing = false - if _speech_composer != null: - _speech_composer.call("set_placement_pending", false) + if _speech_session != null: + _speech_session.cancel_placement() _refresh_status() elif not _palette_drag_payload.is_empty(): _cancel_palette_drag() @@ -1451,9 +1446,8 @@ func _handle_world_click(mb: InputEventMouseButton) -> void: return if _speech_placing: if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: - _speech_placing = false - if _speech_composer != null: - _speech_composer.call("set_placement_pending", false) + if _speech_session != null: + _speech_session.cancel_placement() return if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: if _speech_actor != null and is_instance_valid(_speech_actor) and _speech_composer != null: @@ -1649,8 +1643,7 @@ func _enter_direct_mode(previous_mode: StageMode) -> void: func _enter_play_mode() -> void: - _edit_physics_targets.clear() - _edit_physics_settle_frames = 0 + _run_session.clear_edit_physics_targets() _gizmos.set_enabled(false) _selection.clear_selection() set_placement_mode("") @@ -1674,51 +1667,16 @@ func _start_run() -> void: # child immediately presses PLAY, those delayed writes must not put an # authored prop scale back on the root after begin_play_instance_scale() # moved it to the visual/collision children. - _restore_frames_left = 0 - _elapsed_seconds = 0.0 + _run_session.prepare_start() _set_timestamp(0.0) - _run_state = RunState.RUNNING - _event_dispatch_ready = true - _resume_baseline_frames = 0 - _prop_pause_states.clear() - _area_overlap.clear() - _area_previous_bounds.clear() - _collision_pairs.clear() - _suppressed_contact_pairs.clear() - for node: Node2D in _world_children_selectable(): - if node is PropBlock: - var prop := node as PropBlock - _trace_prop_scale(prop, "before_play_scale_transfer") - prop.begin_play_instance_scale() - _trace_prop_scale(prop, "after_play_scale_transfer") - 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") - elif node is STICKMAN_RIG: - var rig := 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 + _run_session.start_run(_world_children_selectable(), _rule_engine, StageMode.keys()[int(current_mode)], _camera.zoom) _reconcile_prop_contacts() _sync_run_controls() func _pause_run() -> void: - if _run_state != RunState.RUNNING: + if not _run_session.pause_run(_world_children_selectable()): return - _event_dispatch_ready = false - _run_state = RunState.PAUSED - _resume_baseline_frames = 0 - for node: Node2D in _world_children_selectable(): - if node is STICKMAN_RIG: - (node as StickmanRig).pause_simulation() - elif node is RigidBody2D: - _capture_prop_pause_state(node as RigidBody2D) _refresh_event_edge_baseline() _gizmos.set_enabled(false) _selection.clear_selection() @@ -1733,69 +1691,21 @@ func _resume_run() -> void: if _run_state != RunState.PAUSED: return _clear_director_pending() - 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_children_selectable(): - if node is STICKMAN_RIG: - (node as StickmanRig).resume_simulation() - _run_state = RunState.RUNNING - _event_dispatch_ready = false - _resume_baseline_frames = 2 - _area_overlap.clear() - _area_previous_bounds.clear() - _collision_pairs.clear() - _suppressed_contact_pairs.clear() + if not _run_session.resume_run(_world_children_selectable(), _rule_engine): + return _sync_run_controls() simulation_resumed.emit(_elapsed_seconds) func _reset_run() -> void: print("[PropTrace] stage reset_run mode=%s run_state=%s" % [StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)]]) - _event_dispatch_ready = false - _run_state = RunState.STOPPED - _resume_baseline_frames = 0 - _paused_edit_autosave_remaining = -1.0 - _prop_pause_states.clear() - for node: Node2D in _world_children_selectable(): - if node is STICKMAN_RIG: - var rig := 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 := node as RigidBody2D - if node is PropBlock: - var prop := node as PropBlock - var authored: Dictionary = _authored.get(prop.get_instance_id(), {}) - _trace_prop_scale(prop, "before_stop_scale_restore") - 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") - body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC - body.freeze = true - body.linear_velocity = Vector2.ZERO - body.angular_velocity = 0.0 - _restore_authored_state() + _run_session.begin_reset() + _run_session.reset_world(_world_children_selectable(), StageMode.keys()[int(current_mode)], _camera.zoom) _reconcile_prop_contacts() - _restore_frames_left = 3 - _area_overlap.clear() - _area_previous_bounds.clear() - _collision_pairs.clear() - _suppressed_contact_pairs.clear() - _elapsed_seconds = 0.0 + _run_session.arm_authored_restore() + if _rule_engine != null: + _rule_engine.reset_runtime_state() + _run_session.reset_elapsed() _set_timestamp(0.0) _director_visuals.set_live_session(false) _sync_run_controls() @@ -1803,34 +1713,19 @@ func _reset_run() -> void: 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 + return _run_session.capture_prop_pause_state(body) 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] + if _chrome != null: + _chrome.set_timestamp(seconds) func _sync_run_controls() -> void: var running: bool = _run_state == RunState.RUNNING var paused: bool = _run_state == RunState.PAUSED - if _pause_button != null: - _pause_button.visible = running and current_mode == StageMode.PLAY - if _resume_button != null: - _resume_button.visible = paused and current_mode == StageMode.DIRECT - if _reset_button != null: - _reset_button.visible = running or paused - if _timestamp_badge != null: - _timestamp_badge.visible = true + if _chrome != null: + _chrome.set_run_controls(running and current_mode == StageMode.PLAY, paused and current_mode == StageMode.DIRECT, running or paused) func _finish_resume_baseline() -> void: @@ -1851,171 +1746,34 @@ func _pause_toggle_blocked() -> bool: func _refresh_event_edge_baseline() -> void: - var areas: Array[TriggerArea] = [] - for child: Node in _world.get_children(): - if child is TriggerArea: - areas.append(child as TriggerArea) - var movables: Array[Node2D] = [] - for node: Node2D in _world_children_selectable(): - if node is STICKMAN_RIG 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 child: Node2D in _world_children_selectable(): - if not child is PropBlock: - continue - var prop := child as PropBlock - for other: Node in prop.get_colliding_bodies(): - if other == null or not is_instance_valid(other): - continue - _suppressed_contact_pairs["%d:%d" % [prop.get_instance_id(), other.get_instance_id()]] = 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 = "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()] - 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 - + if _rule_engine != null: + _rule_engine.refresh_event_edge_baseline(_world_children_selectable()) func _save_object_state(node: Node2D) -> void: - var authored_rotation := node.global_rotation - if node is STICKMAN_RIG 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 := { - "node": node, - "position": node.global_position, - "rotation": authored_rotation, - } - if node is STICKMAN_RIG: - entry["facing_profile"] = (node as STICKMAN_RIG).get_facing_profile() - if not node is STICKMAN_RIG: - entry["scale"] = node.scale - if node is PropBlock: - entry["roles"] = (node as PropBlock).stage_roles.duplicate(true) - _authored[node.get_instance_id()] = entry + _run_session.save_object_state(node) func _clear_object_state(node: Node2D) -> void: - _authored.erase(node.get_instance_id()) + _run_session.clear_object_state(node) func _trace_changed_prop_scales() -> void: - if not PROP_SCALE_TRACE_ENABLED or _world == null: - return - var current_prop_ids: Dictionary = {} - for child: Node in _world.get_children(): - if not child is PropBlock or not is_instance_valid(child): - continue - var prop := child as PropBlock - var instance_id := prop.get_instance_id() - current_prop_ids[instance_id] = true - var snapshot := _prop_scale_trace_signature(prop) - if String(_prop_scale_trace_previous.get(instance_id, "")) != snapshot: - _trace_prop_scale(prop, "observed_scale_change") - 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) + if _run_session != null and _world != null: + _run_session.trace_changed_prop_scales(_world.get_children(), StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)], _camera.zoom) func _trace_prop_scale(prop: PropBlock, event: String) -> void: - if not PROP_SCALE_TRACE_ENABLED or prop == null or not is_instance_valid(prop): - return - var snapshot := _prop_scale_trace_signature(prop) - _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, - StageMode.keys()[int(current_mode)], - RunState.keys()[int(_run_state)], - _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, - ]) + if _run_session != null: + _run_session.trace_prop_scale(prop, event, StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)], _camera.zoom) func _prop_scale_trace_signature(prop: PropBlock) -> String: - var polygon := prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D - var outline := prop.get_node_or_null(NodePath("Outline")) as Line2D - var visuals := prop.get_node_or_null(NodePath("Visuals")) as Node2D - var collision_polygon := prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D - var polygon_local := polygon.scale if polygon != null else Vector2.ONE - var polygon_global_x := polygon.global_transform.x.length() if polygon != null else 0.0 - var polygon_global_y := polygon.global_transform.y.length() if polygon != null else 0.0 - var outline_local := outline.scale if outline != null else Vector2.ONE - var outline_global_x := outline.global_transform.x.length() if outline != null else 0.0 - var outline_global_y := outline.global_transform.y.length() if outline != null else 0.0 - var visuals_local := visuals.scale if visuals != null else Vector2.ONE - var visuals_global_x := visuals.global_transform.x.length() if visuals != null else 0.0 - var visuals_global_y := visuals.global_transform.y.length() if visuals != null else 0.0 - var collision_point := 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) if _camera != null else "", - ] + return "" if _run_session == null else _run_session.prop_scale_trace_signature(prop, _camera.zoom) func _restore_authored_state() -> void: - for id: int in _authored.keys(): - var entry: Dictionary = _authored[id] - var node := entry.get("node") as Node2D - if node == null or not is_instance_valid(node): - _authored.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") - node.scale = entry["scale"] - if node is PropBlock: - _trace_prop_scale(node as PropBlock, "after_authored_scale_write") - if node is STICKMAN_RIG and entry.has("facing_profile"): - (node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"])) - if node is RigidBody2D: - var body := node as RigidBody2D - body.linear_velocity = Vector2.ZERO - body.angular_velocity = 0.0 - if node is PropBlock: - var prop := node as PropBlock - prop.set_stage_roles(entry.get("roles", prop.stage_roles)) - prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT) + if _run_session != null: + _run_session.restore_authored_state(StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)], _camera.zoom) # --------------------------------------------------------------------------- # Placement @@ -2185,100 +1943,41 @@ func _is_terrain_placement() -> bool: return _spawner.is_terrain_id(_placement_id) -## Paint stride = the active terrain template's AABB extent per axis (decision -## D1). Falls back to TERRAIN_GRID_SIZE for a non-terrain / empty placement so -## the quantizers never divide by zero. func _terrain_stride() -> 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 + return _terrain_painter.terrain_stride(_placement_id) func _world_to_terrain_cell(pos: Vector2) -> Vector2i: - var s := _terrain_stride() - return Vector2i(int(roundf(pos.x / s.x)), int(roundf(pos.y / s.y))) + return _terrain_painter.world_to_terrain_cell(pos, _placement_id) func _terrain_cell_center(cell: Vector2i) -> Vector2: - var s := _terrain_stride() - return Vector2(cell.x * s.x, cell.y * s.y) + return _terrain_painter.terrain_cell_center(cell, _placement_id) ## 16-px grid cell for the advisory _grid_cells dictionary lookups (NOT the ## block-unit paint cell). Used by _is_target_valid. func _world_to_grid_cell(pos: Vector2) -> Vector2i: - var gs := STAGE_SPAWNER.TERRAIN_GRID_SIZE - return Vector2i(int(roundf(pos.x / gs)), int(roundf(pos.y / gs))) + return _spatial_index.world_to_cell(pos) ## World AABB of the block that would be stamped at `cell` (block center + ## the template's local AABB). func _block_aabb(cell: Vector2i) -> Rect2: - var local: Rect2 = _spawner.get_template_aabb(_placement_id) - return Rect2(_terrain_cell_center(cell) + local.position, local.size) + return _terrain_painter.block_aabb(cell, _placement_id) func _begin_terrain_drag(world_pos: Vector2) -> void: - if _snap_enabled: - world_pos = _snap_to_grid(world_pos) _free_ghost() - _drag_painted.clear() - _terrain_dragging = true - _terrain_painting = false - _terrain_press_world = world_pos - _terrain_anchor_cell = _world_to_terrain_cell(world_pos) - _terrain_target_cell = _terrain_anchor_cell + _terrain_painter.begin(_placement_id, world_pos, _snap_enabled, _grid_size) func _update_terrain_drag(world_pos: Vector2) -> void: - if not _terrain_dragging: - return - if not _terrain_painting: - var zoom: float = _camera.zoom.x if _camera != null else 1.0 - if (world_pos - _terrain_press_world).length() * zoom < TERRAIN_DRAG_THRESHOLD_PX: - return - _terrain_painting = true - var target := _world_to_terrain_cell(world_pos) - if Input.is_key_pressed(KEY_SHIFT): - var d := target - _terrain_anchor_cell - if absi(d.x) >= absi(d.y): - target = Vector2i(_terrain_anchor_cell.x + d.x, _terrain_anchor_cell.y) - else: - target = Vector2i(_terrain_anchor_cell.x, _terrain_anchor_cell.y + d.y) - _terrain_target_cell = target - _drag_cells = _bresenham_cells(_terrain_anchor_cell, target) - _refresh_terrain_ghosts() - if _placement_overlay != null: - if target != _terrain_anchor_cell: - _placement_overlay.set_terrain_guide(_terrain_cell_center(_terrain_anchor_cell), _terrain_cell_center(target)) - else: - _placement_overlay.clear_terrain_guide() + _terrain_painter.update(world_pos, _placement_id) 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 err: int = dx + dy - while true: - cells.append(Vector2i(x0, y0)) - if x0 == x1 and y0 == y1: - break - var e2: int = 2 * err - if e2 >= dy: - err += dy - x0 += sx - if e2 <= dx: - err += dx - y0 += sy - return cells + return STAGE_TERRAIN_PAINTER.bresenham_cells(a, b) ## Three-state classification per block cell: 1 empty, 2 same-type skip, 3 @@ -2288,93 +1987,25 @@ func _bresenham_cells(a: Vector2i, b: Vector2i) -> Array[Vector2i]: ## skip. Cells stamped earlier in the current drag (in-drag self-overlap) read ## as same-type skip. func _classify_cell(cell: Vector2i) -> int: - if _drag_painted.has(cell): - return 2 - var aabb := _block_aabb(cell) - var seen: Dictionary = {} - for c: Vector2i in _rasterize_aabb_to_cells(aabb): - for n in _grid_cells.get(c, []): - if n is Node2D: - seen[(n as Node2D).get_instance_id()] = n - var has_same := false - for n in seen.values(): - var node := n as Node2D - if node == null: - continue - if not _aabb_overlaps(aabb, STAGE_SELECTION.get_world_aabb(node)): - continue - if node is TerrainBlock and (node as TerrainBlock).spawn_id == _placement_id: - has_same = true - continue - return 3 - return 2 if has_same else 1 + return _terrain_painter.classify_cell(cell, _placement_id) ## Strict interior overlap (excludes edge/corner contact) so blocks that tile ## edge-to-edge do not register as overlapping. 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 + return STAGE_TERRAIN_PAINTER.aabb_overlaps(a, b) func _refresh_terrain_ghosts() -> void: - if _drag_cells == _last_drag_cells: - return - _last_drag_cells = _drag_cells.duplicate() - _free_terrain_ghosts() - if _placement_id == "" or not _is_terrain_placement(): - return - for cell: Vector2i in _drag_cells: - var state := _classify_cell(cell) - var ghost := _spawner.spawn(_placement_id, _terrain_cell_center(cell)) - 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) - _ghost_array.append(ghost) + _terrain_painter.refresh_terrain_ghosts() func _free_terrain_ghosts() -> void: - for g: Node2D in _ghost_array: - if g != null and is_instance_valid(g): - g.queue_free() - _ghost_array.clear() + _terrain_painter.clear_terrain_ghosts() func _commit_terrain_drag() -> void: - var placed: Array[Node2D] = [] - if not _terrain_painting: - var click_pos: Vector2 = _snap_to_grid(_terrain_press_world) if _snap_enabled else _terrain_press_world - var click_node: Node2D = _spawner.spawn(_placement_id, click_pos) - if click_node != null: - _finalize_placed_node(click_node) - object_placed.emit(click_node) - placed.append(click_node) - for cell: Vector2i in _drag_cells: - if _classify_cell(cell) != 1: - continue - var node := _spawner.spawn(_placement_id, _terrain_cell_center(cell)) - if node == null: - continue - _finalize_placed_node(node) - object_placed.emit(node) - placed.append(node) - _drag_painted[cell] = true - if not placed.is_empty(): - _nav_dirty = true - _rebuild_grid_cells() - _mark_scene_dirty() - _end_terrain_drag() - _refresh_status() + _terrain_painter.commit(_placement_id, _snap_enabled, _grid_size) func _cancel_terrain_drag() -> void: @@ -2382,54 +2013,43 @@ func _cancel_terrain_drag() -> void: func _end_terrain_drag() -> void: - _terrain_dragging = false - _terrain_painting = false - _terrain_press_world = Vector2.ZERO - _drag_cells.clear() - _last_drag_cells.clear() - _drag_painted.clear() - _free_terrain_ghosts() - if _placement_overlay != null: - _placement_overlay.clear_terrain_guide() + _terrain_painter.cancel() # Re-arm the single cursor-following ghost (LMB repeated placement) unless # the tool was just put away (placement_id cleared). if _placement_id != "": _spawn_ghost() + +func _on_terrain_placement_requested(node: Node2D) -> void: + if node == null or not is_instance_valid(node): + return + _finalize_placed_node(node) + object_placed.emit(node) + + +func _on_terrain_stroke_finished(placed_count: int) -> void: + if placed_count > 0: + _nav_dirty = true + _rebuild_grid_cells() + _mark_scene_dirty() + if _placement_id != "": + _spawn_ghost() + _refresh_status() + # --------------------------------------------------------------------------- # Grid spatial dictionary (Phase 4b) — advisory only, never authoritative. # --------------------------------------------------------------------------- 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 gs := STAGE_SPAWNER.TERRAIN_GRID_SIZE - var min_x := int(floorf(aabb.position.x / gs)) - var max_x := int(floorf((aabb.end.x - 0.0001) / gs)) - var min_y := int(floorf(aabb.position.y / gs)) - var max_y := int(floorf((aabb.end.y - 0.0001) / gs)) - 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 + return STAGE_SPATIAL_INDEX.rasterize_aabb_to_cells(aabb) func _rebuild_grid_cells() -> void: - _grid_cells.clear() - for node: Node2D in _world_children_selectable(): - _add_node_to_grid_cells(node) + _spatial_index.rebuild(_world_children_selectable()) func _add_node_to_grid_cells(node: Node2D) -> void: - if node == null or not is_instance_valid(node) or node.is_queued_for_deletion(): - return - var aabb := STAGE_SELECTION.get_world_aabb(node) - for cell: Vector2i in _rasterize_aabb_to_cells(aabb): - var list: Array = _grid_cells.get(cell, []) - if not list.has(node): - list.append(node) - _grid_cells[cell] = list + _spatial_index.add(node) # --------------------------------------------------------------------------- # Selection @@ -2504,14 +2124,16 @@ func delete_selected() -> void: # --------------------------------------------------------------------------- func _mark_scene_dirty() -> void: - if not _suppress_dirty: - _set_scene_dirty(true) - if _run_state == RunState.PAUSED: - _paused_edit_autosave_remaining = 1.0 + if _document == null or not _document.mark_dirty(_suppress_dirty): + return + _update_scene_chrome() + if _run_state == RunState.PAUSED: + _paused_edit_autosave_remaining = 1.0 func _set_scene_dirty(value: bool) -> void: - _dirty = value + if _document != null: + _document.set_dirty(value) _update_scene_chrome() @@ -2523,65 +2145,18 @@ func _on_rig_queue_changed() -> void: func _collect_scene_data(is_autosave: bool = false) -> 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 n := node as Node2D - var type := "" - if n is TerrainBlock: type = "terrain" - elif n is PropBlock or n.get_script() == MISSING_PROP_PLACEHOLDER: type = "prop" - elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman" - elif n is TriggerArea: type = "area" - else: continue - counters[type] = int(counters[type]) + 1 - var file_id := "%s_%d" % [type, int(counters[type])] - runtime_to_file[n.get_instance_id()] = file_id - var authored: Dictionary = _authored.get(n.get_instance_id(), {}) - var pos: Vector2 = authored.get("position", n.global_position) - var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation))) - var entry: Dictionary = {"file_id": file_id, "type": type, "position": STAGE_SCENE_IO.vector_to_data(pos), "rotation": rot} - if type == "terrain": - entry["asset_id"] = (n as TerrainBlock).spawn_id - entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale) - elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER: - entry = (n.get("original_entry") as Dictionary).duplicate(true) - entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale) - entry["stage_object_id"] = String(n.get("stage_object_id")) - elif type == "prop": - var prop := n 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.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 := n as TriggerArea - entry["scale"] = STAGE_SCENE_IO.vector_to_data(area.scale) - entry["size"] = STAGE_SCENE_IO.vector_to_data(area.size) - elif n.get_script() == MISSING_STICKMAN_PLACEHOLDER: - entry = (n.get("original_entry") as Dictionary).duplicate(true) - entry["file_id"] = file_id; entry["type"] = "stickman"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot - else: - var rig := n as StickmanRig - entry["stk_path"] = rig.source_stk_path - entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_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(_event_rules, runtime_to_file) - return {"version": STAGE_SCENE_IO.FILE_VERSION, "scene_name": _scene_name, "camera": {"position": STAGE_SCENE_IO.vector_to_data(_camera.global_position), "zoom": _camera.zoom.x}, "objects": objects, "event_rules": rules_result["rules"], "metadata": {"created_unix": _scene_created_unix if _scene_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_scene_path) if is_autosave else ""}} - + if _scene_adapter == null or _document == null: + return {} + var camera_state: Dictionary = {"position": _camera.global_position, "zoom": _camera.zoom.x} + var document_metadata: Dictionary = { + "scene_name": _scene_name, + "metadata": _document.metadata_for_snapshot(is_autosave), + } + return _scene_adapter.collect(_world, _authored, _event_rules, camera_state, document_metadata) func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool: path = CONTENT_PATHS.canonicalize(path) - if _speech_composer_is_open(): + if _speech_session != null and _speech_session.is_save_blocked(): if is_autosave: return false _speech_pending_scene_save_path = path @@ -2600,41 +2175,23 @@ func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool: if _pending_file_action != PendingFileAction.NONE: _pending_file_action = PendingFileAction.NONE return false - var result: Dictionary = STAGE_SCENE_IO.write_file_safe(path, _collect_scene_data(is_autosave)) - if not bool(result.get("ok", false)): - _show_toast(String(result.get("error", "Could not save scene."))) - if _pending_file_action != PendingFileAction.NONE: _pending_file_action = PendingFileAction.NONE + if _document == null or not _document.save(path, _collect_scene_data(is_autosave), is_autosave): + var error_message: String = "Could not save scene." if _document == null else _document.last_error + _show_toast(error_message) + if _pending_file_action != PendingFileAction.NONE: + _pending_file_action = PendingFileAction.NONE return false if not is_autosave: _paused_edit_autosave_remaining = -1.0 - _current_scene_path = path - if _scene_created_unix <= 0: _scene_created_unix = Time.get_unix_time_from_system() - _set_scene_dirty(false); _add_recent_scene(path) - if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH) + _set_scene_dirty(false) + _save_settings() + _update_recent_menu() if (_speech_composer == null or not _speech_composer.visible) and not _current_scene_path.is_empty(): _speech_recovery_matches_loaded_scene() return true - func _validate_queues_for_save() -> String: - 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 "" + return "" if _document == null else _document.validate_queues(_world) func _reset_scene_content() -> void: @@ -2646,12 +2203,15 @@ func _reset_scene_content() -> void: for child: Node in _world.get_children(): _world.remove_child(child) child.queue_free() - _area_overlap.clear(); _collision_pairs.clear(); _grid_cells.clear(); _director_visuals.set_rules(_event_rules) + if _rule_engine != null: + _rule_engine.reset_runtime_state() + _spatial_index.clear() + _director_visuals.set_rules(_event_rules) func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool: path = CONTENT_PATHS.canonicalize(path) - var read: Dictionary = STAGE_SCENE_IO.read_file(path) + var read: Dictionary = _document.read(path) if not bool(read.get("ok", false)): _show_toast(String(read.get("error", "Could not load scene."))); return false return _apply_scene_data(read["data"], path, is_recovery) @@ -2659,105 +2219,45 @@ func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool: func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool: source_path = CONTENT_PATHS.canonicalize(source_path) - var was_suppressed := _suppress_dirty; _suppress_dirty = true + if _scene_adapter == null: + return false + var was_suppressed: bool = _suppress_dirty + _suppress_dirty = true _reset_scene_content() - var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = [] - 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(entry.get("file_id", "")); var type := 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 pos := 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", "")), pos) - "prop": - node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos) - 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 = pos; _world.add_child(prop_placeholder); node = prop_placeholder - "area": node = _spawner.spawn_area(pos) - "stickman": - node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos) - if node == null: - var placeholder: Node2D = MISSING_STICKMAN_PLACEHOLDER.new() as Node2D - placeholder.configure(entry); placeholder.position = pos; _world.add_child(placeholder); node = placeholder; unavailable[file_id] = true - missing_stickmen += 1 - var missing_path := 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 := 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 loaded_prop := node as PropBlock - loaded_prop.stage_object_id = String(entry.get("stage_object_id", "")) - var default_roles: Dictionary = PROP_STAGE_ROLES.new_placement(loaded_prop.has_usable_grip()) - if entry.get("roles", null) is Dictionary: - loaded_prop.set_stage_roles(PROP_STAGE_ROLES.normalize(entry["roles"], default_roles)) - else: - loaded_prop.set_stage_roles(PROP_STAGE_ROLES.legacy_placement(loaded_prop.has_usable_grip())) - var preset_names: Array = PropBlock.MaterialPreset.keys() - var raw_preset: Variant = entry.get("material_preset", PropBlock.MaterialPreset.NONE) - var index := -1 - if raw_preset is int or raw_preset is float: - var numeric_preset := float(raw_preset) - if is_finite(numeric_preset) and is_equal_approx(numeric_preset, roundf(numeric_preset)): - index = int(numeric_preset) - else: - index = preset_names.find(String(raw_preset)) - if index >= 0 and index < preset_names.size(): - loaded_prop.material_preset = index - else: - warnings.append("Invalid prop material preset") - if node is StickmanRig: - var facing_names: Array = StickmanRig.FacingProfile.keys(); var facing := facing_names.find(String(entry.get("facing_profile", "FORWARD"))) - if facing >= 0: (node as StickmanRig).set_facing_profile(facing) - 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) - _finalize_placed_node(node); _save_object_state(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) - _event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0 - for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1) + var adapter_result: Dictionary = _scene_adapter.apply(data, _world, _spawner) + var loaded_nodes: Array[Node2D] = adapter_result.get("loaded_nodes", []) + for node: Node2D in loaded_nodes: + _finalize_placed_node(node) + _save_object_state(node) + var file_to_runtime: Dictionary = adapter_result.get("file_to_runtime", {}) + var unavailable: Dictionary = adapter_result.get("unavailable", {}) + var warnings: Array[String] = adapter_result.get("warnings", []) + var missing_stickmen: int = int(adapter_result.get("missing_stickmen", 0)) + var missing_paths: Array[String] = adapter_result.get("missing_paths", []) + var decoded_rules: Dictionary = adapter_result.get("decoded_rules", {}) + _event_rules.assign(decoded_rules.get("rules", [])) + _next_rule_id = 0 + for rule: Dictionary in _event_rules: + _next_rule_id = maxi(_next_rule_id, int(rule.get("id", -1)) + 1) _director_visuals.set_rules(_event_rules) var raw_camera: Variant = data.get("camera", {}) - var camera: Dictionary = raw_camera as Dictionary if raw_camera is Dictionary else {} - _camera.global_position = STAGE_SCENE_IO.data_to_vector(camera.get("position"), DEFAULT_CAMERA_POSITION) - _camera.zoom = Vector2.ONE * clampf(float(camera.get("zoom", DEFAULT_CAMERA_ZOOM)), min_zoom, max_zoom) - _scene_name = String(data.get("scene_name", DEFAULT_SCENE_NAME)).strip_edges(); if _scene_name.is_empty(): _scene_name = DEFAULT_SCENE_NAME + var camera_data: Dictionary = raw_camera as Dictionary if raw_camera is Dictionary else {} + _camera.global_position = STAGE_SCENE_IO.data_to_vector(camera_data.get("position"), DEFAULT_CAMERA_POSITION) + _camera.zoom = Vector2.ONE * clampf(float(camera_data.get("zoom", DEFAULT_CAMERA_ZOOM)), min_zoom, max_zoom) var raw_metadata: Variant = data.get("metadata", {}) var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {} - _scene_created_unix = int(metadata.get("created_unix", Time.get_unix_time_from_system())) - _current_scene_path = CONTENT_PATHS.canonicalize(String(metadata.get("source_path", source_path))) if is_recovery else source_path - _rebuild_grid_cells(); _nav_dirty = true; _suppress_dirty = was_suppressed; _set_scene_dirty(is_recovery) + if _document != null: + _document.accept_loaded_document( + source_path, + String(data.get("scene_name", DEFAULT_SCENE_NAME)), + int(metadata.get("created_unix", Time.get_unix_time_from_system())), + String(metadata.get("source_path", source_path)), + is_recovery + ) + _rebuild_grid_cells() + _nav_dirty = true + _suppress_dirty = was_suppressed + _set_scene_dirty(is_recovery) if not warnings.is_empty() or int(decoded_rules.get("skipped", 0)) > 0 or missing_stickmen > 0: _show_toast("Loaded with %d warning(s), %d missing stickman, %d skipped rule(s)" % [warnings.size(), missing_stickmen, int(decoded_rules.get("skipped", 0))]) if not warnings.is_empty(): @@ -2766,10 +2266,10 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) _show_missing_assets_dialog(missing_paths) if not is_recovery: _add_recent_scene(source_path) - if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH) + if _document != null: + _document.remove_autosave() return true - func _show_scene_load_warnings(warnings: Array[String]) -> void: var dialog: AcceptDialog = AcceptDialog.new() dialog.title = "Some saved actions need a look" @@ -2798,10 +2298,9 @@ func _show_missing_assets_dialog(paths: Array[String]) -> void: func _add_recent_scene(path: String) -> void: - path = CONTENT_PATHS.canonicalize(path) - if path.is_empty() or path == AUTOSAVE_PATH: return - _recent_scenes.erase(path); _recent_scenes.push_front(path) - while _recent_scenes.size() > RECENT_SCENE_LIMIT: _recent_scenes.pop_back() + if _document == null: + return + _document.add_recent_path(path) _save_settings() _update_recent_menu() @@ -2810,61 +2309,46 @@ func _update_recent_menu() -> void: if _file_menu == null: return var recent := _file_menu.get_popup().get_node_or_null(NodePath("OpenRecent")) as PopupMenu if recent == null: return - recent.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 := existing != _recent_scenes - _recent_scenes = existing - if pruned: _save_settings() - if _recent_scenes.is_empty(): - recent.add_item("(No recent scenes)", -1); recent.set_item_disabled(0, true) - else: - for index: int in _recent_scenes.size(): recent.add_item(_recent_scenes[index], index) + if _document != null and _document.populate_recent_menu(recent): + _save_settings() func _on_recent_scene_selected(index: int) -> void: - if index < 0 or index >= _recent_scenes.size(): return - var path := _recent_scenes[index] - if not FileAccess.file_exists(path): - _recent_scenes.remove_at(index); _save_settings(); _show_toast("File not found: %s" % path); return - _request_pending_file_action(PendingFileAction.OPEN_PATH, path) + if _document == null: + return + var selection: Dictionary = _document.resolve_recent_selection(index) + if bool(selection.get("missing", false)): + _save_settings() + _show_toast("File not found: %s" % String(selection.get("path", ""))) + return + if bool(selection.get("valid", false)): + _request_pending_file_action(PendingFileAction.OPEN_PATH, String(selection.get("path", ""))) func _update_scene_chrome() -> void: - if is_inside_tree(): - var scene_file := _current_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] + if _chrome != null: + _chrome.set_scene_title(_scene_name, _current_scene_path, _dirty) _refresh_status() func _check_autosave_recovery() -> void: - if not FileAccess.file_exists(AUTOSAVE_PATH): return - var read: Dictionary = STAGE_SCENE_IO.read_file(AUTOSAVE_PATH) - if not bool(read.get("ok", false)): - DirAccess.remove_absolute(AUTOSAVE_PATH); return - var autosave_data: Dictionary = read["data"] as Dictionary - var raw_metadata: Variant = autosave_data.get("metadata", {}) - var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {} - if not bool(metadata.get("autosave", false)): - DirAccess.remove_absolute(AUTOSAVE_PATH); return - var source_path: String = CONTENT_PATHS.canonicalize(String(metadata.get("source_path", ""))) - if not source_path.is_empty() and FileAccess.file_exists(source_path) and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)): - DirAccess.remove_absolute(AUTOSAVE_PATH); return + if _document == null: + return + var inspection: Dictionary = _document.inspect_autosave() + if not bool(inspection.get("available", false)): + return + var autosave_data: Dictionary = inspection.get("data", {}) as Dictionary var dialog := ConfirmationDialog.new() dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?" - dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free()) - dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free()) + dialog.confirmed.connect(func() -> void: _apply_scene_data(autosave_data, AUTOSAVE_PATH, true); dialog.queue_free()) + dialog.canceled.connect(func() -> void: _document.remove_autosave(); dialog.queue_free()) get_node(NodePath("UI")).add_child(dialog); _apply_dialog_theme(dialog); dialog.popup_centered() func _handle_cli_startup(args: Array) -> void: if OS.get_environment("STICKMAN_WALK_DEBUG") == "1": print("[WalkDebug] CLI_STARTUP args=%s" % [args]) - var result: Dictionary = CliSceneLauncher.parse(args) + var result: Dictionary = _document.parse_cli_request(args) if not String(result.get("error", "")).is_empty(): _show_cli_error_dialog(String(result["error"])) _check_autosave_recovery() @@ -2873,8 +2357,8 @@ func _handle_cli_startup(args: Array) -> void: if scene_path.is_empty(): _check_autosave_recovery() return - if FileAccess.file_exists(AUTOSAVE_PATH): - DirAccess.remove_absolute(AUTOSAVE_PATH) + if _document != null and _document.has_autosave(): + _document.remove_autosave() var load: Dictionary = _load_cli_scene(scene_path) if not bool(load.get("ok", false)): _show_cli_error_dialog(String(load.get("error", "Could not load scene."))) @@ -2886,7 +2370,7 @@ func _handle_cli_startup(args: Array) -> void: func _load_cli_scene(path: String) -> Dictionary: - var read: Dictionary = STAGE_SCENE_IO.read_file(path) + var read: Dictionary = _document.read(path) if not bool(read.get("ok", false)): return {"ok": false, "error": String(read.get("error", "Could not load scene."))} if not _apply_scene_data(read["data"], path, false): @@ -2909,7 +2393,7 @@ func _notification(what: int) -> void: _request_pending_file_action(PendingFileAction.QUIT) func _refresh_status() -> void: - if _status_label == null: + if _chrome == null: return var count := _world_children_selectable().size() var sel := _selection.get_selected() @@ -2929,7 +2413,7 @@ func _refresh_status() -> void: text += " | " + _rule_hint if not _toast_text.is_empty(): text = _toast_text + " | " + text - _status_label.text = text + _chrome.set_status(text) func _accent_for_mode(mode: StageMode) -> Color: @@ -2943,91 +2427,29 @@ func _accent_for_mode(mode: StageMode) -> Color: func _sync_mode_buttons() -> void: - var accent := _accent_for_mode(current_mode) - for id: int in _mode_buttons: - var btn: Button = _mode_buttons[id] - var active := int(current_mode) == id - btn.set_pressed_no_signal(active) - if active: - btn.add_theme_color_override("font_color", accent) - btn.add_theme_color_override("font_pressed_color", accent) - btn.add_theme_color_override("font_hover_color", accent) - else: - btn.remove_theme_color_override("font_color") - btn.remove_theme_color_override("font_pressed_color") - btn.remove_theme_color_override("font_hover_color") + if _chrome != null: + _chrome.sync_mode_buttons(int(current_mode), _accent_for_mode(current_mode)) func _apply_mode_frame() -> void: - if _mode_frame == null: - return - _update_stage_chrome_layout() - _mode_frame.visible = true - var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel") - if sb != null: sb.border_color = _accent_for_mode(current_mode) + if _chrome != null: + _chrome.apply_mode_frame(int(current_mode), _accent_for_mode(current_mode)) func _refresh_mode_badge() -> void: - if _mode_badge == null or _mode_badge_label == null: - return - var accent := _accent_for_mode(current_mode) - var text := "✏️ EDIT" - match current_mode: - StageMode.EDIT: - text = "✏️ EDIT" - StageMode.DIRECT: - text = "🎬 DIRECTING" - StageMode.PLAY: - text = "▶️ SIMULATING" - _mode_badge_label.text = text - var sb: StyleBoxFlat = _mode_badge.get_theme_stylebox("panel") - sb.bg_color = accent - var text_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() + if _chrome != null: + _chrome.refresh_mode_badge(int(current_mode), _accent_for_mode(current_mode)) func _apply_cursor() -> void: _last_awaiting_click = _is_awaiting_click() - if _last_awaiting_click: - _apply_action_cursor() - return - Input.set_custom_mouse_cursor(null) - if _speech_badge_cursor_active: - Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND) - return - match current_mode: - StageMode.PLAY: - Input.set_default_cursor_shape(Input.CURSOR_ARROW) - _: - Input.set_default_cursor_shape(Input.CURSOR_CROSS) + if _chrome != null: + _chrome.apply_cursor(int(current_mode), _last_awaiting_click, _speech_badge_cursor_active) func _apply_action_cursor() -> void: - if _action_cursor == null: - return - var hotspot := 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_action_cursor() -> void: - var size := 24 - var img := Image.create(size, size, false, Image.FORMAT_RGBA8) - img.fill(Color(0.0, 0.0, 0.0, 0.0)) - var color := Color(1.0, 0.7, 0.1, 1.0) - var center := Vector2(size, size) * 0.5 - var radius := float(size) * 0.4 - for y: int in size: - for x: int in size: - var p := Vector2(x, y) + Vector2(0.5, 0.5) - var d := p.distance_to(center) - if absf(d - radius) <= 1.5: - img.set_pixel(x, y, color) - if absf(p.x - center.x) <= 1.0 and p.y >= center.y - radius and p.y <= center.y + radius: - img.set_pixel(x, y, color) - if absf(p.y - center.y) <= 1.0 and p.x >= center.x - radius and p.x <= center.x + radius: - img.set_pixel(x, y, color) - _action_cursor = ImageTexture.create_from_image(img) + if _chrome != null: + _chrome.apply_action_cursor() func _apply_popup_theme(popup: PopupMenu) -> void: @@ -3055,75 +2477,36 @@ func _apply_ui_font(control: Control) -> void: 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 + return fallback if _modal_views == null else _modal_views.theme_dialog_size(key, fallback) ## Dialogs are separate native/embedded windows, so they do not reliably ## inherit the CanvasLayer's visual defaults. Apply only local 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) + if _modal_views != null: + _modal_views.apply_dialog_theme(dialog) 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) + if _modal_views != null: + _modal_views.apply_dialog_font_to_children(node, size) func _update_cursor_coords() -> void: - if _status_cursor_coords == null: + if _chrome == null: return if _camera == null or not is_instance_valid(_camera): return var viewport_pos: Vector2 = get_viewport().get_mouse_position() if not _is_stage_frame_position(viewport_pos): - _status_cursor_coords.text = "X: — Y: —" + _chrome.set_cursor_coords("X: — Y: —") return var world_pos := _camera.get_global_mouse_position() - _status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))] + _chrome.set_cursor_coords("X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]) 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))) + return false if _chrome == null else _chrome.is_stage_frame_position(viewport_pos) static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool: @@ -3131,17 +2514,8 @@ static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> func _update_stage_chrome_layout() -> void: - if _mode_frame == null: - return - var palette_width: float = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0 - var left_inset: float = palette_width if current_mode == StageMode.EDIT 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 + if _chrome != null: + _chrome.update_layout(int(current_mode)) func _sync_awaiting_cursor() -> void: @@ -3150,692 +2524,176 @@ func _sync_awaiting_cursor() -> void: func _build_ui() -> void: - var ui := CanvasLayer.new() - ui.name = "UI" - add_child(ui) - - # Mode frame (over the viewport, behind the top bar; amber border in DIRECT). - _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 = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.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) - ui.add_child(_mode_frame) - - var top_bar := PanelContainer.new() - top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) - top_bar.offset_bottom = TOP_BAR_HEIGHT - ui.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) - - _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", _sandbox_theme.integer("fonts.sizes.file_menu", 22, 12, 48) if _sandbox_theme != null else 22) - var file_popup := _file_menu.get_popup() - var file_popup_font_size: int = _sandbox_theme.integer("fonts.sizes.file_menu_popup", 26, 12, 64) if _sandbox_theme != null else 26 - file_popup.add_theme_font_size_override("font_size", file_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", file_popup_font_size) - recent_popup.id_pressed.connect(_on_recent_scene_selected) - 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_id_pressed) - file_popup.about_to_popup.connect(_update_recent_menu) - left_group.add_child(_file_menu) - - # This centered group is independent of left/right controls, preserving its - # window-center position at every supported stage width. - var mode_center := CenterContainer.new() - mode_center.name = "ModeCenter" - # This full-width layout wrapper must not shield File or utility controls. - mode_center.mouse_filter = Control.MOUSE_FILTER_IGNORE - mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) - mode_center.offset_bottom = TOP_BAR_HEIGHT - chrome.add_child(mode_center) - var mode_switcher := HBoxContainer.new() - mode_switcher.name = "ModeSwitcher" - mode_switcher.mouse_filter = Control.MOUSE_FILTER_PASS - mode_switcher.add_theme_constant_override("separation", 4) - mode_center.add_child(mode_switcher) - var mode_defs: Array = [ - [StageMode.EDIT, "✏️ Edit"], - [StageMode.DIRECT, "🎬 Direct"], - [StageMode.PLAY, "▶️ Play"], - ] - for def: Array in mode_defs: - var mid: StageMode = def[0] - var btn := Button.new() - btn.text = String(def[1]) - btn.toggle_mode = true - btn.custom_minimum_size = Vector2(_sandbox_theme.number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0) if _sandbox_theme != null else 124.0, 48.0) - btn.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24) - btn.toggled.connect(_on_mode_segment_toggled.bind(mid)) - mode_switcher.add_child(btn) - _mode_buttons[int(mid)] = btn - _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) - right_group.add_child(_direct_hint_label) - - _grid_check = CheckBox.new() - _grid_check.text = "Grid" - _grid_check.button_pressed = _show_grid - _grid_check.toggled.connect(_on_grid_toggled) - right_group.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) - right_group.add_child(_snap_check) - - _grid_size_label = Label.new() - _grid_size_label.text = "Size" - right_group.add_child(_grid_size_label) - - _grid_size_spin = SpinBox.new() - _grid_size_spin.min_value = MIN_GRID_SIZE - _grid_size_spin.max_value = MAX_GRID_SIZE - _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) - right_group.add_child(_grid_size_spin) - - # Graphical asset palette lives below chrome so stage tools keep their row. - _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 = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0 - if _sandbox_theme != null: _palette_dock.add_theme_stylebox_override("panel", _sandbox_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 _spawner.get_palette_entries(): - var card: Variant = ASSET_CARD.new() - card.configure(entry, null, _sandbox_theme) - card.toggle_mode = true - card.activated.connect(_on_palette_card_activated) - card.drag_started.connect(_begin_palette_drag) - cards.add_child(card) - _palette_buttons[String(entry["placement_id"])] = card - ui.add_child(_palette_dock) - _build_prop_settings_card(ui) + _chrome = STAGE_CHROME_SCRIPT.new() as StageChrome + _chrome.name = "UI" + _chrome.configure( + _sandbox_theme as SandboxTheme, + int(current_mode), + _grid_size, + _snap_enabled, + _show_grid, + _accent_edit, + _accent_direct, + _accent_play, + _status_pill_font_size, + _tooltip_font_size + ) + _chrome.file_menu_requested.connect(_on_file_menu_id_pressed) + _chrome.recent_menu_requested.connect(_update_recent_menu) + _chrome.recent_scene_requested.connect(_on_recent_scene_selected) + _chrome.mode_toggled.connect(_on_mode_segment_toggled) + _chrome.grid_toggled.connect(_on_grid_toggled) + _chrome.snap_toggled.connect(_on_snap_toggled) + _chrome.grid_size_changed.connect(_on_grid_size_changed) + _chrome.palette_activated.connect(_on_palette_card_activated) + _chrome.palette_drag_started.connect(_begin_palette_drag) + _chrome.pause_requested.connect(func() -> void: set_mode(StageMode.DIRECT)) + _chrome.resume_requested.connect(func() -> void: set_mode(StageMode.PLAY)) + _chrome.reset_requested.connect(func() -> void: set_mode(StageMode.EDIT)) + add_child(_chrome) + _chrome.build(_spawner.get_palette_entries()) + var ui: CanvasLayer = _chrome.get_canvas_layer() + _modal_views = STAGE_MODAL_VIEWS_SCRIPT.new() as StageModalViews + _modal_views.configure(_sandbox_theme as SandboxTheme, _ui_font) + _modal_views.object_selected.connect(_on_objects_here_selected) + _build_prop_settings_view(ui) _build_set_down_cancel_button(ui) - - # Bottom status bar (left = status, right = cursor world coords). - var bottom_bar := PanelContainer.new() - bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE) - bottom_bar.offset_top = -STATUS_BAR_HEIGHT - ui.add_child(bottom_bar) - - var status_hbox := HBoxContainer.new() - status_hbox.add_theme_constant_override("separation", 12) - bottom_bar.add_child(status_hbox) - - _status_label = Label.new() - _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _apply_ui_font(_status_label) - status_hbox.add_child(_status_label) - - _status_cursor_coords = Label.new() - _apply_ui_font(_status_cursor_coords) - status_hbox.add_child(_status_cursor_coords) - - # Mode badge pill (top-left, below the top bar). - _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 = (_sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.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) - ui.add_child(_mode_badge) - - _run_controls = HBoxContainer.new() - _run_controls.name = "RunControls" - _run_controls.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) - _run_controls.offset_top = TOP_BAR_HEIGHT + 8.0 - _run_controls.offset_right = -12.0 - _run_controls.add_theme_constant_override("separation", 6) - ui.add_child(_run_controls) - _pause_button = Button.new() - _pause_button.text = "⏸️ Pause" - _pause_button.pressed.connect(func() -> void: set_mode(StageMode.DIRECT)) - _run_controls.add_child(_pause_button) - _resume_button = Button.new() - _resume_button.text = "▶️ Resume" - _resume_button.pressed.connect(func() -> void: set_mode(StageMode.PLAY)) - _run_controls.add_child(_resume_button) - _reset_button = Button.new() - _reset_button.text = "↺ Reset" - _reset_button.pressed.connect(func() -> void: set_mode(StageMode.EDIT)) - _run_controls.add_child(_reset_button) - for button: Button in [_pause_button, _resume_button, _reset_button]: - button.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24) - - _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 timestamp_style := StyleBoxFlat.new() - timestamp_style.bg_color = _sandbox_theme.color("chrome.timestamp_badge.bg_color", Color("#171B24F2")) if _sandbox_theme != null else Color("#171B24F2") - timestamp_style.set_corner_radius_all(_sandbox_theme.integer("chrome.timestamp_badge.corner_radius", 10, 0, 48) if _sandbox_theme != null else 10) - var timestamp_padding: float = float(_sandbox_theme.integer("chrome.timestamp_badge.padding", 8, 0, 48)) if _sandbox_theme != null else 8.0 - timestamp_style.content_margin_left = timestamp_padding - timestamp_style.content_margin_right = timestamp_padding - timestamp_style.content_margin_top = timestamp_padding * 0.5 - timestamp_style.content_margin_bottom = timestamp_padding * 0.5 - _timestamp_badge.add_theme_stylebox_override("panel", timestamp_style) - _timestamp_label = Label.new() - _timestamp_label.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.timestamp_badge", 22, 12, 48) if _sandbox_theme != null else 22) - _timestamp_label.add_theme_color_override("font_color", _sandbox_theme.color("global.text_color", Color("#F5F7FA")) if _sandbox_theme != null else 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) - ui.add_child(_timestamp_badge) _refresh_prop_settings_card() - - # Action tooltip (cursor-attached, hidden unless a click-awaiting step is active). - _tooltip = Label.new() - _tooltip.name = "ActionTooltip" - _tooltip.visible = false - _tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE - var tooltip_style := StyleBoxFlat.new() - tooltip_style.bg_color = Color(0.1, 0.1, 0.1, 0.9) - tooltip_style.border_color = _accent_direct - tooltip_style.set_border_width_all(1) - tooltip_style.set_corner_radius_all(8) - tooltip_style.content_margin_left = 10.0 - tooltip_style.content_margin_right = 10.0 - tooltip_style.content_margin_top = 4.0 - tooltip_style.content_margin_bottom = 4.0 - _tooltip.add_theme_stylebox_override("normal", tooltip_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) - ui.add_child(_tooltip) _sync_run_controls() _set_timestamp(0.0) - - _action_popup = PopupMenu.new() - _action_popup.add_item("🚩 Move To", ACT_WALK) - _action_popup.add_item("💬 Speak", ACT_SPEAK) - _action_popup.add_item("⏳ Wait", ACT_WAIT) - _action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL) - _action_popup.add_item("🔄 Recover", ACT_RECOVER) - _action_popup.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP) - _action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP) - _action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP) - _action_popup.add_item("🫳 Set Down…", ACT_SET_DOWN_PROP) - _action_popup.add_separator() - _action_popup.add_item("⚡ When...", ACT_WHEN) - _action_popup.add_separator() - _action_popup.add_item("📋 Edit Queue…", ACT_EDIT_QUEUE) - _action_popup.add_item("⚡ Edit Rules…", ACT_EDIT_RULES) - _action_popup.id_pressed.connect(_on_action_popup_id_pressed) - _action_popup.about_to_popup.connect(_refresh_action_popup_items) - ui.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() - ui.add_child(_direct_move_popup) - var move_margin := 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.new() - move_column.add_theme_constant_override("separation", 10) - move_margin.add_child(move_column) - var move_row := HBoxContainer.new() - move_row.add_theme_constant_override("separation", 12) - move_column.add_child(move_row) - var move_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.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.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.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(_cancel_direct_move_draft) - _direct_move_popup.window_input.connect(_on_direct_move_popup_input) - _populate_direct_move_styles() - - _trigger_popup = PopupMenu.new() - _trigger_popup.add_item("📍 Arrives at a waypoint", TRIG_ARRIVED) - _trigger_popup.add_item("✅ Completes any action", TRIG_ACTION_FINISHED) - _trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED) - _trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA) - _trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED) - _trigger_popup.add_item("🫴 Picks up a prop", TRIG_PROP_PICKED_UP) - _trigger_popup.add_item("⬇️ Drops a prop", TRIG_PROP_DROPPED) - _trigger_popup.add_item("🏹 Throws a prop", TRIG_PROP_THROWN) - _trigger_popup.add_separator() - _trigger_popup.add_item("⬅ Back to actions", TRIG_BACK) - _trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed) - ui.add_child(_trigger_popup) - - _rule_action_popup = PopupMenu.new() - _rule_action_popup.add_item("🚶 Move To", ACT_WALK) - _rule_action_popup.add_item("💬 Speak", ACT_SPEAK) - _rule_action_popup.add_item("⏳ Wait", ACT_WAIT) - _rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL) - _rule_action_popup.add_item("🔄 Recover", ACT_RECOVER) - _rule_action_popup.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP) - _rule_action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP) - _rule_action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP) - _rule_action_popup.add_item("🫳 Set Down…", ACT_SET_DOWN_PROP) - _rule_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed) - ui.add_child(_rule_action_popup) - - _rule_more_popup = PopupMenu.new() - _rule_more_popup.add_item("➕ Add another action", RULE_MORE_ADD) - _rule_more_popup.add_item("✅ Done", RULE_MORE_DONE) - _rule_more_popup.id_pressed.connect(_on_rule_more_id_pressed) - ui.add_child(_rule_more_popup) - - _speak_dialog = AcceptDialog.new() - _speak_dialog.title = "Speak" - _speak_dialog.confirmed.connect(_on_speak_confirmed) - var 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) - ui.add_child(_speak_dialog) - _apply_dialog_theme(_speak_dialog) - - _wait_dialog = AcceptDialog.new() - _wait_dialog.title = "Wait" - _wait_dialog.confirmed.connect(_on_wait_confirmed) - _wait_spin = SpinBox.new() - _wait_spin.min_value = 0.1 - _wait_spin.max_value = 10.0 - _wait_spin.step = 0.1 - _wait_spin.value = 1.0 - _wait_dialog.add_child(_wait_spin) - ui.add_child(_wait_dialog) - _apply_dialog_theme(_wait_dialog) - + _director_controller.configure( + _camera, + get_viewport(), + _spatial_index, + _placement_overlay as StagePlacementOverlay, + _chrome, + _rule_authoring, + _action_queue_controller + ) + _director_controller.build_ui(ui, _sandbox_theme as SandboxTheme, _ui_font, _emoji_font, { + "walk": ACT_WALK, + "speak": ACT_SPEAK, + "wait": ACT_WAIT, + "ragdoll": ACT_RAGDOLL, + "recover": ACT_RECOVER, + "pick_up_prop": ACT_PICK_UP_PROP, + "drop_prop": ACT_DROP_PROP, + "throw_prop": ACT_THROW_PROP, + "set_down_prop": ACT_SET_DOWN_PROP, + "when": ACT_WHEN, + "edit_queue": ACT_EDIT_QUEUE, + "edit_rules": ACT_EDIT_RULES + }) _apply_popup_theme(_action_popup) + + _rule_authoring.build_ui(ui, { + "trigger_arrived": TRIG_ARRIVED, + "trigger_action_finished": TRIG_ACTION_FINISHED, + "trigger_speech_finished": TRIG_SPEECH_FINISHED, + "trigger_entered_area": TRIG_ENTERED_AREA, + "trigger_collided": TRIG_COLLIDED, + "trigger_prop_picked_up": TRIG_PROP_PICKED_UP, + "trigger_prop_dropped": TRIG_PROP_DROPPED, + "trigger_prop_thrown": TRIG_PROP_THROWN, + "trigger_back": TRIG_BACK, + "action_walk": ACT_WALK, + "action_speak": ACT_SPEAK, + "action_wait": ACT_WAIT, + "action_ragdoll": ACT_RAGDOLL, + "action_recover": ACT_RECOVER, + "action_pick_up_prop": ACT_PICK_UP_PROP, + "action_drop_prop": ACT_DROP_PROP, + "action_throw_prop": ACT_THROW_PROP, + "action_set_down_prop": ACT_SET_DOWN_PROP, + "more_add": RULE_MORE_ADD, + "more_done": RULE_MORE_DONE + }, _ui_font, _emoji_font, _font_sizes, _theme_dialog_size("rule_size", Vector2i(720, 560))) _apply_popup_theme(_trigger_popup) _apply_popup_theme(_rule_action_popup) _apply_popup_theme(_rule_more_popup) - _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.add_child(_selector_dim) + _modal_views.build_action_dialogs(ui) + _speak_dialog = _modal_views.speak_dialog + _speak_dialog.confirmed.connect(_on_speak_confirmed) + _speak_edit = _modal_views.speak_edit + _speak_stop_current_check = _modal_views.speak_stop_current_check + _wait_dialog = _modal_views.wait_dialog + _wait_dialog.confirmed.connect(_on_wait_confirmed) + _wait_spin = _modal_views.wait_spin - _selector = ASSET_SELECTOR.instantiate() as AssetSelector - _selector.item_selected.connect(_on_asset_selected) - _selector.cancelled.connect(_on_selector_cancelled) - _selector.popup_hide.connect(_on_selector_cancelled) - _selector.browse_requested.connect(_on_browse_requested) - _selector.refresh_requested.connect(_on_refresh_requested) - ui.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) + _apply_popup_theme(_action_popup) - _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.add_child(_browse_dialog) + _asset_browser.asset_selected_requested.connect(_on_asset_selected) + _asset_browser.selector_cancel_requested.connect(_on_selector_cancelled) + _asset_browser.asset_load_failed.connect(func(kind: String, path: String) -> void: + _show_toast("Could not load %s: %s" % [kind, path]) + ) + _asset_browser.refresh_requested.connect(_on_refresh_requested) + _asset_browser.build_ui(ui, _ui_font, _emoji_font, _sandbox_theme) - _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"]) - _scene_open_dialog.file_selected.connect(func(path: String) -> void: _load_scene_from_path(path)) - ui.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"]) - _scene_save_dialog.file_selected.connect(_on_scene_save_path_selected) - _scene_save_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE) - ui.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" + _modal_views.build_file_dialogs(ui) + _scene_open_dialog = _modal_views.scene_open_dialog + _scene_save_dialog = _modal_views.scene_save_dialog + _speech_save_gate_dialog = _modal_views.speech_save_gate_dialog _speech_save_gate_dialog.confirmed.connect(_on_speech_save_gate_confirmed) _speech_save_gate_dialog.canceled.connect(_on_speech_save_gate_cancelled) - ui.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) - _rename_scene_dialog.confirmed.connect(func() -> void: - var scene_title := _scene_name_edit.text.strip_edges() - if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty()) - ui.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)) - ui.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)) - ui.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") - _unsaved_changes_dialog.confirmed.connect(_on_unsaved_save) - _unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard()) - _unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE) - ui.add_child(_unsaved_changes_dialog) - _apply_dialog_theme(_unsaved_changes_dialog) + _rename_scene_dialog = _modal_views.rename_scene_dialog + _scene_name_edit = _modal_views.scene_name_edit + _missing_assets_dialog = _modal_views.missing_assets_dialog + _cli_error_dialog = _modal_views.cli_error_dialog + _unsaved_changes_dialog = _modal_views.unsaved_changes_dialog + _document.configure_dialog_workflow( + _scene_open_dialog, + _scene_save_dialog, + _rename_scene_dialog, + _scene_name_edit, + _unsaved_changes_dialog + ) # ---- Phase 3c editor tools ---- - _rig_context_popup = PopupMenu.new() - _rig_context_popup.add_item("📋 Edit Queue…", RIG_CTX_EDIT_QUEUE) - _rig_context_popup.add_item("⚡ Edit Rules…", RIG_CTX_EDIT_RULES) + _modal_views.build_context_and_editor_views( + ui, + _ui_font, + _emoji_font, + _font_sizes, + _theme_dialog_size("queue_size", Vector2i(680, 520)), + _theme_dialog_size("action_size", Vector2i(560, 390)) + ) + _rig_context_popup = _modal_views.rig_context_popup _rig_context_popup.id_pressed.connect(_on_rig_context_id_pressed) _rig_context_popup.about_to_popup.connect(_refresh_rig_context_items) - ui.add_child(_rig_context_popup) _apply_popup_theme(_rig_context_popup) - _waypoint_context = WAYPOINT_CONTEXT.new() + _waypoint_context = _modal_views.waypoint_context _waypoint_context.id_pressed.connect(_on_waypoint_context_id_pressed) - ui.add_child(_waypoint_context) _apply_popup_theme(_waypoint_context) - _confirm_dialog = ConfirmationDialog.new() - _confirm_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300)) + _confirm_dialog = _modal_views.confirm_dialog _confirm_dialog.confirmed.connect(_on_confirm_confirmed) - ui.add_child(_confirm_dialog) - _apply_dialog_theme(_confirm_dialog) - _queue_panel = QUEUE_PANEL.instantiate() as QueuePanel - _queue_panel.edit_requested.connect(_on_queue_panel_edit_requested) - _queue_panel.delete_requested.connect(_on_queue_panel_delete_requested) - _queue_panel.add_requested.connect(_on_queue_panel_add_requested) - _queue_panel.clear_requested.connect(_on_queue_panel_clear_requested) - ui.add_child(_queue_panel) - _queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes) - _queue_panel.min_size = _theme_dialog_size("queue_size", Vector2i(680, 520)) + _queue_panel = _modal_views.queue_panel - _rule_panel = RULE_PANEL.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) - ui.add_child(_rule_panel) - _rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes) - _rule_panel.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560)) + _action_editor = _modal_views.action_editor - _action_editor = ACTION_EDITOR.instantiate() as ActionEditor - _action_editor.committed.connect(_on_action_editor_committed) - _action_editor.cancelled.connect(_on_action_editor_cancelled) - _action_editor.target_requested.connect(_on_action_editor_target_requested) - _action_editor.speech_requested.connect(_on_action_editor_speech_requested) - ui.add_child(_action_editor) - _action_editor.apply_font(_ui_font, _emoji_font, _font_sizes) - _action_editor.apply_theme(_sandbox_theme) - _action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390)) - - _rule_editor = RULE_EDITOR.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) - ui.add_child(_rule_editor) - _rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes) - _rule_editor.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560)) - - _speech_composer = SPEECH_COMPOSER.instantiate() as Control - _speech_composer.connect("draft_changed", _on_speech_draft_changed) - _speech_composer.connect("placement_requested", _on_speech_placement_requested) - _speech_composer.connect("placement_reset_requested", _on_speech_placement_reset) - _speech_composer.connect("bubble_placement_requested", _on_speech_bubble_placement_requested) - _speech_composer.connect("bubble_placement_reset_requested", _on_speech_bubble_placement_reset) - _speech_composer.connect("replay_requested", _on_speech_replay_requested) - _speech_composer.connect("preview_state_changed", _on_speech_preview_state_changed) - _speech_composer.connect("style_context_changed", _on_speech_style_context_changed) - _speech_composer.connect("stage_focus_changed", _on_speech_stage_focus_changed) - _speech_composer.connect("message_dialog_closed", _on_speech_message_dialog_closed) - _speech_composer.connect("committed", _on_speech_committed) - _speech_composer.connect("cancelled", _on_speech_cancelled) - _speech_composer.connect("validation_failed", _on_speech_validation_failed) - ui.add_child(_speech_composer) - _speech_style_toolbar = SPEECH_STYLE_TOOLBAR.new() as Control - _speech_style_toolbar.name = "SpeechStyleToolbar" - _speech_style_toolbar.call("configure", _sandbox_theme) - _speech_style_toolbar.connect("chooser_open_requested", _on_speech_style_chooser_open_requested) - _speech_style_toolbar.connect("choice_selected", _on_speech_style_choice_selected) - _speech_style_toolbar.connect("done_styling_requested", _on_speech_style_done_requested) - _speech_style_toolbar.connect("message_edit_requested", _on_speech_style_message_requested) - _speech_style_toolbar.connect("sequence_action_requested", _on_speech_style_sequence_action_requested) - ui.add_child(_speech_style_toolbar) - _speech_show_toolbar = SPEECH_SHOW_TOOLBAR.new() as Control - _speech_show_toolbar.name = "SpeechShowToolbar" - _speech_show_toolbar.call("configure", _sandbox_theme) - _speech_show_toolbar.connect("choice_selected", _on_speech_show_choice_selected) - _speech_show_toolbar.connect("layout_preset_requested", _on_speech_layout_preset_requested) - _speech_show_toolbar.connect("preview_speech_requested", _on_speech_toolbar_preview_requested) - _speech_show_toolbar.connect("done_speech_requested", _on_speech_done_requested) - _speech_show_toolbar.connect("cancel_speech_requested", _on_speech_cancel_requested) - ui.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" + _speech_composer = _modal_views.speech_composer + _speech_style_toolbar = _modal_views.speech_style_toolbar + _speech_show_toolbar = _modal_views.speech_show_toolbar + _speech_recovery_dialog = _modal_views.speech_recovery_dialog _speech_recovery_dialog.confirmed.connect(_on_speech_recovery_restore) _speech_recovery_dialog.canceled.connect(_on_speech_recovery_discard) - ui.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" + _speech_destination_dialog = _modal_views.speech_destination_dialog _speech_destination_dialog.confirmed.connect(_commit_unbound_recovered_speech) - ui.add_child(_speech_destination_dialog) -func _build_prop_settings_card(ui: CanvasLayer) -> void: - _prop_settings_card = STAGE_INSPECTOR_PANEL.new() - _prop_settings_card.name = "PropSettingsCard" - _prop_settings_card.visible = false - ui.add_child(_prop_settings_card) - _prop_settings_card.call("apply_theme", _sandbox_theme) - _prop_settings_card.connect("close_requested", _on_prop_settings_close_requested) - var body := _prop_settings_card.call("body") as VBoxContainer - body.add_theme_constant_override("separation", 10) - _prop_settings_notice = Label.new() - _prop_settings_notice.text = "Select one prop to change its settings." - _prop_settings_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _prop_settings_notice.add_theme_font_size_override("font_size", _stage_panel_integer("type.body_size", 20, 14, 40)) - _prop_settings_notice.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA"))) - _prop_settings_notice.visible = false - _apply_ui_font(_prop_settings_notice) - body.add_child(_prop_settings_notice) - _prop_settings_fields = VBoxContainer.new() - _prop_settings_fields.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32)) - _prop_settings_fields.size_flags_horizontal = Control.SIZE_EXPAND_FILL - body.add_child(_prop_settings_fields) - _add_prop_settings_heading(_prop_settings_fields, "Where it appears") - _prop_settings_depth_group = _make_prop_choice_group("depth") - _prop_settings_fields.add_child(_prop_settings_depth_group) - 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", _sandbox_theme.color("mode_colors.edit", Color("#22C6FF")) if _sandbox_theme != null else Color("#22C6FF")) - _apply_ui_font(legacy_cue) - _prop_settings_fields.add_child(legacy_cue) - _prop_settings_controls["legacy_cue"] = legacy_cue - - _add_prop_settings_heading(_prop_settings_fields, "When PLAY starts") - _prop_settings_motion_group = _make_prop_choice_group("start_motion") - _prop_settings_fields.add_child(_prop_settings_motion_group) - 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) - _prop_settings_fields.add_child(motion_hint) - _prop_settings_controls["motion_hint"] = motion_hint - - _prop_settings_controls["holds_other_props"] = _make_prop_switch_row(_prop_settings_fields, "holds_other_props", "Props can sit on top") - _prop_settings_controls["blocks_stickmen"] = _make_prop_switch_row(_prop_settings_fields, "blocks_stickmen", "Blocks stickmen") - _prop_settings_controls["can_pick_up"] = _make_prop_switch_row(_prop_settings_fields, "can_pick_up", "Stickmen can pick it up") - var release_section := VBoxContainer.new() - release_section.name = "AfterLettingGo" - release_section.add_theme_constant_override("separation", 8) - _prop_settings_fields.add_child(release_section) - _add_prop_settings_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) - _prop_settings_controls["released_blocks_stickmen"] = _make_prop_switch_row(release_section, "released_blocks_stickmen", "Can bump into stickmen") - _prop_settings_controls["released_bumps_other_props"] = _make_prop_switch_row(release_section, "released_bumps_other_props", "Can bump into other props") - _prop_settings_controls["release_section"] = release_section - - _prop_settings_reopen_button = Button.new() - _prop_settings_reopen_button.name = "ReopenPropSettings" - _prop_settings_reopen_button.text = "Settings" - _prop_settings_reopen_button.custom_minimum_size = Vector2(148.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96))) - _prop_settings_reopen_button.focus_mode = Control.FOCUS_ALL - _prop_settings_reopen_button.visible = false - _prop_settings_reopen_button.pressed.connect(_on_prop_settings_reopen_pressed) - _prop_settings_card.call("style_action_button", _prop_settings_reopen_button, true) - ui.add_child(_prop_settings_reopen_button) - if not get_viewport().size_changed.is_connected(_on_prop_settings_viewport_resized): - get_viewport().size_changed.connect(_on_prop_settings_viewport_resized) - _on_prop_settings_viewport_resized() - +func _build_prop_settings_view(ui: CanvasLayer) -> void: + _prop_settings_view = STAGE_PROP_SETTINGS_VIEW_SCRIPT.new() as StagePropSettingsView + _prop_settings_view.configure(_sandbox_theme as SandboxTheme, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT) + _prop_settings_view.role_choice_requested.connect(_on_prop_role_choice) + _prop_settings_view.role_toggle_requested.connect(_on_prop_role_toggle) + _prop_settings_view.close_requested.connect(_on_prop_settings_close_requested) + ui.add_child(_prop_settings_view) + _prop_settings_view.update_layout(_prop_settings_safe_rect()) func _build_set_down_cancel_button(ui: CanvasLayer) -> void: var button_height: float = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96))) @@ -3853,48 +2711,6 @@ func _build_set_down_cancel_button(ui: CanvasLayer) -> void: ui.add_child(_set_down_cancel_button) -func _add_prop_settings_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_prop_choice_group(key: String) -> Control: - var group: Control = STAGE_PANEL_CHOICE_GROUP.new() - group.call("apply_theme", _sandbox_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.call("add_choice", &"behind", "Behind stickmen", behind_cue) - group.call("add_choice", &"front", "In front of stickmen", front_cue) - else: - group.call("add_choice", &"still", "Stays in place") - group.call("add_choice", &"physics", "Can fall and move") - group.connect("choice_selected", _on_prop_settings_choice_selected.bind(key)) - return group - - -func _make_prop_switch_row(parent: VBoxContainer, key: String, caption: String) -> Control: - var row: Control = STAGE_PANEL_TOGGLE_ROW.new() - row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.call("configure", caption) - row.call("apply_theme", _sandbox_theme) - parent.add_child(row) - row.connect("preferred_height_changed", _on_prop_settings_row_height_changed) - row.connect("toggled", _on_prop_role_toggle.bind(key)) - return row - - -func _on_prop_settings_row_height_changed() -> void: - if _prop_settings_card != null: - _prop_settings_card.call("request_fit") - - func _stage_panel_integer(key: String, fallback: int, minimum: int, maximum: int) -> int: return fallback if _sandbox_theme == null else _sandbox_theme.integer("stage_panels." + key, fallback, minimum, maximum) @@ -3934,131 +2750,51 @@ func _on_prop_role_toggle(enabled: bool, key: String) -> void: func _refresh_prop_settings_card() -> void: - if _prop_settings_card == null or _selection == null: + if _prop_settings_view == null or _selection == null: return var selected: Array[Node2D] = _selection.get_selected() - var has_prop := false + var has_prop: bool = false for node: Node2D in selected: if node is PropBlock: has_prop = true break - var single_prop: PropBlock = selected[0] as PropBlock if selected.size() == 1 and selected[0] is PropBlock else null - var multiple := selected.size() != 1 or single_prop == null - var selection_key := _prop_settings_selection_key(selected) if has_prop else "" - if selection_key != _prop_settings_active_key: - _prop_settings_active_key = selection_key - _prop_settings_dismissed_key = "" - _prop_settings_card.call("reset_scroll") - var in_edit := current_mode == StageMode.EDIT and has_prop - var dismissed := in_edit and not selection_key.is_empty() and selection_key == _prop_settings_dismissed_key - _prop_settings_card.visible = in_edit and not dismissed - _prop_settings_reopen_button.visible = dismissed - if not in_edit: + if not has_prop: + _prop_settings_view.hide_card(true) return - _prop_settings_card.call("set_safe_rect", _prop_settings_safe_rect()) - _prop_settings_notice.visible = multiple - _prop_settings_fields.visible = not multiple - if multiple: - _prop_settings_card.call("set_header", "Props", "📦") - _prop_settings_card.call("request_fit") + if current_mode != StageMode.EDIT: + _prop_settings_view.hide_card() return - var prop := single_prop as PropBlock + var safe_rect: Rect2 = _prop_settings_safe_rect() + if selected.size() != 1 or not selected[0] is PropBlock: + _prop_settings_view.show_multiple_props(selected, safe_rect) + return + var prop: PropBlock = selected[0] as PropBlock var roles: Dictionary = PROP_STAGE_ROLES.normalize(prop.stage_roles) - var display_name := prop.spawn_id if not prop.spawn_id.is_empty() else "Prop" + var display_name: String = prop.spawn_id if not prop.spawn_id.is_empty() else "Prop" var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id) display_name = String(entry.get("name", entry.get("label", display_name))) - _prop_settings_card.call("set_header", display_name, "📦") - var legacy_depth: bool = bool(roles.get("legacy_draw_order", false)) - if legacy_depth: - _prop_settings_depth_group.call("clear_no_signal") - else: - _prop_settings_depth_group.call("select_no_signal", StringName(String(roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)))) - _prop_settings_motion_group.call("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 := _prop_settings_controls.get(key) as Control - if control != null: - control.call("set_checked_no_signal", bool(roles.get(key, false))) - if key == "holds_other_props": - control.call("set_help_text", "Other props can land on this one." if bool(roles.get(key, false)) else "") - elif key == "blocks_stickmen": - control.call("set_help_text", "Stickmen cannot walk through it." if bool(roles.get(key, false)) else "") - elif key == "can_pick_up": - control.call("set_help_text", "") - control.call("set_disabled_state", not prop.has_usable_grip(), "Add a hand hold in Prop Editor first.") - var release_section := _prop_settings_controls.get("release_section") as Control - if release_section != null: - release_section.visible = bool(roles.get("can_pick_up", false)) - var legacy_cue := _prop_settings_controls.get("legacy_cue") as Label - if legacy_cue != null: - legacy_cue.visible = legacy_depth - var motion_hint := _prop_settings_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." - _prop_settings_card.call("request_fit") - - -func _prop_settings_selection_key(selected: Array[Node2D]) -> String: - var instance_ids: Array[int] = [] - for node: Node2D in selected: - if node != null and is_instance_valid(node): - instance_ids.append(node.get_instance_id()) - instance_ids.sort() - var parts: PackedStringArray = [] - for instance_id: int in instance_ids: - parts.append(str(instance_id)) - return ",".join(parts) + _prop_settings_view.show_prop(prop, display_name, roles, safe_rect, selected) func _prop_settings_safe_rect() -> Rect2: - var viewport := get_viewport_rect() - var inset := float(_stage_panel_integer("shell.edge_inset", 12, 0, 48)) - var top_offset := minf(TOP_BAR_HEIGHT + 116.0, maxf(12.0, viewport.size.y - 220.0)) - var bottom_limit := maxf(top_offset + 72.0, viewport.size.y - STATUS_BAR_HEIGHT - 12.0) + 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) return Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0)) -func _on_prop_settings_viewport_resized() -> void: - if _prop_settings_card == null: - return - var safe_rect := _prop_settings_safe_rect() - _prop_settings_card.call("set_safe_rect", safe_rect) - if _prop_settings_reopen_button != null: - var inset := float(_stage_panel_integer("shell.edge_inset", 12, 0, 48)) - var button_width := minf(148.0, maxf(1.0, safe_rect.size.x - inset * 2.0)) - var button_height := float(_stage_panel_integer("action.min_target_size", 48, 44, 96)) - _prop_settings_reopen_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) - _prop_settings_reopen_button.custom_minimum_size.x = button_width - _prop_settings_reopen_button.position = Vector2(safe_rect.end.x - inset - button_width, safe_rect.position.y + inset) - _prop_settings_reopen_button.size = Vector2(button_width, button_height) - - func _on_prop_settings_close_requested() -> void: - if _prop_settings_active_key.is_empty(): + if _prop_settings_view == null or _prop_settings_active_key.is_empty(): return - _prop_settings_dismissed_key = _prop_settings_active_key + _prop_settings_view.set_dismissed(true) _refresh_prop_settings_card() if _prop_settings_reopen_button != null and _prop_settings_reopen_button.visible: _prop_settings_reopen_button.grab_focus() -func _on_prop_settings_reopen_pressed() -> void: - var should_focus := _prop_settings_reopen_button.has_focus() - _prop_settings_dismissed_key = "" - _refresh_prop_settings_card() - if should_focus: - call_deferred("_focus_prop_settings_card") - - -func _focus_prop_settings_card() -> void: - if _prop_settings_card != null and _prop_settings_card.visible: - _prop_settings_card.call("focus_first_control") - - func _on_prop_settings_choice_selected(id: StringName, key: String) -> void: - var value := String(id) + var value: String = String(id) if key == "depth": if value == "behind": value = PROP_STAGE_ROLES.DEPTH_BEHIND @@ -4071,7 +2807,6 @@ func _on_prop_settings_choice_selected(id: StringName, key: String) -> void: value = PROP_STAGE_ROLES.START_PHYSICS _on_prop_role_choice(key, value) - func _build_gizmo_layer() -> void: _gizmos = STAGE_GIZMOS.new() _gizmos.name = "GizmoLayer" @@ -4264,22 +2999,14 @@ func _is_stage_drop_position(viewport_pos: Vector2) -> bool: func _open_selector(kind: String) -> void: for pid: String in _palette_buttons: (_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind) - _selector_kind = kind - var entries: Array[Dictionary] + var selector_state: Dictionary = _asset_browser.open_selector(kind, _spawner.get_selected_stickman_path()) if kind == "stickman": - entries = _stickman_library.scan() - if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty(): - _spawner.selected_stickman_path = String(entries[0]["path"]) - else: - entries = PROP_LIBRARY.scan() - if kind == "stickman" and entries.size() == 1: - _on_asset_selected(entries[0]) - return - _selector_open = true - if _selector_dim != null: - _selector_dim.visible = true - _selector.open(kind, entries) - _load_or_enqueue_thumbnails(kind, entries) + var selected_stickman_path: String = String(selector_state.get("selected_stickman_path", _spawner.get_selected_stickman_path())) + if selected_stickman_path != _spawner.get_selected_stickman_path(): + _spawner.selected_stickman_path = selected_stickman_path + var auto_selection: Dictionary = selector_state.get("auto_selection", {}) as Dictionary + if not auto_selection.is_empty(): + _on_asset_selected(auto_selection) func _on_asset_selected(entry: Dictionary) -> void: @@ -4293,14 +3020,8 @@ func _on_asset_selected(entry: Dictionary) -> void: func _close_selector() -> 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() + if _asset_browser != null: + _asset_browser.close() func _on_selector_cancelled() -> void: @@ -4310,91 +3031,17 @@ func _on_selector_cancelled() -> void: set_placement_mode("") -func _on_browse_requested() -> void: - if _browse_dialog != null: - 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 _on_browse_file_selected(path: String) -> void: - var entry: Dictionary = PROP_LIBRARY.make_custom_entry(path) if _selector_kind == "prop" else _stickman_library.make_entry(path) - if entry.is_empty(): - _show_toast("Could not load %s: %s" % [_selector_kind, path]) - return - _on_asset_selected(entry) - - func _on_refresh_requested() -> void: - if _selector_kind == "prop": - var prop_entries: Array[Dictionary] = PROP_LIBRARY.scan() - _selector.set_entries(prop_entries) - _load_or_enqueue_thumbnails("prop", prop_entries) + var entries: Array[Dictionary] = _asset_browser.refresh_selector_entries() + if _selector_kind != "stickman": return - var entries: Array[Dictionary] = _stickman_library.scan() if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty(): - _spawner.selected_stickman_path = String(entries[0]["path"]) - _selector.set_entries(entries) - _load_or_enqueue_thumbnails("stickman", entries) - - -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(entry.get("path", "")) - var key := _thumbnail_cache.stickman_key(path) - var png := _thumbnail_cache.stickman_png(key) - var tex := _thumbnail_cache.get_texture(png) - if tex != null: - if tex != null and _selector != null: - _selector.set_thumbnail(entry, tex) - else: - _thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation }) - else: - var id := String(entry.get("id", "")) - var png := _thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(id) - var tex := _thumbnail_cache.get_texture(png) - if tex != null: - if tex != null and _selector != null: - _selector.set_thumbnail(entry, tex) - else: - _thumbnail_queue.append({ "kind": kind, "entry": entry, "cache_path": png, "generation": generation }) + _spawner.selected_stickman_path = String(entries[0].get("path", "")) 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(job["kind"]) - var entry: Dictionary = job["entry"] - var tex: Texture2D = null - if kind == "stickman": - var key := String(job["key"]) - var data: Dictionary = entry["data"] - tex = await _stickman_thumb.render(data) - if tex != null: - _thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex) - else: - if bool(entry.get("is_custom", false)): - tex = await _prop_thumb.render_shapes(entry.get("shapes", []) as Array[Dictionary]) - else: - var payload: Dictionary = entry["payload"] - var preset: int = int(entry["material_preset"]) - tex = await _prop_thumb.render(payload, preset) - if tex != null: - var fallback := _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)), tex) - _thumbnail_busy = false - if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null: - _selector.set_thumbnail(entry, tex) + if _asset_browser != null: + _asset_browser.drain_thumbnail_queue() func _on_grid_toggled(pressed: bool) -> void: @@ -4462,99 +3109,30 @@ func _remove_prop_contact_exceptions(prop: PropBlock) -> void: func _show_objects_here_popup(candidates: Array[Node2D]) -> void: - if candidates.size() < 2: + if _modal_views == null: return - if _objects_here_popup != null and is_instance_valid(_objects_here_popup): - _objects_here_popup.queue_free() var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer if ui_layer == null: return - var popup := PopupPanel.new() - popup.name = "ObjectsHere" - popup.exclusive = false - popup.transient = true - popup.unfocusable = false - if _sandbox_theme != null: - popup.theme = _sandbox_theme.control_theme() - ui_layer.add_child(popup) - var margin := 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.new() - column.add_theme_constant_override("separation", 4) - margin.add_child(column) - var heading := Label.new() - heading.text = "Objects here · Choose an object" - _apply_ui_font(heading) - column.add_child(heading) - var scroll := 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.new() - rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL - scroll.add_child(rows) - for node: Node2D in candidates: - var button := Button.new() - var selected_mark := "✓ " if _selection.is_selected(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 viewport_size := get_viewport_rect().size - var popup_size := Vector2i(mini(360, maxi(260, int(viewport_size.x) - 16)), mini(420, maxi(120, int(viewport_size.y) - 16))) - popup.size = popup_size - var pointer: Vector2 = get_viewport().get_mouse_position() + Vector2(12.0, 12.0) - var position := Vector2i(pointer) - 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 _object_here_label(node: Node2D) -> String: - if node is PropBlock: - var prop := node as PropBlock - var depth := "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.get_entry(prop.spawn_id) - var prop_name := 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 + _modal_views.show_objects_here(ui_layer, candidates, _selection.get_selected(), get_viewport_rect().size, get_viewport().get_mouse_position()) func _on_objects_here_selected(node: Node2D) -> void: if node != null and is_instance_valid(node) and node.get_parent() == _world: _selection.select_only(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 _on_transform_committed(nodes: Array[Node2D]) -> void: for node: Node2D in nodes: if node is RigidBody2D: - _edit_physics_targets[node] = node.global_transform + _run_session.add_edit_physics_target(node as RigidBody2D) _sync_editable_physics_transform(node) _save_object_state(node) if node is TerrainBlock: _nav_dirty = true _rebuild_grid_cells() _director_visuals.mark_dirty() - _edit_physics_settle_frames = 3 + _run_session.set_edit_physics_settle_frames(3) _mark_scene_dirty() @@ -4575,6 +3153,21 @@ func _on_file_menu_id_pressed(id: int) -> void: 4: _rename_scene_prompt() +func _on_document_open_path_requested(path: String) -> void: + _load_scene_from_path(path) + + +func _on_document_rename_requested(title: String) -> void: + if title.is_empty() or title == _scene_name: + return + _scene_name = title + _mark_scene_dirty() + + +func _on_document_dialog_cancelled() -> void: + _pending_file_action = PendingFileAction.NONE + + func _on_scene_save_path_selected(path: String) -> void: var final_path := path if final_path.get_extension().to_lower() != "scene": final_path = final_path.get_basename() + ".scene" @@ -4582,7 +3175,7 @@ func _on_scene_save_path_selected(path: String) -> void: func _request_scene_save(path: String = "", force_save_as: bool = false) -> void: - if _speech_composer_is_open(): + if _speech_session != null and _speech_session.is_save_blocked(): _speech_pending_scene_save_path = path _speech_pending_scene_save_as = force_save_as _speech_scene_save_pending = true @@ -4679,11 +3272,11 @@ func _continue_pending_file_action() -> void: match action: PendingFileAction.NEW_SCENE: _reset_scene_content(); _current_scene_path = ""; _scene_name = DEFAULT_SCENE_NAME; _scene_created_unix = Time.get_unix_time_from_system(); _camera.global_position = DEFAULT_CAMERA_POSITION; _camera.zoom = Vector2.ONE * DEFAULT_CAMERA_ZOOM; _set_scene_dirty(false) - if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH) + if _document != null: _document.remove_autosave() PendingFileAction.OPEN_DIALOG: _scene_open_dialog.popup_centered_ratio(0.7) PendingFileAction.OPEN_PATH: _load_scene_from_path(path) PendingFileAction.QUIT: - if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH) + if _document != null: _document.remove_autosave() get_tree().quit() @@ -4699,30 +3292,20 @@ func _on_unsaved_discard() -> void: func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void: _restore_frames_left = 0 - _edit_physics_targets.clear() - _edit_physics_settle_frames = 0 + _run_session.clear_edit_physics_targets() func _on_gizmo_transform_updated(nodes: Array[Node2D]) -> void: for node: Node2D in nodes: if node is RigidBody2D: - _edit_physics_targets[node] = node.global_transform + _run_session.add_edit_physics_target(node as RigidBody2D) _sync_editable_physics_transform(node) func _sync_pending_editable_physics_transforms() -> void: - if _edit_physics_targets.is_empty() or current_mode != StageMode.EDIT: - return - for node: RigidBody2D in _edit_physics_targets.keys().duplicate(): - if node == null or not is_instance_valid(node): - _edit_physics_targets.erase(node) - continue - node.global_transform = _edit_physics_targets[node] - _sync_editable_physics_transform(node) - if not _gizmos.is_dragging() and _edit_physics_settle_frames > 0: - _edit_physics_settle_frames -= 1 - if _edit_physics_settle_frames == 0: - _edit_physics_targets.clear() + var bodies: Array[RigidBody2D] = _run_session.sync_pending_editable_physics_transforms(current_mode == StageMode.EDIT, _gizmos.is_dragging()) + for body: RigidBody2D in bodies: + _sync_editable_physics_transform(body) func _sync_editable_physics_transform(node: Node2D) -> void: @@ -4745,152 +3328,57 @@ func _sync_editable_physics_transform(node: Node2D) -> void: # --------------------------------------------------------------------------- func _handle_direct_click(world_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 _is_target_valid(world_pos): - _show_toast("Choose an open place for Move To") - return + if _director_controller == null: + return + var hit: Node2D = null + if not _pending_walk_target and not _direct_move_target.is_finite(): + hit = _selection.hit_test(world_pos) + var was_pending: bool = _pending_walk_target + _director_controller.handle_direct_click(world_pos, hit, _is_target_valid(world_pos), _world_to_screen(world_pos)) + if was_pending and not _pending_walk_target and _direct_move_target.is_finite(): _stage_dbg("move_to staged target=(%.1f, %.1f)" % [world_pos.x, world_pos.y]) - _direct_move_target = world_pos - _pending_walk_target = false - _show_direct_move_popup(_world_to_screen(world_pos)) - _apply_cursor() - _refresh_status() - return - if _direct_move_target.is_finite(): - return - var hit := _selection.hit_test(world_pos) - if hit is STICKMAN_RIG: - _context_rig = hit as StickmanRig - var screen := _world_to_screen(hit.global_position) - var rect := Rect2i(Vector2i(screen) + Vector2i(24, 0), Vector2i.ZERO) - _set_popup_anchor(rect) - _action_popup.popup(rect) - - -func _populate_direct_move_styles() -> void: - _direct_move_style_ids.clear() - _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(raw_style) - if style.is_empty() or _direct_move_style_ids.has(style): - continue - _direct_move_style_ids.append(style) - var icon := "🚶 " 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 - 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 = get_viewport_rect().size - var popup_size := DIRECT_MOVE_POPUP_SIZE - _direct_move_popup.size = popup_size - var popup_pos := 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 - 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 rig_id: int = _context_rig.get_instance_id() - var action: Dictionary = {"type": ACTION_REGISTRY.MOVE_TO, "target": _direct_move_target, "style": style} - if not _queue_action_and_confirm(_context_rig, action): - _show_toast("Move To was not added. Try again or Cancel.") - return - _direct_move_last_style[rig_id] = style - _cancel_direct_move_draft() - _show_toast("Move To · %s added" % style.capitalize()) func _cancel_direct_move_draft() -> void: - if not _pending_walk_target and not _direct_move_target.is_finite(): - return - _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() + if _director_controller != null: + _director_controller.cancel_direct_move_draft() + + +func _on_direct_move_commit_requested(rig: StickmanRig, action: Dictionary, style: String) -> void: + var succeeded: bool = _queue_action_and_confirm(rig, action) + if _director_controller != null: + _director_controller.complete_direct_move_commit(style, succeeded) + + +func _on_director_state_changed() -> void: _apply_cursor() _refresh_status() -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() - - ## Re-enables/disables the "📋 Edit Queue…" / "⚡ Edit Rules…" items in the Direct ## action popup based on the context rig's queue/rules (entry-point gating, spec §2 ## decision 8). Runs on about_to_popup. func _refresh_action_popup_items() -> void: - if _action_popup == null: - return - var rig := _context_rig - var valid := rig != null and is_instance_valid(rig) - _disable_popup_item(_action_popup, ACT_EDIT_QUEUE, not valid or rig.get_queue().is_empty()) - _disable_popup_item(_action_popup, ACT_EDIT_RULES, not valid or not _rig_has_rules(rig)) + if _director_controller != null: + _director_controller.refresh_action_popup_items(_event_rules) ## Same gating for the stickman right-click context menu. func _refresh_rig_context_items() -> void: - if _rig_context_popup == null: - return - var rig := _panel_rig - var valid := rig != null and is_instance_valid(rig) - _disable_popup_item(_rig_context_popup, RIG_CTX_EDIT_QUEUE, not valid or rig.get_queue().is_empty()) - _disable_popup_item(_rig_context_popup, RIG_CTX_EDIT_RULES, not valid or not _rig_has_rules(rig)) + if _director_controller != null: + _director_controller.refresh_rig_context_items(_panel_rig, _rig_context_popup, _event_rules) ## Disables/enables a PopupMenu item by id (set_item_disabled is index-based and ## the edit items are not at id==index positions once separators are present). func _disable_popup_item(popup: PopupMenu, id: int, disabled: bool) -> void: - var idx := popup.get_item_index(id) - if idx >= 0: - popup.set_item_disabled(idx, disabled) + if _director_controller != null: + _director_controller.disable_popup_item(popup, id, disabled) ## True when the given rig is the source of at least one rule in _event_rules. func _rig_has_rules(rig: StickmanRig) -> bool: - if rig == null or not is_instance_valid(rig): - return false - var id := rig.get_instance_id() - for rule: Dictionary in _event_rules: - var trigger: Dictionary = rule.get("trigger", {}) - if int(trigger.get("source", -1)) == id: - return true - return false + return false if _director_controller == null else _director_controller.rig_has_rules(rig, _event_rules) func _on_action_popup_id_pressed(id: int) -> void: @@ -4898,8 +3386,8 @@ func _on_action_popup_id_pressed(id: int) -> void: return match id: ACT_WALK: - _direct_move_target = Vector2.INF - _pending_walk_target = true + if _director_controller != null: + _director_controller.begin_walk_target() _apply_cursor() _refresh_status() ACT_SPEAK: @@ -4970,178 +3458,65 @@ func _cb_direct_capture_cancel() -> void: # --------------------------------------------------------------------------- func _is_awaiting_click() -> bool: - if _pending_walk_target: - return true - if _capture_kind != CaptureKind.NONE: - return true - return _rule_step == RuleStep.TRIGGER_TARGET \ - or _rule_step == RuleStep.ACTION_TARGET \ - or _rule_step == RuleStep.ACTION_POSITION + return false if _director_controller == null else _director_controller.is_awaiting_click(_capture_kind != CaptureKind.NONE) func _action_hint_text() -> String: - if _pending_walk_target: - return "🚩 Click where they should go" - if _capture_kind != CaptureKind.NONE: - if _capture_kind == CaptureKind.SET_DOWN_POSITION: - var conditional: bool = not bool(_set_down_preview_cache.get("known", false)) - var valid: bool = bool(_set_down_preview_cache.get("valid", false)) - if conditional: - return "Click where to let go · Prop depends on earlier actions" - if not valid: - return String(_set_down_preview_cache.get("reason", "Too far to reach")) - return "Click where to let go · Ready to set down" - if _capture_kind == CaptureKind.THROW_POSITION and not _throw_preview_cache.is_empty(): - if not bool(_throw_preview_cache.get("preview_available", true)): - return "ℹ %s Click to set the throw target." % String(_throw_preview_cache.get("preview_reason", "No throw preview is available.")) - var preview_hint := String(_throw_preview_cache.get("preview_hint", "")) - if not preview_hint.is_empty(): - return "%s %s" % [_capture_hint, preview_hint] - return _capture_hint - match _rule_step: - RuleStep.TRIGGER_TARGET: - var trigger: Dictionary = _rule_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" - RuleStep.ACTION_TARGET: - return "🎯 Click the stickman" - RuleStep.ACTION_POSITION: - var actions: Array = _rule_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 "" + if _director_controller == null: + return "" + return _director_controller.action_hint_text( + _capture_kind_name(_capture_kind), + _capture_hint, + _target_capture.set_down_preview_cache, + _target_capture.throw_preview_cache + ) + + +func _capture_kind_name(kind: CaptureKind) -> String: + match kind: + CaptureKind.POSITION: + return "position" + CaptureKind.THROW_POSITION: + return "throw" + CaptureKind.SET_DOWN_POSITION: + return "set_down" + return "" if kind == CaptureKind.NONE else "target" func _action_origin() -> Vector2: - if _capture_kind == CaptureKind.SET_DOWN_POSITION and _set_down_capture_rig != null and is_instance_valid(_set_down_capture_rig): - return _set_down_capture_rig.global_position - StickmanLocomotion.FOOT_OFFSET - if _capture_kind == CaptureKind.THROW_POSITION and _capture_throw_preview_rig != null and is_instance_valid(_capture_throw_preview_rig): - if not _throw_preview_cache.is_empty(): - return _throw_preview_cache.get("start_position", _capture_throw_preview_rig.global_position) - return _capture_throw_preview_rig.global_position - if _pending_walk_target: - if _context_rig != null and is_instance_valid(_context_rig): - return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET + if _director_controller == null: return Vector2.ZERO - if _capture_kind != CaptureKind.NONE: - if _capture_kind == CaptureKind.POSITION: - var editor_rig: StickmanRig = _action_editor_preview_rig() - if editor_rig != null and is_instance_valid(editor_rig): - return editor_rig.global_position - StickmanLocomotion.FOOT_OFFSET - if _panel_rig != null and is_instance_valid(_panel_rig): - return _panel_rig.global_position - StickmanLocomotion.FOOT_OFFSET - return Vector2.ZERO - if _rule_step != RuleStep.IDLE: - if _rule_context_rig != null and is_instance_valid(_rule_context_rig): - return _rule_context_rig.global_position - StickmanLocomotion.FOOT_OFFSET - var trigger: Dictionary = _rule_builder.get("trigger", {}) - var src := instance_from_id(int(trigger.get("source", -1))) - if src is Node2D and is_instance_valid(src): - return (src as Node2D).global_position - return Vector2.ZERO + return _director_controller.action_origin( + _capture_kind_name(_capture_kind), + _target_capture.set_down_capture_rig, + _target_capture.throw_preview_rig, + _target_capture.throw_preview_cache, + _panel_rig + ) func _is_target_valid(pos: Vector2) -> bool: - var cell := _world_to_grid_cell(pos) - for n in _grid_cells.get(cell, []): - if n is TerrainBlock and STAGE_SELECTION.get_world_aabb(n).has_point(pos): - return false - return true + return true if _director_controller == null else _director_controller.is_target_valid(pos) func _update_action_overlay() -> void: - var awaiting := _is_awaiting_click() - if _placement_overlay != null: - if awaiting: - var origin := _action_origin() - var target := _camera.get_global_mouse_position() - if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.POSITION or _capture_kind == CaptureKind.THROW_POSITION or _capture_kind == CaptureKind.SET_DOWN_POSITION): - target = _snap_to_grid(target) - if _capture_kind == CaptureKind.SET_DOWN_POSITION: - var known: bool = bool(_set_down_preview_cache.get("known", false)) - var valid: bool = bool(_set_down_preview_cache.get("valid", false)) - if known: - var release_transform: Transform2D = _set_down_preview_cache.get("release_transform", Transform2D.IDENTITY) - var approach: Vector2 = _set_down_preview_cache.get("approach_position", origin) - var grip: Vector2 = _set_down_preview_cache.get("grip_position", target) - var preview_prop: PropBlock = _set_down_preview_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 == CaptureKind.THROW_POSITION: - if _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig): - _placement_overlay.set_throw_preview_unavailable(target) - else: - var throw_preview := _throw_preview_cache - if throw_preview.is_empty(): - _placement_overlay.set_throw_preview_unavailable(target) - elif 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(throw_preview.get("target_reachable", true)) - var preview_planned := bool(throw_preview.get("preview_planned", false)) - var preview_uncertain := 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 - StickmanLocomotion.FOOT_OFFSET - _placement_overlay.set_action_trajectory(origin, _direct_move_target, true) - else: - _placement_overlay.clear_action() - if _tooltip != null: - _tooltip.visible = awaiting - if awaiting: - _tooltip.text = _action_hint_text() - var sb: StyleBox = _tooltip.get_theme_stylebox("normal") - var extra := sb.get_minimum_size() if sb != null else Vector2.ZERO - _tooltip.size = _tooltip.get_minimum_size() + extra - _update_tooltip_position() - - -func _update_tooltip_position() -> void: - if _tooltip == null: + if _director_controller == null: return - var mouse := get_viewport().get_mouse_position() - var size := _tooltip.size - var pos := mouse + Vector2(20.0, -20.0) - var vp := get_viewport().get_visible_rect().size - if pos.x + size.x > vp.x: - pos.x = mouse.x - size.x - 20.0 - if pos.y + size.y > vp.y: - pos.y = mouse.y - size.y - 20.0 - if pos.y < 0.0: - pos.y = mouse.y + 20.0 - _tooltip.position = pos + _director_controller.update_action_overlay( + _capture_kind_name(_capture_kind), + _capture_hint, + _target_capture.set_down_preview_cache, + _target_capture.throw_preview_cache, + _target_capture.set_down_capture_rig, + _target_capture.throw_preview_rig, + _snap_enabled, + _grid_size + ) func _on_speak_confirmed() -> void: - if _rule_step == RuleStep.PARAMS: - _set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0, "stop_current": _speak_stop_current_check.button_pressed }) - _open_rule_more_popup() + if _rule_authoring != null and _rule_authoring.accept_action_params_from_dialog({ "text": _speak_edit.text, "duration": 2.0, "stop_current": _speak_stop_current_check.button_pressed }): + _refresh_status() return if _context_rig == null or not is_instance_valid(_context_rig): return @@ -5149,9 +3524,8 @@ func _on_speak_confirmed() -> void: func _on_wait_confirmed() -> void: - if _rule_step == RuleStep.PARAMS: - _set_rule_action_params({ "duration": _wait_spin.value }) - _open_rule_more_popup() + if _rule_authoring != null and _rule_authoring.accept_action_params_from_dialog({ "duration": _wait_spin.value }): + _refresh_status() return if _context_rig == null or not is_instance_valid(_context_rig): return @@ -5163,28 +3537,25 @@ func _on_wait_confirmed() -> void: ## Popup rect at the current mouse position. func _mouse_popup_rect() -> Rect2i: - var mouse := get_viewport().get_mouse_position() - return Rect2i(Vector2i(mouse), Vector2i.ZERO) + return Rect2i() if _rule_authoring == null else _rule_authoring.mouse_popup_rect() ## Records the session popup anchor (the first context menu's screen rect). func _set_popup_anchor(rect: Rect2i) -> void: - _popup_anchor = rect - _popup_anchor_set = true + if _rule_authoring != null: + _rule_authoring.set_popup_anchor(rect) ## Clears the session popup anchor (on rule confirm or cancel). func _clear_popup_anchor() -> void: - _popup_anchor = Rect2i() - _popup_anchor_set = false + if _rule_authoring != null: + _rule_authoring.clear_popup_anchor() ## Returns the session anchor, recording it from the mouse on first use so a ## rule-builder flow that never passed through a first menu still has one. func _popup_anchor_rect() -> Rect2i: - if not _popup_anchor_set: - _set_popup_anchor(_mouse_popup_rect()) - return _popup_anchor + return Rect2i() if _rule_authoring == null else _rule_authoring.popup_anchor_rect() ## Starts a fresh rule build with the given trigger source (instance id; -1 for @@ -5192,36 +3563,25 @@ func _popup_anchor_rect() -> Rect2i: ## Rule Panel's "Add Rule" button. func _begin_rule_build(source_id: int, from_panel: bool = false) -> void: _rule_edit_uncommitted = true - _rule_build_from_panel = from_panel - _rule_builder = { - "trigger": { "source": source_id, "target": -1, "params": {} }, - "actions": [], - } - _rule_editing_id = -1 - var src := instance_from_id(source_id) - _rule_context_rig = src if src is StickmanRig else null - _rule_step = RuleStep.SELECT_TRIGGER - _trigger_popup.popup(_popup_anchor_rect()) + if _rule_authoring != null: + _rule_authoring.begin_rule_build(source_id, from_panel) func _open_rule_action_popup() -> void: - _sync_rule_action_done_item() - _rule_action_popup.popup(_popup_anchor_rect()) + if _rule_authoring != null: + _rule_authoring.open_rule_action_popup() ## Shows the "✅ Done" item in the rule-action popup only while editing an ## existing rule (otherwise there is no way to save without adding an action). func _sync_rule_action_done_item() -> void: - var idx: int = _rule_action_popup.get_item_index(RULE_ACTION_DONE) - var editing: bool = _rule_editing_id >= 0 - if editing and idx < 0: - _rule_action_popup.add_item("✅ Done", RULE_ACTION_DONE) - elif not editing and idx >= 0: - _rule_action_popup.remove_item(idx) + if _rule_authoring != null: + _rule_authoring.sync_rule_action_done_item() func _open_rule_more_popup() -> void: - _rule_more_popup.popup(_popup_anchor_rect()) + if _rule_authoring != null: + _rule_authoring.open_rule_more_popup() func _on_trigger_popup_id_pressed(id: int) -> void: @@ -5232,116 +3592,34 @@ func _on_trigger_popup_id_pressed(id: int) -> void: _action_popup.popup(_popup_anchor_rect()) _refresh_status() return - var trigger: Dictionary = _rule_builder.get("trigger", {}) - trigger["type"] = _trigger_type_string(id) - _rule_builder["trigger"] = trigger - if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED or id == TRIG_PROP_PICKED_UP or id == TRIG_PROP_DROPPED or id == TRIG_PROP_THROWN: - _rule_step = RuleStep.TRIGGER_TARGET - match id: - TRIG_ARRIVED: - _rule_hint = "Click the waypoint to trigger on (Esc or RMB to cancel)" - TRIG_ENTERED_AREA: - _rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)" - TRIG_COLLIDED: - _rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)" - TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN: - _rule_hint = "Click the prop to watch (Esc or RMB to cancel)" - _refresh_status() - else: - _rule_step = RuleStep.SELECT_ACTION - _open_rule_action_popup() + if _rule_authoring != null: + _rule_authoring.on_trigger_selected(id) + if _rule_authoring.step == RuleStep.TRIGGER_TARGET: + _refresh_status() func _on_rule_action_popup_id_pressed(id: int) -> void: - if id == RULE_ACTION_DONE: + if _rule_authoring != null and _rule_authoring.on_action_selected(id): _finalize_rule() return - var action := { "type": _action_type_string(id), "target": -1, "params": {} } - var actions: Array = _rule_builder.get("actions", []) - actions.append(action) - _rule_builder["actions"] = actions - _rule_step = RuleStep.ACTION_TARGET - _rule_hint = "Click the stickman who will %s (Esc or RMB to cancel)" % _action_verb(id) _refresh_status() func _on_rule_more_id_pressed(id: int) -> void: - match id: - RULE_MORE_ADD: - _rule_step = RuleStep.SELECT_ACTION - _open_rule_action_popup() - RULE_MORE_DONE: - _finalize_rule() + if _rule_authoring != null and _rule_authoring.on_more_selected(id): + _finalize_rule() 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 "" + return "" if _rule_authoring == null else _rule_authoring.trigger_type_string(id) func _action_type_string(id: int) -> String: - match id: - ACT_WALK: - return ACTION_REGISTRY.MOVE_TO - ACT_SPEAK: - return "speak" - ACT_WAIT: - return "wait" - ACT_RAGDOLL: - return "ragdoll" - ACT_RECOVER: - return "recover" - ACT_PICK_UP_PROP: - return "pick_up_prop" - ACT_DROP_PROP: - return "drop_prop" - ACT_THROW_PROP: - return "throw_prop" - ACT_SET_DOWN_PROP: - return "set_down_prop" - _: - return "" + return "" if _rule_authoring == null else _rule_authoring.action_type_string(id) func _action_verb(id: int) -> String: - match id: - ACT_WALK: - return "move to a place" - ACT_SPEAK: - return "speak" - ACT_WAIT: - return "wait" - ACT_RAGDOLL: - return "ragdoll" - ACT_RECOVER: - return "recover" - ACT_PICK_UP_PROP: - return "pick up a prop" - ACT_DROP_PROP: - return "drop a prop" - ACT_THROW_PROP: - return "throw a prop" - ACT_SET_DOWN_PROP: - return "set down a prop" - _: - return "act" + return "act" if _rule_authoring == null else _rule_authoring.action_verb(id) func _handle_rule_click(world_pos: Vector2) -> void: @@ -5356,37 +3634,34 @@ func _handle_rule_click(world_pos: Vector2) -> void: func _handle_trigger_target_click(world_pos: Vector2) -> void: var trigger: Dictionary = _rule_builder.get("trigger", {}) - match String(trigger.get("type", "")): + var trigger_type: String = String(trigger.get("type", "")) + match trigger_type: "arrived_at_waypoint": var wp: Vector2 = _director_visuals.hit_test_waypoint(world_pos) if wp.is_finite(): - trigger["params"] = { "waypoint_pos": wp } - trigger["target"] = -1 - _rule_builder["trigger"] = trigger - _rule_step = RuleStep.SELECT_ACTION - _open_rule_action_popup() + if _rule_authoring != null: + _rule_authoring.accept_trigger_waypoint(wp) else: - _rule_hint = "Click a waypoint dot (Esc or RMB to cancel)" + if _rule_authoring != null: + _rule_authoring.set_trigger_target_miss_hint(trigger_type) _refresh_status() "entered_area": var hit := _selection.hit_test(world_pos) if hit is TriggerArea: - trigger["target"] = hit.get_instance_id() - _rule_builder["trigger"] = trigger - _rule_step = RuleStep.SELECT_ACTION - _open_rule_action_popup() + if _rule_authoring != null: + _rule_authoring.accept_trigger_area(hit as TriggerArea) else: - _rule_hint = "Click a trigger area (Esc or RMB to cancel)" + if _rule_authoring != null: + _rule_authoring.set_trigger_target_miss_hint(trigger_type) _refresh_status() "collided", "prop_picked_up", "prop_dropped", "prop_thrown": var hit2 := _selection.hit_test(world_pos) if hit2 is PropBlock: - trigger["target"] = hit2.get_instance_id() - _rule_builder["trigger"] = trigger - _rule_step = RuleStep.SELECT_ACTION - _open_rule_action_popup() + if _rule_authoring != null: + _rule_authoring.accept_trigger_prop(hit2 as PropBlock) else: - _rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)" + if _rule_authoring != null: + _rule_authoring.set_trigger_target_miss_hint(trigger_type) _refresh_status() @@ -5398,10 +3673,7 @@ func _handle_action_target_click(world_pos: Vector2) -> void: var actions: Array = _rule_builder.get("actions", []) if actions.is_empty(): return - var action: Dictionary = actions[actions.size() - 1] - action["target"] = (hit as StickmanRig).get_instance_id() - actions[actions.size() - 1] = action - _rule_builder["actions"] = actions + var action: Dictionary = _rule_authoring.set_last_action_target(int((hit as StickmanRig).get_instance_id())) if _rule_authoring != null else {} match String(action.get("type", "")): "move_to": _open_action_editor("rule_builder_move_to", {}, -1, null, int((hit as StickmanRig).get_instance_id()), false) @@ -5426,79 +3698,35 @@ func _handle_action_target_click(world_pos: Vector2) -> void: func _handle_action_position_click(world_pos: Vector2) -> void: if _snap_enabled: world_pos = _snap_to_grid(world_pos) - var actions: Array = _rule_builder.get("actions", []) - if actions.is_empty(): - return - var action: Dictionary = actions[actions.size() - 1] - var params: Dictionary = action.get("params", {}) - params["target"] = world_pos - action["params"] = params - actions[actions.size() - 1] = action - _rule_builder["actions"] = actions - _open_rule_more_popup() + if _rule_authoring != null: + _rule_authoring.accept_last_action_position(world_pos) func _cb_rule_action_prop_target(value: Node2D) -> void: - if not value is PropBlock: - return - var prop := value as PropBlock - if prop.stage_object_id.is_empty(): - _show_toast("That prop is missing its scene ID") - return - var actions: Array = _rule_builder.get("actions", []) - if actions.is_empty(): - return - 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 - _rule_builder["actions"] = actions - _open_rule_more_popup() + if value is PropBlock and _rule_authoring != null: + _rule_authoring.accept_last_action_prop_target(value as PropBlock) func _cb_rule_action_throw_target(value: Vector2) -> void: - var actions: Array = _rule_builder.get("actions", []) - if actions.is_empty(): - return - var action: Dictionary = actions.back() - var params: Dictionary = action.get("params", {}) - params["target_position"] = value - action["params"] = params - actions[actions.size() - 1] = action - _rule_builder["actions"] = actions - _open_rule_more_popup() + if _rule_authoring != null: + _rule_authoring.accept_last_action_world_target(value) func _cb_rule_action_set_down_target(value: Vector2) -> void: - var actions: Array = _rule_builder.get("actions", []) - if actions.is_empty(): - return - var action: Dictionary = actions.back() - var params: Dictionary = action.get("params", {}) - params["target_position"] = value - action["params"] = params - actions[actions.size() - 1] = action - _rule_builder["actions"] = actions - _open_rule_more_popup() + if _rule_authoring != null: + _rule_authoring.accept_last_action_world_target(value) func _cb_rule_action_capture_cancel() -> void: - _rule_step = RuleStep.ACTION_TARGET - _rule_hint = "Click the stickman who will act (Esc or RMB to cancel)" + if _rule_authoring != null: + _rule_authoring.cancel_last_action_target_capture() _refresh_status() ## Writes dialog-confirmed params onto the last action in the builder. func _set_rule_action_params(params: Dictionary) -> void: - var actions: Array = _rule_builder.get("actions", []) - if actions.is_empty(): - return - var action: Dictionary = actions[actions.size() - 1] - action["params"] = params - actions[actions.size() - 1] = action - _rule_builder["actions"] = actions + if _rule_authoring != null: + _rule_authoring.set_last_action_params(params) func _finalize_rule() -> void: @@ -5548,14 +3776,8 @@ func _cancel_rule_build() -> void: func _reset_rule_builder() -> void: - if _speak_stop_current_check != null: - _speak_stop_current_check.visible = false - _speak_stop_current_check.button_pressed = false - _rule_step = RuleStep.IDLE - _rule_builder = {} - _rule_context_rig = null - _rule_hint = "" - _rule_editing_id = -1 + if _rule_authoring != null: + _rule_authoring.reset_builder() _apply_cursor() @@ -5621,12 +3843,10 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr # authoritative snap_to_standing() cleanup has run. if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready: return - if other == null or not is_instance_valid(other): + if other == null or not is_instance_valid(other) or _rule_engine == null: return - var contact_key: String = "%d:%d" % [prop.get_instance_id(), other.get_instance_id()] - if _suppressed_contact_pairs.has(contact_key): + if not _rule_engine.register_contact(prop, other): return - _suppressed_contact_pairs[contact_key] = true if bool(other.get_meta("stickman_ragdoll_body", false)): return var has_rig_metadata := other.has_meta("stickman_rig") or other.has_meta("part_name") @@ -5639,47 +3859,26 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr return if rig.state == StickmanRig.RigState.RAGDOLL: return - var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()] - 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 := other.get_instance_id() - if bodies.has(body_id): + var contact: Dictionary = _rule_engine.register_rig_body_contact(rig, prop, other) + if not bool(contact.get("accepted", false)): return - var first_contact := bodies.is_empty() - bodies[body_id] = true - pair["bodies"] = bodies - _collision_pairs[key] = pair rig.apply_collision_reaction(part, normal, prop.linear_velocity.length(), position) - if first_contact: + if bool(contact.get("first_contact", false)): _handle_event({ "type": "collided", "source": rig, "target": prop, "position": position, "action": {} }) func _on_prop_separated(other: Node, prop: PropBlock) -> void: if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready: return - if other == null or not is_instance_valid(other): + if other == null or not is_instance_valid(other) or _rule_engine == null: return - _suppressed_contact_pairs.erase("%d:%d" % [prop.get_instance_id(), other.get_instance_id()]) + _rule_engine.unregister_contact(prop, other) if not other.has_meta("stickman_rig"): return var rig := other.get_meta("stickman_rig") as StickmanRig if rig == null or not is_instance_valid(rig): return - var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()] - var pair: Dictionary = _collision_pairs.get(key, {}) - if pair.is_empty(): - return - var bodies: Dictionary = pair.get("bodies", {}) - bodies.erase(other.get_instance_id()) - if bodies.is_empty(): - _collision_pairs.erase(key) - else: - pair["bodies"] = bodies - _collision_pairs[key] = pair + _rule_engine.unregister_rig_body_contact(rig, prop, other) func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void: @@ -5689,59 +3888,24 @@ func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void: 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) + if _rule_engine != null: + _rule_engine.purge_collision_pairs_for_ids(ids) ## Iterates every rule in order; every matching rule executes (no short-circuit). func _handle_event(event: Dictionary) -> void: - if _run_state != RunState.RUNNING or not _event_dispatch_ready: + if _run_state != RunState.RUNNING or not _event_dispatch_ready or _rule_engine == null: return - for rule: Dictionary in _event_rules: - if _rule_matches(rule, event): - _execute_rule_actions(rule) + for rule: Dictionary in _rule_engine.handle_event(event, _event_rules): + _execute_rule_actions(rule) func _rule_matches(rule: Dictionary, event: Dictionary) -> bool: - var trigger: Dictionary = rule.get("trigger", {}) - var rule_type := String(trigger.get("type", "")) - if rule_type != String(event.get("type", "")): - return false - var source := event.get("source") as Node2D - var target := event.get("target") as Node2D - var source_id := int(trigger.get("source", -1)) - var target_id := 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) - return (event.get("position", Vector2.INF) as Vector2).distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON - "action_finished": - if not _ids_match(source_id, source): - return false - var trig_params: Dictionary = trigger.get("params", {}) - var want := String(trig_params.get("action_type", "")) - var action: Dictionary = event.get("action", {}) - return want.is_empty() or ACTION_REGISTRY.canonical_type(want) == ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) - "speech_finished": - return _ids_match(source_id, source) - "entered_area": - return _ids_match(source_id, source) and _ids_match(target_id, target) - "collided": - return _ids_match(source_id, source) and _ids_match(target_id, target) - "prop_picked_up", "prop_dropped", "prop_thrown": - return _ids_match(source_id, source) and _ids_match(target_id, target) - _: - return false + return false if _rule_engine == null else _rule_engine.matches_rule(rule, event) -func _ids_match(a: int, b: Node2D) -> bool: - return a == -1 or (b != null and is_instance_valid(b) and b.get_instance_id() == a) - +func _ids_match(expected_id: int, node: Node2D) -> bool: + return false if _rule_engine == null else _rule_engine.ids_match(expected_id, node) func _execute_rule_actions(rule: Dictionary) -> void: var batches: Dictionary = {} @@ -5769,71 +3933,18 @@ func _action_for_rig(a: Dictionary) -> Dictionary: ## Geometric overlap: TriggerArea children vs ANIMATED stickmen + unfrozen props. ## Edge-triggered on entry; emits an entered_area event per new overlap. func _update_area_entry() -> void: - var areas: Array[TriggerArea] = [] - for child: Node in _world.get_children(): - if child is TriggerArea: - areas.append(child as TriggerArea) - if areas.is_empty(): + if _rule_engine == null: return - var movables: Array[Node2D] = [] - for node: Node2D in _world_children_selectable(): - if node is STICKMAN_RIG: - var rig := node as STICKMAN_RIG - if rig.state == StickmanRig.RigState.ANIMATED: - movables.append(rig) - elif node is PropBlock and not (node as PropBlock).freeze: - movables.append(node) - # Build one swept bound per movable before iterating areas. The stage parent - # polls before child rigs apply this frame's free-fall displacement, so the - # previous->current union prevents fast airborne motion from tunneling - # through a trigger between two observed poses. - var swept_bounds: Dictionary = {} - var live_ids: Dictionary = {} - for node: Node2D in movables: - var node_id := node.get_instance_id() - var current_bounds := STAGE_SELECTION.get_world_aabb(node).abs() - var swept := 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) - for area: TriggerArea in areas: - var area_rect := (area.global_transform * area.get_area_rect()).abs() - for node: Node2D in movables: - var point := _movable_point(node) - var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()] - var inside: bool = (swept_bounds.get( - node.get_instance_id(), Rect2()) as Rect2).intersects(area_rect, true) - var was: bool = bool(_area_overlap.get(key, false)) - if inside and not was: - _area_overlap[key] = true - _handle_event({ "type": "entered_area", "source": node, "target": area, "position": point, "action": {} }) - elif not inside and was: - _area_overlap[key] = false + for event: Dictionary in _rule_engine.physics_tick(_world_children_selectable()): + _handle_event(event) -## Stickman point = feet (root - FOOT_OFFSET); prop point = global position. func _movable_point(node: Node2D) -> Vector2: - if node is STICKMAN_RIG: - return node.global_position - StickmanLocomotion.FOOT_OFFSET - return node.global_position + return Vector2.ZERO if _rule_engine == null else _rule_engine.movable_point(node) -## Trigger entry follows the visible object, not only its origin/feet anchor. -## StageSelection already owns the mounted-geometry world AABB contract used by -## selection and gizmos, so reuse it here to keep what the user sees and what -## the event engine detects in agreement. Point-like fallbacks retain the old -## behavior for any future movable without visual bounds. func _movable_overlaps_area(node: Node2D, area_rect: Rect2) -> bool: - var bounds := 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) - + return false if _rule_engine == null else _rule_engine.movable_overlaps_area(node, area_rect) func _show_toast(msg: String) -> void: _toast_text = msg @@ -5935,14 +4046,7 @@ func _count_rules_for_waypoint(pos: Vector2) -> int: # --------------------------------------------------------------------------- func _begin_capture(kind: CaptureKind, hint: String, on_resolve: Callable, on_cancel: Callable = Callable()) -> void: - _capture_kind = kind - _capture_hint = hint - _capture_callback = on_resolve - _capture_cancel = on_cancel - _capture_throw_preview_rig = null - _capture_throw_preview_prefix.clear() - _throw_preview_cache.clear() - _set_down_preview_cache.clear() + _target_capture.begin(int(kind), hint, on_resolve, on_cancel) if _set_down_cancel_button != null: _set_down_cancel_button.visible = kind == CaptureKind.SET_DOWN_POSITION _apply_cursor() @@ -5951,29 +4055,18 @@ func _begin_capture(kind: CaptureKind, hint: String, on_resolve: Callable, on_ca func _begin_throw_position_capture(rig: StickmanRig, hint: String, on_resolve: Callable, on_cancel: Callable = Callable(), queue_prefix: Array[Dictionary] = []) -> void: _begin_capture(CaptureKind.THROW_POSITION, hint, on_resolve, on_cancel) - _capture_throw_preview_rig = rig if rig != null and is_instance_valid(rig) else null - _capture_throw_preview_prefix = queue_prefix.duplicate(true) - _throw_preview_cache.clear() + _target_capture.throw_preview_rig = rig if rig != null and is_instance_valid(rig) else null + _target_capture.throw_preview_prefix = queue_prefix.duplicate(true) func _begin_set_down_position_capture(rig: StickmanRig, hint: String, on_resolve: Callable, on_cancel: Callable = Callable(), queue_prefix: Array[Dictionary] = []) -> void: _begin_capture(CaptureKind.SET_DOWN_POSITION, hint, 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) - _set_down_preview_cache.clear() + _target_capture.set_down_capture_rig = rig if rig != null and is_instance_valid(rig) else null + _target_capture.set_down_preview_prefix = queue_prefix.duplicate(true) func _end_capture() -> void: - _capture_kind = CaptureKind.NONE - _capture_hint = "" - _capture_callback = Callable() - _capture_cancel = Callable() - _capture_throw_preview_rig = null - _capture_throw_preview_prefix.clear() - _throw_preview_cache.clear() - _set_down_capture_rig = null - _set_down_preview_prefix.clear() - _set_down_preview_cache.clear() + _target_capture.finish() if _set_down_cancel_button != null: _set_down_cancel_button.visible = false _apply_cursor() @@ -5981,87 +4074,33 @@ func _end_capture() -> void: 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 := 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(entry.get("status", StickmanActionRunner.QueueStatus.NOT_STARTED)) - if status == StickmanActionRunner.QueueStatus.COMPLETED or status == StickmanActionRunner.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 == StickmanActionRunner.QueueStatus.ACTIVE, - }) - return result + return _target_capture.throw_preview_prefix_for(rig, before_action_id) func _cancel_capture() -> void: - var cb := _capture_cancel + var callback: Callable = _capture_cancel _end_capture() - if cb.is_valid(): - cb.call() + if callback.is_valid(): + callback.call() func _resolve_capture(world_pos: Vector2) -> void: - var value: Variant = null - match _capture_kind: - CaptureKind.WAYPOINT: - var wp: Vector2 = _director_visuals.hit_test_waypoint(world_pos) - if wp.is_finite(): - value = wp - CaptureKind.AREA: - var hit := _selection.hit_test(world_pos) - if hit is TriggerArea: - value = hit - CaptureKind.PROP: - var hit2 := _selection.hit_test(world_pos) - if hit2 is PropBlock: - value = hit2 - CaptureKind.STICKMAN: - var hit3 := _selection.hit_test(world_pos) - if hit3 is STICKMAN_RIG: - value = hit3 - CaptureKind.POSITION: - value = _snap_to_grid(world_pos) if _snap_enabled else world_pos - CaptureKind.THROW_POSITION: - value = _snap_to_grid(world_pos) if _snap_enabled else world_pos - CaptureKind.SET_DOWN_POSITION: - var release_position: Vector2 = _snap_to_grid(world_pos) if _snap_enabled else world_pos - if release_position.is_finite(): - if not bool(_set_down_preview_cache.get("known", false)) or bool(_set_down_preview_cache.get("valid", false)): - value = release_position - else: - _refresh_status() - return - if value == null: + var resolution: Dictionary = _target_capture.resolve_target(world_pos, _selection, _director_visuals, _snap_enabled, _grid_size) + if not bool(resolution.get("valid", false)): _refresh_status() return - var cb := _capture_callback + var callback: Callable = _capture_callback _end_capture() - if cb.is_valid(): - cb.call(value) - + if callback.is_valid(): + callback.call(resolution.get("value")) # --------------------------------------------------------------------------- # Phase 3c.3 walk-edit (visual waypoint re-placement) # --------------------------------------------------------------------------- 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) + if _action_queue_controller != null: + _action_queue_controller.begin_walk_edit(rig, index, from_panel) # --------------------------------------------------------------------------- @@ -6069,57 +4108,48 @@ func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void: # --------------------------------------------------------------------------- func _open_queue_panel(rig: StickmanRig) -> void: - _panel_rig = rig - _queue_panel.setup(rig) - _queue_panel.popup_centered() + if _action_queue_controller != null: + _action_queue_controller.open_queue_panel(rig) func _on_queue_panel_edit_requested(index: int) -> void: - if _panel_rig == null or not is_instance_valid(_panel_rig): - return - var queue := _panel_rig.get_queue() - if index < 0 or index >= queue.size(): - return - if _queue_action_consumed(_panel_rig.get_action_status(_panel_rig.action_id_at(index))): - _show_toast("Completed actions can only be deleted") - return - var action: Dictionary = queue[index] - _open_action_editor("queue_edit", action.duplicate(true), index, _panel_rig, -1, true) + if _action_queue_controller != null: + _action_queue_controller.edit_queue_action(index) func _on_queue_panel_delete_requested(index: int) -> void: - if _panel_rig == null or not is_instance_valid(_panel_rig): - return - var target_rig: StickmanRig = _panel_rig - var action_id: int = target_rig.action_id_at(index) - if action_id < 0: - return - _ask_confirm("Delete Action", "Delete this action?", func() -> void: - if target_rig != null and is_instance_valid(target_rig): - var current_index: int = target_rig.index_for_action_id(action_id) - if current_index >= 0: - target_rig.remove_action(current_index) - if _queue_panel != null: - _queue_panel.refresh() - ) + if _action_queue_controller != null: + _action_queue_controller.request_delete_action(index) func _on_queue_panel_add_requested() -> void: - if _panel_rig == null or not is_instance_valid(_panel_rig): - return - _open_action_editor("queue_add", {}, -1, _panel_rig, -1, true) + if _action_queue_controller != null: + _action_queue_controller.add_queue_action() func _on_queue_panel_clear_requested() -> void: - if _panel_rig == null or not is_instance_valid(_panel_rig): - return - var target_rig: StickmanRig = _panel_rig - _ask_confirm("Clear Queue", "Remove all actions for %s?" % target_rig.name, func() -> void: - if target_rig != null and is_instance_valid(target_rig): - target_rig.clear_queue() - if _queue_panel != null: - _queue_panel.refresh() - ) + if _action_queue_controller != null: + _action_queue_controller.request_clear_queue() + + +func _on_action_queue_confirmation_requested(title: String, message: String, callback: Callable) -> void: + _ask_confirm(title, message, callback) + + +func _commit_queue_action_delete(rig: StickmanRig, action_id: int) -> void: + if rig != null and is_instance_valid(rig): + var current_index: int = rig.index_for_action_id(action_id) + if current_index >= 0: + rig.remove_action(current_index) + if _queue_panel != null: + _queue_panel.refresh() + + +func _commit_queue_clear(rig: StickmanRig) -> void: + if rig != null and is_instance_valid(rig): + rig.clear_queue() + if _queue_panel != null: + _queue_panel.refresh() func _queue_action_consumed(status: int) -> bool: @@ -6132,68 +4162,42 @@ func _queue_action_consumed(status: int) -> bool: func _open_rules_panel_for_rig(rig: StickmanRig) -> void: _panel_rig = rig - _rule_panel_source_id = rig.get_instance_id() - _rule_panel_waypoint = Vector2.INF - _rule_panel_title = "Source: %s" % rig.name + if _rule_authoring != null: + _rule_authoring.set_panel_for_rig(rig) _show_rule_panel() func _open_trigger_rules_panel(pos: Vector2) -> void: - _rule_panel_source_id = -1 - _rule_panel_waypoint = pos - _rule_panel_title = "Waypoint (%d, %d)" % [int(roundf(pos.x)), int(roundf(pos.y))] + if _rule_authoring != null: + _rule_authoring.set_panel_for_waypoint(pos) _show_rule_panel() func _rule_panel_rules() -> Array[Dictionary]: - var out: Array[Dictionary] = [] - for rule: Dictionary in _event_rules: - if _rule_matches_panel_filter(rule): - out.append(rule) - return out + return [] if _rule_authoring == null else _rule_authoring.panel_rules(_event_rules) func _rule_matches_panel_filter(rule: Dictionary) -> bool: - if _rule_panel_source_id >= 0: - var trigger: Dictionary = rule.get("trigger", {}) - return int(trigger.get("source", -1)) == _rule_panel_source_id - if _rule_panel_waypoint.is_finite(): - var trig: Dictionary = rule.get("trigger", {}) - if String(trig.get("type", "")) != "arrived_at_waypoint": - return false - var params: Dictionary = trig.get("params", {}) - var wp: Vector2 = params.get("waypoint_pos", Vector2.INF) - return wp.is_finite() and wp.distance_to(_rule_panel_waypoint) <= WAYPOINT_MATCH_EPSILON - return true + return true if _rule_authoring == null else _rule_authoring.rule_matches_panel_filter(rule) ## Builds the filtered rule list + filter-id set and pops the panel. func _show_rule_panel() -> void: - _rule_panel_filter_ids.clear() - var rules := _rule_panel_rules() - for r: Dictionary in rules: - _rule_panel_filter_ids.append(int(r.get("id", -1))) - _rule_panel.show_rules(rules, _rule_panel_title) + if _rule_authoring != null: + _rule_authoring.show_rule_panel(_event_rules) ## Refreshes the rule panel's list in place (no re-centering). Safe to call even ## when the panel is hidden (restore flows re-pop it afterwards, so it must not ## skip the rebuild when `visible == false`). func _refresh_rule_panel() -> void: - if _rule_panel == null: - return - _rule_panel_filter_ids.clear() - var rules := _rule_panel_rules() - for r: Dictionary in rules: - _rule_panel_filter_ids.append(int(r.get("id", -1))) - _rule_panel.set_rules(rules, _rule_panel_title) + if _rule_authoring != null: + _rule_authoring.refresh_rule_panel(_event_rules) func _on_rule_panel_edit_requested(rule_id: int) -> void: - for rule: Dictionary in _event_rules: - if int(rule.get("id", -1)) == rule_id: - _open_rule_editor_full(rule, true) - return + if _rule_authoring != null and _rule_authoring.edit_rule_by_id(_event_rules, rule_id): + _rule_edit_uncommitted = true func _on_rule_panel_delete_requested(rule_id: int) -> void: @@ -6204,11 +4208,9 @@ func _on_rule_panel_delete_requested(rule_id: int) -> void: func _on_rule_panel_add_requested() -> void: - if _rule_panel_source_id < 0: - _show_toast("Select a stickman first") - return - _rule_panel.hide() - _begin_rule_build(_rule_panel_source_id, true) + var source_id: int = -1 if _rule_authoring == null else _rule_authoring.begin_panel_add() + if source_id >= 0: + _begin_rule_build(source_id, true) func _on_rule_panel_clear_requested() -> void: @@ -6248,11 +4250,8 @@ func _reorder_filtered_rules(ordered_ids: Array[int]) -> void: ## Re-shows the rule panel after a rule build launched from it finishes. func _restore_rule_build_panel() -> void: - if _rule_build_from_panel: - _rule_build_from_panel = false - _refresh_rule_panel() - if _rule_panel != null: - _rule_panel.popup_centered() + if _rule_authoring != null: + _rule_authoring.restore_rule_build_panel(_event_rules) # --------------------------------------------------------------------------- @@ -6261,23 +4260,19 @@ func _restore_rule_build_panel() -> void: func _open_rule_editor_full(rule: Dictionary, from_panel: bool) -> void: _rule_edit_uncommitted = true - _rule_editor_from_panel = from_panel - if from_panel and _rule_panel != null: - _rule_panel.hide() - _rule_editor.open_full(rule) + if _rule_authoring != null: + _rule_authoring.open_rule_editor_full(rule, from_panel) func _open_rule_editor_consequence(rule: Dictionary) -> void: _rule_edit_uncommitted = true - _rule_editor_from_panel = false - _rule_editor.open_consequence(rule) + if _rule_authoring != null: + _rule_authoring.open_rule_editor_consequence(rule) func _restore_rule_panel() -> void: - if _rule_editor_from_panel and _rule_panel != null: - _rule_editor_from_panel = false - _refresh_rule_panel() - _rule_panel.popup_centered() + if _rule_authoring != null: + _rule_authoring.restore_rule_panel(_event_rules) func _on_rule_editor_committed(rule: Dictionary) -> void: @@ -6307,62 +4302,57 @@ func _on_rule_editor_cancelled() -> void: func _on_rule_editor_trigger_target_requested(type: String) -> void: - _rule_editor.hide() - match TRIGGER_REGISTRY.target_type(type): + var target_type: String = "" if _rule_authoring == null else _rule_authoring.prepare_trigger_target_capture(type) + var hint: String = "" if _rule_authoring == null else _rule_authoring.trigger_target_capture_hint(target_type) + match target_type: "waypoint": - _begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc or RMB to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel) + _begin_capture(CaptureKind.WAYPOINT, hint, _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel) "area": - _begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc or RMB to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel) + _begin_capture(CaptureKind.AREA, hint, _cb_rule_trigger_area, _cb_rule_trigger_cancel) "prop": - _begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc or RMB to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel) + _begin_capture(CaptureKind.PROP, hint, _cb_rule_trigger_prop, _cb_rule_trigger_cancel) func _on_rule_editor_action_add_requested() -> void: - _open_action_editor("rule_add", {}, -1, null, -1, false) + if _action_queue_controller != null: + _action_queue_controller.add_rule_action() func _on_rule_editor_action_edit_requested(index: int) -> void: - var rule_action: Dictionary = _rule_editor.get_action(index) - if rule_action.is_empty(): - return - var flat := ACTION_REGISTRY.from_rule_action(rule_action) - var actor_id := int(rule_action.get("target", -1)) - _open_action_editor("rule_edit", flat, index, null, actor_id, false) + if _action_queue_controller != null: + _action_queue_controller.edit_rule_action(index) # --- Rule editor target-capture callbacks ----------------------------------- func _cb_rule_trigger_waypoint(value: Vector2) -> void: - if _rule_editor != null: - _rule_editor.set_trigger_target(-1, { "waypoint_pos": value }) + if _rule_authoring != null: + _rule_authoring.set_rule_editor_trigger_waypoint(value) func _cb_rule_trigger_area(value: Node2D) -> void: - if _rule_editor != null: - _rule_editor.set_trigger_target(int(value.get_instance_id()), {}) + if _rule_authoring != null: + _rule_authoring.set_rule_editor_trigger_node(value) func _cb_rule_trigger_prop(value: Node2D) -> void: - if _rule_editor != null: - _rule_editor.set_trigger_target(int(value.get_instance_id()), {}) + if _rule_authoring != null: + _rule_authoring.set_rule_editor_trigger_node(value) func _cb_rule_trigger_cancel() -> void: - if _rule_editor != null: - _rule_editor.popup_centered() + if _rule_authoring != null: + _rule_authoring.cancel_trigger_target_capture() func _cb_rule_actor(value: Node2D) -> void: - var actor_id := int(value.get_instance_id()) - var flat: Dictionary = _pending_rule_action_flat - _pending_rule_action_flat = {} - if _rule_editor != null: - _rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, actor_id)) + if _rule_authoring != null: + _rule_authoring.accept_rule_actor(value) func _cb_rule_actor_cancel() -> void: - if _rule_editor != null: - _rule_editor.popup_centered() + if _rule_authoring != null: + _rule_authoring.cancel_rule_actor_capture() # --------------------------------------------------------------------------- @@ -6370,52 +4360,22 @@ func _cb_rule_actor_cancel() -> void: # --------------------------------------------------------------------------- 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: - _action_editor_kind = kind - _action_editor_index = index - _action_editor_rig = rig - _action_editor_action_id = rig.action_id_at(index) if kind == "queue_edit" and rig != null and is_instance_valid(rig) else -1 - _action_editor_target_action_id = target_action_id - _action_editor_insert_after = insert_after - _action_editor_actor_id = actor_id - _action_editor_restore_queue = hide_queue - if hide_queue and _queue_panel != null: - _queue_panel.hide() - elif (kind == "rule_add" or kind == "rule_edit") and _rule_editor != null: - _rule_editor.hide() - if flat.is_empty(): - _action_editor.open_new(kind == "rule_add" or kind == "rule_edit") - else: - _action_editor.open_edit(flat, kind == "rule_add" or kind == "rule_edit") + if _action_queue_controller != null: + _action_queue_controller.open_action_editor(kind, flat, index, rig, actor_id, hide_queue, target_action_id, insert_after) -func _on_action_editor_speech_requested(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 _action_editor_kind == "rule_add": - _pending_speech_initial = initial.duplicate(true) - _begin_capture(CaptureKind.STICKMAN, "🎯 Click the character who will speak (Esc or right-click to cancel)", _cb_speech_actor_capture, _cb_speech_actor_capture_cancel) - return - if actor == null or not is_instance_valid(actor): - _show_toast("Choose a character before composing speech") - _on_action_editor_cancelled() - return - _open_speech_composer(actor, initial, _action_editor_kind == "rule_add" or _action_editor_kind == "rule_edit", "action_editor") +func _begin_action_editor_speech_actor_capture() -> void: + _begin_capture(CaptureKind.STICKMAN, "🎯 Click the character who will speak (Esc or right-click to cancel)", _cb_speech_actor_capture, _cb_speech_actor_capture_cancel) func _cb_speech_actor_capture(value: Node2D) -> void: - if not value is StickmanRig: - _cb_speech_actor_capture_cancel() - return - _action_editor_actor_id = value.get_instance_id() - _open_speech_composer(value as StickmanRig, _pending_speech_initial, true, "action_editor") - _pending_speech_initial.clear() + if _action_queue_controller != null: + _action_queue_controller.accept_pending_speech_actor(value) func _cb_speech_actor_capture_cancel() -> void: - _pending_speech_initial.clear() - if _rule_editor != null: - _rule_editor.popup_centered() + if _action_queue_controller != null: + _action_queue_controller.cancel_pending_speech_actor() func _open_speech_composer(actor: StickmanRig, initial: Dictionary, reactive_options: bool, context: String) -> void: @@ -6437,148 +4397,19 @@ func _open_speech_composer(actor: StickmanRig, initial: Dictionary, reactive_opt _speech_session_kind = context _speech_origin = _capture_speech_origin(actor, context) _speech_restored_unbound = context == "recovered_unbound" - _speech_placing = false - _speech_placement_bubble_index = 0 - _cancel_speech_bubble_pointer() - _speech_actor.set_speech_preview_suppressed(true) - _speech_preview = SpeechBubble.new() - _speech_preview.name = "SpeechPreview" - _speech_preview.bubble_position_changed.connect(_on_speech_bubble_position_changed) - _speech_preview.stage_token_selected.connect(_on_speech_preview_token_selected) - _speech_preview.stage_line_tokens_requested.connect(_on_speech_preview_line_tokens_requested) - _speech_preview.stage_text_tap_detected.connect(_on_speech_preview_text_tap_detected) - _speech_actor.add_child(_speech_preview) - _speech_preview.set_mirrored(_speech_actor.facing_profile == StickmanRig.FacingProfile.LEFT) - _speech_composer.call("open_for", actor, speech_initial, reactive_options) - _speech_draft_dirty = true - _speech_draft_timer = SPEECH_DRAFT_DEBOUNCE - - -func _on_speech_draft_changed(action: Dictionary) -> void: - _speech_draft_dirty = true - _speech_draft_timer = SPEECH_DRAFT_DEBOUNCE - if _speech_preview != null and is_instance_valid(_speech_preview) and _speech_composer != null: - var preview_state: String = String(_speech_composer.call("preview_state_name")) - var preview_action: Dictionary = _speech_composer.call("get_preview_action") - _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) - _speech_preview.render_action(preview_action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") - var context: Dictionary = _speech_composer.call("style_context") - var focus_index: int = int(_speech_composer.call("stage_focus_bubble_index")) - _speech_preview.set_selected_bubble_index(focus_index) - var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {} - _speech_preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1))) - _sync_speech_preview_overflow(action) - _speech_style_toolbar.call("set_context", context) - _refresh_speech_style_toolbar() + _speech_session.open(actor, speech_initial, reactive_options, context) func _update_speech_composition() -> void: - if _speech_composer == null or not _speech_composer.visible: + if _speech_session == null or not _speech_session.is_open(): return if _speech_actor == null or not is_instance_valid(_speech_actor): _show_toast("The character is gone. Your speech draft was saved for recovery.") _finish_speech_composition(false) return - if _speech_preview == null or not is_instance_valid(_speech_preview): + if _speech_session.preview == null or not is_instance_valid(_speech_session.preview): return - _speech_preview.set_mirrored(_speech_actor.facing_profile == StickmanRig.FacingProfile.LEFT) - _speech_preview.set_selected_bubble_index(int(_speech_composer.call("stage_focus_bubble_index"))) - if not bool(_speech_composer.call("preview_visible")): - _speech_preview.visible = false - return - _speech_preview.visible = true - var action: Dictionary = _speech_composer.call("get_preview_action") - var preview_state: String = String(_speech_composer.call("preview_state_name")) - _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) - if _speech_placing: - var world_pos: Vector2 = _camera.get_global_mouse_position() - var hover_action: Dictionary = action.duplicate(true) - var local_center: Vector2 = _speech_actor.to_local(world_pos) - if _speech_placement_bubble_index < 0: - hover_action["placement"] = {"mode": "manual", "center": local_center} - else: - var bubbles: Array = hover_action.get("bubbles", []) - if _speech_placement_bubble_index < bubbles.size() and bubbles[_speech_placement_bubble_index] is Dictionary: - var bubble: Dictionary = bubbles[_speech_placement_bubble_index] - bubble["placement"] = {"mode": "manual", "center": local_center} - bubbles[_speech_placement_bubble_index] = bubble - hover_action["bubbles"] = bubbles - _speech_preview.set_placement_ghost(false) - _speech_preview.render_action(hover_action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") - else: - _speech_preview.set_placement_ghost(false) - _speech_preview.render_action(action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") - _sync_speech_preview_overflow(action) - var context: Dictionary = _speech_composer.call("style_context") - _speech_preview.set_selected_token(int(context.get("bubble_index", -1)), int((context.get("target", {}) as Dictionary).get("start", -1)), int((context.get("target", {}) as Dictionary).get("end", -1))) - _refresh_speech_style_toolbar() - - -func _on_speech_placement_requested() -> void: - _speech_placement_bubble_index = -1 - _speech_placing = true - _speech_composer.call("set_placement_pending", true, -1) - _show_toast("Click the stage to place the bubble. Esc cancels.") - - -func _on_speech_placement_reset() -> void: - _speech_placing = false - if _speech_composer != null: - _speech_composer.call("set_placement_pending", false) - - -func _on_speech_bubble_placement_requested(bubble_index: int) -> void: - _speech_placement_bubble_index = bubble_index - _speech_placing = true - _speech_composer.call("set_placement_pending", true, bubble_index) - _show_toast("Move this bubble: click the stage. Esc cancels.") - - -func _on_speech_bubble_placement_reset(_bubble_index: int) -> void: - _speech_placing = false - if _speech_composer != null: - _speech_composer.call("set_placement_pending", false) - - -func _on_speech_bubble_position_changed(bubble_index: int, center_rig: Vector2) -> void: - if _speech_composer == null or not _speech_composer.visible: - return - _speech_composer.call("set_bubble_placement_preview", bubble_index, center_rig) - - -func _on_speech_replay_requested() -> void: - if _speech_composer != null and _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.visible = true - var action: Dictionary = _speech_composer.call("get_preview_action") - var preview_state: String = String(_speech_composer.call("preview_state_name")) - _speech_preview.render_action(action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") - _sync_speech_preview_overflow(action) - - -func _on_speech_toolbar_preview_requested() -> void: - if _speech_composer != null and _speech_composer.visible: - _speech_composer.call("replay_preview_once") - - -func _on_speech_preview_state_changed(_state: String, elapsed: float, preview_visible: bool) -> void: - if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null: - return - _speech_preview.visible = preview_visible - if preview_visible: - var action: Dictionary = _speech_composer.call("get_preview_action") - _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) - _speech_preview.render_action(action, elapsed, true, _state != "playing") - _sync_speech_preview_overflow(action) - _refresh_speech_style_toolbar() - - -func _sync_speech_preview_overflow(action: Dictionary) -> void: - if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null: - return - var overflow: bool = _speech_preview.has_display_overflow() - var arrangement: String = String(action.get("arrangement", "together")) - var bubble_layout: String = String(action.get("bubble_layout", "stack")) - _speech_composer.call("set_preview_overflow", overflow, arrangement, bubble_layout) + _speech_session.update_composition(_camera.get_global_mouse_position()) func _on_speech_committed(action: Dictionary) -> void: @@ -6619,129 +4450,38 @@ func _on_speech_cancelled() -> void: func _finish_speech_composition(clear_recovery: bool = false) -> void: - if _speech_composer != null and _speech_composer.visible and not clear_recovery: - var matching_queue_edit: bool = false + var preserve_draft: bool = false + var matching_queue_edit: bool = false + if _speech_session != null and _speech_session.is_open() and not clear_recovery: var origin_check: Dictionary = _validate_speech_origin(_speech_origin) var draft_value: Variant = _speech_composer.call("get_draft") if not origin_check.is_empty() and String(origin_check.get("context", "")) == "action_editor" and String(origin_check.get("kind", "")) == "queue_edit" and draft_value is Dictionary: matching_queue_edit = _speech_action_matches_queue_edit(draft_value as Dictionary, origin_check["actor"] as StickmanRig, int(origin_check["queue_index"])) - if matching_queue_edit: - _clear_speech_draft_file() - else: - _persist_speech_draft_now() - if clear_recovery: + preserve_draft = not matching_queue_edit + if clear_recovery or matching_queue_edit: _clear_speech_draft_file() - _speech_placing = false - _cancel_speech_bubble_pointer() - _speech_placement_bubble_index = 0 - if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker): - _speech_bubble_picker.hide() - if _speech_token_picker != null and is_instance_valid(_speech_token_picker): - _speech_token_picker.hide() - if _speech_style_toolbar != null: - _speech_style_toolbar.call("close_chooser") - _speech_style_toolbar.call("set_message_dialog_open", false) - _speech_style_toolbar.hide() - if _speech_show_toolbar != null: - _speech_show_toolbar.hide() + if _speech_session != null: + _speech_session.close(clear_recovery, preserve_draft) if _speech_destination_dialog != null: _speech_destination_dialog.hide() if _speech_save_gate_dialog != null: _speech_save_gate_dialog.hide() - if _speech_composer != null: - _speech_composer.call("set_placement_pending", false) - if _speech_preview != null and is_instance_valid(_speech_preview): - _speech_preview.queue_free() - _speech_preview = null - if _speech_actor != null and is_instance_valid(_speech_actor): - _speech_actor.set_speech_preview_suppressed(false) - _speech_actor = null - _speech_session_kind = "" - if _speech_composer != null: - _speech_composer.call("close_without_commit") - _speech_draft_timer = -1.0 - _speech_draft_dirty = false - _speech_origin.clear() _speech_restored_unbound = false func _capture_speech_origin(actor: StickmanRig, context: String) -> Dictionary: - var origin: Dictionary = { - "context": context, - "scene_path": CONTENT_PATHS.canonicalize(_current_scene_path), - "scene_fingerprint": _speech_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": _action_editor_kind, - "action_editor_index": _action_editor_index, + if _speech_session == null: + return {} + var editor_state: Dictionary = { + "kind": _action_editor_kind, + "index": _action_editor_index, + "rig": _action_editor_rig, + "action_id": _action_editor_action_id, + "target_action_id": _action_editor_target_action_id, "restore_queue": _action_editor_restore_queue, - "queue_index": -1, - "queue_action": {}, "insert_after": _action_editor_insert_after, } - if context != "action_editor" or actor == null or not is_instance_valid(actor): - return origin - if _action_editor_kind == "queue_edit" and _action_editor_rig == actor and _action_editor_action_id >= 0: - var queue_index: int = actor.index_for_action_id(_action_editor_action_id) - origin["queue_index"] = queue_index - origin["queue_action"] = _queue_action_wire_at(actor, queue_index) - elif _action_editor_kind == "queue_insert" and _action_editor_rig == actor: - var insert_index: int = _action_editor_index - if _action_editor_target_action_id >= 0: - insert_index = actor.index_for_action_id(_action_editor_target_action_id) - origin["queue_index"] = insert_index - origin["queue_action"] = _queue_action_wire_at(actor, insert_index) - return origin - - -func _speech_actor_file_id(actor: StickmanRig) -> String: - if actor == null or not is_instance_valid(actor): - 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 STICKMAN_RIG 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(): - 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 + return _speech_session.capture_origin(actor, context, _current_scene_path, _speech_scene_fingerprint(), editor_state) func _speech_scene_fingerprint() -> String: @@ -6756,53 +4496,28 @@ func _speech_scene_fingerprint() -> String: return digest.finish().hex_encode() +func _speech_actor_file_id(actor: StickmanRig) -> String: + return "" if _speech_session == null else _speech_session.speech_actor_file_id(actor) + + +func _speech_object_type(node: Node2D) -> String: + return "" if _speech_session == null else _speech_session.speech_object_type(node) + + +func _find_speech_actor(file_id: String, source_path: String) -> StickmanRig: + return null if _speech_session == null else _speech_session.find_speech_actor(file_id, source_path) + + 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 one_action: Array[Dictionary] = [queue[index]] - var encoded: Dictionary = STAGE_SCENE_IO.encode_queue(one_action) - var actions: Array = encoded.get("actions", []) - return actions[0] if not actions.is_empty() and actions[0] is Dictionary else {} + return {} if _speech_session == null else _speech_session.queue_action_wire_at(actor, index) func _validate_speech_origin(origin: Dictionary) -> Dictionary: - origin = _canonicalize_speech_origin(origin) - if _current_scene_path.is_empty() or String(origin.get("scene_path", "")) != _current_scene_path: - return {} - var scene_fingerprint_matches: bool = String(origin.get("scene_fingerprint", "")) == _speech_scene_fingerprint() - var actor: StickmanRig = _find_speech_actor(String(origin.get("actor_file_id", "")), String(origin.get("actor_stk_path", ""))) - if actor == null: - return {} - var context: String = String(origin.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(origin.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(origin.get("queue_index", -1)) - var expected: Dictionary = origin.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} + return {} if _speech_session == null else _speech_session.validate_origin(origin, _current_scene_path, _speech_scene_fingerprint()) func _canonicalize_speech_origin(origin: Dictionary) -> Dictionary: - var canonical: Dictionary = origin.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 + return {} if _speech_session == null else _speech_session.canonicalize_origin(origin) func _check_speech_recovery() -> void: @@ -6813,53 +4528,20 @@ func _check_speech_recovery() -> void: func _speech_recovery_matches_loaded_scene() -> bool: if _current_scene_path.is_empty(): return false - if not FileAccess.file_exists(SPEECH_DRAFT_PATH) and not FileAccess.file_exists(SPEECH_DRAFT_PATH + ".bak"): + if _speech_session == null or not _speech_session.has_draft_file(): return false - var document: Dictionary = _read_speech_draft_document() - var origin_value: Variant = document.get("origin", null) - if not origin_value is Dictionary: - return false - var validated: Dictionary = _validate_speech_origin(origin_value as Dictionary) - if validated.is_empty(): - return 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"])): - _clear_speech_draft_file() - return false - return true + var inspection: Dictionary = _speech_session.inspect_recovery(_current_scene_path, _speech_scene_fingerprint()) + if bool(inspection.get("clear_matching_edit", false)): + _clear_speech_draft_file() + return bool(inspection.get("available", false)) 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 + return false if _speech_session == null else _speech_session.speech_action_matches_queue_edit(draft, actor, queue_index) func _read_speech_draft_document() -> Dictionary: - if not FileAccess.file_exists(SPEECH_DRAFT_PATH) and FileAccess.file_exists(SPEECH_DRAFT_PATH + ".bak"): - DirAccess.rename_absolute(ProjectSettings.globalize_path(SPEECH_DRAFT_PATH + ".bak"), ProjectSettings.globalize_path(SPEECH_DRAFT_PATH)) - if not FileAccess.file_exists(SPEECH_DRAFT_PATH): - return {} - var file: FileAccess = FileAccess.open(SPEECH_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)) != SPEECH_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_speech_origin(origin_value as Dictionary) - return document + return {} if _speech_session == null else _speech_session.read_draft_document() func _on_speech_recovery_restore() -> void: @@ -6876,7 +4558,7 @@ func _on_speech_recovery_restore() -> void: var recovered_payload: Dictionary = (payload_value as Dictionary).duplicate(true) var origin_action_value: Variant = _speech_restored_origin.get("queue_action", null) if String(_speech_restored_origin.get("context", "")) == "action_editor" and String(_speech_restored_origin.get("action_editor_kind", "")) == "queue_edit" and origin_action_value is Dictionary and String((origin_action_value as Dictionary).get("type", "")) == "speak": - recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, origin_action_value as Dictionary) + recovered_payload = _speech_session.restore_empty_recovered_bubble_text(recovered_payload, origin_action_value as Dictionary) var restored_from_pending_edit: bool = false var pending_actor: StickmanRig = instance_from_id(int(_speech_pending_open.get("actor_id", 0))) as StickmanRig var pending_initial_value: Variant = _speech_pending_open.get("initial", {}) @@ -6886,7 +4568,7 @@ func _on_speech_recovery_restore() -> void: var pending_actions: Array = pending_encoded.get("actions", []) var saved_origin_action: Variant = _speech_restored_origin.get("queue_action", null) if not pending_actions.is_empty() and pending_actions[0] == saved_origin_action: - recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, pending_initial) + recovered_payload = _speech_session.restore_empty_recovered_bubble_text(recovered_payload, pending_initial) if validated.is_empty(): validated = { "actor": pending_actor, @@ -6900,7 +4582,7 @@ func _on_speech_recovery_restore() -> void: var saved_index: int = int(validated.get("queue_index", -1)) var saved_queue: Array[Dictionary] = saved_actor.get_queue() if saved_index >= 0 and saved_index < saved_queue.size(): - recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, saved_queue[saved_index]) + recovered_payload = _speech_session.restore_empty_recovered_bubble_text(recovered_payload, saved_queue[saved_index]) _speech_restored_payload = SPEECH_PAYLOAD.normalize(recovered_payload) var selected_bubble: int = clampi(int(document.get("selected_bubble", 0)), 0, maxi(0, (_speech_restored_payload.get("bubbles", []) as Array).size() - 1)) _speech_restored_selected_bubble = selected_bubble @@ -6938,34 +4620,6 @@ func _on_speech_recovery_restore() -> void: _clear_pending_speech_request() -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 _on_speech_recovery_discard() -> void: _clear_speech_draft_file() _clear_pending_speech_request() @@ -7005,76 +4659,21 @@ func _clear_pending_speech_request() -> void: 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_position: Variant = (bubble_placement as Dictionary).get("center", null) - if String((bubble_placement as Dictionary).get("mode", "auto")) == "manual" and bubble_position is Vector2: - (bubble_placement as Dictionary)["center"] = STAGE_SCENE_IO.vector_to_data(bubble_position) - bubbles[index] = bubble - if not bubbles.is_empty(): - payload["bubbles"] = bubbles - return payload + return action.duplicate(true) if _speech_session == null else _speech_session.speech_payload_for_json(action) func _persist_speech_draft_now() -> void: - if _speech_composer == null or not _speech_composer.visible or not _speech_draft_dirty: - return - var document: Dictionary = { - "schema": SPEECH_DRAFT_SCHEMA, - "modified_unix": Time.get_unix_time_from_system(), - "draft": _speech_payload_for_json(_speech_composer.call("get_draft")), - "selected_bubble": int(_speech_composer.call("selected_bubble_index")), - "origin": _canonicalize_speech_origin(_speech_origin), - } - var tmp_path: String = SPEECH_DRAFT_PATH + ".tmp" - var bak_path: String = SPEECH_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(SPEECH_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(SPEECH_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) - _speech_draft_dirty = false + if _speech_session != null: + _speech_session.persist_draft_now() func _clear_speech_draft_file() -> void: - _speech_draft_timer = -1.0 - _speech_draft_dirty = false + if _speech_session != null: + _speech_session.clear_draft_file() _speech_pending_commit_action.clear() _speech_restored_payload.clear() _speech_restored_origin.clear() _speech_restored_selected_bubble = 0 - for path: String in [SPEECH_DRAFT_PATH, SPEECH_DRAFT_PATH + ".tmp", SPEECH_DRAFT_PATH + ".bak"]: - var absolute: String = ProjectSettings.globalize_path(path) - if FileAccess.file_exists(path): - DirAccess.remove_absolute(absolute) func _commit_speech_action(action: Dictionary) -> bool: @@ -7210,123 +4809,37 @@ func _queue_action_and_confirm(rig: StickmanRig, action: Dictionary, index: int func _on_action_editor_cancelled() -> void: - _action_editor.hide() - match _action_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": - var actions: Array = _rule_builder.get("actions", []) - if not actions.is_empty(): - actions.pop_back() - _rule_builder["actions"] = actions - _rule_step = RuleStep.SELECT_ACTION - if actions.is_empty(): - _open_rule_action_popup() - else: - _open_rule_more_popup() - _: - pass + if _action_queue_controller != null and _action_queue_controller.cancel_action_editor() and _rule_authoring != null: + _rule_authoring.cancel_move_to_action_editor() -func _on_action_editor_target_requested() -> void: - _action_editor.hide() - var action_type := _action_editor.current_action_type() - if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "throw_prop" or action_type == "set_down_prop"): - _begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_editor_actor_for_target, _cb_editor_actor_capture_cancel) - return - _begin_editor_action_target_capture() - - -func _begin_editor_action_target_capture() -> void: - match _action_editor.current_action_type(): - "move_to": - var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk" - _begin_capture(CaptureKind.POSITION, "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, _cb_editor_walk_target, _cb_editor_walk_target_cancel) - "pick_up_prop": - _begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel) - "throw_prop": - var preview_rig := _action_editor_preview_rig() - var queue_prefix: Array[Dictionary] = [] - if _action_editor_kind == "queue_edit": - queue_prefix = _throw_preview_prefix_for(preview_rig, _action_editor_action_id) - elif _action_editor_kind == "queue_add": - queue_prefix = _throw_preview_prefix_for(preview_rig) - _begin_throw_position_capture( - preview_rig, - "🏹 Click where to throw (Esc or RMB to cancel)", - _cb_editor_throw_target, - _cb_editor_walk_target_cancel, - queue_prefix - ) - "set_down_prop": - var preview_rig := _action_editor_preview_rig() - var queue_prefix: Array[Dictionary] = [] - if _action_editor_kind == "queue_edit": - queue_prefix = _throw_preview_prefix_for(preview_rig, _action_editor_action_id) - elif _action_editor_kind == "queue_add": - queue_prefix = _throw_preview_prefix_for(preview_rig) - _begin_set_down_position_capture(preview_rig, "Click where to let go (Esc or RMB to cancel)", _cb_editor_set_down_target, _cb_editor_walk_target_cancel, queue_prefix) - _: - _begin_capture(CaptureKind.POSITION, "🚩 Click the target (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel) - - -func _cb_editor_walk_target(value: Vector2) -> void: - if _action_editor != null: - _action_editor.set_walk_target(value) - - -func _cb_editor_prop_target(value: Node2D) -> void: - if _action_editor != null and value is PropBlock: - var prop := value as PropBlock - if not prop.stage_object_id.is_empty(): - _action_editor.set_prop_target(prop.stage_object_id) - else: - _action_editor.popup_centered() - - -func _cb_editor_throw_target(value: Vector2) -> void: - if _action_editor != null: - _action_editor.set_throw_target(value) - - -func _cb_editor_set_down_target(value: Vector2) -> void: - if _action_editor != null: - _action_editor.set_down_target(value) - - -func _cb_editor_actor_for_target(value: Node2D) -> void: - if not value is StickmanRig: - if _action_editor != null: - _action_editor.popup_centered() - return - _action_editor_actor_id = int(value.get_instance_id()) - _begin_editor_action_target_capture() - - -func _cb_editor_actor_capture_cancel() -> void: - if _action_editor != null: - _action_editor.popup_centered() - - -func _action_editor_preview_rig() -> StickmanRig: - if _action_editor_rig != null and is_instance_valid(_action_editor_rig): - return _action_editor_rig - var actor := instance_from_id(_action_editor_actor_id) as StickmanRig - return actor if actor != null and is_instance_valid(actor) else null - - -func _cb_editor_walk_target_cancel() -> void: - if _action_editor != null: - _action_editor.popup_centered() +func _on_action_editor_capture_request( + kind: StringName, + hint: String, + on_resolve: Callable, + on_cancel: Callable, + preview_rig: StickmanRig, + exclude_action_id: int, + use_queue_prefix: bool +) -> void: + match kind: + &"actor": + _begin_capture(CaptureKind.STICKMAN, hint, on_resolve, on_cancel) + &"position": + _begin_capture(CaptureKind.POSITION, hint, on_resolve, on_cancel) + &"prop": + _begin_capture(CaptureKind.PROP, hint, on_resolve, on_cancel) + &"throw_position": + var queue_prefix: Array[Dictionary] = _throw_preview_prefix_for(preview_rig, exclude_action_id) if use_queue_prefix else [] + _begin_throw_position_capture(preview_rig, hint, on_resolve, on_cancel, queue_prefix) + &"set_down_position": + var queue_prefix: Array[Dictionary] = _throw_preview_prefix_for(preview_rig, exclude_action_id) if use_queue_prefix else [] + _begin_set_down_position_capture(preview_rig, hint, on_resolve, on_cancel, queue_prefix) func _restore_queue_panel() -> void: - if _action_editor_restore_queue and _queue_panel != null: - _queue_panel.refresh() - _queue_panel.popup_centered() + if _action_queue_controller != null: + _action_queue_controller.restore_queue_panel() # --------------------------------------------------------------------------- @@ -7388,23 +4901,13 @@ func _apply_grid_settings() -> void: func _set_edit_controls_visible(visible: bool) -> void: - for btn: Button in _palette_buttons.values(): - btn.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 + if _chrome != null: + _chrome.set_edit_controls_visible(visible) func _set_direct_controls_visible(visible: bool) -> void: - if _direct_hint_label != null: - _direct_hint_label.visible = visible + if _chrome != null: + _chrome.set_direct_controls_visible(visible) ## Direct Node2D children of World, excluding the ragdoll body container. @@ -7423,139 +4926,25 @@ func _world_children_selectable() -> Array[Node2D]: # Theme loading (Phase 4b) # --------------------------------------------------------------------------- -## Loads the theme JSON. `source_path` is overridable for tests (missing / -## malformed fallback); defaults to the committed res:// theme. +## Loads the theme JSON through StageThemeController for compatibility callers. func _load_theme(source_path: String = THEME_PATH) -> void: - _sandbox_theme = SANDBOX_THEME.new() - _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.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: Dictionary = _theme.get("fonts", {}) - _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) - # Optional per-widget object form (e.g. "action_popup": { size, bold, italic }); - # its `size` overrides the flat action_popup_font_size key. Bold/italic only - # exist in the object form, so they default back to false on every load (a - # later theme that omits the object must not inherit a previous theme's flags). - _action_popup_bold = false - _action_popup_italic = false - var ap: Variant = fonts.get("action_popup", null) - if ap is Dictionary: - var apd := ap as Dictionary - if apd.has("size"): - _action_popup_font_size = int(apd["size"]) - _action_popup_bold = bool(apd.get("bold", false)) - _action_popup_italic = bool(apd.get("italic", false)) - var grid: Dictionary = _theme.get("grid", {}) - _theme_grid_default = clampf(float(grid.get("snap_size", DEFAULT_GRID_SIZE)), MIN_GRID_SIZE, MAX_GRID_SIZE) - var colors: Dictionary = _theme.get("mode_colors", {}) - _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) - # Schema v2 wins only when the source itself opts into v2. Legacy ad-hoc - # theme files retain their Phase 3 flat-key precedence for compatibility. - if String((json as Dictionary).get("version", "")) == SANDBOX_THEME.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) - _theme_grid_default = _sandbox_theme.number("grid.snap_size", _theme_grid_default, MIN_GRID_SIZE, MAX_GRID_SIZE) - _build_font_sizes() + _theme_controller.load_theme(source_path, MIN_GRID_SIZE, MAX_GRID_SIZE) -## Builds the `sizes` dict passed to each Phase 3c widget's apply_font(): the -## per-widget font sizes, the panel title/row sizes, the panel-title bold flag, -## and the resolved bold/italic Font variants. 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, - } + _theme_controller.build_font_sizes() -## Resolves a style-variant font: a dedicated path is honoured first, then a -## FontVariation embolden over the base font (bold only), then the base font. func _resolve_style_font(path: String, base: Font, bold: bool) -> Font: - if not path.is_empty(): - var loaded := _load_font(path) - if loaded != null: - return loaded - if base == null: - return null - if not bold: - return base - var variation := FontVariation.new() - variation.base_font = base - variation.variation_embolden = 0.9 - return variation + return _theme_controller.resolve_style_font(path, base, bold) static func _parse_color(html: String, fallback: Color) -> Color: - var s := html.strip_edges() - if s.begins_with("#"): - s = s.substr(1) - if s.is_empty(): - return fallback - return Color.from_string(s, fallback) + return STAGE_THEME_CONTROLLER_SCRIPT.parse_color(html, fallback) 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 res := ResourceLoader.load(path) - if res is Font: - return res - push_warning("SandboxStage: theme font '%s' is not a Font; using fallback." % path) - return null - + return _theme_controller.load_font(path) func _apply_theme_to_visuals() -> void: if _director_visuals != null: @@ -7588,20 +4977,22 @@ func _load_settings() -> void: _grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE) _snap_enabled = bool(d.get("snap_to_grid", false)) _show_grid = bool(d.get("show_grid", true)) - _recent_scenes.clear() + var loaded_recent_scenes: Array[String] = [] + var settings_migrated: bool = false var raw_recents: Variant = d.get("recent_scenes", []) if raw_recents is Array: - var settings_migrated: bool = false for raw: Variant in raw_recents: var old_path: String = String(raw) var path: String = CONTENT_PATHS.canonicalize(old_path) settings_migrated = settings_migrated or path != old_path - if not path.is_empty() and FileAccess.file_exists(path) and not _recent_scenes.has(path): - _recent_scenes.append(path) - if _recent_scenes.size() >= RECENT_SCENE_LIMIT: + if not path.is_empty() and FileAccess.file_exists(path) and not loaded_recent_scenes.has(path): + loaded_recent_scenes.append(path) + if loaded_recent_scenes.size() >= RECENT_SCENE_LIMIT: break - if settings_migrated: - _save_settings() + if _document != null: + _document.set_recent_paths(loaded_recent_scenes) + if settings_migrated: + _save_settings() func _save_settings() -> void: diff --git a/scripts/editors/stage/stage_action_queue_controller.gd b/scripts/editors/stage/stage_action_queue_controller.gd new file mode 100644 index 0000000..fdc075f --- /dev/null +++ b/scripts/editors/stage/stage_action_queue_controller.gd @@ -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() diff --git a/scripts/editors/stage/stage_action_queue_controller.gd.uid b/scripts/editors/stage/stage_action_queue_controller.gd.uid new file mode 100644 index 0000000..81298fe --- /dev/null +++ b/scripts/editors/stage/stage_action_queue_controller.gd.uid @@ -0,0 +1 @@ +uid://boninh5ktcxr diff --git a/scripts/editors/stage/stage_asset_browser.gd b/scripts/editors/stage/stage_asset_browser.gd new file mode 100644 index 0000000..ec8be9e --- /dev/null +++ b/scripts/editors/stage/stage_asset_browser.gd @@ -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 diff --git a/scripts/editors/stage/stage_asset_browser.gd.uid b/scripts/editors/stage/stage_asset_browser.gd.uid new file mode 100644 index 0000000..8de7f49 --- /dev/null +++ b/scripts/editors/stage/stage_asset_browser.gd.uid @@ -0,0 +1 @@ +uid://bpwukead70hk8 diff --git a/scripts/editors/stage/stage_chrome.gd b/scripts/editors/stage/stage_chrome.gd new file mode 100644 index 0000000..9033418 --- /dev/null +++ b/scripts/editors/stage/stage_chrome.gd @@ -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) diff --git a/scripts/editors/stage/stage_chrome.gd.uid b/scripts/editors/stage/stage_chrome.gd.uid new file mode 100644 index 0000000..1ece79e --- /dev/null +++ b/scripts/editors/stage/stage_chrome.gd.uid @@ -0,0 +1 @@ +uid://doh7myy4tevx4 diff --git a/scripts/editors/stage/stage_director_controller.gd b/scripts/editors/stage/stage_director_controller.gd new file mode 100644 index 0000000..cc6f833 --- /dev/null +++ b/scripts/editors/stage/stage_director_controller.gd @@ -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 "") diff --git a/scripts/editors/stage/stage_director_controller.gd.uid b/scripts/editors/stage/stage_director_controller.gd.uid new file mode 100644 index 0000000..2649a9a --- /dev/null +++ b/scripts/editors/stage/stage_director_controller.gd.uid @@ -0,0 +1 @@ +uid://br8nw38ybdkgr diff --git a/scripts/editors/stage/stage_document_controller.gd b/scripts/editors/stage/stage_document_controller.gd new file mode 100644 index 0000000..e4b2007 --- /dev/null +++ b/scripts/editors/stage/stage_document_controller.gd @@ -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 "" diff --git a/scripts/editors/stage/stage_document_controller.gd.uid b/scripts/editors/stage/stage_document_controller.gd.uid new file mode 100644 index 0000000..64400ce --- /dev/null +++ b/scripts/editors/stage/stage_document_controller.gd.uid @@ -0,0 +1 @@ +uid://dx1wd5y3v7d1o diff --git a/scripts/editors/stage/stage_modal_views.gd b/scripts/editors/stage/stage_modal_views.gd new file mode 100644 index 0000000..e768e88 --- /dev/null +++ b/scripts/editors/stage/stage_modal_views.gd @@ -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) diff --git a/scripts/editors/stage/stage_modal_views.gd.uid b/scripts/editors/stage/stage_modal_views.gd.uid new file mode 100644 index 0000000..972f1fc --- /dev/null +++ b/scripts/editors/stage/stage_modal_views.gd.uid @@ -0,0 +1 @@ +uid://c1xy8k68n1abq diff --git a/scripts/editors/stage/stage_prop_settings_view.gd b/scripts/editors/stage/stage_prop_settings_view.gd new file mode 100644 index 0000000..ecfb247 --- /dev/null +++ b/scripts/editors/stage/stage_prop_settings_view.gd @@ -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) diff --git a/scripts/editors/stage/stage_prop_settings_view.gd.uid b/scripts/editors/stage/stage_prop_settings_view.gd.uid new file mode 100644 index 0000000..dc412a6 --- /dev/null +++ b/scripts/editors/stage/stage_prop_settings_view.gd.uid @@ -0,0 +1 @@ +uid://brp43as6806r2 diff --git a/scripts/editors/stage/stage_rule_authoring_controller.gd b/scripts/editors/stage/stage_rule_authoring_controller.gd new file mode 100644 index 0000000..4a78653 --- /dev/null +++ b/scripts/editors/stage/stage_rule_authoring_controller.gd @@ -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) diff --git a/scripts/editors/stage/stage_rule_authoring_controller.gd.uid b/scripts/editors/stage/stage_rule_authoring_controller.gd.uid new file mode 100644 index 0000000..7da86c0 --- /dev/null +++ b/scripts/editors/stage/stage_rule_authoring_controller.gd.uid @@ -0,0 +1 @@ +uid://btfdwp0ogfw8r diff --git a/scripts/editors/stage/stage_run_session.gd b/scripts/editors/stage/stage_run_session.gd new file mode 100644 index 0000000..2c679f3 --- /dev/null +++ b/scripts/editors/stage/stage_run_session.gd @@ -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 diff --git a/scripts/editors/stage/stage_run_session.gd.uid b/scripts/editors/stage/stage_run_session.gd.uid new file mode 100644 index 0000000..eb76fc1 --- /dev/null +++ b/scripts/editors/stage/stage_run_session.gd.uid @@ -0,0 +1 @@ +uid://7mkon4j4j1pl diff --git a/scripts/editors/stage/stage_scene_adapter.gd b/scripts/editors/stage/stage_scene_adapter.gd new file mode 100644 index 0000000..a9e5ea3 --- /dev/null +++ b/scripts/editors/stage/stage_scene_adapter.gd @@ -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 "" diff --git a/scripts/editors/stage/stage_scene_adapter.gd.uid b/scripts/editors/stage/stage_scene_adapter.gd.uid new file mode 100644 index 0000000..184d899 --- /dev/null +++ b/scripts/editors/stage/stage_scene_adapter.gd.uid @@ -0,0 +1 @@ +uid://cj0p4105ehlop diff --git a/scripts/editors/stage/stage_speech_draft_store.gd b/scripts/editors/stage/stage_speech_draft_store.gd new file mode 100644 index 0000000..4b2a21d --- /dev/null +++ b/scripts/editors/stage/stage_speech_draft_store.gd @@ -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 diff --git a/scripts/editors/stage/stage_speech_draft_store.gd.uid b/scripts/editors/stage/stage_speech_draft_store.gd.uid new file mode 100644 index 0000000..46ac811 --- /dev/null +++ b/scripts/editors/stage/stage_speech_draft_store.gd.uid @@ -0,0 +1 @@ +uid://bxd7eblfc2yoh diff --git a/scripts/editors/stage/stage_speech_session.gd b/scripts/editors/stage/stage_speech_session.gd new file mode 100644 index 0000000..7906fef --- /dev/null +++ b/scripts/editors/stage/stage_speech_session.gd @@ -0,0 +1,1037 @@ +class_name StageSpeechSession +extends Node +## Coordinates the active composer, preview, and pointer session. + +const SPEECH_DRAFT_STORE_SCRIPT := preload("res://scripts/editors/stage/stage_speech_draft_store.gd") +const DRAFT_PATH: String = SPEECH_DRAFT_STORE_SCRIPT.DRAFT_PATH +const DRAFT_DEBOUNCE: float = SPEECH_DRAFT_STORE_SCRIPT.DRAFT_DEBOUNCE +const DRAFT_SCHEMA: int = SPEECH_DRAFT_STORE_SCRIPT.DRAFT_SCHEMA +const BUBBLE_DRAG_THRESHOLD: float = 8.0 + +signal camera_input_requested(event: InputEvent) +signal camera_pan_cancel_requested +signal toast_requested(message: String) +signal commit_requested(action: Dictionary) +signal cancel_requested + +var _world: Node2D +var _composer: Control +var _style_toolbar: Control +var _show_toolbar: Control +var _camera: Camera2D +var _viewport: Viewport +var _ui_layer: CanvasLayer +var actor: StickmanRig +var session_kind: String = "" +var placing: bool = false +var placement_bubble_index: int = 0 +var preview: SpeechBubble +var bubble_dragging: bool = false +var bubble_pointer_pending: bool = false +var bubble_pointer_moved: bool = false +var bubble_pointer_on_badge: bool = false +var bubble_pointer_start_screen: Vector2 = Vector2.ZERO +var bubble_pointer_start_world: Vector2 = Vector2.ZERO +var bubble_pointer_hits: Array[int] = [] +var bubble_pointer_index: int = -1 +var bubble_pointer_sequence: int = 0 +var bubble_word_clicked: bool = false +var badge_cursor_active: bool = false +var bubble_picker: PopupPanel +var token_picker: PopupPanel +var _camera_panning: bool = false +var _last_input_mark_handled: bool = false +var _draft_store: StageSpeechDraftStore +var origin: Dictionary: + get: + return {} if _draft_store == null else _draft_store.origin + set(value): + if _draft_store != null: + _draft_store.origin = value +var draft_dirty: bool: + get: + return false if _draft_store == null else _draft_store.draft_dirty + set(value): + if _draft_store != null: + _draft_store.draft_dirty = value +var draft_timer: float: + get: + return -1.0 if _draft_store == null else _draft_store.draft_timer + set(value): + if _draft_store != null: + _draft_store.draft_timer = value + + +func configure(world: Node2D, composer: Control, style_toolbar: Control, show_toolbar: Control, camera: Camera2D, viewport: Viewport, ui_layer: CanvasLayer) -> void: + _world = world + _composer = composer + _draft_store = SPEECH_DRAFT_STORE_SCRIPT.new() as StageSpeechDraftStore + _draft_store.configure(world, composer) + _style_toolbar = style_toolbar + _show_toolbar = show_toolbar + _camera = camera + _viewport = viewport + _ui_layer = ui_layer + _connect_signal(_composer, &"draft_changed", on_draft_changed) + _connect_signal(_composer, &"preview_state_changed", on_preview_state_changed) + _connect_signal(_composer, &"style_context_changed", on_style_context_changed) + _connect_signal(_composer, &"stage_focus_changed", on_stage_focus_changed) + _connect_signal(_composer, &"message_dialog_closed", on_message_dialog_closed) + _connect_signal(_composer, &"committed", _on_composer_committed) + _connect_signal(_composer, &"cancelled", _on_composer_cancelled) + _connect_signal(_composer, &"validation_failed", on_validation_failed) + _connect_signal(_composer, &"replay_requested", on_replay_requested) + _connect_signal(_style_toolbar, &"chooser_open_requested", on_style_chooser_open_requested) + _connect_signal(_style_toolbar, &"choice_selected", on_style_choice_selected) + _connect_signal(_style_toolbar, &"done_styling_requested", on_style_done_requested) + _connect_signal(_style_toolbar, &"message_edit_requested", on_style_message_requested) + _connect_signal(_style_toolbar, &"sequence_action_requested", on_style_sequence_action_requested) + _connect_signal(_show_toolbar, &"choice_selected", on_show_choice_selected) + _connect_signal(_show_toolbar, &"layout_preset_requested", on_layout_preset_requested) + _connect_signal(_show_toolbar, &"preview_speech_requested", on_toolbar_preview_requested) + _connect_signal(_show_toolbar, &"done_speech_requested", on_done_requested) + _connect_signal(_show_toolbar, &"cancel_speech_requested", on_cancel_requested) + _connect_signal(_composer, &"placement_requested", on_placement_requested) + _connect_signal(_composer, &"placement_reset_requested", on_placement_reset_requested) + _connect_signal(_composer, &"bubble_placement_requested", on_bubble_placement_requested) + _connect_signal(_composer, &"bubble_placement_reset_requested", on_bubble_placement_reset_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 open(actor_value: StickmanRig, initial: Dictionary, reactive_options: bool, context: String) -> void: + actor = actor_value + session_kind = context + placing = false + placement_bubble_index = 0 + cancel_bubble_pointer() + if actor == null or not is_instance_valid(actor) or _composer == null: + return + actor.set_speech_preview_suppressed(true) + preview = SpeechBubble.new() + preview.name = "SpeechPreview" + preview.bubble_position_changed.connect(_on_bubble_position_changed) + preview.stage_token_selected.connect(select_bubble_token_from_stage) + preview.stage_line_tokens_requested.connect(on_preview_line_tokens_requested) + preview.stage_text_tap_detected.connect(on_preview_text_tap_detected) + actor.add_child(preview) + preview.set_mirrored(actor.facing_profile == StickmanRig.FacingProfile.LEFT) + _composer.call("open_for", actor, initial, reactive_options) + arm_draft_save() + + +func close(clear_recovery: bool, preserve_draft: bool) -> void: + if clear_recovery: + clear_draft_file() + elif preserve_draft and is_open(): + persist_draft_now() + placing = false + cancel_bubble_pointer() + placement_bubble_index = 0 + if bubble_picker != null and is_instance_valid(bubble_picker): + bubble_picker.hide() + if token_picker != null and is_instance_valid(token_picker): + token_picker.hide() + if _style_toolbar != null: + _style_toolbar.call("close_chooser") + _style_toolbar.call("set_message_dialog_open", false) + _style_toolbar.hide() + if _show_toolbar != null: + _show_toolbar.hide() + if _composer != null: + _composer.call("set_placement_pending", false) + if preview != null and is_instance_valid(preview): + preview.queue_free() + preview = null + if actor != null and is_instance_valid(actor): + actor.set_speech_preview_suppressed(false) + actor = null + session_kind = "" + if _composer != null: + _composer.call("close_without_commit") + draft_timer = -1.0 + draft_dirty = false + origin.clear() + + +func is_save_blocked() -> bool: + return is_open() + + +func _on_composer_committed(action: Dictionary) -> void: + commit_requested.emit(action) + + +func _on_composer_cancelled() -> void: + cancel_requested.emit() + + +func _on_bubble_position_changed(bubble_index: int, center_rig: Vector2) -> void: + if is_open(): + _composer.call("set_bubble_placement_preview", bubble_index, center_rig) + + +func on_draft_changed(action: Dictionary) -> void: + arm_draft_save() + if preview == null or not is_instance_valid(preview) or _composer == null: + return + var preview_state: String = String(_composer.call("preview_state_name")) + var preview_action: Dictionary = _composer.call("get_preview_action") + preview.set_editor_effect_preview(_composer.call("editor_effect_demo_context")) + preview.render_action(preview_action, float(_composer.call("preview_elapsed")), true, preview_state != "playing") + var context: Dictionary = _composer.call("style_context") + var focus_index: int = int(_composer.call("stage_focus_bubble_index")) + preview.set_selected_bubble_index(focus_index) + var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {} + preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1))) + sync_preview_overflow(action) + if _style_toolbar != null: + _style_toolbar.call("set_context", context) + refresh_style_toolbar() + + +func on_preview_state_changed(state: String, elapsed: float, preview_visible: bool) -> void: + if preview == null or not is_instance_valid(preview) or _composer == null: + return + preview.visible = preview_visible + if preview_visible: + var action: Dictionary = _composer.call("get_preview_action") + preview.set_editor_effect_preview(_composer.call("editor_effect_demo_context")) + preview.render_action(action, elapsed, true, state != "playing") + sync_preview_overflow(action) + refresh_style_toolbar() + + +func on_replay_requested() -> void: + if _composer == null or preview == null or not is_instance_valid(preview): + return + preview.visible = true + var action: Dictionary = _composer.call("get_preview_action") + var preview_state: String = String(_composer.call("preview_state_name")) + preview.render_action(action, float(_composer.call("preview_elapsed")), true, preview_state != "playing") + sync_preview_overflow(action) + + +func on_toolbar_preview_requested() -> void: + if is_open(): + _composer.call("replay_preview_once") + + +func update_composition(world_position: Vector2) -> void: + if not is_open() or actor == null or not is_instance_valid(actor) or preview == null or not is_instance_valid(preview): + return + preview.set_mirrored(actor.facing_profile == StickmanRig.FacingProfile.LEFT) + preview.set_selected_bubble_index(int(_composer.call("stage_focus_bubble_index"))) + if not bool(_composer.call("preview_visible")): + preview.visible = false + return + preview.visible = true + var action: Dictionary = _composer.call("get_preview_action") + var preview_state: String = String(_composer.call("preview_state_name")) + preview.set_editor_effect_preview(_composer.call("editor_effect_demo_context")) + if placing: + var hover_action: Dictionary = action.duplicate(true) + var local_center: Vector2 = actor.to_local(world_position) + if placement_bubble_index < 0: + hover_action["placement"] = {"mode": "manual", "center": local_center} + else: + var bubbles: Array = hover_action.get("bubbles", []) + if placement_bubble_index < bubbles.size() and bubbles[placement_bubble_index] is Dictionary: + var bubble: Dictionary = bubbles[placement_bubble_index] + bubble["placement"] = {"mode": "manual", "center": local_center} + bubbles[placement_bubble_index] = bubble + hover_action["bubbles"] = bubbles + preview.set_placement_ghost(false) + preview.render_action(hover_action, float(_composer.call("preview_elapsed")), true, preview_state != "playing") + else: + preview.set_placement_ghost(false) + preview.render_action(action, float(_composer.call("preview_elapsed")), true, preview_state != "playing") + sync_preview_overflow(action) + var context: Dictionary = _composer.call("style_context") + var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {} + preview.set_selected_token(int(context.get("bubble_index", -1)), int(target.get("start", -1)), int(target.get("end", -1))) + refresh_style_toolbar() + + +func sync_preview_overflow(action: Dictionary) -> void: + if preview == null or not is_instance_valid(preview) or _composer == null: + return + var overflow: bool = preview.has_display_overflow() + var arrangement: String = String(action.get("arrangement", "together")) + var bubble_layout: String = String(action.get("bubble_layout", "stack")) + _composer.call("set_preview_overflow", overflow, arrangement, bubble_layout) + + +func set_camera_panning(enabled: bool) -> void: + _camera_panning = enabled + + +func begin_placement(bubble_index: int) -> void: + if not is_open(): + return + placement_bubble_index = bubble_index + placing = true + _composer.call("set_placement_pending", true, bubble_index) + + +func cancel_placement() -> void: + placing = false + if _composer != null and is_instance_valid(_composer): + _composer.call("set_placement_pending", false) + + +func on_placement_requested() -> void: + begin_placement(-1) + toast_requested.emit("Click the stage to place the bubble. Esc cancels.") + + +func on_placement_reset_requested() -> void: + cancel_placement() + + +func on_bubble_placement_requested(bubble_index: int) -> void: + begin_placement(bubble_index) + toast_requested.emit("Move this bubble: click the stage. Esc cancels.") + + +func on_bubble_placement_reset_requested(_bubble_index: int) -> void: + cancel_placement() + + +func place_bubble_at(viewport_position: Vector2) -> bool: + if not placing or actor == null or not is_instance_valid(actor) or _composer == null: + return false + if preview != null and is_instance_valid(preview): + preview.set_placement_ghost(false) + var local_center: Vector2 = actor.to_local(viewport_to_world(viewport_position)) + if placement_bubble_index < 0: + _composer.call("set_placement_preview", local_center) + else: + _composer.call("set_bubble_placement_preview", placement_bubble_index, local_center) + placing = false + _composer.call("set_placement_pending", false) + placement_bubble_index = 0 + return true + + +func should_mark_input_handled() -> bool: + return _last_input_mark_handled + + +func is_open() -> bool: + return _composer != null and is_instance_valid(_composer) and _composer.visible + + +func speech_event_position(event: InputEvent) -> Vector2: + if event is InputEventMouse: + return (event as InputEventMouse).position + if event is InputEventScreenTouch: + return (event as InputEventScreenTouch).position + if event is InputEventGesture: + return (event as InputEventGesture).position + return Vector2(-1.0, -1.0) + + +func stage_viewport_rect() -> Rect2: + var viewport_size: Vector2 = _viewport.get_visible_rect().size if _viewport != null else Vector2.ZERO + return Rect2(Vector2(12.0, 72.0), Vector2(maxf(1.0, viewport_size.x - 24.0), maxf(1.0, viewport_size.y - 120.0))) + + +func viewport_to_world(viewport_position: Vector2) -> Vector2: + if _viewport == null: + return Vector2.ZERO + return _viewport.get_canvas_transform().affine_inverse() * viewport_position + + +func handle_input(event: InputEvent) -> bool: + _last_input_mark_handled = false + if is_open() and bool(_composer.call("is_message_dialog_open")): + if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE: + _composer.call("close_message_dialog") + _last_input_mark_handled = true + return true + if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE and _style_toolbar != null and bool(_style_toolbar.call("is_chooser_open")): + _style_toolbar.call("close_chooser") + _last_input_mark_handled = true + return true + if _style_toolbar != null and bool(_style_toolbar.call("is_chooser_open")) and _is_pointer_press(event): + var chooser_point: Vector2 = speech_event_position(event) + if not bool(_style_toolbar.call("chooser_contains_viewport_point", chooser_point)): + _style_toolbar.call("close_chooser") + if _composer_owns_event(event): + _camera_panning = false + camera_pan_cancel_requested.emit() + if bubble_pointer_pending: + cancel_bubble_pointer() + return true + if route_stage_input(event): + return true + if bubble_pointer_pending: + if event is InputEventMouseMotion: + update_bubble_pointer((event as InputEventMouseMotion).position) + return true + if event is InputEventScreenDrag: + update_bubble_pointer((event as InputEventScreenDrag).position) + return true + var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed + var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed + if mouse_release or touch_release: + if bubble_dragging: + if preview != null and is_instance_valid(preview): + preview.end_bubble_drag() + bubble_dragging = false + call_deferred("clear_bubble_pointer", bubble_pointer_sequence) + else: + var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position + call_deferred("resolve_bubble_pointer_click", bubble_pointer_sequence, release_position) + return true + return false + + +func handle_unhandled_input(event: InputEvent) -> bool: + _last_input_mark_handled = false + if is_open() and bool(_composer.call("is_message_dialog_open")): + _last_input_mark_handled = true + return true + if bubble_pointer_pending: + var bubble_mouse_event: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT + var bubble_touch_event: bool = event is InputEventScreenTouch + if bubble_mouse_event or bubble_touch_event: + _last_input_mark_handled = true + return true + if is_open(): + var speech_point: Vector2 = speech_event_position(event) + var in_speech_stage: bool = speech_point.x >= 0.0 and speech_point.y >= 0.0 and stage_viewport_rect().has_point(speech_point) + var placement_press: bool = false + if placing and event is InputEventMouseButton: + var mouse_button: InputEventMouseButton = event as InputEventMouseButton + placement_press = mouse_button.pressed and (mouse_button.button_index == MOUSE_BUTTON_LEFT or mouse_button.button_index == MOUSE_BUTTON_RIGHT) + elif placing and event is InputEventScreenTouch: + placement_press = (event as InputEventScreenTouch).pressed + if in_speech_stage and not placement_press and (event is InputEventMouseButton or event is InputEventMouseMotion or event is InputEventScreenTouch or event is InputEventScreenDrag or event is InputEventGesture): + _last_input_mark_handled = true + return true + return false + + +func _composer_owns_event(event: InputEvent) -> bool: + if _composer == null or not _composer.visible or not _composer.has_method("contains_viewport_point"): + return false + var point: Vector2 = speech_event_position(event) + if point.x < 0.0 or point.y < 0.0: + return false + if bool(_composer.call("contains_viewport_point", point)): + return true + if _style_toolbar != null and bool(_style_toolbar.call("contains_viewport_point", point)): + return true + if _show_toolbar != null and bool(_show_toolbar.call("contains_viewport_point", point)): + return true + if token_picker != null and is_instance_valid(token_picker) and token_picker.visible: + return Rect2(token_picker.position, Vector2(token_picker.size)).has_point(point) + return false + + +func _is_pointer_press(event: InputEvent) -> bool: + return (event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and (event as InputEventMouseButton).pressed) or (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) + + +func route_stage_input(event: InputEvent) -> bool: + if not is_open(): + return false + if bubble_pointer_pending: + if event is InputEventMouseMotion: + update_bubble_pointer((event as InputEventMouseMotion).position) + return true + if event is InputEventScreenDrag: + update_bubble_pointer((event as InputEventScreenDrag).position) + return true + var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed + var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed + if mouse_release or touch_release: + if bubble_dragging: + if preview != null and is_instance_valid(preview): + preview.end_bubble_drag() + bubble_dragging = false + call_deferred("clear_bubble_pointer", bubble_pointer_sequence) + else: + var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position + call_deferred("resolve_bubble_pointer_click", bubble_pointer_sequence, release_position) + return true + var point: Vector2 = speech_event_position(event) + if point.x < 0.0 or point.y < 0.0 or not stage_viewport_rect().has_point(point): + return false + if event is InputEventMagnifyGesture: + camera_input_requested.emit(event) + _last_input_mark_handled = true + return true + if event is InputEventPanGesture: + camera_input_requested.emit(event) + _last_input_mark_handled = true + return true + if event is InputEventMouseButton: + var mouse_button: InputEventMouseButton = event as InputEventMouseButton + if mouse_button.button_index == MOUSE_BUTTON_MIDDLE or mouse_button.button_index == MOUSE_BUTTON_WHEEL_UP or mouse_button.button_index == MOUSE_BUTTON_WHEEL_DOWN: + camera_input_requested.emit(mouse_button) + _last_input_mark_handled = true + return true + if mouse_button.button_index == MOUSE_BUTTON_LEFT: + if mouse_button.pressed: + if placing: + return false + begin_bubble_pointer(mouse_button.position) + _last_input_mark_handled = not bubble_pointer_pending + return true + _last_input_mark_handled = true + return true + if mouse_button.button_index == MOUSE_BUTTON_RIGHT and mouse_button.pressed and placing: + return false + _last_input_mark_handled = true + return true + if event is InputEventMouseMotion: + if _camera_panning: + camera_input_requested.emit(event) + _last_input_mark_handled = true + return true + if event is InputEventScreenTouch: + var touch: InputEventScreenTouch = event as InputEventScreenTouch + if touch.pressed: + if placing: + return false + begin_bubble_pointer(touch.position) + _last_input_mark_handled = not bubble_pointer_pending + return true + _last_input_mark_handled = true + return true + if event is InputEventScreenDrag: + _last_input_mark_handled = true + return true + return false + + +func begin_bubble_pointer(screen_position: Vector2) -> void: + if preview == null or not is_instance_valid(preview) or _composer == null or not _composer.visible: + return + if not stage_viewport_rect().has_point(screen_position): + return + var pointer_world: Vector2 = viewport_to_world(screen_position) + var badge_index: int = preview.hit_test_order_badge(pointer_world) + var hits: Array[int] = [] + if badge_index >= 0: + hits.append(badge_index) + else: + hits = preview.hit_test_bubbles(pointer_world) + bubble_pointer_sequence += 1 + bubble_pointer_pending = true + bubble_pointer_moved = false + bubble_pointer_on_badge = badge_index >= 0 + bubble_pointer_start_screen = screen_position + bubble_pointer_start_world = viewport_to_world(screen_position) + bubble_pointer_hits = hits + bubble_pointer_index = hits[0] if not hits.is_empty() else -1 + bubble_word_clicked = false + bubble_dragging = false + + +func update_bubble_pointer(screen_position: Vector2) -> void: + if preview == null or not is_instance_valid(preview): + cancel_bubble_pointer() + return + if not bubble_dragging: + if screen_position.distance_to(bubble_pointer_start_screen) < BUBBLE_DRAG_THRESHOLD: + return + bubble_pointer_moved = true + if bubble_pointer_on_badge or bubble_pointer_hits.is_empty(): + return + var drag_bubble_index: int = preview.hit_test_bubble_for_drag(bubble_pointer_start_world) + if drag_bubble_index < 0: + drag_bubble_index = bubble_pointer_index + select_bubble_from_stage(drag_bubble_index) + bubble_dragging = preview.begin_bubble_drag(bubble_pointer_start_world, drag_bubble_index) + if not bubble_dragging: + return + preview.update_bubble_drag(viewport_to_world(screen_position)) + + +func resolve_bubble_pointer_click(sequence: int, screen_position: Vector2) -> void: + if not bubble_pointer_pending or sequence != bubble_pointer_sequence: + return + if not bubble_pointer_moved and not bubble_word_clicked: + if bubble_pointer_hits.is_empty(): + if _composer != null and _composer.visible: + _composer.call("clear_stage_focus") + clear_bubble_pointer(sequence) + return + select_bubble_from_stage(bubble_pointer_index) + if bubble_pointer_hits.size() > 1: + show_bubble_picker(bubble_pointer_hits, screen_position) + clear_bubble_pointer(sequence) + + +func clear_bubble_pointer(sequence: int) -> void: + if sequence != bubble_pointer_sequence: + return + bubble_pointer_pending = false + bubble_pointer_moved = false + bubble_pointer_on_badge = false + bubble_pointer_hits.clear() + bubble_pointer_index = -1 + bubble_word_clicked = false + + +func cancel_bubble_pointer() -> void: + if bubble_dragging and preview != null and is_instance_valid(preview): + preview.end_bubble_drag() + bubble_dragging = false + bubble_pointer_pending = false + bubble_pointer_moved = false + bubble_pointer_on_badge = false + bubble_pointer_hits.clear() + bubble_pointer_index = -1 + bubble_word_clicked = false + bubble_pointer_sequence += 1 + + +func select_bubble_from_stage(index: int) -> void: + if _composer == null or not _composer.visible: + return + if _style_toolbar != null and bool(_style_toolbar.call("is_chooser_open")): + _style_toolbar.call("close_chooser") + _composer.call("select_bubble_from_stage", index) + if preview != null and is_instance_valid(preview): + preview.set_selected_bubble_index(index) + preview.set_selected_token(-1, -1, -1) + refresh_style_toolbar() + + +func select_bubble_token_from_stage(bubble_index: int, start: int, end: int) -> void: + if bubble_pointer_pending: + bubble_word_clicked = true + if _composer == null or not _composer.visible: + return + if _style_toolbar != null and bool(_style_toolbar.call("is_chooser_open")): + _style_toolbar.call("close_chooser") + _composer.call("select_bubble_token_from_stage", bubble_index, start, end) + if preview != null and is_instance_valid(preview): + preview.set_selected_bubble_index(bubble_index) + preview.set_selected_token(bubble_index, start, end) + refresh_style_toolbar() + + +func show_bubble_picker(hits: Array[int], screen_position: Vector2) -> void: + if bubble_picker != null and is_instance_valid(bubble_picker): + bubble_picker.hide() + if _ui_layer == null or _composer == null: + return + var picker: PopupPanel = PopupPanel.new() + picker.name = "SpeechBubblePicker" + picker.exclusive = false + picker.transient = true + picker.unfocusable = false + _ui_layer.add_child(picker) + var margin: MarginContainer = MarginContainer.new() + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_top", 8) + margin.add_theme_constant_override("margin_bottom", 8) + picker.add_child(margin) + var column: VBoxContainer = VBoxContainer.new() + column.add_theme_constant_override("separation", 6) + margin.add_child(column) + var title: Label = Label.new() + title.text = "Choose a bubble" + title.add_theme_font_size_override("font_size", 18) + column.add_child(title) + for index: int in hits: + var choice: Button = Button.new() + choice.text = bubble_picker_label(index) + choice.custom_minimum_size = Vector2(240.0, 44.0) + choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL + choice.pressed.connect(_on_bubble_picker_selected.bind(index)) + column.add_child(choice) + var viewport_size: Vector2 = _viewport.get_visible_rect().size + var popup_width: int = mini(280, maxi(220, int(viewport_size.x) - 16)) + var popup_height: int = mini(80 + hits.size() * 50, maxi(100, int(viewport_size.y) - 16)) + picker.size = Vector2i(popup_width, popup_height) + var popup_position: Vector2i = Vector2i(screen_position + Vector2(12.0, 12.0)) + popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8)) + popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8)) + bubble_picker = picker + picker.popup_hide.connect(_on_bubble_picker_closed.bind(picker)) + picker.position = popup_position + picker.popup() + + +func bubble_picker_label(index: int) -> String: + var bubbles: Array = [] + if _composer != null and _composer.has_method("get_draft"): + var draft: Dictionary = _composer.call("get_draft") + bubbles = draft.get("bubbles", []) + if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: + return "Bubble %d · Empty" % (index + 1) + var text: String = String((bubbles[index] as Dictionary).get("text", "")).replace("\n", " ").strip_edges() + if text.is_empty(): + text = "Empty" + elif text.length() > 20: + text = text.substr(0, 19) + "…" + return "Bubble %d · %s" % [index + 1, text] + + +func _on_bubble_picker_selected(index: int) -> void: + select_bubble_from_stage(index) + if bubble_picker != null and is_instance_valid(bubble_picker): + bubble_picker.hide() + + +func _on_bubble_picker_closed(picker: PopupPanel) -> void: + if bubble_picker == picker: + bubble_picker = null + if is_instance_valid(picker): + picker.queue_free() + + +func on_preview_line_tokens_requested(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void: + if bubble_pointer_pending: + bubble_word_clicked = true + show_token_picker(bubble_index, spans, viewport_position) + + +func on_preview_text_tap_detected(_bubble_index: int) -> void: + if bubble_pointer_pending: + bubble_word_clicked = true + + +func show_token_picker(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void: + if spans.is_empty() or _composer == null: + return + if token_picker != null and is_instance_valid(token_picker): + token_picker.hide() + if _ui_layer == null: + return + var picker: PopupPanel = PopupPanel.new() + picker.name = "SpeechTokenPicker" + picker.exclusive = false + picker.transient = true + picker.unfocusable = false + _ui_layer.add_child(picker) + var margin: MarginContainer = MarginContainer.new() + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_top", 8) + margin.add_theme_constant_override("margin_bottom", 8) + picker.add_child(margin) + var column: VBoxContainer = VBoxContainer.new() + column.add_theme_constant_override("separation", 6) + margin.add_child(column) + var title: Label = Label.new() + title.text = "Choose a word or mark" + title.add_theme_font_size_override("font_size", 18) + column.add_child(title) + var scroll: ScrollContainer = ScrollContainer.new() + scroll.custom_minimum_size = Vector2(300.0, minf(320.0, float(spans.size()) * 54.0)) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + column.add_child(scroll) + var choices: HFlowContainer = HFlowContainer.new() + choices.add_theme_constant_override("h_separation", 5) + choices.add_theme_constant_override("v_separation", 5) + scroll.add_child(choices) + var draft: Dictionary = _composer.call("get_draft") + var bubbles: Array = draft.get("bubbles", []) + var text: String = String((bubbles[bubble_index] as Dictionary).get("text", "")) if bubble_index >= 0 and bubble_index < bubbles.size() and bubbles[bubble_index] is Dictionary else "" + for span: Vector2i in spans: + if span.x < 0 or span.y <= span.x or span.y > text.length(): + continue + var token: String = text.substr(span.x, span.y - span.x) + var choice: Button = Button.new() + choice.text = token + choice.custom_minimum_size = Vector2(maxf(52.0, minf(160.0, 22.0 + float(token.length()) * 14.0)), 48.0) + choice.pressed.connect(_on_token_picker_selected.bind(bubble_index, span.x, span.y)) + choices.add_child(choice) + var viewport_size: Vector2 = _viewport.get_visible_rect().size + var popup_width: int = mini(360, maxi(220, int(viewport_size.x) - 16)) + var popup_height: int = mini(420, maxi(130, int(viewport_size.y) - 16)) + picker.size = Vector2i(popup_width, popup_height) + var popup_position: Vector2i = Vector2i(viewport_position + Vector2(12.0, 12.0)) + popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8)) + popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8)) + token_picker = picker + picker.popup_hide.connect(_on_token_picker_closed.bind(picker)) + picker.position = popup_position + picker.popup() + + +func _on_token_picker_selected(bubble_index: int, start: int, end: int) -> void: + select_bubble_token_from_stage(bubble_index, start, end) + if token_picker != null and is_instance_valid(token_picker): + token_picker.hide() + + +func _on_token_picker_closed(picker: PopupPanel) -> void: + if token_picker == picker: + token_picker = null + if is_instance_valid(picker): + picker.queue_free() + + +func on_style_chooser_open_requested(kind: StringName) -> void: + if _composer == null or _style_toolbar == null: + return + if bool(_composer.call("is_message_dialog_open")): + _composer.call("close_message_dialog") + _composer.call("set_style_category", kind) + _style_toolbar.call("set_context", _composer.call("style_context")) + _style_toolbar.call("present_chooser", kind) + + +func on_style_choice_selected(kind: StringName, value: String) -> void: + if _composer == null: + return + _composer.call("apply_style_choice", kind, value) + _style_toolbar.call("set_context", _composer.call("style_context")) + + +func on_style_sequence_action_requested(action: StringName) -> void: + if _composer == null or not _composer.visible: + return + match action: + &"earlier": _composer.call("move_selected_bubble", -1) + &"later": _composer.call("move_selected_bubble", 1) + &"add": + _composer.call("add_bubble_after_selected") + call_deferred("resolve_added_bubble_collision") + &"remove": _composer.call("remove_selected_bubble") + &"undo_remove": _composer.call("undo_remove_bubble") + refresh_style_toolbar() + + +func resolve_added_bubble_collision() -> void: + if not is_open() or preview == null or not is_instance_valid(preview): + return + var bubble_index: int = int(_composer.call("selected_bubble_index")) + var suggestion: Dictionary = preview.suggest_added_bubble_center(bubble_index) + if not bool(suggestion.get("collided", false)): + return + var center: Variant = suggestion.get("center", null) + if center is Vector2: + _composer.call("set_bubble_placement_preview", bubble_index, center) + + +func on_done_requested() -> void: + if is_open(): + _composer.call("request_done_speech") + + +func on_cancel_requested() -> void: + if is_open(): + _composer.call("request_cancel_speech") + + +func on_validation_failed(message: String, bubble_index: int) -> void: + toast_requested.emit(message) + if bubble_index >= 0 and is_open(): + _composer.call("open_message_dialog") + refresh_style_toolbar() + + +func on_show_choice_selected(key: StringName, value: Variant) -> void: + if _composer == null or not _composer.visible: + return + var action: Dictionary = _composer.call("get_preview_action") + if key == &"connect_bubbles" and bool(value) and String(action.get("bubble_layout", "stack")) == "same_spot": + if preview == null or not is_instance_valid(preview): + return + var bubbles: Array = action.get("bubbles", []) + var centers: Array = preview.bubble_layout_centers(&"stack") + if bubbles.size() < 2 or centers.size() != bubbles.size(): + return + _composer.call("apply_bubble_layout", centers, "stack") + _composer.call("choose_speech_show", key, value) + _show_toolbar.call("set_context", _composer.call("get_preview_action"), _composer.call("reactive_options_enabled")) + refresh_style_toolbar() + + +func on_layout_preset_requested(preset: StringName) -> void: + if _composer == null or not _composer.visible or preview == null or not is_instance_valid(preview): + return + var centers: Array = preview.bubble_layout_centers(preset) + var action: Dictionary = _composer.call("get_preview_action") + var bubbles: Array = action.get("bubbles", []) + if centers.is_empty() or centers.size() != bubbles.size(): + return + _composer.call("apply_bubble_layout", centers, String(preset)) + _show_toolbar.call("set_context", _composer.call("get_preview_action"), _composer.call("reactive_options_enabled")) + refresh_style_toolbar() + + +func on_stage_focus_changed(bubble_index: int) -> void: + if preview != null and is_instance_valid(preview): + preview.set_selected_bubble_index(bubble_index) + if bubble_index < 0: + preview.set_selected_token(-1, -1, -1) + refresh_style_toolbar() + + +func on_style_message_requested(opened: bool) -> void: + if _composer == null or _style_toolbar == null: + return + if opened: + _style_toolbar.call("close_chooser") + _composer.call("open_message_dialog") + else: + _composer.call("close_message_dialog") + refresh_style_toolbar() + + +func on_message_dialog_closed() -> void: + if _style_toolbar != null: + _style_toolbar.call("set_message_dialog_open", false) + refresh_style_toolbar() + + +func on_style_done_requested() -> void: + if _composer != null and bool(_composer.call("is_message_dialog_open")): + _composer.call("close_message_dialog") + if _composer != null: + _composer.call("clear_stage_focus") + + +func on_style_context_changed(context: Dictionary) -> void: + if _style_toolbar != null: + _style_toolbar.call("set_context", context) + if preview != null and is_instance_valid(preview): + var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {} + preview.set_editor_effect_preview(_composer.call("editor_effect_demo_context")) + var focus_index: int = int(_composer.call("stage_focus_bubble_index")) + preview.set_selected_bubble_index(focus_index) + preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1))) + refresh_style_toolbar() + + +func refresh_style_toolbar() -> void: + if _style_toolbar == null or _show_toolbar == null or _composer == null or preview == null or not is_instance_valid(preview) or not _composer.visible: + if _style_toolbar != null: + _style_toolbar.hide() + if _show_toolbar != null: + _show_toolbar.hide() + sync_badge_avoidance() + return + var preview_state: String = String(_composer.call("preview_state_name")) + var unavailable: bool = placing or bubble_dragging or preview_state != "static" or not preview.visible + var stage_focus_index: int = int(_composer.call("stage_focus_bubble_index")) + if stage_focus_index < 0: + _style_toolbar.hide() + _show_toolbar.call("set_context", _composer.call("get_preview_action"), _composer.call("reactive_options_enabled")) + _show_toolbar.call("set_busy", unavailable) + if unavailable: + _show_toolbar.hide() + sync_badge_avoidance() + return + var group_rect: Rect2 = preview.group_frames_viewport_rect() + sync_badge_avoidance() + _show_toolbar.call("set_anchor_rect", group_rect, stage_viewport_rect()) + return + _show_toolbar.hide() + _style_toolbar.call("set_busy", unavailable) + if unavailable: + _style_toolbar.hide() + sync_badge_avoidance() + return + var context: Dictionary = _composer.call("style_context") + _style_toolbar.call("set_context", context) + var bubble_index: int = int(context.get("bubble_index", -1)) + var frame_rect: Rect2 = preview.selected_frame_viewport_rect(bubble_index) + sync_badge_avoidance() + _style_toolbar.call("set_anchor_rect", frame_rect, stage_viewport_rect()) + _composer.call("set_message_dialog_anchor", frame_rect, stage_viewport_rect(), _style_toolbar.call("toolbar_viewport_rect")) + + +func sync_badge_avoidance() -> void: + var badge_rects: Array[Rect2] = [] + if preview != null and is_instance_valid(preview): + badge_rects = preview.order_badge_viewport_rects() + if _show_toolbar != null and is_instance_valid(_show_toolbar): + _show_toolbar.call("set_badge_avoidance_rects", badge_rects) + if _style_toolbar != null and is_instance_valid(_style_toolbar): + _style_toolbar.call("set_badge_avoidance_rects", badge_rects) + + +func should_point_badge(mouse_position: Vector2, eligible: bool) -> bool: + return eligible and is_open() and not placing and stage_viewport_rect().has_point(mouse_position) and preview != null and is_instance_valid(preview) and preview.hit_test_order_badge(viewport_to_world(mouse_position)) >= 0 + + +func arm_draft_save() -> void: + if _draft_store != null: + _draft_store.arm_draft_save() + + +func update(delta: float) -> void: + if _draft_store != null: + _draft_store.update(delta) + + +func capture_origin(actor_value: StickmanRig, context: String, scene_path: String, scene_fingerprint: String, editor_state: Dictionary) -> Dictionary: + return {} if _draft_store == null else _draft_store.capture_origin(actor_value, context, scene_path, scene_fingerprint, editor_state) + + +func validate_origin(origin_value: Dictionary, current_scene_path: String, current_scene_fingerprint: String) -> Dictionary: + return {} if _draft_store == null else _draft_store.validate_origin(origin_value, current_scene_path, current_scene_fingerprint) + + +func speech_actor_file_id(actor_value: StickmanRig) -> String: + return "" if _draft_store == null else _draft_store.speech_actor_file_id(actor_value) + + +func speech_object_type(node: Node2D) -> String: + return "" if _draft_store == null else _draft_store.speech_object_type(node) + + +func find_speech_actor(file_id: String, source_path: String) -> StickmanRig: + return null if _draft_store == null else _draft_store.find_speech_actor(file_id, source_path) + + +func queue_action_wire_at(actor_value: StickmanRig, index: int) -> Dictionary: + return {} if _draft_store == null else _draft_store.queue_action_wire_at(actor_value, index) + + +func canonicalize_origin(origin_value: Dictionary) -> Dictionary: + return {} if _draft_store == null else _draft_store.canonicalize_origin(origin_value) + + +func speech_action_matches_queue_edit(draft: Dictionary, actor_value: StickmanRig, queue_index: int) -> bool: + return false if _draft_store == null else _draft_store.speech_action_matches_queue_edit(draft, actor_value, queue_index) + + +func inspect_recovery(current_scene_path: String, current_scene_fingerprint: String) -> Dictionary: + return {"available": false, "clear_matching_edit": false} if _draft_store == null else _draft_store.inspect_recovery(current_scene_path, current_scene_fingerprint) + + +func restore_empty_recovered_bubble_text(recovered: Dictionary, saved_action: Dictionary) -> Dictionary: + return recovered.duplicate(true) if _draft_store == null else _draft_store.restore_empty_recovered_bubble_text(recovered, saved_action) + + +func read_draft_document() -> Dictionary: + return {} if _draft_store == null else _draft_store.read_draft_document() + + +func speech_payload_for_json(action: Dictionary) -> Dictionary: + return action.duplicate(true) if _draft_store == null else _draft_store.speech_payload_for_json(action) + + +func persist_draft_now() -> void: + if _draft_store != null: + _draft_store.persist_draft_now() + + +func clear_draft_file() -> void: + if _draft_store != null: + _draft_store.clear_draft_file() + + +func has_draft_file() -> bool: + return false if _draft_store == null else _draft_store.has_draft_file() + + +func _queue_action_consumed(status: int) -> bool: + return false if _draft_store == null else _draft_store._queue_action_consumed(status) diff --git a/scripts/editors/stage/stage_speech_session.gd.uid b/scripts/editors/stage/stage_speech_session.gd.uid new file mode 100644 index 0000000..1a72fce --- /dev/null +++ b/scripts/editors/stage/stage_speech_session.gd.uid @@ -0,0 +1 @@ +uid://buutqpkalyet7 diff --git a/scripts/editors/stage/stage_target_capture_session.gd b/scripts/editors/stage/stage_target_capture_session.gd new file mode 100644 index 0000000..343f0d3 --- /dev/null +++ b/scripts/editors/stage/stage_target_capture_session.gd @@ -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) diff --git a/scripts/editors/stage/stage_target_capture_session.gd.uid b/scripts/editors/stage/stage_target_capture_session.gd.uid new file mode 100644 index 0000000..3d9adbc --- /dev/null +++ b/scripts/editors/stage/stage_target_capture_session.gd.uid @@ -0,0 +1 @@ +uid://dy7djgj73h5n7 diff --git a/scripts/editors/stage/stage_terrain_painter.gd b/scripts/editors/stage/stage_terrain_painter.gd new file mode 100644 index 0000000..6e34e61 --- /dev/null +++ b/scripts/editors/stage/stage_terrain_painter.gd @@ -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) diff --git a/scripts/editors/stage/stage_terrain_painter.gd.uid b/scripts/editors/stage/stage_terrain_painter.gd.uid new file mode 100644 index 0000000..9bf00f7 --- /dev/null +++ b/scripts/editors/stage/stage_terrain_painter.gd.uid @@ -0,0 +1 @@ +uid://dlaa2syr0pq50 diff --git a/scripts/editors/stage/stage_theme_controller.gd b/scripts/editors/stage/stage_theme_controller.gd new file mode 100644 index 0000000..572b390 --- /dev/null +++ b/scripts/editors/stage/stage_theme_controller.gd @@ -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 diff --git a/scripts/editors/stage/stage_theme_controller.gd.uid b/scripts/editors/stage/stage_theme_controller.gd.uid new file mode 100644 index 0000000..90fb800 --- /dev/null +++ b/scripts/editors/stage/stage_theme_controller.gd.uid @@ -0,0 +1 @@ +uid://bj5hbnq564akd diff --git a/scripts/stage/stage_rule_engine.gd b/scripts/stage/stage_rule_engine.gd new file mode 100644 index 0000000..15ddef7 --- /dev/null +++ b/scripts/stage/stage_rule_engine.gd @@ -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()] diff --git a/scripts/stage/stage_rule_engine.gd.uid b/scripts/stage/stage_rule_engine.gd.uid new file mode 100644 index 0000000..5afd773 --- /dev/null +++ b/scripts/stage/stage_rule_engine.gd.uid @@ -0,0 +1 @@ +uid://cbdfvuscs7b47 diff --git a/scripts/stage/stage_spatial_index.gd b/scripts/stage/stage_spatial_index.gd new file mode 100644 index 0000000..205dfcd --- /dev/null +++ b/scripts/stage/stage_spatial_index.gd @@ -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 diff --git a/scripts/stage/stage_spatial_index.gd.uid b/scripts/stage/stage_spatial_index.gd.uid new file mode 100644 index 0000000..2efab67 --- /dev/null +++ b/scripts/stage/stage_spatial_index.gd.uid @@ -0,0 +1 @@ +uid://b6wxhc8fh5o15