Add new stage editor scripts for target capture, terrain painting, theme management, rule engine, and spatial indexing
- Implement `StageTargetCaptureSession` for managing target capture sessions with throw and set down positions. - Create `StageTerrainPainter` for terrain drag painting and overlap classification with stroke preview. - Introduce `StageThemeController` to handle theme values and resource loading for the Sandbox Stage. - Develop `StageRuleEngine` for runtime event matching and collision bookkeeping. - Add `StageSpatialIndex` for broadphase stage lookups with advisory spatial indexing.
This commit is contained in:
@@ -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) |
|
| 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) |
|
| 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) |
|
| 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-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 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-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_
|
_End of Log_
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Sandbox Stage composition refactor
|
||||||
|
|
||||||
|
## Status and goal
|
||||||
|
|
||||||
|
Architecture specification for a behavior-preserving refactor of `scripts/editors/stage/sandbox_stage.gd`. The script is roughly 7,600 physical lines and combines scene orchestration with terrain painting, stage UI construction, document workflow, rule dispatch, and speech editing. The goal is to make the Godot script editor practical to use by moving coherent state and behavior into smaller scripts. This is an internal refactor: the stage's visible behavior, `.scene` format, and launch path stay the same.
|
||||||
|
|
||||||
|
**Implementation status (2026-10-10):** Approved extraction slices 1–16 are implemented. The root is 5,009 lines, down from the approximate 7,600-line baseline (about 34%); the below-3,800-line target remains unmet by 1,209 lines. A focused Godot 4.7.1 `--check-only` parse of the root passes and loads its component scripts. Behavior and in-editor responsiveness checks remain pending. Further reduction would require revisiting the coordinator boundary for world input/selection, scene recovery application, or save and commit transactions.
|
||||||
|
|
||||||
|
Implement this in the ordered slices below. Each slice must leave the stage usable and independently reviewable. Do not move methods merely to reduce line count: move the state they own, and remove the old implementation after the replacement is wired.
|
||||||
|
|
||||||
|
## Existing boundaries and constraints
|
||||||
|
|
||||||
|
- `SandboxStage` is the `Node2D` root of `scenes/stage/sandbox_stage.tscn`; its `Camera2D` and `World` children are stable. Keep `SandboxStage` as the scene script and public stage facade.
|
||||||
|
- Existing helpers already own spawning, selection, gizmos, grid drawing, director visuals, placement overlay, action/trigger registries, and JSON schema conversion. Extend these boundaries rather than duplicating their logic.
|
||||||
|
- `StageSceneIO` remains the `.scene` schema authority. Do not change `FILE_VERSION`, serialized fields, migration, or validation as part of this refactor. Route persisted paths through `ContentPaths.canonicalize` as today.
|
||||||
|
- The shared Stage inspector controls and `stage_panels` theme tokens remain the basis for selected-object cards. The prop settings presentation may move, but its role writes, dirty marking, and contact reconciliation remain coordinated by the stage.
|
||||||
|
- Current tests and tools call some underscored stage methods and inspect fields. Preserve compatibility through small forwarding methods or references during this refactor; remove a facade only in a separately approved API cleanup.
|
||||||
|
- The working tree may contain unrelated edits to `sandbox_stage.gd`. Preserve them. Do not use a wholesale file replacement or revert.
|
||||||
|
|
||||||
|
## Composition rules
|
||||||
|
|
||||||
|
1. The root owns `current_mode`, run-state transitions, the `World` and camera lifecycle, input priority, authored object state, selection, and the final decision to mutate the scene. `_ready`, `_process`, `_physics_process`, `_input`, and `_unhandled_input` should become dispatchers with explicit component calls.
|
||||||
|
2. A component owns its private state and exposes a small typed API. Give it references it actually needs in `configure(...)` or construction. It must not discover the stage with `get_parent()` or call arbitrary stage methods through a stored `SandboxStage` reference.
|
||||||
|
3. Components report intents or results with signals or typed return values. The root handles cross-domain effects such as `_finalize_placed_node`, `_mark_scene_dirty`, navigation invalidation, queue/rule commits, and mode changes. Avoid generic `Callable` bags and duplicated writable state.
|
||||||
|
4. Use `RefCounted` for data and calculation services without a tree lifecycle; use `Node` or `Control` only for components that need `_process`, input, children, popups, or rendering. The root creates and owns tree components in `_ready` or through a small scene child. Keep the dependency direction root -> component -> existing lower-level helpers. Avoid circular script preloads and new autoloads.
|
||||||
|
5. One owner per fact. When the root needs a state for input priority, ask the component (for example, `is_dragging()` or `is_open()`) instead of mirroring a boolean. Keep compatibility accessors only for concrete existing callers; if a caller currently mutates an exposed value (the terrain tests seed `_drag_painted` directly), temporarily expose the component-owned value rather than keeping a second copy, and record that exception for later API cleanup.
|
||||||
|
6. Scene input order is a contract. Speech UI and stage bubble input have first refusal, then palette dragging, camera gestures, placement/director capture, and world selection. A component's `handle_input(event) -> bool` reports consumption; the root alone calls `get_viewport().set_input_as_handled()` at the same points as today.
|
||||||
|
|
||||||
|
## Component boundaries
|
||||||
|
|
||||||
|
| Component and location | Owns | Root-facing contract | Explicitly outside its ownership |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `StageSpatialIndex`, `scripts/stage/stage_spatial_index.gd` (`RefCounted`) | Advisory 16 px cell dictionary, AABB rasterization, rebuild/add/clear, candidate lookup | `rebuild(nodes)`, `add(node)`, `clear()`, `candidates_for_aabb(aabb)`, `candidates_at(world_pos)` | Collision authority, terrain painting, selection decisions |
|
||||||
|
| `StageTerrainPainter`, `scripts/editors/stage/stage_terrain_painter.gd` (`Node`) | Terrain drag start/threshold, cell path, overlap classification, painted-cell suppression, multiple ghosts and guide | `configure(spawner, world, camera, ghost_holder, overlay, spatial_index)`, `begin(...)`, `update(...)`, `commit(...)`, `cancel()`, `is_dragging()`; emit `placement_requested(node)` for each created block and `stroke_finished(placed_count)` once | Authored state, dirty flag, navigation rebuild, `object_placed` stage signal, single cursor ghost |
|
||||||
|
| `StageChrome`, `scripts/editors/stage/stage_chrome.gd` (`CanvasLayer`) | Top/status bars, mode controls, menus/dialog presentation, cursor/status visuals, theme application and layout | `configure(theme, initial_state)`, `set_mode(...)`, `set_status(...)`, `set_scene_title(...)`, `set_run_state(...)`; typed signals for user requests. Root may retain references to old controls through forwarding accessors during migration | Stage mode transitions, save/load decisions, world mutation |
|
||||||
|
| `StagePropSettingsView`, `scripts/editors/stage/stage_prop_settings_view.gd` (`Control`) | Selected prop card construction, layout, choice/toggle presentation and dismissal UI | `show_prop(prop, roles, safe_rect)`, `hide_card()`, `set_dismissed(...)`; signals `role_choice_requested`, `role_toggle_requested`, `close_requested` | Writing `PropBlock` roles, contact reconciliation, dirty flag. Use existing `StageInspectorPanel`, `StagePanelChoiceGroup`, `StagePanelToggleRow`, and theme tokens |
|
||||||
|
| `StageDocumentController`, `scripts/editors/stage/stage_document_controller.gd` (`RefCounted` or `Node` if timer needed) | Current path/name/created time, dirty/recent list, autosave path and recovery bookkeeping, file read/write and CLI file request coordination | `save(path, snapshot, autosave) -> bool`, `read(path) -> Dictionary`, `mark_dirty()`, `accept_loaded_document(...)`, `recent_paths()`; signals for errors/recovery prompts | Building or applying world snapshots, `StageSceneIO` schema implementation, speech save gate decisions |
|
||||||
|
| `StageSceneAdapter`, `scripts/editors/stage/stage_scene_adapter.gd` (`RefCounted`) | Mapping live objects and authored transforms to a scene dictionary; reconstructing objects and runtime IDs from a validated scene dictionary | `collect(world, authored, rules, camera_state, document_metadata) -> Dictionary`; `apply(data, world, spawner) -> Dictionary` with loaded objects, ID map, warnings and missing paths | Opening files, recent list, UI dialogs, mode switching, dirty state. Stage performs post-load signal wiring/finalization and rule/view refresh |
|
||||||
|
| `StageRuleEngine`, `scripts/stage/stage_rule_engine.gd` (`RefCounted` or `Node`) | Runtime event matching, area entry edges, collision pair suppression and reset/baseline bookkeeping | `handle_event(event, rules)`, `physics_tick(rules, ...)`, `reset_runtime_state()`; signal `rule_triggered(rule)` | Rule-builder UI, authored rule storage/edits, action queue mutation. Root executes matched consequences |
|
||||||
|
| `StageSpeechSession`, `scripts/editors/stage/stage_speech_session.gd` (`Node`) | Active composer/preview/toolbar session, bubble pointer routing, temporary popups | `open(actor, initial, context)`, `handle_input(event) -> bool`, `update(delta)`, `close(...)`, `is_open()`, `is_save_blocked()`; signals for commit/cancel, target capture and save-gate resolution | Draft file persistence and recovery identity, final queue/rule write, scene save, generic camera controls, mode transition |
|
||||||
|
| `StageSpeechDraftStore`, `scripts/editors/stage/stage_speech_draft_store.gd` (`RefCounted`) | Speech draft debounce and atomic file persistence, serialized payload conversion, canonical recovery origin and actor/queue identity validation | `configure(world, composer)`, `arm_draft_save()`, `update(delta)`, `capture_origin(...)`, `validate_origin(...)`, `read_draft_document()`, `persist_draft_now()`, `clear_draft_file()` | Preview/input routing, final queue/rule write, scene save decision, mode transition |
|
||||||
|
|
||||||
|
These contracts describe ownership, not a demand for one giant constructor. Small parameter objects are acceptable where they make call sites clearer, provided state still has one owner. The developer may split `StageChrome` and `StageSpeechSession` further if either remains unwieldy; keep each split aligned with a distinct state lifecycle.
|
||||||
|
|
||||||
|
## Ordered implementation slices
|
||||||
|
|
||||||
|
### 1. Spatial index and terrain painter
|
||||||
|
|
||||||
|
Extract `_world_to_grid_cell`, `_rasterize_aabb_to_cells`, `_rebuild_grid_cells`, `_add_node_to_grid_cells`, and all reads of `_grid_cells` into `StageSpatialIndex`. The index is advisory only. Its queries must return the same candidates and use the current edge-cell treatment. Director target validity currently reads this index, so route that read through its API before moving the painter.
|
||||||
|
|
||||||
|
Extract `_terrain_stride` through `_end_terrain_drag`, including `_bresenham_cells`, `_classify_cell`, and the multiple-ghost state, into `StageTerrainPainter`. Preserve the current single-click behavior, drag threshold in screen pixels, Shift axis constraint, three classification states, strict interior overlap, snap behavior, guide/ghost visuals, and duplicate-cell suppression. The stage receives each created node, finalizes it once, emits its existing `object_placed` signal once, then marks dirty/rebuilds navigation/index once per completed nonempty stroke. Cancellation must free ghosts and guide and rearm the single cursor ghost exactly as today. Keep old stage method names as small forwarding methods where callers need them.
|
||||||
|
|
||||||
|
### 2. Stage presentation
|
||||||
|
|
||||||
|
Move `_build_ui` and visual chrome update/style methods into `StageChrome` in sections, while the root connects its signals to existing handlers. Move the prop settings card into `StagePropSettingsView` using the shared Stage panel system. Keep the present control order, focus, popup anchoring, sizing, theme values, and mode-specific visibility. The root remains the only caller that changes modes or roles. Retain explicit references or forwarding accessors for tools that inspect `_file_menu`, `_selector_dim`, `_palette_buttons`, or other existing controls until those callers are migrated separately.
|
||||||
|
|
||||||
|
### 3. Document workflow and scene adapter
|
||||||
|
|
||||||
|
Separate the current `_collect_scene_data` / `_apply_scene_data` mapping from file workflow. `StageSceneAdapter` must preserve missing stickman/prop placeholders, stable file/runtime ID mapping, authored transforms, prop roles/material/depth, queues/rules, camera, warning messages, and post-load hooks. The root controls the reset/apply transaction: validate before destructive reset, suppress dirty during reconstruction, then rebuild the index/navigation and refresh visuals once. `StageDocumentController` owns file paths, recents, autosave/recovery metadata, safe writes via `StageSceneIO`, and CLI requests. Keep the existing speech-save gate before normal scene writes. Keep old `SandboxStage` save/load/collect/apply methods as facades for callers.
|
||||||
|
|
||||||
|
### 4. Runtime rule engine
|
||||||
|
|
||||||
|
Move event matching, edge tracking, and contact pair cleanup into `StageRuleEngine`. The root supplies its current authored rules to each dispatch rather than giving the engine a second mutable rule list; this also keeps existing callers that assign `_event_rules` directly working. The root controls whether PLAY dispatch is enabled. Reset all runtime pairs and area baselines on the same mode transitions as now, including pause/resume behavior. Emit a matched rule in stored order; the root keeps `_execute_rule_actions` and its existing queue semantics. Preserve rule IDs, source/target matching, waypoint tolerance, visible-AABB area detection, and one event per contact/entry until separation.
|
||||||
|
|
||||||
|
### 5. Speech session
|
||||||
|
|
||||||
|
Move speech state and handlers in two steps: first draft/recovery/origin logic, then preview/toolbar/pointer routing. `SpeechComposer` remains the authority for draft payloads; `StageSpeechSession` coordinates the stage-side session and preview without writing an authored action. Root handlers for `committed` apply one action to the existing direct/queue/rule destination. Preserve input suppression while composing, camera navigation where currently allowed, bubble overlap picker, placement, token selection, toolbar focus, draft recovery, the explicit Done/Cancel boundary, and deferred scene save. The root calls session input before ordinary stage input, preserving Escape and pointer priority.
|
||||||
|
|
||||||
|
### 6. Final root cleanup
|
||||||
|
|
||||||
|
Remove obsolete duplicate state and helpers after all callers use component APIs. Keep `SandboxStage` readable as a coordinator: lifecycle, top-level input dispatch, mode transitions, object finalization, cross-domain event handlers, and compatibility facades. Aim to reduce the root by more than half without creating another monolithic script; review any new component approaching roughly 1,000 lines for a natural second boundary. Do not change the root scene path or class name.
|
||||||
|
|
||||||
|
### 7. Director and authoring workflow consolidation (approved continuation)
|
||||||
|
|
||||||
|
The first extraction pass leaves the root at roughly 6,200 lines because DIRECT actions, target-capture presentation, queue/action editing, and authored rule-building still live together. Continue the refactor with these bounded components:
|
||||||
|
|
||||||
|
| Component and location | Owns | Root-facing contract | Explicitly outside its ownership |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `StageDirectorController`, `scripts/editors/stage/stage_director_controller.gd` (`Node`) | Direct-action popups and context menus, direct-move draft/style state, director hint text and overlay presentation inputs | `configure(...)`, `build_ui(...)`, `handle_direct_click(world_pos)`, `update_presentation(...)`; signals for queue-action, speech, capture, panel, toast, and status requests | Queue mutation, rule mutation, scene dirty state, mode transitions, world hit-testing priority |
|
||||||
|
| `StageRuleAuthoringController`, `scripts/editors/stage/stage_rule_authoring_controller.gd` (`RefCounted`) | Rule-builder draft/step state, trigger/action chooser presentation, RulePanel and RuleEditor presentation and local filters; target-result and parameter callbacks that update its draft | Typed begin/click/edit APIs that accept current rules or identifiers; emit explicit capture/editor requests; expose read-only state needed by director hints | Persistent `_event_rules` storage, rule IDs, world hit-testing, save gate, rule execution, authored rule commit/reorder/delete/clear, dirty marking |
|
||||||
|
| `StageActionQueueController`, `scripts/editors/stage/stage_action_queue_controller.gd` (`RefCounted`) | QueuePanel and ActionEditor presentation, action-editor context, reorder/edit workflow state, action parameter UI, queue delete/clear confirmation prompts, and action target request details | Typed `open_queue`, `open_editor`, and result APIs; signals for confirmation presentation, action commit, target capture, speech composition, and panel requests | `StickmanRig` queue writes, authored-world state, target hit-testing/capture ownership, scene save and dirty decisions |
|
||||||
|
|
||||||
|
The components may be split further by lifecycle if one approaches 1,000 lines. The root remains the sole owner of the authored rule array and action-queue writes. Components may return proposed values or emit typed requests; they must not retain a copied mutable rules/queue collection. Pass the current rules to a method when a view needs them. Keep stage input ordering in `SandboxStage`: it decides which workflow receives a world click, translates the event position, and applies the resulting request. Preserve current test-facing underscored methods and state through forwarding facades while callers are migrated.
|
||||||
|
|
||||||
|
The continuation target is a root below roughly half of the original ~7,600-line script, without transferring world mutation, mode ownership, or input priority to a component. This addendum authorizes the additional component files and the focused root wiring needed to reach that boundary; it does not authorize behavior or serialization changes.
|
||||||
|
|
||||||
|
### 8. Run lifecycle and authored snapshot (approved continuation)
|
||||||
|
|
||||||
|
`StageRunSession`, `scripts/editors/stage/stage_run_session.gd` (`RefCounted`), owns the mutable run-state fields, paused rigid-body snapshots, authored transform/role snapshots, deferred restore count, prop-scale trace history, paused-edit autosave countdown, and editable rigid-body transform targets. It receives the current selectable world nodes and `StageRuleEngine` through typed method arguments; it does not retain a stage or world reference. The root keeps mode transition ordering, timestamp/run-control presentation, rule-edge baseline coordination, contact reconciliation, signals, dirty marking, navigation work, and final scene reset ordering. Existing underscored state fields and authored-state methods stay as forwarding facades for current callers. The component may apply the run lifecycle operations directly to the supplied nodes, while the root remains responsible for cross-domain effects and the final transition decision.
|
||||||
|
|
||||||
|
This continuation narrows the original composition rule that listed authored object state as root-owned: the authored snapshot data now has one owner in `StageRunSession`, while `SandboxStage` remains its compatibility facade and the owner of the mode/world transaction. No scene fields, run timing, input order, or simulation semantics change.
|
||||||
|
|
||||||
|
### 9. Asset selector and thumbnail queue (approved continuation)
|
||||||
|
|
||||||
|
`StageAssetBrowser`, `scripts/editors/stage/stage_asset_browser.gd` (`Node`), owns stickman/prop library scans, selector open/refresh coordination, browse entry validation and dialog presentation, selector generation state, thumbnail cache/render helpers, and the asynchronous thumbnail queue. It receives the existing UI layer, fonts, and theme through `build_ui(...)`; it has no `SandboxStage` reference. It emits typed asset-selection, load-error, cancellation, and refresh requests. The root remains responsible for choosing the placement asset through `StageSpawner`, changing placement mode, and applying the refresh fallback selection. Existing underscored selector/library/cache fields and thumbnail queue methods remain forwarding facades while callers are migrated.
|
||||||
|
|
||||||
|
### 10. Speech draft persistence split (approved continuation)
|
||||||
|
|
||||||
|
`StageSpeechDraftStore`, `scripts/editors/stage/stage_speech_draft_store.gd` (`RefCounted`), owns the durable recovery draft lifecycle that previously enlarged `StageSpeechSession`: debounce state, atomic `.tmp`/`.bak` replacement, payload conversion, recovery availability checks, recovered bubble-text preparation, scene path origin, and actor/queue identity validation. It receives only `World` and the active composer in `configure(...)`, and does not retain `SandboxStage`. `StageSpeechSession` keeps typed forwarding methods and owns live composer, preview, pointer, and toolbar state. The root computes the live scene fingerprint, applies recovered editor/session state, and keeps save-gate and final action-commit decisions in `SandboxStage`.
|
||||||
|
|
||||||
|
### 11. Modal construction and presentation
|
||||||
|
|
||||||
|
`StageModalViews`, `scripts/editors/stage/stage_modal_views.gd` (`RefCounted`), owns creation and shared sizing/theme application for the stage's modal dialogs, authoring popups, and the overlapping-object picker. `SandboxStage` supplies the existing layer/theme/font inputs, keeps its dialog/control references as compatibility facades, and handles the selected object after the view emits it. The component does not hold a `SandboxStage` reference and does not decide save/load, mode, or authored-state actions.
|
||||||
|
|
||||||
|
### 12. Theme value loading
|
||||||
|
|
||||||
|
`StageThemeController`, `scripts/editors/stage/stage_theme_controller.gd` (`RefCounted`), owns theme file parsing, font resolution, color/font-size configuration, and theme defaults. Root theme fields remain forwarding properties for current callers. `SandboxStage` remains responsible for applying the resolved values to live stage visuals and controls.
|
||||||
|
|
||||||
|
### 13. Target capture and document workflow details
|
||||||
|
|
||||||
|
`StageTargetCaptureSession`, `scripts/editors/stage/stage_target_capture_session.gd` (`RefCounted`), owns capture kind/callback state and throw/set-down preview state and resolution helpers. `SandboxStage` retains stage input priority, cursor/status presentation, and final callback dispatch; legacy capture fields and method names remain forwarding facades.
|
||||||
|
|
||||||
|
`StageDocumentController` also owns recent-menu population/selection bookkeeping, scene-dialog signal wiring for open/save/rename/unsaved requests, and scene-save queue validation. It emits typed workflow requests; the root performs scene apply, rename dirty marking, pending-file-action decisions, confirmation execution, recent settings persistence, and the speech save gate before normal writes. Queue validation observes the supplied world and preserves the existing save error message and order semantics.
|
||||||
|
|
||||||
|
### 14. Authoring callback ownership refinement
|
||||||
|
|
||||||
|
`StageRuleAuthoringController` handles draft-only callbacks for rule target results, action parameters, and RuleEditor targets. `SandboxStage` still performs world hit-tests, starts/stops capture, and owns all persistent rule array changes, final commits, save-gate decisions, and dirty marking. Existing underscored root callbacks remain forwarding facades.
|
||||||
|
|
||||||
|
`StageActionQueueController` connects QueuePanel and ActionEditor UI signals internally, builds queue delete/clear confirmation requests, and resolves action-editor presentation state and target-capture requests. The root presents confirmation through the shared dialog and performs each confirmed queue write. For target capture, the root retains event priority and capture ownership; the controller supplies typed kind/hint/callback requests and receives the final target to update the editor.
|
||||||
|
|
||||||
|
`StageSpeechSession` connects the composer, preview, picker, placement/reset, and toolbar signals internally and emits its existing toast requests. The root keeps stage input routing and the final speech commit/save-gate boundary. Redundant private root forwarding callbacks with no project or test callsites may be removed once direct component wiring is verified; keep the root camera bridge, speech-open/save-gate access, cursor integration, final commit/cancel handlers, and any test-facing facades.
|
||||||
|
|
||||||
|
### 15. Remove superseded component callback wrappers
|
||||||
|
|
||||||
|
After the internal signal connections are in place, remove private root callbacks that only forward speech preview/picker/toolbar/placement signals, prop-card viewport/reopen signals, or ActionEditor target callbacks into their owning components. `StageSpeechSession` owns its UI signal routing; `StagePropSettingsView` owns reopen and viewport layout; and `StageActionQueueController` owns ActionEditor target and speech request handling. `SandboxStage` retains generic capture initiation/result handling, camera input routing, live speech cursor and save-gate checks, final speech commit/cancel decisions, prop role writes, and test-facing queue/rule facades. Remove only handlers with no connection or caller after checking project and test sources.
|
||||||
|
|
||||||
|
### 16. Move remaining view and recovery preparation
|
||||||
|
|
||||||
|
`StageModalViews` now builds the overlapping-object picker and emits the selected `Node2D`; the root validates that it still belongs to `World` before changing selection. `StageAssetBrowser` owns selector scanning/open/refresh setup and browse-file entry validation, while root placement-asset choice and fallback selection remain stage decisions. `StageSpeechDraftStore` computes whether a recovered draft is valid for the supplied scene identity and prepares empty recovered bubble text from a saved queue action. The root still computes the current scene fingerprint, applies recovered editor state, and decides when a matching draft is cleared.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Stage opens through `scenes/stage/sandbox_stage.tscn`, and CLI scene playback still loads/plays a `.scene` with the same errors for missing paths.
|
||||||
|
- EDIT placement, terrain click/drag, selection, prop settings, DIRECT queues/rules/captures, PLAY/pause/resume/reset, and speech composition behave as before. Input and cancellation priorities are unchanged.
|
||||||
|
- Saving and loading an existing scene yields the same schema, canonical paths, authored transforms, object order, placeholders, action queues, rules, metadata, and recovery behavior. No save migration is introduced.
|
||||||
|
- Each extracted component has one documented owner for its mutable state. The root does not keep a second mutable copy. Component-to-root communication uses typed APIs/signals, with no parent traversal or circular dependency.
|
||||||
|
- Existing stage-facing methods and fields used by current callers continue to work through facades during this refactor. Godot parses every changed script after each slice. The developer should perform focused manual stage checks for the slice before moving on; the final in-editor responsiveness must be judged in the Godot IDE, since splitting source files alone does not guarantee a specific performance gain.
|
||||||
|
|
||||||
|
## Handoff notes
|
||||||
|
|
||||||
|
Slices 1–16 have been delivered. The root retains world input priority, hit-testing and selection decisions, authored rule/queue writes, scene recovery application, and save/commit decisions to preserve ordering and existing stage-facing APIs. The original size target has not been reached; a further pass should first agree on whether any of those coordinator boundaries or compatibility facades may move. This specification authorizes architectural refactoring only; defer visual redesign, new features, rule semantics changes, and serialization changes.
|
||||||
+1634
-4243
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
|||||||
|
class_name StageActionQueueController
|
||||||
|
extends RefCounted
|
||||||
|
## Owns queue/action editor presentation and the local edit session context.
|
||||||
|
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||||
|
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||||
|
|
||||||
|
signal toast_requested(message: String)
|
||||||
|
signal confirmation_requested(title: String, message: String, callback: Callable)
|
||||||
|
signal queue_action_delete_commit_requested(rig: StickmanRig, action_id: int)
|
||||||
|
signal queue_clear_commit_requested(rig: StickmanRig)
|
||||||
|
signal action_commit_requested(action: Dictionary)
|
||||||
|
signal action_cancel_requested
|
||||||
|
signal capture_request_requested(
|
||||||
|
kind: StringName,
|
||||||
|
hint: String,
|
||||||
|
on_resolve: Callable,
|
||||||
|
on_cancel: Callable,
|
||||||
|
preview_rig: StickmanRig,
|
||||||
|
exclude_action_id: int,
|
||||||
|
use_queue_prefix: bool
|
||||||
|
)
|
||||||
|
signal speech_composition_requested(actor: StickmanRig, initial: Dictionary, reactive_options: bool, context: String)
|
||||||
|
signal speech_actor_capture_requested
|
||||||
|
|
||||||
|
var panel_rig: StickmanRig
|
||||||
|
var editor_kind: String = ""
|
||||||
|
var editor_index: int = -1
|
||||||
|
var editor_rig: StickmanRig
|
||||||
|
var editor_action_id: int = -1
|
||||||
|
var editor_target_action_id: int = -1
|
||||||
|
var editor_insert_after: bool = false
|
||||||
|
var editor_actor_id: int = -1
|
||||||
|
var pending_speech_initial: Dictionary = {}
|
||||||
|
var editor_restore_queue: bool = false
|
||||||
|
var waypoint_rig: StickmanRig
|
||||||
|
var waypoint_index: int = -1
|
||||||
|
var waypoint_action_id: int = -1
|
||||||
|
var waypoint_position: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
var _queue_panel: QueuePanel
|
||||||
|
var _action_editor: ActionEditor
|
||||||
|
var _rule_editor: RuleEditor
|
||||||
|
|
||||||
|
|
||||||
|
func configure(queue_panel: QueuePanel, action_editor: ActionEditor, rule_editor: RuleEditor) -> void:
|
||||||
|
_queue_panel = queue_panel
|
||||||
|
_action_editor = action_editor
|
||||||
|
_rule_editor = rule_editor
|
||||||
|
_connect_signal(_queue_panel, &"edit_requested", edit_queue_action)
|
||||||
|
_connect_signal(_queue_panel, &"delete_requested", _on_queue_delete_requested)
|
||||||
|
_connect_signal(_queue_panel, &"add_requested", add_queue_action)
|
||||||
|
_connect_signal(_queue_panel, &"clear_requested", _on_queue_clear_requested)
|
||||||
|
_connect_signal(_action_editor, &"committed", _on_action_committed)
|
||||||
|
_connect_signal(_action_editor, &"cancelled", _on_action_cancelled)
|
||||||
|
_connect_signal(_action_editor, &"target_requested", _on_target_requested)
|
||||||
|
_connect_signal(_action_editor, &"speech_requested", _on_speech_requested)
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_signal(source: Object, signal_name: StringName, callback: Callable) -> void:
|
||||||
|
if source != null and source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
|
||||||
|
source.connect(signal_name, callback)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_queue_delete_requested(index: int) -> void:
|
||||||
|
request_delete_action(index)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_queue_clear_requested() -> void:
|
||||||
|
request_clear_queue()
|
||||||
|
|
||||||
|
|
||||||
|
func request_delete_action(index: int) -> void:
|
||||||
|
if panel_rig == null or not is_instance_valid(panel_rig):
|
||||||
|
return
|
||||||
|
var action_id: int = panel_rig.action_id_at(index)
|
||||||
|
if action_id >= 0:
|
||||||
|
confirmation_requested.emit("Delete Action", "Delete this action?", Callable(self, "_confirm_delete_action").bind(panel_rig, action_id))
|
||||||
|
|
||||||
|
|
||||||
|
func _confirm_delete_action(rig: StickmanRig, action_id: int) -> void:
|
||||||
|
queue_action_delete_commit_requested.emit(rig, action_id)
|
||||||
|
|
||||||
|
|
||||||
|
func request_clear_queue() -> void:
|
||||||
|
if panel_rig == null or not is_instance_valid(panel_rig):
|
||||||
|
return
|
||||||
|
var target_rig: StickmanRig = panel_rig
|
||||||
|
confirmation_requested.emit(
|
||||||
|
"Clear Queue",
|
||||||
|
"Remove all actions for %s?" % target_rig.name,
|
||||||
|
Callable(self, "_confirm_clear_queue").bind(target_rig)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _confirm_clear_queue(rig: StickmanRig) -> void:
|
||||||
|
queue_clear_commit_requested.emit(rig)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_committed(action: Dictionary) -> void:
|
||||||
|
action_commit_requested.emit(action)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_cancelled() -> void:
|
||||||
|
action_cancel_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_target_requested() -> void:
|
||||||
|
request_action_target_capture()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_speech_requested(initial: Dictionary) -> void:
|
||||||
|
handle_speech_request(initial)
|
||||||
|
|
||||||
|
|
||||||
|
func handle_speech_request(initial: Dictionary) -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.hide()
|
||||||
|
var actor: StickmanRig = action_editor_preview_rig()
|
||||||
|
if (actor == null or not is_instance_valid(actor)) and editor_kind == "rule_add":
|
||||||
|
pending_speech_initial = initial.duplicate(true)
|
||||||
|
speech_actor_capture_requested.emit()
|
||||||
|
return
|
||||||
|
if actor == null or not is_instance_valid(actor):
|
||||||
|
toast_requested.emit("Choose a character before composing speech")
|
||||||
|
action_cancel_requested.emit()
|
||||||
|
return
|
||||||
|
speech_composition_requested.emit(actor, initial.duplicate(true), editor_kind in ["rule_add", "rule_edit"], "action_editor")
|
||||||
|
|
||||||
|
|
||||||
|
func accept_pending_speech_actor(value: Node2D) -> void:
|
||||||
|
if not value is StickmanRig:
|
||||||
|
cancel_pending_speech_actor()
|
||||||
|
return
|
||||||
|
var actor: StickmanRig = value as StickmanRig
|
||||||
|
set_editor_actor_id(int(actor.get_instance_id()))
|
||||||
|
var initial: Dictionary = pending_speech_initial.duplicate(true)
|
||||||
|
pending_speech_initial.clear()
|
||||||
|
speech_composition_requested.emit(actor, initial, true, "action_editor")
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_pending_speech_actor() -> void:
|
||||||
|
pending_speech_initial.clear()
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
|
||||||
|
var queue: Array[Dictionary] = rig.get_queue()
|
||||||
|
if index < 0 or index >= queue.size() or not ACTION_REGISTRY.is_move_action(queue[index]):
|
||||||
|
return
|
||||||
|
open_action_editor("queue_edit", queue[index].duplicate(true), index, rig, -1, from_panel)
|
||||||
|
|
||||||
|
|
||||||
|
func open_queue_panel(rig: StickmanRig) -> void:
|
||||||
|
panel_rig = rig
|
||||||
|
if _queue_panel == null:
|
||||||
|
return
|
||||||
|
_queue_panel.setup(rig)
|
||||||
|
_queue_panel.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func edit_queue_action(index: int) -> void:
|
||||||
|
if panel_rig == null or not is_instance_valid(panel_rig):
|
||||||
|
return
|
||||||
|
var queue: Array[Dictionary] = panel_rig.get_queue()
|
||||||
|
if index < 0 or index >= queue.size():
|
||||||
|
return
|
||||||
|
var action_id: int = panel_rig.action_id_at(index)
|
||||||
|
var action_status: int = panel_rig.get_action_status(action_id)
|
||||||
|
if action_status == ACTION_RUNNER.QueueStatus.COMPLETED or action_status == ACTION_RUNNER.QueueStatus.INTERRUPTED:
|
||||||
|
toast_requested.emit("Completed actions can only be deleted")
|
||||||
|
return
|
||||||
|
open_action_editor("queue_edit", queue[index].duplicate(true), index, panel_rig, -1, true)
|
||||||
|
|
||||||
|
|
||||||
|
func add_queue_action() -> void:
|
||||||
|
if panel_rig == null or not is_instance_valid(panel_rig):
|
||||||
|
return
|
||||||
|
open_action_editor("queue_add", {}, -1, panel_rig, -1, true)
|
||||||
|
|
||||||
|
|
||||||
|
func add_rule_action() -> void:
|
||||||
|
open_action_editor("rule_add", {}, -1, null, -1, false)
|
||||||
|
|
||||||
|
|
||||||
|
func edit_rule_action(index: int) -> void:
|
||||||
|
if _rule_editor == null:
|
||||||
|
return
|
||||||
|
var rule_action: Dictionary = _rule_editor.get_action(index)
|
||||||
|
if rule_action.is_empty():
|
||||||
|
return
|
||||||
|
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(rule_action)
|
||||||
|
var actor_id: int = int(rule_action.get("target", -1))
|
||||||
|
open_action_editor("rule_edit", flat, index, null, actor_id, false)
|
||||||
|
|
||||||
|
|
||||||
|
func open_action_editor(
|
||||||
|
kind: String,
|
||||||
|
flat: Dictionary,
|
||||||
|
index: int,
|
||||||
|
rig: StickmanRig,
|
||||||
|
actor_id: int,
|
||||||
|
hide_queue: bool,
|
||||||
|
target_action_id: int = -1,
|
||||||
|
insert_after: bool = false
|
||||||
|
) -> void:
|
||||||
|
editor_kind = kind
|
||||||
|
editor_index = index
|
||||||
|
editor_rig = rig
|
||||||
|
editor_action_id = rig.action_id_at(index) if kind == "queue_edit" and rig != null and is_instance_valid(rig) else -1
|
||||||
|
editor_target_action_id = target_action_id
|
||||||
|
editor_insert_after = insert_after
|
||||||
|
editor_actor_id = actor_id
|
||||||
|
editor_restore_queue = hide_queue
|
||||||
|
if hide_queue and _queue_panel != null:
|
||||||
|
_queue_panel.hide()
|
||||||
|
elif kind in ["rule_add", "rule_edit"] and _rule_editor != null:
|
||||||
|
_rule_editor.hide()
|
||||||
|
if _action_editor == null:
|
||||||
|
return
|
||||||
|
if flat.is_empty():
|
||||||
|
_action_editor.open_new(kind in ["rule_add", "rule_edit"])
|
||||||
|
else:
|
||||||
|
_action_editor.open_edit(flat, kind in ["rule_add", "rule_edit"])
|
||||||
|
|
||||||
|
|
||||||
|
func set_editor_actor_id(actor_id: int) -> void:
|
||||||
|
editor_actor_id = actor_id
|
||||||
|
|
||||||
|
|
||||||
|
func action_editor_preview_rig() -> StickmanRig:
|
||||||
|
if editor_rig != null and is_instance_valid(editor_rig):
|
||||||
|
return editor_rig
|
||||||
|
var actor: StickmanRig = instance_from_id(editor_actor_id) as StickmanRig
|
||||||
|
return actor if actor != null and is_instance_valid(actor) else null
|
||||||
|
|
||||||
|
|
||||||
|
func restore_queue_panel() -> void:
|
||||||
|
if editor_restore_queue and _queue_panel != null:
|
||||||
|
_queue_panel.refresh()
|
||||||
|
_queue_panel.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_action_editor() -> bool:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.hide()
|
||||||
|
match editor_kind:
|
||||||
|
"queue_add", "queue_edit":
|
||||||
|
restore_queue_panel()
|
||||||
|
"rule_add", "rule_edit":
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.popup_centered()
|
||||||
|
"rule_builder_move_to":
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func set_walk_target(position: Vector2) -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.set_walk_target(position)
|
||||||
|
|
||||||
|
|
||||||
|
func set_prop_target(stage_id: String) -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.set_prop_target(stage_id)
|
||||||
|
|
||||||
|
|
||||||
|
func set_throw_target(position: Vector2) -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.set_throw_target(position)
|
||||||
|
|
||||||
|
|
||||||
|
func set_down_target(position: Vector2) -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.set_down_target(position)
|
||||||
|
|
||||||
|
|
||||||
|
func show_action_editor() -> void:
|
||||||
|
if _action_editor != null:
|
||||||
|
_action_editor.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func request_action_target_capture() -> void:
|
||||||
|
if _action_editor == null:
|
||||||
|
return
|
||||||
|
_action_editor.hide()
|
||||||
|
var action_type: String = _action_editor.current_action_type()
|
||||||
|
if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop"]:
|
||||||
|
_emit_capture_request(
|
||||||
|
&"actor",
|
||||||
|
"🎯 Click the stickman who will act (Esc or RMB to cancel)",
|
||||||
|
Callable(self, "accept_action_actor"),
|
||||||
|
Callable(self, "cancel_target_capture")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
match action_type:
|
||||||
|
ACTION_REGISTRY.MOVE_TO:
|
||||||
|
var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk"
|
||||||
|
_emit_capture_request(&"position", "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture"))
|
||||||
|
"pick_up_prop":
|
||||||
|
_emit_capture_request(&"prop", "🫴 Click the prop to pick up (Esc or RMB to cancel)", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture"))
|
||||||
|
"throw_prop":
|
||||||
|
_emit_capture_request(&"throw_position", "🏹 Click where to throw (Esc or RMB to cancel)", Callable(self, "set_throw_target"), Callable(self, "cancel_target_capture"))
|
||||||
|
"set_down_prop":
|
||||||
|
_emit_capture_request(&"set_down_position", "Click where to let go (Esc or RMB to cancel)", Callable(self, "set_down_target"), Callable(self, "cancel_target_capture"))
|
||||||
|
_:
|
||||||
|
_emit_capture_request(&"position", "🚩 Click the target (Esc or RMB to cancel)", Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture"))
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_capture_request(kind: StringName, hint: String, on_resolve: Callable, on_cancel: Callable) -> void:
|
||||||
|
var preview_rig: StickmanRig = action_editor_preview_rig()
|
||||||
|
var use_queue_prefix: bool = editor_kind in ["queue_edit", "queue_add"]
|
||||||
|
var exclude_action_id: int = editor_action_id if editor_kind == "queue_edit" else -1
|
||||||
|
capture_request_requested.emit(kind, hint, on_resolve, on_cancel, preview_rig, exclude_action_id, use_queue_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
func accept_action_actor(value: Node2D) -> void:
|
||||||
|
if not value is StickmanRig:
|
||||||
|
cancel_target_capture()
|
||||||
|
return
|
||||||
|
set_editor_actor_id(int(value.get_instance_id()))
|
||||||
|
request_action_target_capture()
|
||||||
|
|
||||||
|
|
||||||
|
func accept_prop_target(value: Node2D) -> void:
|
||||||
|
if value is PropBlock and not (value as PropBlock).stage_object_id.is_empty():
|
||||||
|
set_prop_target((value as PropBlock).stage_object_id)
|
||||||
|
else:
|
||||||
|
show_action_editor()
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_target_capture() -> void:
|
||||||
|
show_action_editor()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://boninh5ktcxr
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
class_name StageAssetBrowser
|
||||||
|
extends Node
|
||||||
|
## Owns the stage asset selector, library scans, and thumbnail queue.
|
||||||
|
|
||||||
|
signal asset_selected_requested(entry: Dictionary)
|
||||||
|
signal asset_load_failed(kind: String, path: String)
|
||||||
|
signal selector_cancel_requested
|
||||||
|
signal refresh_requested
|
||||||
|
|
||||||
|
const ASSET_SELECTOR_SCENE := preload("res://scenes/stage/asset_selector.tscn")
|
||||||
|
const STICKMAN_LIBRARY_SCRIPT := preload("res://scripts/characters/stickman/stickman_library.gd")
|
||||||
|
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
|
||||||
|
const THUMBNAIL_CACHE_SCRIPT := preload("res://scripts/shared/thumbnails/thumbnail_cache.gd")
|
||||||
|
const STICKMAN_THUMBNAIL_SCRIPT := preload("res://scripts/shared/thumbnails/stickman_thumbnail.gd")
|
||||||
|
const PROP_THUMBNAIL_SCRIPT := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
|
||||||
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
|
|
||||||
|
const SELECTOR_DIM_ALPHA: float = 0.5
|
||||||
|
|
||||||
|
var stickman_library: StickmanLibrary
|
||||||
|
var thumbnail_cache: ThumbnailCache
|
||||||
|
var stickman_thumbnail: StickmanThumbnail
|
||||||
|
var prop_thumbnail: PropThumbnail
|
||||||
|
var selector: AssetSelector
|
||||||
|
var selector_open: bool = false
|
||||||
|
var selector_kind: String = ""
|
||||||
|
var selector_dim: ColorRect
|
||||||
|
var browse_dialog: FileDialog
|
||||||
|
var thumbnail_queue: Array[Dictionary] = []
|
||||||
|
var thumbnail_busy: bool = false
|
||||||
|
var thumbnail_generation: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
stickman_library = STICKMAN_LIBRARY_SCRIPT.new() as StickmanLibrary
|
||||||
|
thumbnail_cache = THUMBNAIL_CACHE_SCRIPT.new() as ThumbnailCache
|
||||||
|
stickman_thumbnail = STICKMAN_THUMBNAIL_SCRIPT.new() as StickmanThumbnail
|
||||||
|
prop_thumbnail = PROP_THUMBNAIL_SCRIPT.new() as PropThumbnail
|
||||||
|
add_child(stickman_thumbnail)
|
||||||
|
add_child(prop_thumbnail)
|
||||||
|
|
||||||
|
|
||||||
|
func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_theme: SandboxTheme) -> void:
|
||||||
|
selector_dim = ColorRect.new()
|
||||||
|
selector_dim.name = "SelectorDim"
|
||||||
|
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
|
||||||
|
selector_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
selector_dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
selector_dim.visible = false
|
||||||
|
ui_layer.add_child(selector_dim)
|
||||||
|
|
||||||
|
selector = ASSET_SELECTOR_SCENE.instantiate() as AssetSelector
|
||||||
|
selector.item_selected.connect(func(entry: Dictionary) -> void: asset_selected_requested.emit(entry))
|
||||||
|
selector.cancelled.connect(_on_selector_cancelled)
|
||||||
|
selector.popup_hide.connect(_on_selector_cancelled)
|
||||||
|
selector.browse_requested.connect(show_browse_dialog)
|
||||||
|
selector.refresh_requested.connect(func() -> void: refresh_requested.emit())
|
||||||
|
ui_layer.add_child(selector)
|
||||||
|
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
|
||||||
|
selector.apply_font(ui_font, emoji_font)
|
||||||
|
selector.apply_theme(sandbox_theme)
|
||||||
|
|
||||||
|
browse_dialog = FileDialog.new()
|
||||||
|
browse_dialog.title = "Open .stk"
|
||||||
|
browse_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||||
|
browse_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||||||
|
browse_dialog.current_dir = ProjectSettings.globalize_path(CONTENT_PATHS.STICKMEN_DIR)
|
||||||
|
browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
|
||||||
|
browse_dialog.file_selected.connect(_on_browse_file_selected)
|
||||||
|
ui_layer.add_child(browse_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
func scan_entries(kind: String) -> Array[Dictionary]:
|
||||||
|
if kind == "stickman":
|
||||||
|
return stickman_library.scan()
|
||||||
|
return PROP_LIBRARY_SCRIPT.scan()
|
||||||
|
|
||||||
|
|
||||||
|
func make_browse_entry(kind: String, path: String) -> Dictionary:
|
||||||
|
if kind == "prop":
|
||||||
|
return PROP_LIBRARY_SCRIPT.make_custom_entry(path)
|
||||||
|
return stickman_library.make_entry(path)
|
||||||
|
|
||||||
|
|
||||||
|
func open_selector(kind: String, current_stickman_path: String) -> Dictionary:
|
||||||
|
set_selector_kind(kind)
|
||||||
|
var entries: Array[Dictionary] = scan_entries(kind)
|
||||||
|
var selected_stickman_path: String = current_stickman_path
|
||||||
|
if kind == "stickman" and not entries.is_empty() and stickman_library.find_by_path(current_stickman_path).is_empty():
|
||||||
|
selected_stickman_path = String(entries[0].get("path", ""))
|
||||||
|
var auto_selection: Dictionary = entries[0] if kind == "stickman" and entries.size() == 1 else {}
|
||||||
|
if auto_selection.is_empty():
|
||||||
|
open(kind, entries)
|
||||||
|
return {
|
||||||
|
"entries": entries,
|
||||||
|
"selected_stickman_path": selected_stickman_path,
|
||||||
|
"auto_selection": auto_selection,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func set_selector_kind(kind: String) -> void:
|
||||||
|
selector_kind = kind
|
||||||
|
|
||||||
|
|
||||||
|
func open(kind: String, entries: Array[Dictionary]) -> void:
|
||||||
|
selector_kind = kind
|
||||||
|
selector_open = true
|
||||||
|
if selector_dim != null:
|
||||||
|
selector_dim.visible = true
|
||||||
|
if selector != null:
|
||||||
|
selector.open(kind, entries)
|
||||||
|
load_or_enqueue_thumbnails(kind, entries)
|
||||||
|
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
selector_open = false
|
||||||
|
selector_kind = ""
|
||||||
|
thumbnail_generation += 1
|
||||||
|
thumbnail_queue.clear()
|
||||||
|
if selector_dim != null:
|
||||||
|
selector_dim.visible = false
|
||||||
|
if selector != null:
|
||||||
|
selector.hide()
|
||||||
|
|
||||||
|
|
||||||
|
func show_browse_dialog() -> void:
|
||||||
|
if browse_dialog == null:
|
||||||
|
return
|
||||||
|
if selector_kind == "prop":
|
||||||
|
browse_dialog.title = "Open .prop"
|
||||||
|
browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
|
||||||
|
else:
|
||||||
|
browse_dialog.title = "Open .stk"
|
||||||
|
browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
|
||||||
|
browse_dialog.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func set_entries(entries: Array[Dictionary]) -> void:
|
||||||
|
if selector != null:
|
||||||
|
selector.set_entries(entries)
|
||||||
|
load_or_enqueue_thumbnails(selector_kind, entries)
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_selector_entries() -> Array[Dictionary]:
|
||||||
|
var entries: Array[Dictionary] = scan_entries(selector_kind)
|
||||||
|
set_entries(entries)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
func _on_browse_file_selected(path: String) -> void:
|
||||||
|
var entry: Dictionary = make_browse_entry(selector_kind, path)
|
||||||
|
if entry.is_empty():
|
||||||
|
asset_load_failed.emit(selector_kind, path)
|
||||||
|
return
|
||||||
|
asset_selected_requested.emit(entry)
|
||||||
|
|
||||||
|
|
||||||
|
func load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
|
||||||
|
thumbnail_queue.clear()
|
||||||
|
thumbnail_generation += 1
|
||||||
|
var generation: int = thumbnail_generation
|
||||||
|
for entry: Dictionary in entries:
|
||||||
|
if kind == "stickman":
|
||||||
|
var path: String = String(entry.get("path", ""))
|
||||||
|
var key: String = thumbnail_cache.stickman_key(path)
|
||||||
|
var png: String = thumbnail_cache.stickman_png(key)
|
||||||
|
var texture: Texture2D = thumbnail_cache.get_texture(png)
|
||||||
|
if texture != null:
|
||||||
|
if selector != null:
|
||||||
|
selector.set_thumbnail(entry, texture)
|
||||||
|
else:
|
||||||
|
thumbnail_queue.append({"kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation})
|
||||||
|
else:
|
||||||
|
var id: String = String(entry.get("id", ""))
|
||||||
|
var png: String = thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else thumbnail_cache.prop_png(id)
|
||||||
|
var texture: Texture2D = thumbnail_cache.get_texture(png)
|
||||||
|
if texture != null:
|
||||||
|
if selector != null:
|
||||||
|
selector.set_thumbnail(entry, texture)
|
||||||
|
else:
|
||||||
|
thumbnail_queue.append({"kind": kind, "entry": entry, "cache_path": png, "generation": generation})
|
||||||
|
|
||||||
|
|
||||||
|
func drain_thumbnail_queue() -> void:
|
||||||
|
if thumbnail_busy or thumbnail_queue.is_empty():
|
||||||
|
return
|
||||||
|
thumbnail_busy = true
|
||||||
|
var job: Dictionary = thumbnail_queue.pop_front()
|
||||||
|
var kind: String = String(job["kind"])
|
||||||
|
var entry: Dictionary = job["entry"]
|
||||||
|
var texture: Texture2D = null
|
||||||
|
if kind == "stickman":
|
||||||
|
var key: String = String(job["key"])
|
||||||
|
var data: Dictionary = entry["data"]
|
||||||
|
texture = await stickman_thumbnail.render(data)
|
||||||
|
if texture != null:
|
||||||
|
thumbnail_cache.store_texture(String(job.get("cache_path", thumbnail_cache.stickman_png(key))), texture)
|
||||||
|
else:
|
||||||
|
if bool(entry.get("is_custom", false)):
|
||||||
|
texture = await prop_thumbnail.render_shapes(entry.get("shapes", []) as Array[Dictionary])
|
||||||
|
else:
|
||||||
|
var payload: Dictionary = entry["payload"]
|
||||||
|
var preset: int = int(entry["material_preset"])
|
||||||
|
texture = await prop_thumbnail.render(payload, preset)
|
||||||
|
if texture != null:
|
||||||
|
var fallback: String = thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else thumbnail_cache.prop_png(String(entry["id"]))
|
||||||
|
thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), texture)
|
||||||
|
thumbnail_busy = false
|
||||||
|
if selector != null and selector_open and int(job.get("generation", -1)) == thumbnail_generation and texture != null:
|
||||||
|
selector.set_thumbnail(entry, texture)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_selector_cancelled() -> void:
|
||||||
|
if selector_open:
|
||||||
|
selector_cancel_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func get_selector() -> AssetSelector:
|
||||||
|
return selector
|
||||||
|
|
||||||
|
|
||||||
|
func get_browse_dialog() -> FileDialog:
|
||||||
|
return browse_dialog
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bpwukead70hk8
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
class_name StageChrome
|
||||||
|
extends CanvasLayer
|
||||||
|
## Shared stage presentation shell. Domain decisions are reported to SandboxStage.
|
||||||
|
|
||||||
|
const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd")
|
||||||
|
|
||||||
|
signal file_menu_requested(id: int)
|
||||||
|
signal recent_menu_requested
|
||||||
|
signal recent_scene_requested(index: int)
|
||||||
|
signal mode_toggled(pressed: bool, mode: int)
|
||||||
|
signal grid_toggled(pressed: bool)
|
||||||
|
signal snap_toggled(pressed: bool)
|
||||||
|
signal grid_size_changed(value: float)
|
||||||
|
signal palette_activated(payload: Dictionary)
|
||||||
|
signal palette_drag_started(payload: Dictionary)
|
||||||
|
signal pause_requested
|
||||||
|
signal resume_requested
|
||||||
|
signal reset_requested
|
||||||
|
|
||||||
|
const TOP_BAR_HEIGHT: float = 64.0
|
||||||
|
const STATUS_BAR_HEIGHT: float = 38.0
|
||||||
|
|
||||||
|
var _stage_theme: SandboxTheme
|
||||||
|
var _current_mode: int = 0
|
||||||
|
var _grid_size: float = 15.0
|
||||||
|
var _snap_enabled: bool = false
|
||||||
|
var _show_grid: bool = true
|
||||||
|
var _accent_edit: Color = Color("#22C6FF")
|
||||||
|
var _accent_direct: Color = Color("#FFB300")
|
||||||
|
var _accent_play: Color = Color("#33DD77")
|
||||||
|
var _status_pill_font_size: int = 16
|
||||||
|
var _tooltip_font_size: int = 18
|
||||||
|
|
||||||
|
var _mode_buttons: Dictionary = {}
|
||||||
|
var _palette_buttons: Dictionary = {}
|
||||||
|
var _palette_dock: PanelContainer
|
||||||
|
var _file_menu: MenuButton
|
||||||
|
var _status_label: Label
|
||||||
|
var _status_cursor_coords: Label
|
||||||
|
var _direct_hint_label: Label
|
||||||
|
var _mode_frame: Panel
|
||||||
|
var _mode_badge: PanelContainer
|
||||||
|
var _mode_badge_label: Label
|
||||||
|
var _timestamp_badge: PanelContainer
|
||||||
|
var _timestamp_label: Label
|
||||||
|
var _pause_button: Button
|
||||||
|
var _resume_button: Button
|
||||||
|
var _reset_button: Button
|
||||||
|
var _tooltip: Label
|
||||||
|
var _grid_check: CheckBox
|
||||||
|
var _snap_check: CheckBox
|
||||||
|
var _grid_size_spin: SpinBox
|
||||||
|
var _grid_size_label: Label
|
||||||
|
var _action_cursor: ImageTexture
|
||||||
|
|
||||||
|
|
||||||
|
func configure(
|
||||||
|
stage_theme: SandboxTheme,
|
||||||
|
initial_mode: int,
|
||||||
|
grid_size: float,
|
||||||
|
snap_enabled: bool,
|
||||||
|
show_grid: bool,
|
||||||
|
accent_edit: Color,
|
||||||
|
accent_direct: Color,
|
||||||
|
accent_play: Color,
|
||||||
|
status_pill_font_size: int,
|
||||||
|
tooltip_font_size: int
|
||||||
|
) -> void:
|
||||||
|
_stage_theme = stage_theme
|
||||||
|
_current_mode = initial_mode
|
||||||
|
_grid_size = grid_size
|
||||||
|
_snap_enabled = snap_enabled
|
||||||
|
_show_grid = show_grid
|
||||||
|
_accent_edit = accent_edit
|
||||||
|
_accent_direct = accent_direct
|
||||||
|
_accent_play = accent_play
|
||||||
|
_status_pill_font_size = status_pill_font_size
|
||||||
|
_tooltip_font_size = tooltip_font_size
|
||||||
|
|
||||||
|
|
||||||
|
func build(palette_entries: Array[Dictionary]) -> void:
|
||||||
|
_build_action_cursor()
|
||||||
|
_build_mode_frame()
|
||||||
|
_build_top_bar()
|
||||||
|
_build_palette(palette_entries)
|
||||||
|
_build_status_bar()
|
||||||
|
_build_mode_badge()
|
||||||
|
_build_run_controls()
|
||||||
|
_build_timestamp_badge()
|
||||||
|
_build_action_tooltip()
|
||||||
|
|
||||||
|
|
||||||
|
func get_canvas_layer() -> CanvasLayer:
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_file_menu() -> MenuButton:
|
||||||
|
return _file_menu
|
||||||
|
|
||||||
|
|
||||||
|
func get_mode_buttons() -> Dictionary:
|
||||||
|
return _mode_buttons
|
||||||
|
|
||||||
|
|
||||||
|
func get_palette_buttons() -> Dictionary:
|
||||||
|
return _palette_buttons
|
||||||
|
|
||||||
|
|
||||||
|
func get_palette_dock() -> PanelContainer:
|
||||||
|
return _palette_dock
|
||||||
|
|
||||||
|
|
||||||
|
func get_status_label() -> Label:
|
||||||
|
return _status_label
|
||||||
|
|
||||||
|
|
||||||
|
func get_status_cursor_coords() -> Label:
|
||||||
|
return _status_cursor_coords
|
||||||
|
|
||||||
|
|
||||||
|
func get_direct_hint_label() -> Label:
|
||||||
|
return _direct_hint_label
|
||||||
|
|
||||||
|
|
||||||
|
func get_mode_frame() -> Panel:
|
||||||
|
return _mode_frame
|
||||||
|
|
||||||
|
|
||||||
|
func get_mode_badge() -> PanelContainer:
|
||||||
|
return _mode_badge
|
||||||
|
|
||||||
|
|
||||||
|
func get_mode_badge_label() -> Label:
|
||||||
|
return _mode_badge_label
|
||||||
|
|
||||||
|
|
||||||
|
func get_timestamp_badge() -> PanelContainer:
|
||||||
|
return _timestamp_badge
|
||||||
|
|
||||||
|
|
||||||
|
func get_timestamp_label() -> Label:
|
||||||
|
return _timestamp_label
|
||||||
|
|
||||||
|
|
||||||
|
func get_pause_button() -> Button:
|
||||||
|
return _pause_button
|
||||||
|
|
||||||
|
|
||||||
|
func get_resume_button() -> Button:
|
||||||
|
return _resume_button
|
||||||
|
|
||||||
|
|
||||||
|
func get_reset_button() -> Button:
|
||||||
|
return _reset_button
|
||||||
|
|
||||||
|
|
||||||
|
func get_run_controls() -> HBoxContainer:
|
||||||
|
return get_node_or_null("RunControls") as HBoxContainer
|
||||||
|
|
||||||
|
|
||||||
|
func get_tooltip() -> Label:
|
||||||
|
return _tooltip
|
||||||
|
|
||||||
|
|
||||||
|
func get_grid_check() -> CheckBox:
|
||||||
|
return _grid_check
|
||||||
|
|
||||||
|
|
||||||
|
func get_snap_check() -> CheckBox:
|
||||||
|
return _snap_check
|
||||||
|
|
||||||
|
|
||||||
|
func get_grid_size_spin() -> SpinBox:
|
||||||
|
return _grid_size_spin
|
||||||
|
|
||||||
|
|
||||||
|
func get_grid_size_label() -> Label:
|
||||||
|
return _grid_size_label
|
||||||
|
|
||||||
|
|
||||||
|
func sync_mode_buttons(mode: int, accent: Color) -> void:
|
||||||
|
_current_mode = mode
|
||||||
|
for id: int in _mode_buttons:
|
||||||
|
var button: Button = _mode_buttons[id]
|
||||||
|
var active: bool = mode == id
|
||||||
|
button.set_pressed_no_signal(active)
|
||||||
|
if active:
|
||||||
|
button.add_theme_color_override("font_color", accent)
|
||||||
|
button.add_theme_color_override("font_pressed_color", accent)
|
||||||
|
button.add_theme_color_override("font_hover_color", accent)
|
||||||
|
else:
|
||||||
|
button.remove_theme_color_override("font_color")
|
||||||
|
button.remove_theme_color_override("font_pressed_color")
|
||||||
|
button.remove_theme_color_override("font_hover_color")
|
||||||
|
|
||||||
|
|
||||||
|
func apply_mode_frame(mode: int, accent: Color) -> void:
|
||||||
|
if _mode_frame == null:
|
||||||
|
return
|
||||||
|
update_layout(mode)
|
||||||
|
_mode_frame.visible = true
|
||||||
|
var style: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel") as StyleBoxFlat
|
||||||
|
if style != null:
|
||||||
|
style.border_color = accent
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_mode_badge(mode: int, accent: Color) -> void:
|
||||||
|
if _mode_badge == null or _mode_badge_label == null:
|
||||||
|
return
|
||||||
|
var text: String = "✏️ EDIT"
|
||||||
|
match mode:
|
||||||
|
1:
|
||||||
|
text = "🎬 DIRECTING"
|
||||||
|
2:
|
||||||
|
text = "▶️ SIMULATING"
|
||||||
|
_mode_badge_label.text = text
|
||||||
|
var style: StyleBoxFlat = _mode_badge.get_theme_stylebox("panel") as StyleBoxFlat
|
||||||
|
if style != null:
|
||||||
|
style.bg_color = accent
|
||||||
|
var text_color: Color = Color.BLACK if accent.get_luminance() > 0.5 else Color.WHITE
|
||||||
|
_mode_badge_label.add_theme_color_override("font_color", text_color)
|
||||||
|
_mode_badge.size = _mode_badge.get_combined_minimum_size()
|
||||||
|
|
||||||
|
|
||||||
|
func update_layout(mode: int) -> void:
|
||||||
|
if _mode_frame == null:
|
||||||
|
return
|
||||||
|
var palette_width: float = _theme_number("palette.dock_width", 116.0, 80.0, 260.0)
|
||||||
|
var left_inset: float = palette_width if mode == 0 else 0.0
|
||||||
|
_mode_frame.offset_left = left_inset
|
||||||
|
_mode_frame.offset_top = TOP_BAR_HEIGHT
|
||||||
|
_mode_frame.offset_right = 0.0
|
||||||
|
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
|
||||||
|
if _mode_badge != null:
|
||||||
|
_mode_badge.offset_left = left_inset + 12.0
|
||||||
|
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
|
||||||
|
|
||||||
|
|
||||||
|
func set_status(text_value: String) -> void:
|
||||||
|
if _status_label != null:
|
||||||
|
_status_label.text = text_value
|
||||||
|
|
||||||
|
|
||||||
|
func set_cursor_coords(text_value: String) -> void:
|
||||||
|
if _status_cursor_coords != null:
|
||||||
|
_status_cursor_coords.text = text_value
|
||||||
|
|
||||||
|
|
||||||
|
func set_scene_title(scene_name: String, scene_path: String, dirty: bool) -> void:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
|
var scene_file: String = scene_path.get_file()
|
||||||
|
if scene_file.is_empty():
|
||||||
|
scene_file = "Unsaved"
|
||||||
|
get_window().title = "%s%s — %s — Stickman Studio" % [scene_name, " *" if dirty else "", scene_file]
|
||||||
|
|
||||||
|
|
||||||
|
func set_timestamp(seconds: float) -> void:
|
||||||
|
if _timestamp_label == null:
|
||||||
|
return
|
||||||
|
var safe_seconds: float = maxf(seconds, 0.0)
|
||||||
|
var minutes: int = int(floor(safe_seconds / 60.0))
|
||||||
|
var remainder: float = fmod(safe_seconds, 60.0)
|
||||||
|
var centiseconds: int = int(floor(remainder * 100.0))
|
||||||
|
_timestamp_label.text = "⏱️ %02d:%02d.%02d" % [minutes, int(centiseconds / 100), centiseconds % 100]
|
||||||
|
|
||||||
|
|
||||||
|
func set_run_controls(pause_visible: bool, resume_visible: bool, reset_visible: bool) -> void:
|
||||||
|
if _pause_button != null:
|
||||||
|
_pause_button.visible = pause_visible
|
||||||
|
if _resume_button != null:
|
||||||
|
_resume_button.visible = resume_visible
|
||||||
|
if _reset_button != null:
|
||||||
|
_reset_button.visible = reset_visible
|
||||||
|
if _timestamp_badge != null:
|
||||||
|
_timestamp_badge.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func set_action_tooltip(visible: bool, text_value: String = "") -> void:
|
||||||
|
if _tooltip == null:
|
||||||
|
return
|
||||||
|
_tooltip.visible = visible
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
_tooltip.text = text_value
|
||||||
|
var style: StyleBox = _tooltip.get_theme_stylebox("normal")
|
||||||
|
var extra: Vector2 = style.get_minimum_size() if style != null else Vector2.ZERO
|
||||||
|
_tooltip.size = _tooltip.get_minimum_size() + extra
|
||||||
|
var mouse: Vector2 = get_viewport().get_mouse_position()
|
||||||
|
var size: Vector2 = _tooltip.size
|
||||||
|
var position: Vector2 = mouse + Vector2(20.0, -20.0)
|
||||||
|
var viewport_size: Vector2 = get_viewport().get_visible_rect().size
|
||||||
|
if position.x + size.x > viewport_size.x:
|
||||||
|
position.x = mouse.x - size.x - 20.0
|
||||||
|
if position.y + size.y > viewport_size.y:
|
||||||
|
position.y = mouse.y - size.y - 20.0
|
||||||
|
if position.y < 0.0:
|
||||||
|
position.y = mouse.y + 20.0
|
||||||
|
_tooltip.position = position
|
||||||
|
|
||||||
|
|
||||||
|
func set_edit_controls_visible(visible: bool) -> void:
|
||||||
|
for button: Button in _palette_buttons.values():
|
||||||
|
button.visible = visible
|
||||||
|
if _palette_dock != null:
|
||||||
|
_palette_dock.visible = visible
|
||||||
|
if _grid_check != null:
|
||||||
|
_grid_check.visible = visible
|
||||||
|
if _snap_check != null:
|
||||||
|
_snap_check.visible = visible
|
||||||
|
if _grid_size_spin != null:
|
||||||
|
_grid_size_spin.visible = visible
|
||||||
|
if _grid_size_label != null:
|
||||||
|
_grid_size_label.visible = visible
|
||||||
|
|
||||||
|
|
||||||
|
func set_direct_controls_visible(visible: bool) -> void:
|
||||||
|
if _direct_hint_label != null:
|
||||||
|
_direct_hint_label.visible = visible
|
||||||
|
|
||||||
|
|
||||||
|
func is_stage_frame_position(viewport_pos: Vector2) -> bool:
|
||||||
|
if _mode_frame == null:
|
||||||
|
return false
|
||||||
|
var viewport_size: Vector2 = get_window().size
|
||||||
|
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||||||
|
viewport_size = Vector2(1280.0, 720.0)
|
||||||
|
var left: float = _mode_frame.offset_left
|
||||||
|
var top: float = _mode_frame.offset_top
|
||||||
|
var right: float = maxf(left + 1.0, viewport_size.x)
|
||||||
|
var bottom: float = maxf(top + 1.0, viewport_size.y + _mode_frame.offset_bottom)
|
||||||
|
return is_point_in_stage_frame(viewport_pos, Rect2(Vector2(left, top), Vector2(right - left, bottom - top)))
|
||||||
|
|
||||||
|
|
||||||
|
func apply_cursor(mode: int, awaiting_click: bool, speech_badge_active: bool) -> void:
|
||||||
|
if awaiting_click:
|
||||||
|
apply_action_cursor()
|
||||||
|
return
|
||||||
|
Input.set_custom_mouse_cursor(null)
|
||||||
|
if speech_badge_active:
|
||||||
|
Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND)
|
||||||
|
elif mode == 2:
|
||||||
|
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
|
||||||
|
else:
|
||||||
|
Input.set_default_cursor_shape(Input.CURSOR_CROSS)
|
||||||
|
|
||||||
|
|
||||||
|
func apply_action_cursor() -> void:
|
||||||
|
if _action_cursor == null:
|
||||||
|
return
|
||||||
|
var hotspot: Vector2 = Vector2(_action_cursor.get_width() * 0.5, _action_cursor.get_height() * 0.5)
|
||||||
|
Input.set_custom_mouse_cursor(_action_cursor, Input.CURSOR_ARROW, hotspot)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_mode_frame() -> void:
|
||||||
|
_mode_frame = Panel.new()
|
||||||
|
_mode_frame.name = "ModeFrame"
|
||||||
|
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_mode_frame.offset_left = _theme_number("palette.dock_width", 116.0, 80.0, 260.0)
|
||||||
|
_mode_frame.offset_top = TOP_BAR_HEIGHT
|
||||||
|
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
|
||||||
|
var frame_style := StyleBoxFlat.new()
|
||||||
|
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
||||||
|
frame_style.border_color = _accent_direct
|
||||||
|
frame_style.set_border_width_all(3)
|
||||||
|
_mode_frame.add_theme_stylebox_override("panel", frame_style)
|
||||||
|
add_child(_mode_frame)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_top_bar() -> void:
|
||||||
|
var top_bar := PanelContainer.new()
|
||||||
|
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||||
|
top_bar.offset_bottom = TOP_BAR_HEIGHT
|
||||||
|
add_child(top_bar)
|
||||||
|
var chrome := Control.new()
|
||||||
|
chrome.name = "Content"
|
||||||
|
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
top_bar.add_child(chrome)
|
||||||
|
var left_group := HBoxContainer.new()
|
||||||
|
left_group.name = "LeftControls"
|
||||||
|
left_group.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
|
left_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||||
|
left_group.offset_left = 8.0
|
||||||
|
left_group.offset_top = 8.0
|
||||||
|
chrome.add_child(left_group)
|
||||||
|
var right_group := HBoxContainer.new()
|
||||||
|
right_group.name = "RightControls"
|
||||||
|
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
|
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
right_group.offset_right = -8.0
|
||||||
|
right_group.offset_top = 8.0
|
||||||
|
right_group.add_theme_constant_override("separation", 8)
|
||||||
|
chrome.add_child(right_group)
|
||||||
|
_build_file_menu(left_group)
|
||||||
|
_build_mode_switcher(chrome)
|
||||||
|
_build_grid_controls(right_group)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_file_menu(parent: HBoxContainer) -> void:
|
||||||
|
_file_menu = MenuButton.new()
|
||||||
|
_file_menu.name = "FileMenu"
|
||||||
|
_file_menu.text = "File"
|
||||||
|
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
|
||||||
|
_file_menu.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.file_menu", 22, 12, 48))
|
||||||
|
var file_popup: PopupMenu = _file_menu.get_popup()
|
||||||
|
var popup_font_size: int = _theme_integer("fonts.sizes.file_menu_popup", 26, 12, 64)
|
||||||
|
file_popup.add_theme_font_size_override("font_size", popup_font_size)
|
||||||
|
file_popup.add_item("New", 0)
|
||||||
|
file_popup.add_item("Open…", 1)
|
||||||
|
var recent_popup := PopupMenu.new()
|
||||||
|
recent_popup.name = "OpenRecent"
|
||||||
|
recent_popup.add_theme_font_size_override("font_size", popup_font_size)
|
||||||
|
recent_popup.id_pressed.connect(_on_recent_scene_requested)
|
||||||
|
file_popup.add_child(recent_popup)
|
||||||
|
file_popup.add_submenu_item("Open Recent", "OpenRecent")
|
||||||
|
file_popup.add_item("Save", 2)
|
||||||
|
file_popup.add_item("Save As…", 3)
|
||||||
|
file_popup.add_item("Rename Scene…", 4)
|
||||||
|
file_popup.id_pressed.connect(_on_file_menu_requested)
|
||||||
|
file_popup.about_to_popup.connect(_on_recent_menu_requested)
|
||||||
|
parent.add_child(_file_menu)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_mode_switcher(parent: Control) -> void:
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.name = "ModeCenter"
|
||||||
|
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||||
|
center.offset_bottom = TOP_BAR_HEIGHT
|
||||||
|
parent.add_child(center)
|
||||||
|
var switcher := HBoxContainer.new()
|
||||||
|
switcher.name = "ModeSwitcher"
|
||||||
|
switcher.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
|
switcher.add_theme_constant_override("separation", 4)
|
||||||
|
center.add_child(switcher)
|
||||||
|
var definitions: Array = [[0, "✏️ Edit"], [1, "🎬 Direct"], [2, "▶️ Play"]]
|
||||||
|
for definition: Array in definitions:
|
||||||
|
var mode: int = int(definition[0])
|
||||||
|
var button := Button.new()
|
||||||
|
button.text = String(definition[1])
|
||||||
|
button.toggle_mode = true
|
||||||
|
button.custom_minimum_size = Vector2(_theme_number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0), 48.0)
|
||||||
|
button.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.mode_switcher", 24, 12, 48))
|
||||||
|
button.toggled.connect(_on_mode_toggled.bind(mode))
|
||||||
|
switcher.add_child(button)
|
||||||
|
_mode_buttons[mode] = button
|
||||||
|
_direct_hint_label = Label.new()
|
||||||
|
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
|
||||||
|
_direct_hint_label.visible = false
|
||||||
|
_apply_ui_font(_direct_hint_label)
|
||||||
|
var right_group := parent.get_node_or_null(NodePath("RightControls")) as HBoxContainer
|
||||||
|
if right_group != null:
|
||||||
|
right_group.add_child(_direct_hint_label)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_grid_controls(parent: HBoxContainer) -> void:
|
||||||
|
_grid_check = CheckBox.new()
|
||||||
|
_grid_check.text = "Grid"
|
||||||
|
_grid_check.button_pressed = _show_grid
|
||||||
|
_grid_check.toggled.connect(_on_grid_toggled)
|
||||||
|
parent.add_child(_grid_check)
|
||||||
|
_snap_check = CheckBox.new()
|
||||||
|
_snap_check.text = "Snap"
|
||||||
|
_snap_check.button_pressed = _snap_enabled
|
||||||
|
_snap_check.toggled.connect(_on_snap_toggled)
|
||||||
|
parent.add_child(_snap_check)
|
||||||
|
_grid_size_label = Label.new()
|
||||||
|
_grid_size_label.text = "Size"
|
||||||
|
parent.add_child(_grid_size_label)
|
||||||
|
_grid_size_spin = SpinBox.new()
|
||||||
|
_grid_size_spin.min_value = 1.0
|
||||||
|
_grid_size_spin.max_value = 100.0
|
||||||
|
_grid_size_spin.step = 1.0
|
||||||
|
_grid_size_spin.value = _grid_size
|
||||||
|
_grid_size_spin.rounded = true
|
||||||
|
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
|
||||||
|
parent.add_child(_grid_size_spin)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_palette(entries: Array[Dictionary]) -> void:
|
||||||
|
_palette_dock = PanelContainer.new()
|
||||||
|
_palette_dock.name = "PaletteDock"
|
||||||
|
_palette_dock.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
|
||||||
|
_palette_dock.offset_top = TOP_BAR_HEIGHT
|
||||||
|
_palette_dock.offset_bottom = -STATUS_BAR_HEIGHT
|
||||||
|
_palette_dock.offset_right = _theme_number("palette.dock_width", 116.0, 80.0, 260.0)
|
||||||
|
if _stage_theme != null:
|
||||||
|
_palette_dock.add_theme_stylebox_override("panel", _stage_theme.stylebox("palette"))
|
||||||
|
var scroll := ScrollContainer.new()
|
||||||
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
_palette_dock.add_child(scroll)
|
||||||
|
var cards := VBoxContainer.new()
|
||||||
|
cards.name = "PaletteCards"
|
||||||
|
cards.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
scroll.add_child(cards)
|
||||||
|
for entry: Dictionary in entries:
|
||||||
|
var card: Variant = ASSET_CARD.new()
|
||||||
|
card.configure(entry, null, _stage_theme)
|
||||||
|
card.toggle_mode = true
|
||||||
|
card.activated.connect(_on_palette_activated)
|
||||||
|
card.drag_started.connect(_on_palette_drag_started)
|
||||||
|
cards.add_child(card)
|
||||||
|
_palette_buttons[String(entry["placement_id"])] = card
|
||||||
|
add_child(_palette_dock)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_status_bar() -> void:
|
||||||
|
var bottom_bar := PanelContainer.new()
|
||||||
|
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
|
bottom_bar.offset_top = -STATUS_BAR_HEIGHT
|
||||||
|
add_child(bottom_bar)
|
||||||
|
var status_row := HBoxContainer.new()
|
||||||
|
status_row.add_theme_constant_override("separation", 12)
|
||||||
|
bottom_bar.add_child(status_row)
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_apply_ui_font(_status_label)
|
||||||
|
status_row.add_child(_status_label)
|
||||||
|
_status_cursor_coords = Label.new()
|
||||||
|
_apply_ui_font(_status_cursor_coords)
|
||||||
|
status_row.add_child(_status_cursor_coords)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_mode_badge() -> void:
|
||||||
|
_mode_badge = PanelContainer.new()
|
||||||
|
_mode_badge.name = "ModeBadge"
|
||||||
|
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||||
|
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
|
||||||
|
_mode_badge.offset_left = _theme_number("palette.dock_width", 116.0, 80.0, 260.0) + 12.0
|
||||||
|
var badge_style := StyleBoxFlat.new()
|
||||||
|
badge_style.bg_color = _accent_edit
|
||||||
|
badge_style.set_corner_radius_all(12)
|
||||||
|
badge_style.content_margin_left = 12.0
|
||||||
|
badge_style.content_margin_right = 12.0
|
||||||
|
badge_style.content_margin_top = 4.0
|
||||||
|
badge_style.content_margin_bottom = 4.0
|
||||||
|
_mode_badge.add_theme_stylebox_override("panel", badge_style)
|
||||||
|
_mode_badge_label = Label.new()
|
||||||
|
_mode_badge_label.add_theme_font_size_override("font_size", _status_pill_font_size)
|
||||||
|
_apply_ui_font(_mode_badge_label)
|
||||||
|
_mode_badge.add_child(_mode_badge_label)
|
||||||
|
add_child(_mode_badge)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_run_controls() -> void:
|
||||||
|
var controls := HBoxContainer.new()
|
||||||
|
controls.name = "RunControls"
|
||||||
|
controls.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
controls.offset_top = TOP_BAR_HEIGHT + 8.0
|
||||||
|
controls.offset_right = -12.0
|
||||||
|
controls.add_theme_constant_override("separation", 6)
|
||||||
|
add_child(controls)
|
||||||
|
_pause_button = _make_run_button("⏸️ Pause", 0)
|
||||||
|
_resume_button = _make_run_button("▶️ Resume", 1)
|
||||||
|
_reset_button = _make_run_button("↺ Reset", 2)
|
||||||
|
controls.add_child(_pause_button)
|
||||||
|
controls.add_child(_resume_button)
|
||||||
|
controls.add_child(_reset_button)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_run_button(label: String, request_kind: int) -> Button:
|
||||||
|
var button := Button.new()
|
||||||
|
button.text = label
|
||||||
|
button.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.mode_switcher", 24, 12, 48))
|
||||||
|
button.pressed.connect(_on_run_button_pressed.bind(request_kind))
|
||||||
|
return button
|
||||||
|
|
||||||
|
|
||||||
|
func _on_run_button_pressed(request_kind: int) -> void:
|
||||||
|
match request_kind:
|
||||||
|
0:
|
||||||
|
pause_requested.emit()
|
||||||
|
1:
|
||||||
|
resume_requested.emit()
|
||||||
|
2:
|
||||||
|
reset_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_timestamp_badge() -> void:
|
||||||
|
_timestamp_badge = PanelContainer.new()
|
||||||
|
_timestamp_badge.name = "TimestampBadge"
|
||||||
|
_timestamp_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
_timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_timestamp_badge.offset_left = -220.0
|
||||||
|
_timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0
|
||||||
|
_timestamp_badge.offset_right = -12.0
|
||||||
|
_timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = _theme_color("chrome.timestamp_badge.bg_color", Color("#171B24F2"))
|
||||||
|
style.set_corner_radius_all(_theme_integer("chrome.timestamp_badge.corner_radius", 10, 0, 48))
|
||||||
|
var padding: float = float(_theme_integer("chrome.timestamp_badge.padding", 8, 0, 48))
|
||||||
|
style.content_margin_left = padding
|
||||||
|
style.content_margin_right = padding
|
||||||
|
style.content_margin_top = padding * 0.5
|
||||||
|
style.content_margin_bottom = padding * 0.5
|
||||||
|
_timestamp_badge.add_theme_stylebox_override("panel", style)
|
||||||
|
_timestamp_label = Label.new()
|
||||||
|
_timestamp_label.add_theme_font_size_override("font_size", _theme_integer("fonts.sizes.timestamp_badge", 22, 12, 48))
|
||||||
|
_timestamp_label.add_theme_color_override("font_color", _theme_color("global.text_color", Color("#F5F7FA")))
|
||||||
|
_timestamp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_timestamp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_timestamp_label.custom_minimum_size = Vector2(192.0, 30.0)
|
||||||
|
_apply_ui_font(_timestamp_label)
|
||||||
|
_timestamp_badge.add_child(_timestamp_label)
|
||||||
|
add_child(_timestamp_badge)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_action_tooltip() -> void:
|
||||||
|
_tooltip = Label.new()
|
||||||
|
_tooltip.name = "ActionTooltip"
|
||||||
|
_tooltip.visible = false
|
||||||
|
_tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.1, 0.1, 0.1, 0.9)
|
||||||
|
style.border_color = _accent_direct
|
||||||
|
style.set_border_width_all(1)
|
||||||
|
style.set_corner_radius_all(8)
|
||||||
|
style.content_margin_left = 10.0
|
||||||
|
style.content_margin_right = 10.0
|
||||||
|
style.content_margin_top = 4.0
|
||||||
|
style.content_margin_bottom = 4.0
|
||||||
|
_tooltip.add_theme_stylebox_override("normal", style)
|
||||||
|
_tooltip.add_theme_font_size_override("font_size", _tooltip_font_size)
|
||||||
|
_tooltip.add_theme_color_override("font_color", Color.WHITE)
|
||||||
|
_apply_ui_font(_tooltip)
|
||||||
|
add_child(_tooltip)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_action_cursor() -> void:
|
||||||
|
var size: int = 24
|
||||||
|
var image: Image = Image.create(size, size, false, Image.FORMAT_RGBA8)
|
||||||
|
image.fill(Color(0.0, 0.0, 0.0, 0.0))
|
||||||
|
var color: Color = Color(1.0, 0.7, 0.1, 1.0)
|
||||||
|
var center: Vector2 = Vector2(size, size) * 0.5
|
||||||
|
var radius: float = float(size) * 0.4
|
||||||
|
for y: int in size:
|
||||||
|
for x: int in size:
|
||||||
|
var point: Vector2 = Vector2(x, y) + Vector2(0.5, 0.5)
|
||||||
|
var distance: float = point.distance_to(center)
|
||||||
|
if absf(distance - radius) <= 1.5:
|
||||||
|
image.set_pixel(x, y, color)
|
||||||
|
if absf(point.x - center.x) <= 1.0 and point.y >= center.y - radius and point.y <= center.y + radius:
|
||||||
|
image.set_pixel(x, y, color)
|
||||||
|
if absf(point.y - center.y) <= 1.0 and point.x >= center.x - radius and point.x <= center.x + radius:
|
||||||
|
image.set_pixel(x, y, color)
|
||||||
|
_action_cursor = ImageTexture.create_from_image(image)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_ui_font(control: Control) -> void:
|
||||||
|
if control == null or _stage_theme == null:
|
||||||
|
return
|
||||||
|
var font: Font = _stage_theme.font()
|
||||||
|
if font != null:
|
||||||
|
control.add_theme_font_override("font", font)
|
||||||
|
|
||||||
|
|
||||||
|
func _theme_number(key: String, fallback: float, minimum: float, maximum: float) -> float:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.number(key, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _theme_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.integer(key, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _theme_color(key: String, fallback: Color) -> Color:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.color(key, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_file_menu_requested(id: int) -> void:
|
||||||
|
file_menu_requested.emit(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_recent_menu_requested() -> void:
|
||||||
|
recent_menu_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_recent_scene_requested(index: int) -> void:
|
||||||
|
recent_scene_requested.emit(index)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_mode_toggled(pressed: bool, mode: int) -> void:
|
||||||
|
mode_toggled.emit(pressed, mode)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_grid_toggled(pressed: bool) -> void:
|
||||||
|
grid_toggled.emit(pressed)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_snap_toggled(pressed: bool) -> void:
|
||||||
|
snap_toggled.emit(pressed)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_grid_size_changed(value: float) -> void:
|
||||||
|
grid_size_changed.emit(value)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_palette_activated(payload: Dictionary) -> void:
|
||||||
|
palette_activated.emit(payload)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_palette_drag_started(payload: Dictionary) -> void:
|
||||||
|
palette_drag_started.emit(payload)
|
||||||
|
|
||||||
|
|
||||||
|
static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool:
|
||||||
|
return frame_rect.abs().has_point(viewport_pos)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://doh7myy4tevx4
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
class_name StageDirectorController
|
||||||
|
extends Node
|
||||||
|
## Owns direct-action UI state and director hint/trajectory presentation.
|
||||||
|
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||||
|
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||||
|
const STICKMAN_LOCOMOTION := preload("res://scripts/characters/stickman/stickman_locomotion.gd")
|
||||||
|
const RULE_STEP_TRIGGER_TARGET: int = StageRuleAuthoringController.Step.TRIGGER_TARGET
|
||||||
|
const RULE_STEP_ACTION_TARGET: int = StageRuleAuthoringController.Step.ACTION_TARGET
|
||||||
|
const RULE_STEP_ACTION_POSITION: int = StageRuleAuthoringController.Step.ACTION_POSITION
|
||||||
|
const RULE_STEP_IDLE: int = StageRuleAuthoringController.Step.IDLE
|
||||||
|
const DIRECT_MOVE_POPUP_SIZE: Vector2i = Vector2i(380, 172)
|
||||||
|
|
||||||
|
signal toast_requested(message: String)
|
||||||
|
signal direct_move_commit_requested(rig: StickmanRig, action: Dictionary, style: String)
|
||||||
|
signal direct_action_requested(id: int)
|
||||||
|
signal action_popup_opening
|
||||||
|
signal state_changed
|
||||||
|
|
||||||
|
var context_rig: StickmanRig
|
||||||
|
var pending_walk_target: bool = false
|
||||||
|
var direct_move_target: Vector2 = Vector2.INF
|
||||||
|
var direct_move_last_style: Dictionary = {}
|
||||||
|
var _direct_move_style_ids: Array[String] = []
|
||||||
|
|
||||||
|
var _camera: Camera2D
|
||||||
|
var _viewport: Viewport
|
||||||
|
var _spatial_index: StageSpatialIndex
|
||||||
|
var _placement_overlay: StagePlacementOverlay
|
||||||
|
var _chrome: StageChrome
|
||||||
|
var _rule_authoring: StageRuleAuthoringController
|
||||||
|
var _action_queue: StageActionQueueController
|
||||||
|
var _action_popup: PopupMenu
|
||||||
|
var _direct_move_popup: PopupPanel
|
||||||
|
var _direct_move_style_option: OptionButton
|
||||||
|
var _sandbox_theme: SandboxTheme
|
||||||
|
var _ui_font: Font
|
||||||
|
var _emoji_font: Font
|
||||||
|
|
||||||
|
|
||||||
|
func configure(
|
||||||
|
camera: Camera2D,
|
||||||
|
viewport: Viewport,
|
||||||
|
spatial_index: StageSpatialIndex,
|
||||||
|
placement_overlay: StagePlacementOverlay,
|
||||||
|
chrome: StageChrome,
|
||||||
|
rule_authoring: StageRuleAuthoringController,
|
||||||
|
action_queue: StageActionQueueController
|
||||||
|
) -> void:
|
||||||
|
_camera = camera
|
||||||
|
_viewport = viewport
|
||||||
|
_spatial_index = spatial_index
|
||||||
|
_placement_overlay = placement_overlay
|
||||||
|
_chrome = chrome
|
||||||
|
_rule_authoring = rule_authoring
|
||||||
|
_action_queue = action_queue
|
||||||
|
|
||||||
|
|
||||||
|
func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_font: Font, action_ids: Dictionary) -> void:
|
||||||
|
_sandbox_theme = sandbox_theme
|
||||||
|
_ui_font = ui_font
|
||||||
|
_emoji_font = emoji_font
|
||||||
|
_action_popup = PopupMenu.new()
|
||||||
|
_action_popup.name = "DirectActionPopup"
|
||||||
|
_action_popup.add_item("🚩 Move To", int(action_ids.get("walk", 0)))
|
||||||
|
_action_popup.add_item("💬 Speak", int(action_ids.get("speak", 1)))
|
||||||
|
_action_popup.add_item("⏳ Wait", int(action_ids.get("wait", 2)))
|
||||||
|
_action_popup.add_item("💥 Ragdoll", int(action_ids.get("ragdoll", 3)))
|
||||||
|
_action_popup.add_item("🔄 Recover", int(action_ids.get("recover", 4)))
|
||||||
|
_action_popup.add_item("🫴 Pick Up Prop…", int(action_ids.get("pick_up_prop", 9)))
|
||||||
|
_action_popup.add_item("⬇️ Drop Prop", int(action_ids.get("drop_prop", 10)))
|
||||||
|
_action_popup.add_item("🏹 Throw Prop…", int(action_ids.get("throw_prop", 11)))
|
||||||
|
_action_popup.add_item("🫳 Set Down…", int(action_ids.get("set_down_prop", 12)))
|
||||||
|
_action_popup.add_separator()
|
||||||
|
_action_popup.add_item("⚡ When...", int(action_ids.get("when", 5)))
|
||||||
|
_action_popup.add_separator()
|
||||||
|
_action_popup.add_item("📋 Edit Queue…", int(action_ids.get("edit_queue", 7)))
|
||||||
|
_action_popup.add_item("⚡ Edit Rules…", int(action_ids.get("edit_rules", 8)))
|
||||||
|
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
|
||||||
|
_action_popup.about_to_popup.connect(_on_action_popup_about_to_popup)
|
||||||
|
parent.add_child(_action_popup)
|
||||||
|
|
||||||
|
_direct_move_popup = PopupPanel.new()
|
||||||
|
_direct_move_popup.name = "DirectMovePopup"
|
||||||
|
_direct_move_popup.exclusive = true
|
||||||
|
_direct_move_popup.popup_window = true
|
||||||
|
_direct_move_popup.min_size = DIRECT_MOVE_POPUP_SIZE
|
||||||
|
if _sandbox_theme != null:
|
||||||
|
_direct_move_popup.theme = _sandbox_theme.control_theme()
|
||||||
|
parent.add_child(_direct_move_popup)
|
||||||
|
var move_margin: MarginContainer = MarginContainer.new()
|
||||||
|
move_margin.add_theme_constant_override("margin_left", 14)
|
||||||
|
move_margin.add_theme_constant_override("margin_right", 14)
|
||||||
|
move_margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
move_margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
_direct_move_popup.add_child(move_margin)
|
||||||
|
var move_column: VBoxContainer = VBoxContainer.new()
|
||||||
|
move_column.add_theme_constant_override("separation", 10)
|
||||||
|
move_margin.add_child(move_column)
|
||||||
|
var move_row: HBoxContainer = HBoxContainer.new()
|
||||||
|
move_row.add_theme_constant_override("separation", 12)
|
||||||
|
move_column.add_child(move_row)
|
||||||
|
var move_label: Label = Label.new()
|
||||||
|
move_label.text = "Movement"
|
||||||
|
_apply_ui_font(move_label)
|
||||||
|
move_label.add_theme_font_size_override("font_size", 23)
|
||||||
|
move_row.add_child(move_label)
|
||||||
|
_direct_move_style_option = OptionButton.new()
|
||||||
|
_direct_move_style_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_direct_move_style_option.custom_minimum_size = Vector2(210.0, 54.0)
|
||||||
|
_direct_move_style_option.add_theme_font_size_override("font_size", 25)
|
||||||
|
if _emoji_font != null:
|
||||||
|
_direct_move_style_option.add_theme_font_override("font", _emoji_font)
|
||||||
|
else:
|
||||||
|
_apply_ui_font(_direct_move_style_option)
|
||||||
|
move_row.add_child(_direct_move_style_option)
|
||||||
|
var move_buttons: HBoxContainer = HBoxContainer.new()
|
||||||
|
move_buttons.alignment = BoxContainer.ALIGNMENT_END
|
||||||
|
move_buttons.add_theme_constant_override("separation", 8)
|
||||||
|
move_column.add_child(move_buttons)
|
||||||
|
var move_cancel: Button = Button.new()
|
||||||
|
move_cancel.text = "Cancel"
|
||||||
|
move_cancel.custom_minimum_size = Vector2(112.0, 52.0)
|
||||||
|
move_cancel.pressed.connect(cancel_direct_move_draft)
|
||||||
|
_apply_ui_font(move_cancel)
|
||||||
|
move_cancel.add_theme_font_size_override("font_size", 22)
|
||||||
|
move_buttons.add_child(move_cancel)
|
||||||
|
var move_add: Button = Button.new()
|
||||||
|
move_add.text = "Add Move To"
|
||||||
|
move_add.custom_minimum_size = Vector2(166.0, 52.0)
|
||||||
|
move_add.pressed.connect(commit_direct_move_draft)
|
||||||
|
_apply_ui_font(move_add)
|
||||||
|
move_add.add_theme_font_size_override("font_size", 22)
|
||||||
|
move_buttons.add_child(move_add)
|
||||||
|
_direct_move_popup.popup_hide.connect(_on_direct_move_popup_hidden)
|
||||||
|
_direct_move_popup.window_input.connect(_on_direct_move_popup_input)
|
||||||
|
populate_direct_move_styles()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_ui_font(control: Control) -> void:
|
||||||
|
if control != null and _ui_font != null:
|
||||||
|
control.add_theme_font_override("font", _ui_font)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_popup_id_pressed(id: int) -> void:
|
||||||
|
direct_action_requested.emit(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_popup_about_to_popup() -> void:
|
||||||
|
action_popup_opening.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_direct_move_popup_hidden() -> void:
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_direct_move_popup_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
|
||||||
|
|
||||||
|
func get_action_popup() -> PopupMenu:
|
||||||
|
return _action_popup
|
||||||
|
|
||||||
|
|
||||||
|
func get_direct_move_popup() -> PopupPanel:
|
||||||
|
return _direct_move_popup
|
||||||
|
|
||||||
|
|
||||||
|
func get_direct_move_style_option() -> OptionButton:
|
||||||
|
return _direct_move_style_option
|
||||||
|
|
||||||
|
|
||||||
|
func get_direct_move_style_ids() -> Array[String]:
|
||||||
|
return _direct_move_style_ids
|
||||||
|
|
||||||
|
|
||||||
|
func get_direct_move_last_style() -> Dictionary:
|
||||||
|
return direct_move_last_style
|
||||||
|
|
||||||
|
|
||||||
|
func handle_direct_click(world_pos: Vector2, hit: Node2D, target_valid: bool, hit_screen_pos: Vector2) -> void:
|
||||||
|
if pending_walk_target:
|
||||||
|
if context_rig == null or not is_instance_valid(context_rig):
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
return
|
||||||
|
if not target_valid:
|
||||||
|
toast_requested.emit("Choose an open place for Move To")
|
||||||
|
return
|
||||||
|
direct_move_target = world_pos
|
||||||
|
pending_walk_target = false
|
||||||
|
show_direct_move_popup(hit_screen_pos)
|
||||||
|
state_changed.emit()
|
||||||
|
return
|
||||||
|
if direct_move_target.is_finite() or not hit is StickmanRig:
|
||||||
|
return
|
||||||
|
context_rig = hit as StickmanRig
|
||||||
|
var screen_pos: Vector2 = _world_to_screen(context_rig.global_position)
|
||||||
|
var popup_rect: Rect2i = Rect2i(Vector2i(screen_pos) + Vector2i(24, 0), Vector2i.ZERO)
|
||||||
|
if _rule_authoring != null:
|
||||||
|
_rule_authoring.set_popup_anchor(popup_rect)
|
||||||
|
if _action_popup != null:
|
||||||
|
_action_popup.popup(popup_rect)
|
||||||
|
|
||||||
|
|
||||||
|
func _world_to_screen(world_pos: Vector2) -> Vector2:
|
||||||
|
if _camera == null or _viewport == null:
|
||||||
|
return Vector2.ZERO
|
||||||
|
var viewport_size: Vector2 = _viewport.get_visible_rect().size
|
||||||
|
return (world_pos - _camera.position) * _camera.zoom + viewport_size * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
func begin_walk_target() -> void:
|
||||||
|
direct_move_target = Vector2.INF
|
||||||
|
pending_walk_target = true
|
||||||
|
state_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func populate_direct_move_styles() -> void:
|
||||||
|
_direct_move_style_ids.clear()
|
||||||
|
if _direct_move_style_option == null:
|
||||||
|
return
|
||||||
|
_direct_move_style_option.clear()
|
||||||
|
var template: Dictionary = ACTION_REGISTRY.ACTION_TEMPLATES.get(ACTION_REGISTRY.MOVE_TO, {})
|
||||||
|
var params: Array = template.get("params", [])
|
||||||
|
for entry: Variant in params:
|
||||||
|
if not entry is Dictionary:
|
||||||
|
continue
|
||||||
|
var param: Dictionary = entry
|
||||||
|
if String(param.get("key", "")) != "style":
|
||||||
|
continue
|
||||||
|
var choices: Array = param.get("choices", [])
|
||||||
|
for raw_style: Variant in choices:
|
||||||
|
var style: String = String(raw_style)
|
||||||
|
if style.is_empty() or _direct_move_style_ids.has(style):
|
||||||
|
continue
|
||||||
|
_direct_move_style_ids.append(style)
|
||||||
|
var icon: String = "🚶 " if style == ACTION_REGISTRY.WALK else ("🏃 " if style == ACTION_REGISTRY.RUN else "")
|
||||||
|
_direct_move_style_option.add_item(icon + style.capitalize())
|
||||||
|
break
|
||||||
|
if _direct_move_style_ids.is_empty():
|
||||||
|
_direct_move_style_ids.append(ACTION_REGISTRY.WALK)
|
||||||
|
_direct_move_style_option.add_item("🚶 Walk")
|
||||||
|
|
||||||
|
|
||||||
|
func show_direct_move_popup(screen_pos: Vector2) -> void:
|
||||||
|
if _direct_move_popup == null or _direct_move_style_option == null:
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
return
|
||||||
|
if context_rig == null or not is_instance_valid(context_rig):
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
return
|
||||||
|
var rig_id: int = context_rig.get_instance_id()
|
||||||
|
var preferred: String = String(direct_move_last_style.get(rig_id, ACTION_REGISTRY.WALK))
|
||||||
|
var index: int = _direct_move_style_ids.find(preferred)
|
||||||
|
_direct_move_style_option.select(index if index >= 0 else 0)
|
||||||
|
var viewport_size: Vector2 = _viewport.get_visible_rect().size if _viewport != null else Vector2.ZERO
|
||||||
|
var popup_size: Vector2i = DIRECT_MOVE_POPUP_SIZE
|
||||||
|
_direct_move_popup.size = popup_size
|
||||||
|
var popup_pos: Vector2i = Vector2i(screen_pos + Vector2(18.0, 12.0))
|
||||||
|
popup_pos.x = clampi(popup_pos.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
|
||||||
|
popup_pos.y = clampi(popup_pos.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
|
||||||
|
_direct_move_popup.position = popup_pos
|
||||||
|
_direct_move_popup.popup()
|
||||||
|
_direct_move_style_option.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
|
func commit_direct_move_draft() -> void:
|
||||||
|
if not direct_move_target.is_finite() or context_rig == null or not is_instance_valid(context_rig):
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
return
|
||||||
|
if _direct_move_style_option == null:
|
||||||
|
return
|
||||||
|
var index: int = _direct_move_style_option.selected
|
||||||
|
if index < 0 or index >= _direct_move_style_ids.size():
|
||||||
|
return
|
||||||
|
var style: String = _direct_move_style_ids[index]
|
||||||
|
var action: Dictionary = { "type": ACTION_REGISTRY.MOVE_TO, "target": direct_move_target, "style": style }
|
||||||
|
direct_move_commit_requested.emit(context_rig, action, style)
|
||||||
|
|
||||||
|
|
||||||
|
func complete_direct_move_commit(style: String, succeeded: bool) -> void:
|
||||||
|
if not succeeded:
|
||||||
|
toast_requested.emit("Move To was not added. Try again or Cancel.")
|
||||||
|
return
|
||||||
|
if context_rig != null and is_instance_valid(context_rig):
|
||||||
|
direct_move_last_style[context_rig.get_instance_id()] = style
|
||||||
|
cancel_direct_move_draft()
|
||||||
|
toast_requested.emit("Move To · %s added" % style.capitalize())
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_direct_move_draft() -> bool:
|
||||||
|
if not pending_walk_target and not direct_move_target.is_finite():
|
||||||
|
return false
|
||||||
|
pending_walk_target = false
|
||||||
|
direct_move_target = Vector2.INF
|
||||||
|
context_rig = null
|
||||||
|
if _direct_move_popup != null and _direct_move_popup.visible:
|
||||||
|
_direct_move_popup.hide()
|
||||||
|
state_changed.emit()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_action_popup_items(rules: Array[Dictionary]) -> void:
|
||||||
|
if _action_popup == null:
|
||||||
|
return
|
||||||
|
var valid: bool = context_rig != null and is_instance_valid(context_rig)
|
||||||
|
_disable_popup_item(_action_popup, 7, not valid or context_rig.get_queue().is_empty())
|
||||||
|
_disable_popup_item(_action_popup, 8, not valid or not rig_has_rules(context_rig, rules))
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_rig_context_items(rig: StickmanRig, popup: PopupMenu, rules: Array[Dictionary]) -> void:
|
||||||
|
if popup == null:
|
||||||
|
return
|
||||||
|
var valid: bool = rig != null and is_instance_valid(rig)
|
||||||
|
_disable_popup_item(popup, 0, not valid or rig.get_queue().is_empty())
|
||||||
|
_disable_popup_item(popup, 1, not valid or not rig_has_rules(rig, rules))
|
||||||
|
|
||||||
|
|
||||||
|
func disable_popup_item(popup: PopupMenu, id: int, disabled: bool) -> void:
|
||||||
|
_disable_popup_item(popup, id, disabled)
|
||||||
|
|
||||||
|
|
||||||
|
func rig_has_rules(rig: StickmanRig, rules: Array[Dictionary]) -> bool:
|
||||||
|
if rig == null or not is_instance_valid(rig):
|
||||||
|
return false
|
||||||
|
var id: int = rig.get_instance_id()
|
||||||
|
for rule: Dictionary in rules:
|
||||||
|
var trigger: Dictionary = rule.get("trigger", {})
|
||||||
|
if int(trigger.get("source", -1)) == id:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _disable_popup_item(popup: PopupMenu, id: int, disabled: bool) -> void:
|
||||||
|
var index: int = popup.get_item_index(id)
|
||||||
|
if index >= 0:
|
||||||
|
popup.set_item_disabled(index, disabled)
|
||||||
|
|
||||||
|
|
||||||
|
func is_awaiting_click(capture_active: bool) -> bool:
|
||||||
|
if pending_walk_target or capture_active:
|
||||||
|
return true
|
||||||
|
if _rule_authoring == null:
|
||||||
|
return false
|
||||||
|
return _rule_authoring.step == RULE_STEP_TRIGGER_TARGET \
|
||||||
|
or _rule_authoring.step == RULE_STEP_ACTION_TARGET \
|
||||||
|
or _rule_authoring.step == RULE_STEP_ACTION_POSITION
|
||||||
|
|
||||||
|
|
||||||
|
func action_hint_text(capture_kind: String, capture_hint: String, set_down_cache: Dictionary, throw_cache: Dictionary) -> String:
|
||||||
|
if pending_walk_target:
|
||||||
|
return "🚩 Click where they should go"
|
||||||
|
if not capture_kind.is_empty():
|
||||||
|
if capture_kind == "set_down":
|
||||||
|
var conditional: bool = not bool(set_down_cache.get("known", false))
|
||||||
|
var valid: bool = bool(set_down_cache.get("valid", false))
|
||||||
|
if conditional:
|
||||||
|
return "Click where to let go · Prop depends on earlier actions"
|
||||||
|
if not valid:
|
||||||
|
return String(set_down_cache.get("reason", "Too far to reach"))
|
||||||
|
return "Click where to let go · Ready to set down"
|
||||||
|
if capture_kind == "throw" and not throw_cache.is_empty():
|
||||||
|
if not bool(throw_cache.get("preview_available", true)):
|
||||||
|
return "ℹ %s Click to set the throw target." % String(throw_cache.get("preview_reason", "No throw preview is available."))
|
||||||
|
var preview_hint: String = String(throw_cache.get("preview_hint", ""))
|
||||||
|
if not preview_hint.is_empty():
|
||||||
|
return "%s %s" % [capture_hint, preview_hint]
|
||||||
|
return capture_hint
|
||||||
|
if _rule_authoring == null:
|
||||||
|
return ""
|
||||||
|
match _rule_authoring.step:
|
||||||
|
RULE_STEP_TRIGGER_TARGET:
|
||||||
|
var trigger: Dictionary = _rule_authoring.builder.get("trigger", {})
|
||||||
|
match String(trigger.get("type", "")):
|
||||||
|
"arrived_at_waypoint":
|
||||||
|
return "📍 Click the waypoint"
|
||||||
|
"entered_area":
|
||||||
|
return "🎯 Click the trigger area"
|
||||||
|
"collided":
|
||||||
|
return "💥 Click the prop"
|
||||||
|
_:
|
||||||
|
return "🎯 Click the target"
|
||||||
|
RULE_STEP_ACTION_TARGET:
|
||||||
|
return "🎯 Click the stickman"
|
||||||
|
RULE_STEP_ACTION_POSITION:
|
||||||
|
var actions: Array = _rule_authoring.builder.get("actions", [])
|
||||||
|
var style: String = ACTION_REGISTRY.WALK
|
||||||
|
if not actions.is_empty():
|
||||||
|
var move_action: Dictionary = actions.back()
|
||||||
|
style = ACTION_REGISTRY.checked_style(move_action.get("params", {}).get("style", null))
|
||||||
|
return "🚩 Click where they should %s" % ("run" if style == ACTION_REGISTRY.RUN else "walk")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func action_origin(
|
||||||
|
capture_kind: String,
|
||||||
|
set_down_rig: StickmanRig,
|
||||||
|
throw_rig: StickmanRig,
|
||||||
|
throw_cache: Dictionary,
|
||||||
|
panel_rig: StickmanRig
|
||||||
|
) -> Vector2:
|
||||||
|
if capture_kind == "set_down" and set_down_rig != null and is_instance_valid(set_down_rig):
|
||||||
|
return set_down_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
|
||||||
|
if capture_kind == "throw" and throw_rig != null and is_instance_valid(throw_rig):
|
||||||
|
if not throw_cache.is_empty():
|
||||||
|
return throw_cache.get("start_position", throw_rig.global_position)
|
||||||
|
return throw_rig.global_position
|
||||||
|
if pending_walk_target:
|
||||||
|
if context_rig != null and is_instance_valid(context_rig):
|
||||||
|
return context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
|
||||||
|
return Vector2.ZERO
|
||||||
|
if not capture_kind.is_empty():
|
||||||
|
if capture_kind == "position" and _action_queue != null:
|
||||||
|
var editor_rig: StickmanRig = _action_queue.action_editor_preview_rig()
|
||||||
|
if editor_rig != null and is_instance_valid(editor_rig):
|
||||||
|
return editor_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
|
||||||
|
if panel_rig != null and is_instance_valid(panel_rig):
|
||||||
|
return panel_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
|
||||||
|
return Vector2.ZERO
|
||||||
|
if _rule_authoring != null and _rule_authoring.step != RULE_STEP_IDLE:
|
||||||
|
if _rule_authoring.context_rig != null and is_instance_valid(_rule_authoring.context_rig):
|
||||||
|
return _rule_authoring.context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
|
||||||
|
var trigger: Dictionary = _rule_authoring.builder.get("trigger", {})
|
||||||
|
var source: Object = instance_from_id(int(trigger.get("source", -1)))
|
||||||
|
if source is Node2D and is_instance_valid(source):
|
||||||
|
return (source as Node2D).global_position
|
||||||
|
return Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func is_target_valid(position: Vector2) -> bool:
|
||||||
|
if _spatial_index == null:
|
||||||
|
return true
|
||||||
|
for node: Node2D in _spatial_index.candidates_at(position):
|
||||||
|
if node is TerrainBlock and STAGE_SELECTION.get_world_aabb(node).has_point(position):
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func update_action_overlay(
|
||||||
|
capture_kind: String,
|
||||||
|
capture_hint: String,
|
||||||
|
set_down_cache: Dictionary,
|
||||||
|
throw_cache: Dictionary,
|
||||||
|
set_down_rig: StickmanRig,
|
||||||
|
throw_rig: StickmanRig,
|
||||||
|
snap_enabled: bool,
|
||||||
|
grid_size: float
|
||||||
|
) -> void:
|
||||||
|
var awaiting: bool = is_awaiting_click(not capture_kind.is_empty())
|
||||||
|
if _placement_overlay != null:
|
||||||
|
if awaiting:
|
||||||
|
var origin: Vector2 = action_origin(capture_kind, set_down_rig, throw_rig, throw_cache, _action_queue.panel_rig if _action_queue != null else null)
|
||||||
|
var target: Vector2 = _camera.get_global_mouse_position() if _camera != null else Vector2.ZERO
|
||||||
|
if snap_enabled and (capture_kind in ["position", "throw", "set_down"] or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION)):
|
||||||
|
if grid_size > 0.0:
|
||||||
|
target = Vector2(roundf(target.x / grid_size) * grid_size, roundf(target.y / grid_size) * grid_size)
|
||||||
|
if capture_kind == "set_down":
|
||||||
|
var known: bool = bool(set_down_cache.get("known", false))
|
||||||
|
var valid: bool = bool(set_down_cache.get("valid", false))
|
||||||
|
if known:
|
||||||
|
var release_transform: Transform2D = set_down_cache.get("release_transform", Transform2D.IDENTITY)
|
||||||
|
var approach: Vector2 = set_down_cache.get("approach_position", origin)
|
||||||
|
var grip: Vector2 = set_down_cache.get("grip_position", target)
|
||||||
|
var preview_prop: PropBlock = set_down_cache.get("prop") as PropBlock
|
||||||
|
_placement_overlay.set_set_down_preview(origin, target, true, valid, preview_prop, release_transform, approach, grip)
|
||||||
|
else:
|
||||||
|
_placement_overlay.set_set_down_preview(origin, target, false, false)
|
||||||
|
elif capture_kind == "throw":
|
||||||
|
if throw_rig == null or not is_instance_valid(throw_rig):
|
||||||
|
_placement_overlay.set_throw_preview_unavailable(target)
|
||||||
|
else:
|
||||||
|
var throw_preview: Dictionary = throw_cache
|
||||||
|
if throw_preview.is_empty() or not bool(throw_preview.get("preview_available", true)):
|
||||||
|
_placement_overlay.set_throw_preview_unavailable(target)
|
||||||
|
else:
|
||||||
|
var trajectory_points: PackedVector2Array = throw_preview.get("trajectory_points", PackedVector2Array())
|
||||||
|
var effective_target: Vector2 = throw_preview.get("effective_target", target)
|
||||||
|
var collision_position: Vector2 = throw_preview.get("collision_position", Vector2.INF)
|
||||||
|
var target_reachable: bool = bool(throw_preview.get("target_reachable", true))
|
||||||
|
var preview_planned: bool = bool(throw_preview.get("preview_planned", false))
|
||||||
|
var preview_uncertain: bool = bool(throw_preview.get("preview_uncertain", false))
|
||||||
|
_placement_overlay.set_throw_trajectory(
|
||||||
|
throw_preview["start_position"], target, throw_preview["landing_position"], target_reachable,
|
||||||
|
trajectory_points, effective_target, collision_position, preview_planned, preview_uncertain
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_placement_overlay.set_action_trajectory(origin, target, is_target_valid(target))
|
||||||
|
elif direct_move_target.is_finite() and context_rig != null and is_instance_valid(context_rig):
|
||||||
|
var origin: Vector2 = context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
|
||||||
|
_placement_overlay.set_action_trajectory(origin, direct_move_target, true)
|
||||||
|
else:
|
||||||
|
_placement_overlay.clear_action()
|
||||||
|
if _chrome != null:
|
||||||
|
_chrome.set_action_tooltip(awaiting, action_hint_text(capture_kind, capture_hint, set_down_cache, throw_cache) if awaiting else "")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://br8nw38ybdkgr
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
class_name StageDocumentController
|
||||||
|
extends RefCounted
|
||||||
|
## Owns Sandbox Stage document identity, recent paths, and safe file operations.
|
||||||
|
|
||||||
|
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||||
|
const CLI_SCENE_LAUNCHER := preload("res://scripts/tools/cli_scene_launcher.gd")
|
||||||
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
|
const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene"
|
||||||
|
const RECENT_LIMIT: int = 5
|
||||||
|
const DEFAULT_SCENE_NAME: String = "Untitled"
|
||||||
|
|
||||||
|
signal open_path_requested(path: String)
|
||||||
|
signal save_path_selected(path: String)
|
||||||
|
signal rename_requested(title: String)
|
||||||
|
signal unsaved_save_requested
|
||||||
|
signal unsaved_discard_requested
|
||||||
|
signal dialog_cancelled
|
||||||
|
|
||||||
|
var current_path: String = ""
|
||||||
|
var scene_name: String = DEFAULT_SCENE_NAME
|
||||||
|
var created_unix: int = 0
|
||||||
|
var dirty: bool = false
|
||||||
|
var last_error: String = ""
|
||||||
|
var _recent_scenes: Array[String] = []
|
||||||
|
var _dialog_workflow_connected: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func configure_dialog_workflow(
|
||||||
|
open_dialog: FileDialog,
|
||||||
|
save_dialog: FileDialog,
|
||||||
|
rename_dialog: ConfirmationDialog,
|
||||||
|
name_edit: LineEdit,
|
||||||
|
unsaved_dialog: ConfirmationDialog
|
||||||
|
) -> void:
|
||||||
|
if _dialog_workflow_connected:
|
||||||
|
return
|
||||||
|
_dialog_workflow_connected = true
|
||||||
|
_connect_signal(open_dialog, &"file_selected", _on_open_path_selected)
|
||||||
|
_connect_signal(save_dialog, &"file_selected", _on_save_path_selected)
|
||||||
|
_connect_signal(save_dialog, &"canceled", _on_dialog_cancelled)
|
||||||
|
_connect_signal(rename_dialog, &"confirmed", _on_rename_confirmed.bind(name_edit))
|
||||||
|
_connect_signal(unsaved_dialog, &"confirmed", _on_unsaved_save_confirmed)
|
||||||
|
_connect_signal(unsaved_dialog, &"custom_action", _on_unsaved_custom_action)
|
||||||
|
_connect_signal(unsaved_dialog, &"canceled", _on_dialog_cancelled)
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_signal(source: Object, signal_name: StringName, callback: Callable) -> void:
|
||||||
|
if source != null and source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
|
||||||
|
source.connect(signal_name, callback)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_open_path_selected(path: String) -> void:
|
||||||
|
open_path_requested.emit(path)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_save_path_selected(path: String) -> void:
|
||||||
|
save_path_selected.emit(path)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rename_confirmed(name_edit: LineEdit) -> void:
|
||||||
|
rename_requested.emit(name_edit.text.strip_edges() if name_edit != null else "")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_unsaved_save_confirmed() -> void:
|
||||||
|
unsaved_save_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_unsaved_custom_action(action: StringName) -> void:
|
||||||
|
if action == &"discard":
|
||||||
|
unsaved_discard_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_dialog_cancelled() -> void:
|
||||||
|
dialog_cancelled.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func mark_dirty(suppressed: bool = false) -> bool:
|
||||||
|
if suppressed:
|
||||||
|
return false
|
||||||
|
dirty = true
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func set_dirty(value: bool) -> void:
|
||||||
|
dirty = value
|
||||||
|
|
||||||
|
|
||||||
|
func metadata_for_snapshot(is_autosave: bool) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"created_unix": created_unix if created_unix > 0 else Time.get_unix_time_from_system(),
|
||||||
|
"modified_unix": Time.get_unix_time_from_system(),
|
||||||
|
"autosave": is_autosave,
|
||||||
|
"source_path": CONTENT_PATHS.canonicalize(current_path) if is_autosave else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func save(path: String, snapshot: Dictionary, is_autosave: bool = false) -> bool:
|
||||||
|
last_error = ""
|
||||||
|
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
|
||||||
|
var result: Dictionary = STAGE_SCENE_IO.write_file_safe(canonical_path, snapshot)
|
||||||
|
if not bool(result.get("ok", false)):
|
||||||
|
last_error = String(result.get("error", "Could not save scene."))
|
||||||
|
return false
|
||||||
|
if not is_autosave:
|
||||||
|
current_path = canonical_path
|
||||||
|
if created_unix <= 0:
|
||||||
|
created_unix = Time.get_unix_time_from_system()
|
||||||
|
dirty = false
|
||||||
|
add_recent_path(canonical_path)
|
||||||
|
remove_autosave()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func read(path: String) -> Dictionary:
|
||||||
|
return STAGE_SCENE_IO.read_file(CONTENT_PATHS.canonicalize(path))
|
||||||
|
|
||||||
|
|
||||||
|
func parse_cli_request(args: Array) -> Dictionary:
|
||||||
|
return CLI_SCENE_LAUNCHER.parse(args)
|
||||||
|
|
||||||
|
|
||||||
|
func accept_loaded_document(
|
||||||
|
source_path: String,
|
||||||
|
loaded_name: String,
|
||||||
|
loaded_created_unix: int,
|
||||||
|
recovery_source_path: String,
|
||||||
|
is_recovery: bool
|
||||||
|
) -> void:
|
||||||
|
var normalized_name: String = loaded_name.strip_edges()
|
||||||
|
scene_name = normalized_name if not normalized_name.is_empty() else DEFAULT_SCENE_NAME
|
||||||
|
created_unix = loaded_created_unix if loaded_created_unix > 0 else Time.get_unix_time_from_system()
|
||||||
|
current_path = CONTENT_PATHS.canonicalize(recovery_source_path) if is_recovery else CONTENT_PATHS.canonicalize(source_path)
|
||||||
|
dirty = is_recovery
|
||||||
|
|
||||||
|
|
||||||
|
func add_recent_path(path: String) -> void:
|
||||||
|
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
|
||||||
|
if canonical_path.is_empty() or canonical_path == AUTOSAVE_PATH:
|
||||||
|
return
|
||||||
|
_recent_scenes.erase(canonical_path)
|
||||||
|
_recent_scenes.push_front(canonical_path)
|
||||||
|
while _recent_scenes.size() > RECENT_LIMIT:
|
||||||
|
_recent_scenes.pop_back()
|
||||||
|
|
||||||
|
|
||||||
|
func remove_recent_path(path: String) -> void:
|
||||||
|
_recent_scenes.erase(CONTENT_PATHS.canonicalize(path))
|
||||||
|
|
||||||
|
|
||||||
|
func set_recent_paths(paths: Array[String]) -> void:
|
||||||
|
_recent_scenes.clear()
|
||||||
|
for path: String in paths:
|
||||||
|
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
|
||||||
|
if canonical_path.is_empty() or _recent_scenes.has(canonical_path):
|
||||||
|
continue
|
||||||
|
_recent_scenes.append(canonical_path)
|
||||||
|
if _recent_scenes.size() >= RECENT_LIMIT:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
func get_recent_paths() -> Array[String]:
|
||||||
|
return _recent_scenes
|
||||||
|
|
||||||
|
|
||||||
|
func populate_recent_menu(popup: PopupMenu) -> bool:
|
||||||
|
if popup == null:
|
||||||
|
return false
|
||||||
|
popup.clear()
|
||||||
|
var existing: Array[String] = []
|
||||||
|
for path: String in _recent_scenes:
|
||||||
|
var canonical_path: String = CONTENT_PATHS.canonicalize(path)
|
||||||
|
if FileAccess.file_exists(canonical_path) and not existing.has(canonical_path):
|
||||||
|
existing.append(canonical_path)
|
||||||
|
var pruned: bool = existing != _recent_scenes
|
||||||
|
set_recent_paths(existing)
|
||||||
|
if _recent_scenes.is_empty():
|
||||||
|
popup.add_item("(No recent scenes)", -1)
|
||||||
|
popup.set_item_disabled(0, true)
|
||||||
|
else:
|
||||||
|
for index: int in _recent_scenes.size():
|
||||||
|
popup.add_item(_recent_scenes[index], index)
|
||||||
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_recent_selection(index: int) -> Dictionary:
|
||||||
|
if index < 0 or index >= _recent_scenes.size():
|
||||||
|
return {"valid": false, "path": "", "missing": false}
|
||||||
|
var path: String = _recent_scenes[index]
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
remove_recent_path(path)
|
||||||
|
return {"valid": false, "path": path, "missing": true}
|
||||||
|
return {"valid": true, "path": path, "missing": false}
|
||||||
|
|
||||||
|
|
||||||
|
func clear_recent_paths() -> void:
|
||||||
|
_recent_scenes.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func has_autosave() -> bool:
|
||||||
|
return FileAccess.file_exists(AUTOSAVE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
func inspect_autosave() -> Dictionary:
|
||||||
|
if not has_autosave():
|
||||||
|
return {"available": false, "data": {}, "error": ""}
|
||||||
|
var read_result: Dictionary = read(AUTOSAVE_PATH)
|
||||||
|
if not bool(read_result.get("ok", false)):
|
||||||
|
remove_autosave()
|
||||||
|
return {"available": false, "data": {}, "error": String(read_result.get("error", "Could not read autosave."))}
|
||||||
|
var data: Dictionary = read_result.get("data", {}) as Dictionary
|
||||||
|
var raw_metadata: Variant = data.get("metadata", {})
|
||||||
|
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
|
||||||
|
if not bool(metadata.get("autosave", false)):
|
||||||
|
remove_autosave()
|
||||||
|
return {"available": false, "data": {}, "error": ""}
|
||||||
|
var source_path: String = CONTENT_PATHS.canonicalize(String(metadata.get("source_path", "")))
|
||||||
|
if not source_path.is_empty() and FileAccess.file_exists(source_path):
|
||||||
|
var autosave_modified: int = int(metadata.get("modified_unix", 0))
|
||||||
|
if autosave_modified <= int(FileAccess.get_modified_time(source_path)):
|
||||||
|
remove_autosave()
|
||||||
|
return {"available": false, "data": {}, "error": ""}
|
||||||
|
return {"available": true, "data": data, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
func remove_autosave() -> void:
|
||||||
|
if has_autosave():
|
||||||
|
DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
func validate_queues(world: Node2D) -> String:
|
||||||
|
if world == null:
|
||||||
|
return ""
|
||||||
|
for child: Node in world.get_children():
|
||||||
|
if not child is StickmanRig:
|
||||||
|
continue
|
||||||
|
var rig: StickmanRig = child as StickmanRig
|
||||||
|
var actions: Array[Dictionary] = rig.get_queue()
|
||||||
|
var encoded: Dictionary = STAGE_SCENE_IO.encode_queue(actions)
|
||||||
|
if (encoded.get("actions", []) as Array).size() == actions.size():
|
||||||
|
continue
|
||||||
|
for action_index: int in actions.size():
|
||||||
|
var one_action: Array[Dictionary] = [actions[action_index]]
|
||||||
|
var one_encoded: Dictionary = STAGE_SCENE_IO.encode_queue(one_action)
|
||||||
|
if (one_encoded.get("actions", []) as Array).size() == 1:
|
||||||
|
continue
|
||||||
|
var character_name: String = rig.source_stk_path.get_file().trim_suffix(".stk")
|
||||||
|
if character_name.is_empty():
|
||||||
|
character_name = String(rig.name)
|
||||||
|
return "Can't save: %s action %d (%s) can't be saved. Edit or remove it from the queue." % [character_name, action_index + 1, String(actions[action_index].get("type", "unknown"))]
|
||||||
|
return ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dx1wd5y3v7d1o
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
class_name StageModalViews
|
||||||
|
extends RefCounted
|
||||||
|
## Builds Sandbox Stage dialogs and editor popups without owning their decisions.
|
||||||
|
|
||||||
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
|
const QUEUE_PANEL_SCENE := preload("res://scenes/editors/stage/queue_panel.tscn")
|
||||||
|
const ACTION_EDITOR_SCENE := preload("res://scenes/editors/stage/action_editor.tscn")
|
||||||
|
const SPEECH_COMPOSER_SCENE := preload("res://scenes/editors/stage/speech_composer.tscn")
|
||||||
|
const SPEECH_STYLE_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_style_toolbar.gd")
|
||||||
|
const SPEECH_SHOW_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_show_toolbar.gd")
|
||||||
|
const WAYPOINT_CONTEXT_SCRIPT := preload("res://scripts/stage/waypoint_context.gd")
|
||||||
|
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
|
||||||
|
signal object_selected(node: Node2D)
|
||||||
|
|
||||||
|
var speak_dialog: AcceptDialog
|
||||||
|
var speak_edit: LineEdit
|
||||||
|
var speak_stop_current_check: CheckBox
|
||||||
|
var wait_dialog: AcceptDialog
|
||||||
|
var wait_spin: SpinBox
|
||||||
|
var scene_open_dialog: FileDialog
|
||||||
|
var scene_save_dialog: FileDialog
|
||||||
|
var speech_save_gate_dialog: ConfirmationDialog
|
||||||
|
var rename_scene_dialog: ConfirmationDialog
|
||||||
|
var scene_name_edit: LineEdit
|
||||||
|
var missing_assets_dialog: AcceptDialog
|
||||||
|
var cli_error_dialog: AcceptDialog
|
||||||
|
var unsaved_changes_dialog: ConfirmationDialog
|
||||||
|
var rig_context_popup: PopupMenu
|
||||||
|
var waypoint_context: PopupMenu
|
||||||
|
var confirm_dialog: ConfirmationDialog
|
||||||
|
var queue_panel: QueuePanel
|
||||||
|
var action_editor: ActionEditor
|
||||||
|
var speech_composer: Control
|
||||||
|
var speech_style_toolbar: Control
|
||||||
|
var speech_show_toolbar: Control
|
||||||
|
var speech_recovery_dialog: ConfirmationDialog
|
||||||
|
var speech_destination_dialog: ConfirmationDialog
|
||||||
|
var _objects_here_popup: PopupPanel
|
||||||
|
var _speak_box: VBoxContainer
|
||||||
|
var _sandbox_theme: SandboxTheme
|
||||||
|
var _ui_font: Font
|
||||||
|
|
||||||
|
|
||||||
|
func configure(sandbox_theme: SandboxTheme, ui_font: Font) -> void:
|
||||||
|
_sandbox_theme = sandbox_theme
|
||||||
|
_ui_font = ui_font
|
||||||
|
|
||||||
|
|
||||||
|
func build_action_dialogs(parent: Node) -> void:
|
||||||
|
speak_dialog = AcceptDialog.new()
|
||||||
|
speak_dialog.title = "Speak"
|
||||||
|
_speak_box = VBoxContainer.new()
|
||||||
|
speak_dialog.add_child(_speak_box)
|
||||||
|
speak_edit = LineEdit.new()
|
||||||
|
speak_edit.placeholder_text = "Say something…"
|
||||||
|
speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
|
||||||
|
_speak_box.add_child(speak_edit)
|
||||||
|
speak_stop_current_check = CheckBox.new()
|
||||||
|
speak_stop_current_check.text = "Stop what he's doing?"
|
||||||
|
speak_stop_current_check.visible = false
|
||||||
|
_speak_box.add_child(speak_stop_current_check)
|
||||||
|
speak_dialog.register_text_enter(speak_edit)
|
||||||
|
parent.add_child(speak_dialog)
|
||||||
|
apply_dialog_theme(speak_dialog)
|
||||||
|
|
||||||
|
wait_dialog = AcceptDialog.new()
|
||||||
|
wait_dialog.title = "Wait"
|
||||||
|
wait_spin = SpinBox.new()
|
||||||
|
wait_spin.min_value = 0.1
|
||||||
|
wait_spin.max_value = 10.0
|
||||||
|
wait_spin.step = 0.1
|
||||||
|
wait_spin.value = 1.0
|
||||||
|
wait_dialog.add_child(wait_spin)
|
||||||
|
parent.add_child(wait_dialog)
|
||||||
|
apply_dialog_theme(wait_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
func build_file_dialogs(parent: Node) -> void:
|
||||||
|
scene_open_dialog = FileDialog.new()
|
||||||
|
scene_open_dialog.name = "SceneOpenDialog"
|
||||||
|
scene_open_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||||
|
scene_open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||||||
|
scene_open_dialog.current_dir = ProjectSettings.globalize_path(CONTENT_PATHS.STAGES_DIR)
|
||||||
|
scene_open_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
|
||||||
|
parent.add_child(scene_open_dialog)
|
||||||
|
|
||||||
|
scene_save_dialog = FileDialog.new()
|
||||||
|
scene_save_dialog.name = "SceneSaveDialog"
|
||||||
|
scene_save_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||||
|
scene_save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
|
||||||
|
scene_save_dialog.current_dir = ProjectSettings.globalize_path(CONTENT_PATHS.STAGES_DIR)
|
||||||
|
scene_save_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
|
||||||
|
parent.add_child(scene_save_dialog)
|
||||||
|
|
||||||
|
speech_save_gate_dialog = ConfirmationDialog.new()
|
||||||
|
speech_save_gate_dialog.name = "FinishSpeechBeforeSaveDialog"
|
||||||
|
speech_save_gate_dialog.title = "Finish speech before saving"
|
||||||
|
speech_save_gate_dialog.dialog_text = "Choose Done speech to finish editing before the scene is saved."
|
||||||
|
speech_save_gate_dialog.ok_button_text = "Done speech"
|
||||||
|
speech_save_gate_dialog.get_cancel_button().text = "Keep editing"
|
||||||
|
parent.add_child(speech_save_gate_dialog)
|
||||||
|
apply_dialog_theme(speech_save_gate_dialog)
|
||||||
|
|
||||||
|
rename_scene_dialog = ConfirmationDialog.new()
|
||||||
|
rename_scene_dialog.name = "RenameSceneDialog"
|
||||||
|
rename_scene_dialog.title = "Rename Scene"
|
||||||
|
rename_scene_dialog.cancel_button_text = "Cancel"
|
||||||
|
rename_scene_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
|
scene_name_edit = LineEdit.new()
|
||||||
|
scene_name_edit.name = "SceneNameEdit"
|
||||||
|
scene_name_edit.placeholder_text = "Scene name"
|
||||||
|
scene_name_edit.custom_minimum_size = Vector2(460.0, 0.0)
|
||||||
|
rename_scene_dialog.add_child(scene_name_edit)
|
||||||
|
parent.add_child(rename_scene_dialog)
|
||||||
|
apply_dialog_theme(rename_scene_dialog)
|
||||||
|
|
||||||
|
missing_assets_dialog = AcceptDialog.new()
|
||||||
|
missing_assets_dialog.name = "MissingAssetsDialog"
|
||||||
|
missing_assets_dialog.title = "Missing Scene Assets"
|
||||||
|
missing_assets_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
|
parent.add_child(missing_assets_dialog)
|
||||||
|
apply_dialog_theme(missing_assets_dialog)
|
||||||
|
|
||||||
|
cli_error_dialog = AcceptDialog.new()
|
||||||
|
cli_error_dialog.name = "CliErrorDialog"
|
||||||
|
cli_error_dialog.title = "Could Not Load Scene"
|
||||||
|
cli_error_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
|
parent.add_child(cli_error_dialog)
|
||||||
|
apply_dialog_theme(cli_error_dialog)
|
||||||
|
|
||||||
|
unsaved_changes_dialog = ConfirmationDialog.new()
|
||||||
|
unsaved_changes_dialog.name = "UnsavedChangesDialog"
|
||||||
|
unsaved_changes_dialog.title = "⚠️ Unsaved Changes"
|
||||||
|
unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"
|
||||||
|
unsaved_changes_dialog.ok_button_text = "💾 Save"
|
||||||
|
unsaved_changes_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
|
unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
|
||||||
|
parent.add_child(unsaved_changes_dialog)
|
||||||
|
apply_dialog_theme(unsaved_changes_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
func build_context_and_editor_views(
|
||||||
|
parent: Node,
|
||||||
|
font: Font,
|
||||||
|
emoji_font: Font,
|
||||||
|
font_sizes: Dictionary,
|
||||||
|
queue_size: Vector2i,
|
||||||
|
action_size: Vector2i
|
||||||
|
) -> void:
|
||||||
|
rig_context_popup = PopupMenu.new()
|
||||||
|
rig_context_popup.add_item("📋 Edit Queue…", 0)
|
||||||
|
rig_context_popup.add_item("⚡ Edit Rules…", 1)
|
||||||
|
parent.add_child(rig_context_popup)
|
||||||
|
|
||||||
|
waypoint_context = WAYPOINT_CONTEXT_SCRIPT.new() as PopupMenu
|
||||||
|
parent.add_child(waypoint_context)
|
||||||
|
|
||||||
|
confirm_dialog = ConfirmationDialog.new()
|
||||||
|
confirm_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
|
parent.add_child(confirm_dialog)
|
||||||
|
apply_dialog_theme(confirm_dialog)
|
||||||
|
|
||||||
|
queue_panel = QUEUE_PANEL_SCENE.instantiate() as QueuePanel
|
||||||
|
parent.add_child(queue_panel)
|
||||||
|
queue_panel.apply_font(font, emoji_font, font_sizes)
|
||||||
|
queue_panel.min_size = queue_size
|
||||||
|
|
||||||
|
action_editor = ACTION_EDITOR_SCENE.instantiate() as ActionEditor
|
||||||
|
parent.add_child(action_editor)
|
||||||
|
action_editor.apply_font(font, emoji_font, font_sizes)
|
||||||
|
action_editor.apply_theme(_sandbox_theme)
|
||||||
|
action_editor.min_size = action_size
|
||||||
|
|
||||||
|
speech_composer = SPEECH_COMPOSER_SCENE.instantiate() as Control
|
||||||
|
parent.add_child(speech_composer)
|
||||||
|
speech_style_toolbar = SPEECH_STYLE_TOOLBAR_SCRIPT.new() as Control
|
||||||
|
speech_style_toolbar.name = "SpeechStyleToolbar"
|
||||||
|
speech_style_toolbar.call("configure", _sandbox_theme)
|
||||||
|
parent.add_child(speech_style_toolbar)
|
||||||
|
speech_show_toolbar = SPEECH_SHOW_TOOLBAR_SCRIPT.new() as Control
|
||||||
|
speech_show_toolbar.name = "SpeechShowToolbar"
|
||||||
|
speech_show_toolbar.call("configure", _sandbox_theme)
|
||||||
|
parent.add_child(speech_show_toolbar)
|
||||||
|
|
||||||
|
speech_recovery_dialog = ConfirmationDialog.new()
|
||||||
|
speech_recovery_dialog.title = "Speech draft found"
|
||||||
|
speech_recovery_dialog.dialog_text = "Restore your unsaved speech draft?"
|
||||||
|
speech_recovery_dialog.get_ok_button().text = "Restore"
|
||||||
|
speech_recovery_dialog.get_cancel_button().text = "Discard"
|
||||||
|
parent.add_child(speech_recovery_dialog)
|
||||||
|
|
||||||
|
speech_destination_dialog = ConfirmationDialog.new()
|
||||||
|
speech_destination_dialog.title = "Save recovered speech"
|
||||||
|
speech_destination_dialog.dialog_text = "Add this speech to the chosen character’s action queue?"
|
||||||
|
speech_destination_dialog.get_ok_button().text = "Add to queue"
|
||||||
|
speech_destination_dialog.get_cancel_button().text = "Keep editing"
|
||||||
|
parent.add_child(speech_destination_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
func show_objects_here(
|
||||||
|
parent: Node,
|
||||||
|
candidates: Array[Node2D],
|
||||||
|
selected_nodes: Array[Node2D],
|
||||||
|
viewport_size: Vector2,
|
||||||
|
pointer_position: Vector2
|
||||||
|
) -> void:
|
||||||
|
if parent == null or candidates.size() < 2:
|
||||||
|
return
|
||||||
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||||
|
_objects_here_popup.queue_free()
|
||||||
|
var popup: PopupPanel = PopupPanel.new()
|
||||||
|
popup.name = "ObjectsHere"
|
||||||
|
popup.exclusive = false
|
||||||
|
popup.transient = true
|
||||||
|
popup.unfocusable = false
|
||||||
|
if _sandbox_theme != null:
|
||||||
|
popup.theme = _sandbox_theme.control_theme()
|
||||||
|
parent.add_child(popup)
|
||||||
|
var margin: MarginContainer = MarginContainer.new()
|
||||||
|
for side: String in ["left", "right", "top", "bottom"]:
|
||||||
|
margin.add_theme_constant_override("margin_" + side, 8)
|
||||||
|
popup.add_child(margin)
|
||||||
|
var column: VBoxContainer = VBoxContainer.new()
|
||||||
|
column.add_theme_constant_override("separation", 4)
|
||||||
|
margin.add_child(column)
|
||||||
|
var heading: Label = Label.new()
|
||||||
|
heading.text = "Objects here · Choose an object"
|
||||||
|
if _ui_font != null:
|
||||||
|
heading.add_theme_font_override("font", _ui_font)
|
||||||
|
column.add_child(heading)
|
||||||
|
var scroll: ScrollContainer = ScrollContainer.new()
|
||||||
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
scroll.custom_minimum_size = Vector2(300.0, minf(360.0, float(candidates.size()) * 48.0))
|
||||||
|
column.add_child(scroll)
|
||||||
|
var rows: VBoxContainer = VBoxContainer.new()
|
||||||
|
rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
scroll.add_child(rows)
|
||||||
|
for node: Node2D in candidates:
|
||||||
|
var button: Button = Button.new()
|
||||||
|
var selected_mark: String = "✓ " if selected_nodes.has(node) else ""
|
||||||
|
button.text = selected_mark + _object_here_label(node)
|
||||||
|
button.custom_minimum_size = Vector2(300.0, 44.0)
|
||||||
|
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
button.pressed.connect(_on_objects_here_selected.bind(node))
|
||||||
|
rows.add_child(button)
|
||||||
|
var popup_size: Vector2i = Vector2i(
|
||||||
|
mini(360, maxi(260, int(viewport_size.x) - 16)),
|
||||||
|
mini(420, maxi(120, int(viewport_size.y) - 16))
|
||||||
|
)
|
||||||
|
popup.size = popup_size
|
||||||
|
var position: Vector2i = Vector2i(pointer_position + Vector2(12.0, 12.0))
|
||||||
|
position.x = clampi(position.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
|
||||||
|
position.y = clampi(position.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
|
||||||
|
_objects_here_popup = popup
|
||||||
|
popup.popup_hide.connect(_on_objects_here_popup_closed.bind(popup))
|
||||||
|
popup.position = position
|
||||||
|
popup.popup()
|
||||||
|
|
||||||
|
|
||||||
|
func get_objects_here_popup() -> PopupPanel:
|
||||||
|
return _objects_here_popup
|
||||||
|
|
||||||
|
|
||||||
|
func _object_here_label(node: Node2D) -> String:
|
||||||
|
if node is PropBlock:
|
||||||
|
var prop: PropBlock = node as PropBlock
|
||||||
|
var depth: String = "Behind characters" if String(prop.stage_roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else "In front of characters"
|
||||||
|
var entry: Dictionary = PROP_LIBRARY_SCRIPT.get_entry(prop.spawn_id)
|
||||||
|
var prop_name: String = String(entry.get("name", entry.get("label", prop.spawn_id)))
|
||||||
|
return "📦 %s · %s" % [prop_name, depth]
|
||||||
|
if node is TerrainBlock:
|
||||||
|
return "🧱 %s · Terrain" % String(node.get("spawn_id"))
|
||||||
|
if node is StickmanRig:
|
||||||
|
return "🧍 %s · Stickman" % node.name
|
||||||
|
return "🎯 %s" % node.name
|
||||||
|
|
||||||
|
|
||||||
|
func _on_objects_here_selected(node: Node2D) -> void:
|
||||||
|
object_selected.emit(node)
|
||||||
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||||
|
_objects_here_popup.hide()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_objects_here_popup_closed(popup: PopupPanel) -> void:
|
||||||
|
if _objects_here_popup == popup:
|
||||||
|
_objects_here_popup = null
|
||||||
|
if is_instance_valid(popup):
|
||||||
|
popup.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
|
||||||
|
if _sandbox_theme == null:
|
||||||
|
return fallback
|
||||||
|
var raw: Variant = _sandbox_theme.value("chrome.dialogs." + key, [])
|
||||||
|
if raw is Array and (raw as Array).size() == 2:
|
||||||
|
return Vector2i(maxi(320, int(raw[0])), maxi(180, int(raw[1])))
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
## Dialogs are separate native/embedded windows, so apply local visual overrides.
|
||||||
|
func apply_dialog_theme(dialog: Window) -> void:
|
||||||
|
if dialog == null:
|
||||||
|
return
|
||||||
|
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
|
||||||
|
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
|
||||||
|
dialog.add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null:
|
||||||
|
dialog.add_theme_font_override("font", _ui_font)
|
||||||
|
if dialog is AcceptDialog:
|
||||||
|
var message: Label = (dialog as AcceptDialog).get_label()
|
||||||
|
if message != null:
|
||||||
|
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
message.add_theme_font_size_override("font_size", message_size)
|
||||||
|
if _ui_font != null:
|
||||||
|
message.add_theme_font_override("font", _ui_font)
|
||||||
|
var ok: Button = (dialog as AcceptDialog).get_ok_button()
|
||||||
|
if ok != null:
|
||||||
|
ok.add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null:
|
||||||
|
ok.add_theme_font_override("font", _ui_font)
|
||||||
|
if dialog is ConfirmationDialog:
|
||||||
|
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
|
||||||
|
if cancel != null:
|
||||||
|
cancel.add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null:
|
||||||
|
cancel.add_theme_font_override("font", _ui_font)
|
||||||
|
_apply_dialog_font_to_children(dialog, size)
|
||||||
|
|
||||||
|
|
||||||
|
func apply_dialog_font_to_children(node: Node, size: int) -> void:
|
||||||
|
_apply_dialog_font_to_children(node, size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_dialog_font_to_children(node: Node, size: int) -> void:
|
||||||
|
for child: Node in node.get_children():
|
||||||
|
if child is Control:
|
||||||
|
(child as Control).add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null:
|
||||||
|
(child as Control).add_theme_font_override("font", _ui_font)
|
||||||
|
_apply_dialog_font_to_children(child, size)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c1xy8k68n1abq
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
class_name StagePropSettingsView
|
||||||
|
extends Control
|
||||||
|
## Presentation-only selected-prop settings card for the Sandbox Stage.
|
||||||
|
|
||||||
|
const STAGE_INSPECTOR_PANEL := preload("res://scripts/shared/stage_ui/stage_inspector_panel.gd")
|
||||||
|
const STAGE_PANEL_CHOICE_GROUP := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd")
|
||||||
|
const STAGE_PANEL_TOGGLE_ROW := preload("res://scripts/shared/stage_ui/stage_panel_toggle_row.gd")
|
||||||
|
const PROP_SETTINGS_DEPTH_CUE := preload("res://scripts/editors/stage/prop_settings_depth_cue.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
|
||||||
|
signal role_choice_requested(key: String, value: String)
|
||||||
|
signal role_toggle_requested(enabled: bool, key: String)
|
||||||
|
signal close_requested
|
||||||
|
|
||||||
|
var _stage_theme: SandboxTheme
|
||||||
|
var _top_bar_height: float = 64.0
|
||||||
|
var _status_bar_height: float = 38.0
|
||||||
|
var _card: StageInspectorPanel
|
||||||
|
var _reopen_button: Button
|
||||||
|
var _fields: VBoxContainer
|
||||||
|
var _notice: Label
|
||||||
|
var _depth_group: StagePanelChoiceGroup
|
||||||
|
var _motion_group: StagePanelChoiceGroup
|
||||||
|
var _controls: Dictionary = {}
|
||||||
|
var _active_selection_key: String = ""
|
||||||
|
var _dismissed_selection_key: String = ""
|
||||||
|
|
||||||
|
|
||||||
|
func configure(stage_theme: SandboxTheme, top_bar_height: float, status_bar_height: float) -> void:
|
||||||
|
_stage_theme = stage_theme
|
||||||
|
_top_bar_height = top_bar_height
|
||||||
|
_status_bar_height = status_bar_height
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_build()
|
||||||
|
if not get_viewport().size_changed.is_connected(_on_viewport_resized):
|
||||||
|
get_viewport().size_changed.connect(_on_viewport_resized)
|
||||||
|
_on_viewport_resized()
|
||||||
|
|
||||||
|
|
||||||
|
func show_prop(
|
||||||
|
prop: PropBlock,
|
||||||
|
title: String,
|
||||||
|
roles: Dictionary,
|
||||||
|
safe_rect: Rect2,
|
||||||
|
selection: Array[Node2D]
|
||||||
|
) -> void:
|
||||||
|
if prop == null or not is_instance_valid(prop):
|
||||||
|
hide_card()
|
||||||
|
return
|
||||||
|
_update_selection_key(selection)
|
||||||
|
var dismissed: bool = _active_selection_key == _dismissed_selection_key
|
||||||
|
_card.visible = not dismissed
|
||||||
|
_reopen_button.visible = dismissed
|
||||||
|
if dismissed:
|
||||||
|
return
|
||||||
|
_card.set_safe_rect(safe_rect)
|
||||||
|
_notice.visible = false
|
||||||
|
_fields.visible = true
|
||||||
|
_card.set_header(title, "📦")
|
||||||
|
_sync_roles(prop, roles)
|
||||||
|
|
||||||
|
|
||||||
|
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
|
||||||
|
_update_selection_key(selection)
|
||||||
|
var dismissed: bool = _active_selection_key == _dismissed_selection_key
|
||||||
|
_card.visible = not dismissed
|
||||||
|
_reopen_button.visible = dismissed
|
||||||
|
if dismissed:
|
||||||
|
return
|
||||||
|
_card.set_safe_rect(safe_rect)
|
||||||
|
_notice.visible = true
|
||||||
|
_fields.visible = false
|
||||||
|
_card.set_header("Props", "📦")
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func hide_card(reset_selection: bool = false) -> void:
|
||||||
|
if reset_selection and not _active_selection_key.is_empty():
|
||||||
|
_update_selection_key([])
|
||||||
|
if _card != null:
|
||||||
|
_card.visible = false
|
||||||
|
if _reopen_button != null:
|
||||||
|
_reopen_button.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func set_dismissed(dismissed: bool) -> void:
|
||||||
|
if _active_selection_key.is_empty():
|
||||||
|
return
|
||||||
|
_dismissed_selection_key = _active_selection_key if dismissed else ""
|
||||||
|
|
||||||
|
|
||||||
|
func update_layout(safe_rect: Rect2) -> void:
|
||||||
|
if _card == null:
|
||||||
|
return
|
||||||
|
_card.set_safe_rect(safe_rect)
|
||||||
|
if _reopen_button == null:
|
||||||
|
return
|
||||||
|
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
|
||||||
|
var button_width: float = minf(148.0, maxf(1.0, safe_rect.size.x - inset * 2.0))
|
||||||
|
var button_height: float = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
|
||||||
|
_reopen_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||||
|
_reopen_button.custom_minimum_size.x = button_width
|
||||||
|
_reopen_button.position = Vector2(safe_rect.end.x - inset - button_width, safe_rect.position.y + inset)
|
||||||
|
_reopen_button.size = Vector2(button_width, button_height)
|
||||||
|
|
||||||
|
|
||||||
|
func get_card() -> Control:
|
||||||
|
return _card
|
||||||
|
|
||||||
|
|
||||||
|
func get_reopen_button() -> Button:
|
||||||
|
return _reopen_button
|
||||||
|
|
||||||
|
|
||||||
|
func get_fields() -> VBoxContainer:
|
||||||
|
return _fields
|
||||||
|
|
||||||
|
|
||||||
|
func get_notice() -> Label:
|
||||||
|
return _notice
|
||||||
|
|
||||||
|
|
||||||
|
func get_depth_group() -> Control:
|
||||||
|
return _depth_group
|
||||||
|
|
||||||
|
|
||||||
|
func get_motion_group() -> Control:
|
||||||
|
return _motion_group
|
||||||
|
|
||||||
|
|
||||||
|
func get_controls() -> Dictionary:
|
||||||
|
return _controls
|
||||||
|
|
||||||
|
|
||||||
|
func get_active_selection_key() -> String:
|
||||||
|
return _active_selection_key
|
||||||
|
|
||||||
|
|
||||||
|
func get_dismissed_selection_key() -> String:
|
||||||
|
return _dismissed_selection_key
|
||||||
|
|
||||||
|
|
||||||
|
func focus_card() -> void:
|
||||||
|
_focus_card()
|
||||||
|
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
_card = STAGE_INSPECTOR_PANEL.new() as StageInspectorPanel
|
||||||
|
_card.name = "PropSettingsCard"
|
||||||
|
_card.visible = false
|
||||||
|
add_child(_card)
|
||||||
|
_card.apply_theme(_stage_theme)
|
||||||
|
_card.close_requested.connect(_on_card_close_requested)
|
||||||
|
var body: VBoxContainer = _card.body()
|
||||||
|
body.add_theme_constant_override("separation", 10)
|
||||||
|
|
||||||
|
_notice = Label.new()
|
||||||
|
_notice.text = "Select one prop to change its settings."
|
||||||
|
_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_notice.add_theme_font_size_override("font_size", _stage_panel_integer("type.body_size", 20, 14, 40))
|
||||||
|
_notice.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
|
||||||
|
_notice.visible = false
|
||||||
|
_apply_ui_font(_notice)
|
||||||
|
body.add_child(_notice)
|
||||||
|
|
||||||
|
_fields = VBoxContainer.new()
|
||||||
|
_fields.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
|
||||||
|
_fields.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
body.add_child(_fields)
|
||||||
|
_add_heading(_fields, "Where it appears")
|
||||||
|
_depth_group = _make_choice_group("depth")
|
||||||
|
_fields.add_child(_depth_group)
|
||||||
|
_add_legacy_order_cue()
|
||||||
|
|
||||||
|
_add_heading(_fields, "When PLAY starts")
|
||||||
|
_motion_group = _make_choice_group("start_motion")
|
||||||
|
_fields.add_child(_motion_group)
|
||||||
|
_add_motion_hint()
|
||||||
|
_controls["holds_other_props"] = _make_toggle_row(_fields, "holds_other_props", "Props can sit on top")
|
||||||
|
_controls["blocks_stickmen"] = _make_toggle_row(_fields, "blocks_stickmen", "Blocks stickmen")
|
||||||
|
_controls["can_pick_up"] = _make_toggle_row(_fields, "can_pick_up", "Stickmen can pick it up")
|
||||||
|
_add_release_options()
|
||||||
|
|
||||||
|
_reopen_button = Button.new()
|
||||||
|
_reopen_button.name = "ReopenPropSettings"
|
||||||
|
_reopen_button.text = "Settings"
|
||||||
|
_reopen_button.custom_minimum_size = Vector2(148.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||||
|
_reopen_button.focus_mode = Control.FOCUS_ALL
|
||||||
|
_reopen_button.visible = false
|
||||||
|
_reopen_button.pressed.connect(_on_reopen_pressed)
|
||||||
|
_card.style_action_button(_reopen_button, true)
|
||||||
|
add_child(_reopen_button)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_legacy_order_cue() -> void:
|
||||||
|
var legacy_cue := Label.new()
|
||||||
|
legacy_cue.name = "LegacyOrderCue"
|
||||||
|
legacy_cue.text = "Old scene order"
|
||||||
|
legacy_cue.visible = false
|
||||||
|
legacy_cue.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||||
|
legacy_cue.add_theme_color_override("font_color", _theme_color("mode_colors.edit", Color("#22C6FF")))
|
||||||
|
_apply_ui_font(legacy_cue)
|
||||||
|
_fields.add_child(legacy_cue)
|
||||||
|
_controls["legacy_cue"] = legacy_cue
|
||||||
|
|
||||||
|
|
||||||
|
func _add_motion_hint() -> void:
|
||||||
|
var motion_hint := Label.new()
|
||||||
|
motion_hint.name = "MotionHint"
|
||||||
|
motion_hint.text = "Stays here while PLAY runs."
|
||||||
|
motion_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
motion_hint.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||||
|
motion_hint.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
|
||||||
|
_apply_ui_font(motion_hint)
|
||||||
|
_fields.add_child(motion_hint)
|
||||||
|
_controls["motion_hint"] = motion_hint
|
||||||
|
|
||||||
|
|
||||||
|
func _add_release_options() -> void:
|
||||||
|
var release_section := VBoxContainer.new()
|
||||||
|
release_section.name = "AfterLettingGo"
|
||||||
|
release_section.add_theme_constant_override("separation", 8)
|
||||||
|
_fields.add_child(release_section)
|
||||||
|
_add_heading(release_section, "After letting go")
|
||||||
|
var release_intro := Label.new()
|
||||||
|
release_intro.text = "After Set Down, Drop, or Throw:"
|
||||||
|
release_intro.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||||
|
release_intro.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
|
||||||
|
_apply_ui_font(release_intro)
|
||||||
|
release_section.add_child(release_intro)
|
||||||
|
_controls["released_blocks_stickmen"] = _make_toggle_row(release_section, "released_blocks_stickmen", "Can bump into stickmen")
|
||||||
|
_controls["released_bumps_other_props"] = _make_toggle_row(release_section, "released_bumps_other_props", "Can bump into other props")
|
||||||
|
_controls["release_section"] = release_section
|
||||||
|
|
||||||
|
|
||||||
|
func _add_heading(parent: VBoxContainer, text_value: String) -> void:
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = text_value
|
||||||
|
label.add_theme_font_size_override("font_size", _stage_panel_integer("type.section_size", 20, 12, 40))
|
||||||
|
label.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
|
||||||
|
_apply_ui_font(label)
|
||||||
|
parent.add_child(label)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_choice_group(key: String) -> StagePanelChoiceGroup:
|
||||||
|
var group := STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
|
||||||
|
group.apply_theme(_stage_theme)
|
||||||
|
if key == "depth":
|
||||||
|
var behind_cue: Control = PROP_SETTINGS_DEPTH_CUE.new()
|
||||||
|
behind_cue.call("configure", false, _stage_panel_color("prop.stickman_cue_color", Color("#F5F7FA")), _stage_panel_color("prop.prop_cue_color", Color("#B7824D")), _stage_panel_color("prop.cue_outline_color", Color("#171B24")))
|
||||||
|
var front_cue: Control = PROP_SETTINGS_DEPTH_CUE.new()
|
||||||
|
front_cue.call("configure", true, _stage_panel_color("prop.stickman_cue_color", Color("#F5F7FA")), _stage_panel_color("prop.prop_cue_color", Color("#B7824D")), _stage_panel_color("prop.cue_outline_color", Color("#171B24")))
|
||||||
|
group.add_choice(&"behind", "Behind stickmen", behind_cue)
|
||||||
|
group.add_choice(&"front", "In front of stickmen", front_cue)
|
||||||
|
else:
|
||||||
|
group.add_choice(&"still", "Stays in place")
|
||||||
|
group.add_choice(&"physics", "Can fall and move")
|
||||||
|
group.choice_selected.connect(_on_choice_selected.bind(key))
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
func _make_toggle_row(parent: VBoxContainer, key: String, caption: String) -> StagePanelToggleRow:
|
||||||
|
var row := STAGE_PANEL_TOGGLE_ROW.new() as StagePanelToggleRow
|
||||||
|
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
row.configure(caption)
|
||||||
|
row.apply_theme(_stage_theme)
|
||||||
|
parent.add_child(row)
|
||||||
|
row.preferred_height_changed.connect(_on_row_height_changed)
|
||||||
|
row.toggled.connect(_on_role_toggled.bind(key))
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func _sync_roles(prop: PropBlock, roles: Dictionary) -> void:
|
||||||
|
var legacy_depth: bool = bool(roles.get("legacy_draw_order", false))
|
||||||
|
if legacy_depth:
|
||||||
|
_depth_group.clear_no_signal()
|
||||||
|
else:
|
||||||
|
_depth_group.select_no_signal(StringName(String(roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND))))
|
||||||
|
_motion_group.select_no_signal(StringName(String(roles.get("start_motion", PROP_STAGE_ROLES.START_STILL))))
|
||||||
|
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props"]:
|
||||||
|
var control := _controls.get(key) as StagePanelToggleRow
|
||||||
|
if control == null:
|
||||||
|
continue
|
||||||
|
control.set_checked_no_signal(bool(roles.get(key, false)))
|
||||||
|
if key == "holds_other_props":
|
||||||
|
control.set_help_text("Other props can land on this one." if bool(roles.get(key, false)) else "")
|
||||||
|
elif key == "blocks_stickmen":
|
||||||
|
control.set_help_text("Stickmen cannot walk through it." if bool(roles.get(key, false)) else "")
|
||||||
|
elif key == "can_pick_up":
|
||||||
|
control.set_help_text("")
|
||||||
|
control.set_disabled_state(not prop.has_usable_grip(), "Add a hand hold in Prop Editor first.")
|
||||||
|
var release_section := _controls.get("release_section") as Control
|
||||||
|
if release_section != null:
|
||||||
|
release_section.visible = bool(roles.get("can_pick_up", false))
|
||||||
|
var legacy_cue := _controls.get("legacy_cue") as Label
|
||||||
|
if legacy_cue != null:
|
||||||
|
legacy_cue.visible = legacy_depth
|
||||||
|
var motion_hint := _controls.get("motion_hint") as Label
|
||||||
|
if motion_hint != null:
|
||||||
|
if String(roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_STILL:
|
||||||
|
motion_hint.text = "Stays here until picked up." if bool(roles.get("can_pick_up", false)) else "Stays here while PLAY runs."
|
||||||
|
else:
|
||||||
|
motion_hint.text = "Gravity and bumps can move it."
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_selection_key(selection: Array[Node2D]) -> void:
|
||||||
|
var keys: PackedStringArray = []
|
||||||
|
var instance_ids: Array[int] = []
|
||||||
|
for node: Node2D in selection:
|
||||||
|
if node != null and is_instance_valid(node):
|
||||||
|
instance_ids.append(node.get_instance_id())
|
||||||
|
instance_ids.sort()
|
||||||
|
for instance_id: int in instance_ids:
|
||||||
|
keys.append(str(instance_id))
|
||||||
|
var selection_key: String = ",".join(keys)
|
||||||
|
if selection_key == _active_selection_key:
|
||||||
|
return
|
||||||
|
_active_selection_key = selection_key
|
||||||
|
_dismissed_selection_key = ""
|
||||||
|
_card.reset_scroll()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_choice_selected(id: StringName, key: String) -> void:
|
||||||
|
var value: String = String(id)
|
||||||
|
if key == "depth":
|
||||||
|
if value == "behind":
|
||||||
|
value = PROP_STAGE_ROLES.DEPTH_BEHIND
|
||||||
|
elif value == "front":
|
||||||
|
value = PROP_STAGE_ROLES.DEPTH_FRONT
|
||||||
|
else:
|
||||||
|
if value == "still":
|
||||||
|
value = PROP_STAGE_ROLES.START_STILL
|
||||||
|
elif value == "physics":
|
||||||
|
value = PROP_STAGE_ROLES.START_PHYSICS
|
||||||
|
role_choice_requested.emit(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_role_toggled(enabled: bool, key: String) -> void:
|
||||||
|
role_toggle_requested.emit(enabled, key)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_row_height_changed() -> void:
|
||||||
|
if _card != null:
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_card_close_requested() -> void:
|
||||||
|
close_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_reopen_pressed() -> void:
|
||||||
|
var should_focus: bool = _reopen_button.has_focus()
|
||||||
|
set_dismissed(false)
|
||||||
|
_card.visible = true
|
||||||
|
_reopen_button.visible = false
|
||||||
|
if should_focus:
|
||||||
|
call_deferred("_focus_card")
|
||||||
|
|
||||||
|
|
||||||
|
func _focus_card() -> void:
|
||||||
|
if _card != null and _card.visible:
|
||||||
|
_card.focus_first_control()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_viewport_resized() -> void:
|
||||||
|
var viewport: Rect2 = get_viewport_rect()
|
||||||
|
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
|
||||||
|
var top_offset: float = minf(_top_bar_height + 116.0, maxf(12.0, viewport.size.y - 220.0))
|
||||||
|
var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - _status_bar_height - 12.0)
|
||||||
|
var safe_rect := Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))
|
||||||
|
update_layout(safe_rect)
|
||||||
|
|
||||||
|
|
||||||
|
func _stage_panel_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.integer("stage_panels." + key, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _stage_panel_color(key: String, fallback: Color) -> Color:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.color("stage_panels." + key, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
func _theme_color(key: String, fallback: Color) -> Color:
|
||||||
|
return fallback if _stage_theme == null else _stage_theme.color(key, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_ui_font(control: Control) -> void:
|
||||||
|
if control != null and _stage_theme != null:
|
||||||
|
var font: Font = _stage_theme.font()
|
||||||
|
if font != null:
|
||||||
|
control.add_theme_font_override("font", font)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://brp43as6806r2
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
class_name StageRuleAuthoringController
|
||||||
|
extends RefCounted
|
||||||
|
## Owns rule-builder interaction state and the rule authoring panels.
|
||||||
|
|
||||||
|
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||||
|
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
||||||
|
const RULE_PANEL_SCENE := preload("res://scenes/editors/stage/rule_panel.tscn")
|
||||||
|
const RULE_EDITOR_SCENE := preload("res://scenes/editors/stage/rule_editor.tscn")
|
||||||
|
|
||||||
|
enum Step { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
|
||||||
|
|
||||||
|
const TRIG_ARRIVED: int = 0
|
||||||
|
const TRIG_ACTION_FINISHED: int = 1
|
||||||
|
const TRIG_SPEECH_FINISHED: int = 2
|
||||||
|
const TRIG_ENTERED_AREA: int = 3
|
||||||
|
const TRIG_COLLIDED: int = 4
|
||||||
|
const TRIG_BACK: int = 5
|
||||||
|
const TRIG_PROP_PICKED_UP: int = 6
|
||||||
|
const TRIG_PROP_DROPPED: int = 7
|
||||||
|
const TRIG_PROP_THROWN: int = 8
|
||||||
|
const RULE_MORE_ADD: int = 0
|
||||||
|
const RULE_MORE_DONE: int = 1
|
||||||
|
const RULE_ACTION_DONE: int = 6
|
||||||
|
const WAYPOINT_MATCH_EPSILON: float = 24.0
|
||||||
|
|
||||||
|
signal trigger_selected(id: int)
|
||||||
|
signal action_selected(id: int)
|
||||||
|
signal more_selected(id: int)
|
||||||
|
signal rule_panel_edit_requested(rule_id: int)
|
||||||
|
signal rule_panel_delete_requested(rule_id: int)
|
||||||
|
signal rule_panel_add_requested
|
||||||
|
signal rule_panel_clear_requested
|
||||||
|
signal rule_panel_reorder_requested(ordered_ids: Array[int])
|
||||||
|
signal rule_editor_committed(rule: Dictionary)
|
||||||
|
signal rule_editor_cancelled
|
||||||
|
signal rule_editor_trigger_target_requested(type: String)
|
||||||
|
signal rule_editor_action_add_requested
|
||||||
|
signal rule_editor_action_edit_requested(index: int)
|
||||||
|
signal toast_requested(message: String)
|
||||||
|
|
||||||
|
var step: int = Step.IDLE
|
||||||
|
var builder: Dictionary = {}
|
||||||
|
var context_rig: StickmanRig
|
||||||
|
var hint: String = ""
|
||||||
|
var editing_id: int = -1
|
||||||
|
var build_from_panel: bool = false
|
||||||
|
var editor_from_panel: bool = false
|
||||||
|
var popup_anchor: Rect2i = Rect2i()
|
||||||
|
var popup_anchor_set: bool = false
|
||||||
|
var panel_source_id: int = -1
|
||||||
|
var panel_waypoint: Vector2 = Vector2.INF
|
||||||
|
var panel_title: String = ""
|
||||||
|
var panel_filter_ids: Array[int] = []
|
||||||
|
var pending_action_flat: Dictionary = {}
|
||||||
|
|
||||||
|
var _trigger_popup: PopupMenu
|
||||||
|
var _action_popup: PopupMenu
|
||||||
|
var _more_popup: PopupMenu
|
||||||
|
var _direct_popup: PopupMenu
|
||||||
|
var _rule_panel: RulePanel
|
||||||
|
var _rule_editor: RuleEditor
|
||||||
|
var _speak_stop_current_check: CheckBox
|
||||||
|
var _viewport: Viewport
|
||||||
|
|
||||||
|
|
||||||
|
func configure(
|
||||||
|
direct_popup: PopupMenu,
|
||||||
|
speak_stop_current_check: CheckBox,
|
||||||
|
viewport: Viewport
|
||||||
|
) -> void:
|
||||||
|
_direct_popup = direct_popup
|
||||||
|
_speak_stop_current_check = speak_stop_current_check
|
||||||
|
_viewport = viewport
|
||||||
|
|
||||||
|
|
||||||
|
func build_ui(
|
||||||
|
parent: Node,
|
||||||
|
popup_ids: Dictionary,
|
||||||
|
ui_font: Font,
|
||||||
|
emoji_font: Font,
|
||||||
|
font_sizes: Dictionary,
|
||||||
|
rule_size: Vector2i
|
||||||
|
) -> void:
|
||||||
|
_trigger_popup = PopupMenu.new()
|
||||||
|
_trigger_popup.add_item("📍 Arrives at a waypoint", int(popup_ids.get("trigger_arrived", TRIG_ARRIVED)))
|
||||||
|
_trigger_popup.add_item("✅ Completes any action", int(popup_ids.get("trigger_action_finished", TRIG_ACTION_FINISHED)))
|
||||||
|
_trigger_popup.add_item("💬 Finishes speaking", int(popup_ids.get("trigger_speech_finished", TRIG_SPEECH_FINISHED)))
|
||||||
|
_trigger_popup.add_item("🎯 Enters trigger area", int(popup_ids.get("trigger_entered_area", TRIG_ENTERED_AREA)))
|
||||||
|
_trigger_popup.add_item("💥 Collides with something", int(popup_ids.get("trigger_collided", TRIG_COLLIDED)))
|
||||||
|
_trigger_popup.add_item("🫴 Picks up a prop", int(popup_ids.get("trigger_prop_picked_up", TRIG_PROP_PICKED_UP)))
|
||||||
|
_trigger_popup.add_item("⬇️ Drops a prop", int(popup_ids.get("trigger_prop_dropped", TRIG_PROP_DROPPED)))
|
||||||
|
_trigger_popup.add_item("🏹 Throws a prop", int(popup_ids.get("trigger_prop_thrown", TRIG_PROP_THROWN)))
|
||||||
|
_trigger_popup.add_separator()
|
||||||
|
_trigger_popup.add_item("⬅ Back to actions", int(popup_ids.get("trigger_back", TRIG_BACK)))
|
||||||
|
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
|
||||||
|
parent.add_child(_trigger_popup)
|
||||||
|
|
||||||
|
_action_popup = PopupMenu.new()
|
||||||
|
_action_popup.add_item("🚶 Move To", int(popup_ids.get("action_walk", 0)))
|
||||||
|
_action_popup.add_item("💬 Speak", int(popup_ids.get("action_speak", 1)))
|
||||||
|
_action_popup.add_item("⏳ Wait", int(popup_ids.get("action_wait", 2)))
|
||||||
|
_action_popup.add_item("💥 Ragdoll", int(popup_ids.get("action_ragdoll", 3)))
|
||||||
|
_action_popup.add_item("🔄 Recover", int(popup_ids.get("action_recover", 4)))
|
||||||
|
_action_popup.add_item("🫴 Pick Up Prop…", int(popup_ids.get("action_pick_up_prop", 9)))
|
||||||
|
_action_popup.add_item("⬇️ Drop Prop", int(popup_ids.get("action_drop_prop", 10)))
|
||||||
|
_action_popup.add_item("🏹 Throw Prop…", int(popup_ids.get("action_throw_prop", 11)))
|
||||||
|
_action_popup.add_item("🫳 Set Down…", int(popup_ids.get("action_set_down_prop", 12)))
|
||||||
|
_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
|
||||||
|
parent.add_child(_action_popup)
|
||||||
|
|
||||||
|
_more_popup = PopupMenu.new()
|
||||||
|
_more_popup.add_item("➕ Add another action", int(popup_ids.get("more_add", RULE_MORE_ADD)))
|
||||||
|
_more_popup.add_item("✅ Done", int(popup_ids.get("more_done", RULE_MORE_DONE)))
|
||||||
|
_more_popup.id_pressed.connect(_on_rule_more_popup_id_pressed)
|
||||||
|
parent.add_child(_more_popup)
|
||||||
|
|
||||||
|
_rule_panel = RULE_PANEL_SCENE.instantiate() as RulePanel
|
||||||
|
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
|
||||||
|
_rule_panel.delete_requested.connect(_on_rule_panel_delete_requested)
|
||||||
|
_rule_panel.add_requested.connect(_on_rule_panel_add_requested)
|
||||||
|
_rule_panel.clear_requested.connect(_on_rule_panel_clear_requested)
|
||||||
|
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
|
||||||
|
parent.add_child(_rule_panel)
|
||||||
|
_rule_panel.apply_font(ui_font, emoji_font, font_sizes)
|
||||||
|
_rule_panel.min_size = rule_size
|
||||||
|
|
||||||
|
_rule_editor = RULE_EDITOR_SCENE.instantiate() as RuleEditor
|
||||||
|
_rule_editor.committed.connect(_on_rule_editor_committed)
|
||||||
|
_rule_editor.cancelled.connect(_on_rule_editor_cancelled)
|
||||||
|
_rule_editor.trigger_target_requested.connect(_on_rule_editor_trigger_target_requested)
|
||||||
|
_rule_editor.action_add_requested.connect(_on_rule_editor_action_add_requested)
|
||||||
|
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
|
||||||
|
parent.add_child(_rule_editor)
|
||||||
|
_rule_editor.apply_font(ui_font, emoji_font, font_sizes)
|
||||||
|
_rule_editor.min_size = rule_size
|
||||||
|
|
||||||
|
|
||||||
|
func get_trigger_popup() -> PopupMenu:
|
||||||
|
return _trigger_popup
|
||||||
|
|
||||||
|
|
||||||
|
func get_action_popup() -> PopupMenu:
|
||||||
|
return _action_popup
|
||||||
|
|
||||||
|
|
||||||
|
func get_more_popup() -> PopupMenu:
|
||||||
|
return _more_popup
|
||||||
|
|
||||||
|
|
||||||
|
func get_rule_panel() -> RulePanel:
|
||||||
|
return _rule_panel
|
||||||
|
|
||||||
|
|
||||||
|
func get_rule_editor() -> RuleEditor:
|
||||||
|
return _rule_editor
|
||||||
|
|
||||||
|
|
||||||
|
func edit_rule_by_id(rules: Array[Dictionary], rule_id: int) -> bool:
|
||||||
|
for rule: Dictionary in rules:
|
||||||
|
if int(rule.get("id", -1)) == rule_id:
|
||||||
|
open_rule_editor_full(rule, true)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func begin_panel_add() -> int:
|
||||||
|
if panel_source_id < 0:
|
||||||
|
toast_requested.emit("Select a stickman first")
|
||||||
|
return -1
|
||||||
|
if _rule_panel != null:
|
||||||
|
_rule_panel.hide()
|
||||||
|
return panel_source_id
|
||||||
|
|
||||||
|
|
||||||
|
func prepare_trigger_target_capture(type: String) -> String:
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.hide()
|
||||||
|
return TRIGGER_REGISTRY.target_type(type)
|
||||||
|
|
||||||
|
|
||||||
|
func trigger_target_capture_hint(target_type: String) -> String:
|
||||||
|
match target_type:
|
||||||
|
"waypoint":
|
||||||
|
return "📍 Click the waypoint (Esc or RMB to cancel)"
|
||||||
|
"area":
|
||||||
|
return "🎯 Click the trigger area (Esc or RMB to cancel)"
|
||||||
|
"prop":
|
||||||
|
return "💥 Click the prop (Esc or RMB to cancel)"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _on_trigger_popup_id_pressed(id: int) -> void:
|
||||||
|
trigger_selected.emit(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_action_popup_id_pressed(id: int) -> void:
|
||||||
|
action_selected.emit(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_more_popup_id_pressed(id: int) -> void:
|
||||||
|
more_selected.emit(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_panel_edit_requested(rule_id: int) -> void:
|
||||||
|
rule_panel_edit_requested.emit(rule_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_panel_delete_requested(rule_id: int) -> void:
|
||||||
|
rule_panel_delete_requested.emit(rule_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_panel_add_requested() -> void:
|
||||||
|
rule_panel_add_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_panel_clear_requested() -> void:
|
||||||
|
rule_panel_clear_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_panel_reorder_requested(ordered_ids: Array[int]) -> void:
|
||||||
|
rule_panel_reorder_requested.emit(ordered_ids)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_editor_committed(rule: Dictionary) -> void:
|
||||||
|
rule_editor_committed.emit(rule)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_editor_cancelled() -> void:
|
||||||
|
rule_editor_cancelled.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_editor_trigger_target_requested(type: String) -> void:
|
||||||
|
rule_editor_trigger_target_requested.emit(type)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_editor_action_add_requested() -> void:
|
||||||
|
rule_editor_action_add_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rule_editor_action_edit_requested(index: int) -> void:
|
||||||
|
rule_editor_action_edit_requested.emit(index)
|
||||||
|
|
||||||
|
|
||||||
|
func mouse_popup_rect() -> Rect2i:
|
||||||
|
var mouse_position: Vector2 = _viewport.get_mouse_position() if _viewport != null else Vector2.ZERO
|
||||||
|
return Rect2i(Vector2i(mouse_position), Vector2i.ZERO)
|
||||||
|
|
||||||
|
|
||||||
|
func set_popup_anchor(rect: Rect2i) -> void:
|
||||||
|
popup_anchor = rect
|
||||||
|
popup_anchor_set = true
|
||||||
|
|
||||||
|
|
||||||
|
func clear_popup_anchor() -> void:
|
||||||
|
popup_anchor = Rect2i()
|
||||||
|
popup_anchor_set = false
|
||||||
|
|
||||||
|
|
||||||
|
func popup_anchor_rect() -> Rect2i:
|
||||||
|
if not popup_anchor_set:
|
||||||
|
set_popup_anchor(mouse_popup_rect())
|
||||||
|
return popup_anchor
|
||||||
|
|
||||||
|
|
||||||
|
func begin_rule_build(source_id: int, from_panel: bool = false) -> void:
|
||||||
|
build_from_panel = from_panel
|
||||||
|
builder = {
|
||||||
|
"trigger": { "source": source_id, "target": -1, "params": {} },
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
editing_id = -1
|
||||||
|
var source: Object = instance_from_id(source_id)
|
||||||
|
context_rig = source as StickmanRig if source is StickmanRig else null
|
||||||
|
step = Step.SELECT_TRIGGER
|
||||||
|
if _trigger_popup != null:
|
||||||
|
_trigger_popup.popup(popup_anchor_rect())
|
||||||
|
|
||||||
|
|
||||||
|
func open_rule_action_popup() -> void:
|
||||||
|
sync_rule_action_done_item()
|
||||||
|
if _action_popup != null:
|
||||||
|
_action_popup.popup(popup_anchor_rect())
|
||||||
|
|
||||||
|
|
||||||
|
func sync_rule_action_done_item() -> void:
|
||||||
|
if _action_popup == null:
|
||||||
|
return
|
||||||
|
var index: int = _action_popup.get_item_index(RULE_ACTION_DONE)
|
||||||
|
var editing: bool = editing_id >= 0
|
||||||
|
if editing and index < 0:
|
||||||
|
_action_popup.add_item("✅ Done", RULE_ACTION_DONE)
|
||||||
|
elif not editing and index >= 0:
|
||||||
|
_action_popup.remove_item(index)
|
||||||
|
|
||||||
|
|
||||||
|
func open_rule_more_popup() -> void:
|
||||||
|
if _more_popup != null:
|
||||||
|
_more_popup.popup(popup_anchor_rect())
|
||||||
|
|
||||||
|
|
||||||
|
func on_trigger_selected(id: int) -> void:
|
||||||
|
var trigger: Dictionary = builder.get("trigger", {})
|
||||||
|
trigger["type"] = trigger_type_string(id)
|
||||||
|
builder["trigger"] = trigger
|
||||||
|
if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN]:
|
||||||
|
step = Step.TRIGGER_TARGET
|
||||||
|
match id:
|
||||||
|
TRIG_ARRIVED:
|
||||||
|
hint = "Click the waypoint to trigger on (Esc or RMB to cancel)"
|
||||||
|
TRIG_ENTERED_AREA:
|
||||||
|
hint = "Click the trigger area to watch (Esc or RMB to cancel)"
|
||||||
|
TRIG_COLLIDED:
|
||||||
|
hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||||
|
TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN:
|
||||||
|
hint = "Click the prop to watch (Esc or RMB to cancel)"
|
||||||
|
else:
|
||||||
|
step = Step.SELECT_ACTION
|
||||||
|
open_rule_action_popup()
|
||||||
|
|
||||||
|
|
||||||
|
func on_action_selected(id: int) -> bool:
|
||||||
|
if id == RULE_ACTION_DONE:
|
||||||
|
return true
|
||||||
|
var action: Dictionary = { "type": action_type_string(id), "target": -1, "params": {} }
|
||||||
|
var actions: Array = builder.get("actions", [])
|
||||||
|
actions.append(action)
|
||||||
|
builder["actions"] = actions
|
||||||
|
step = Step.ACTION_TARGET
|
||||||
|
hint = "Click the stickman who will %s (Esc or RMB to cancel)" % action_verb(id)
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func on_more_selected(id: int) -> bool:
|
||||||
|
match id:
|
||||||
|
RULE_MORE_ADD:
|
||||||
|
step = Step.SELECT_ACTION
|
||||||
|
open_rule_action_popup()
|
||||||
|
return false
|
||||||
|
RULE_MORE_DONE:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func trigger_type_string(id: int) -> String:
|
||||||
|
match id:
|
||||||
|
TRIG_ARRIVED:
|
||||||
|
return "arrived_at_waypoint"
|
||||||
|
TRIG_ACTION_FINISHED:
|
||||||
|
return "action_finished"
|
||||||
|
TRIG_SPEECH_FINISHED:
|
||||||
|
return "speech_finished"
|
||||||
|
TRIG_ENTERED_AREA:
|
||||||
|
return "entered_area"
|
||||||
|
TRIG_COLLIDED:
|
||||||
|
return "collided"
|
||||||
|
TRIG_PROP_PICKED_UP:
|
||||||
|
return "prop_picked_up"
|
||||||
|
TRIG_PROP_DROPPED:
|
||||||
|
return "prop_dropped"
|
||||||
|
TRIG_PROP_THROWN:
|
||||||
|
return "prop_thrown"
|
||||||
|
_:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func action_type_string(id: int) -> String:
|
||||||
|
match id:
|
||||||
|
0:
|
||||||
|
return ACTION_REGISTRY.MOVE_TO
|
||||||
|
1:
|
||||||
|
return "speak"
|
||||||
|
2:
|
||||||
|
return "wait"
|
||||||
|
3:
|
||||||
|
return "ragdoll"
|
||||||
|
4:
|
||||||
|
return "recover"
|
||||||
|
9:
|
||||||
|
return "pick_up_prop"
|
||||||
|
10:
|
||||||
|
return "drop_prop"
|
||||||
|
11:
|
||||||
|
return "throw_prop"
|
||||||
|
12:
|
||||||
|
return "set_down_prop"
|
||||||
|
_:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func action_verb(id: int) -> String:
|
||||||
|
match id:
|
||||||
|
0:
|
||||||
|
return "move to a place"
|
||||||
|
1:
|
||||||
|
return "speak"
|
||||||
|
2:
|
||||||
|
return "wait"
|
||||||
|
3:
|
||||||
|
return "ragdoll"
|
||||||
|
4:
|
||||||
|
return "recover"
|
||||||
|
9:
|
||||||
|
return "pick up a prop"
|
||||||
|
10:
|
||||||
|
return "drop a prop"
|
||||||
|
11:
|
||||||
|
return "throw a prop"
|
||||||
|
12:
|
||||||
|
return "set down a prop"
|
||||||
|
_:
|
||||||
|
return "act"
|
||||||
|
|
||||||
|
|
||||||
|
func set_trigger_target(target_id: int, params: Dictionary) -> void:
|
||||||
|
var trigger: Dictionary = builder.get("trigger", {})
|
||||||
|
trigger["target"] = target_id
|
||||||
|
trigger["params"] = params
|
||||||
|
builder["trigger"] = trigger
|
||||||
|
step = Step.SELECT_ACTION
|
||||||
|
|
||||||
|
|
||||||
|
func accept_trigger_waypoint(position: Vector2) -> void:
|
||||||
|
set_trigger_target(-1, { "waypoint_pos": position })
|
||||||
|
open_rule_action_popup()
|
||||||
|
|
||||||
|
|
||||||
|
func accept_trigger_area(area: TriggerArea) -> void:
|
||||||
|
if area == null or not is_instance_valid(area):
|
||||||
|
return
|
||||||
|
set_trigger_target(int(area.get_instance_id()), {})
|
||||||
|
open_rule_action_popup()
|
||||||
|
|
||||||
|
|
||||||
|
func accept_trigger_prop(prop: PropBlock) -> void:
|
||||||
|
if prop == null or not is_instance_valid(prop):
|
||||||
|
return
|
||||||
|
set_trigger_target(int(prop.get_instance_id()), {})
|
||||||
|
open_rule_action_popup()
|
||||||
|
|
||||||
|
|
||||||
|
func set_trigger_target_miss_hint(trigger_type: String) -> void:
|
||||||
|
match trigger_type:
|
||||||
|
"arrived_at_waypoint":
|
||||||
|
hint = "Click a waypoint dot (Esc or RMB to cancel)"
|
||||||
|
"entered_area":
|
||||||
|
hint = "Click a trigger area (Esc or RMB to cancel)"
|
||||||
|
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
|
||||||
|
hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||||
|
|
||||||
|
|
||||||
|
func set_trigger_target_node(value: Node2D) -> void:
|
||||||
|
if value is TriggerArea:
|
||||||
|
accept_trigger_area(value as TriggerArea)
|
||||||
|
elif value is PropBlock:
|
||||||
|
accept_trigger_prop(value as PropBlock)
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_trigger_target_capture() -> void:
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func set_rule_editor_trigger_waypoint(position: Vector2) -> void:
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.set_trigger_target(-1, { "waypoint_pos": position })
|
||||||
|
|
||||||
|
|
||||||
|
func set_rule_editor_trigger_node(value: Node2D) -> void:
|
||||||
|
if value == null or not is_instance_valid(value):
|
||||||
|
return
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.set_trigger_target(int(value.get_instance_id()), {})
|
||||||
|
|
||||||
|
|
||||||
|
func accept_rule_actor(value: Node2D) -> void:
|
||||||
|
if value == null or not is_instance_valid(value):
|
||||||
|
return
|
||||||
|
var actor_id: int = int(value.get_instance_id())
|
||||||
|
var flat: Dictionary = pending_action_flat
|
||||||
|
pending_action_flat = {}
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, actor_id))
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_rule_actor_capture() -> void:
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func set_last_action_target(target_id: int) -> Dictionary:
|
||||||
|
var actions: Array = builder.get("actions", [])
|
||||||
|
if actions.is_empty():
|
||||||
|
return {}
|
||||||
|
var action: Dictionary = actions.back()
|
||||||
|
action["target"] = target_id
|
||||||
|
actions[actions.size() - 1] = action
|
||||||
|
builder["actions"] = actions
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
func set_last_action_params(params: Dictionary) -> void:
|
||||||
|
var actions: Array = builder.get("actions", [])
|
||||||
|
if actions.is_empty():
|
||||||
|
return
|
||||||
|
var action: Dictionary = actions.back()
|
||||||
|
action["params"] = params
|
||||||
|
actions[actions.size() - 1] = action
|
||||||
|
builder["actions"] = actions
|
||||||
|
|
||||||
|
|
||||||
|
func accept_last_action_position(position: Vector2) -> void:
|
||||||
|
set_last_action_param("target", position)
|
||||||
|
open_rule_more_popup()
|
||||||
|
|
||||||
|
|
||||||
|
func accept_last_action_prop_target(prop: PropBlock) -> bool:
|
||||||
|
if prop == null or not is_instance_valid(prop):
|
||||||
|
return false
|
||||||
|
if prop.stage_object_id.is_empty():
|
||||||
|
toast_requested.emit("That prop is missing its scene ID")
|
||||||
|
return false
|
||||||
|
var actions: Array = builder.get("actions", [])
|
||||||
|
if actions.is_empty():
|
||||||
|
return false
|
||||||
|
var action: Dictionary = actions.back()
|
||||||
|
var params: Dictionary = action.get("params", {})
|
||||||
|
params["target_prop_id"] = prop.stage_object_id
|
||||||
|
params["hand"] = String(params.get("hand", "auto"))
|
||||||
|
action["params"] = params
|
||||||
|
actions[actions.size() - 1] = action
|
||||||
|
builder["actions"] = actions
|
||||||
|
open_rule_more_popup()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func accept_last_action_world_target(position: Vector2) -> void:
|
||||||
|
set_last_action_param("target_position", position)
|
||||||
|
open_rule_more_popup()
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_last_action_target_capture() -> void:
|
||||||
|
step = Step.ACTION_TARGET
|
||||||
|
hint = "Click the stickman who will act (Esc or RMB to cancel)"
|
||||||
|
|
||||||
|
|
||||||
|
func accept_action_params_from_dialog(params: Dictionary) -> bool:
|
||||||
|
if step != Step.PARAMS:
|
||||||
|
return false
|
||||||
|
set_last_action_params(params)
|
||||||
|
open_rule_more_popup()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func set_last_action_param(key: String, value: Variant) -> void:
|
||||||
|
var actions: Array = builder.get("actions", [])
|
||||||
|
if actions.is_empty():
|
||||||
|
return
|
||||||
|
var action: Dictionary = actions.back()
|
||||||
|
var params: Dictionary = action.get("params", {})
|
||||||
|
params[key] = value
|
||||||
|
action["params"] = params
|
||||||
|
actions[actions.size() - 1] = action
|
||||||
|
builder["actions"] = actions
|
||||||
|
|
||||||
|
|
||||||
|
func reset_builder() -> void:
|
||||||
|
if _speak_stop_current_check != null:
|
||||||
|
_speak_stop_current_check.visible = false
|
||||||
|
_speak_stop_current_check.button_pressed = false
|
||||||
|
step = Step.IDLE
|
||||||
|
builder.clear()
|
||||||
|
context_rig = null
|
||||||
|
hint = ""
|
||||||
|
editing_id = -1
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_move_to_action_editor() -> void:
|
||||||
|
var actions: Array = builder.get("actions", [])
|
||||||
|
if not actions.is_empty():
|
||||||
|
actions.pop_back()
|
||||||
|
builder["actions"] = actions
|
||||||
|
step = Step.SELECT_ACTION
|
||||||
|
if actions.is_empty():
|
||||||
|
open_rule_action_popup()
|
||||||
|
else:
|
||||||
|
open_rule_more_popup()
|
||||||
|
|
||||||
|
|
||||||
|
func set_panel_for_rig(rig: StickmanRig) -> void:
|
||||||
|
panel_source_id = rig.get_instance_id()
|
||||||
|
panel_waypoint = Vector2.INF
|
||||||
|
panel_title = "Source: %s" % rig.name
|
||||||
|
|
||||||
|
|
||||||
|
func set_panel_for_waypoint(position: Vector2) -> void:
|
||||||
|
panel_source_id = -1
|
||||||
|
panel_waypoint = position
|
||||||
|
panel_title = "Waypoint (%d, %d)" % [int(roundf(position.x)), int(roundf(position.y))]
|
||||||
|
|
||||||
|
|
||||||
|
func panel_rules(rules: Array[Dictionary]) -> Array[Dictionary]:
|
||||||
|
var filtered: Array[Dictionary] = []
|
||||||
|
for rule: Dictionary in rules:
|
||||||
|
if rule_matches_panel_filter(rule):
|
||||||
|
filtered.append(rule)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
func rule_matches_panel_filter(rule: Dictionary) -> bool:
|
||||||
|
if panel_source_id >= 0:
|
||||||
|
var trigger: Dictionary = rule.get("trigger", {})
|
||||||
|
return int(trigger.get("source", -1)) == panel_source_id
|
||||||
|
if panel_waypoint.is_finite():
|
||||||
|
var trigger: Dictionary = rule.get("trigger", {})
|
||||||
|
if String(trigger.get("type", "")) != "arrived_at_waypoint":
|
||||||
|
return false
|
||||||
|
var params: Dictionary = trigger.get("params", {})
|
||||||
|
var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF)
|
||||||
|
return waypoint.is_finite() and waypoint.distance_to(panel_waypoint) <= WAYPOINT_MATCH_EPSILON
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func show_rule_panel(rules: Array[Dictionary]) -> void:
|
||||||
|
panel_filter_ids.clear()
|
||||||
|
var filtered: Array[Dictionary] = panel_rules(rules)
|
||||||
|
for rule: Dictionary in filtered:
|
||||||
|
panel_filter_ids.append(int(rule.get("id", -1)))
|
||||||
|
if _rule_panel != null:
|
||||||
|
_rule_panel.show_rules(filtered, panel_title)
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_rule_panel(rules: Array[Dictionary]) -> void:
|
||||||
|
if _rule_panel == null:
|
||||||
|
return
|
||||||
|
panel_filter_ids.clear()
|
||||||
|
var filtered: Array[Dictionary] = panel_rules(rules)
|
||||||
|
for rule: Dictionary in filtered:
|
||||||
|
panel_filter_ids.append(int(rule.get("id", -1)))
|
||||||
|
_rule_panel.set_rules(filtered, panel_title)
|
||||||
|
|
||||||
|
|
||||||
|
func restore_rule_build_panel(rules: Array[Dictionary]) -> bool:
|
||||||
|
if not build_from_panel:
|
||||||
|
return false
|
||||||
|
build_from_panel = false
|
||||||
|
refresh_rule_panel(rules)
|
||||||
|
if _rule_panel != null:
|
||||||
|
_rule_panel.popup_centered()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func open_rule_editor_full(rule: Dictionary, from_panel: bool) -> void:
|
||||||
|
editor_from_panel = from_panel
|
||||||
|
if from_panel and _rule_panel != null:
|
||||||
|
_rule_panel.hide()
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.open_full(rule)
|
||||||
|
|
||||||
|
|
||||||
|
func open_rule_editor_consequence(rule: Dictionary) -> void:
|
||||||
|
editor_from_panel = false
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.open_consequence(rule)
|
||||||
|
|
||||||
|
|
||||||
|
func restore_rule_panel(rules: Array[Dictionary]) -> void:
|
||||||
|
if not editor_from_panel:
|
||||||
|
return
|
||||||
|
editor_from_panel = false
|
||||||
|
refresh_rule_panel(rules)
|
||||||
|
if _rule_panel != null:
|
||||||
|
_rule_panel.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
|
func get_rule_editor_action(index: int) -> Dictionary:
|
||||||
|
return {} if _rule_editor == null else _rule_editor.get_action(index)
|
||||||
|
|
||||||
|
|
||||||
|
func set_rule_editor_trigger_target(target_id: int, params: Dictionary) -> void:
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.set_trigger_target(target_id, params)
|
||||||
|
|
||||||
|
|
||||||
|
func set_rule_editor_action(index: int, action: Dictionary) -> void:
|
||||||
|
if _rule_editor != null:
|
||||||
|
_rule_editor.set_action(index, action)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://btfdwp0ogfw8r
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
class_name StageRunSession
|
||||||
|
extends RefCounted
|
||||||
|
## Owns run lifecycle state and the authored transforms restored between runs.
|
||||||
|
|
||||||
|
const STICKMAN_RIG_SCRIPT := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
|
||||||
|
const STATE_STOPPED: int = 0
|
||||||
|
const STATE_RUNNING: int = 1
|
||||||
|
const STATE_PAUSED: int = 2
|
||||||
|
const PROP_SCALE_TRACE_ENABLED: bool = true
|
||||||
|
|
||||||
|
var run_state: int = STATE_STOPPED
|
||||||
|
var elapsed_seconds: float = 0.0
|
||||||
|
var event_dispatch_ready: bool = false
|
||||||
|
var resume_baseline_frames: int = 0
|
||||||
|
var prop_pause_states: Dictionary = {}
|
||||||
|
var paused_edit_autosave_remaining: float = -1.0
|
||||||
|
var authored_state: Dictionary = {}
|
||||||
|
var restore_frames_left: int = 0
|
||||||
|
var prop_scale_trace_previous: Dictionary = {}
|
||||||
|
var edit_physics_targets: Dictionary = {} # { RigidBody2D: Transform2D }
|
||||||
|
var edit_physics_settle_frames: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func prepare_start() -> void:
|
||||||
|
restore_frames_left = 0
|
||||||
|
elapsed_seconds = 0.0
|
||||||
|
prop_pause_states.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func start_run(
|
||||||
|
world_nodes: Array[Node2D],
|
||||||
|
rule_engine: StageRuleEngine,
|
||||||
|
stage_mode_name: String,
|
||||||
|
camera_zoom: Vector2
|
||||||
|
) -> void:
|
||||||
|
run_state = STATE_RUNNING
|
||||||
|
event_dispatch_ready = true
|
||||||
|
resume_baseline_frames = 0
|
||||||
|
if rule_engine != null:
|
||||||
|
rule_engine.reset_runtime_state()
|
||||||
|
for node: Node2D in world_nodes:
|
||||||
|
if node is PropBlock:
|
||||||
|
var prop: PropBlock = node as PropBlock
|
||||||
|
trace_prop_scale(prop, "before_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom)
|
||||||
|
prop.begin_play_instance_scale()
|
||||||
|
trace_prop_scale(prop, "after_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom)
|
||||||
|
var start_role: PropBlock.RuntimeRole = PropBlock.RuntimeRole.START_STILL if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_STILL else PropBlock.RuntimeRole.START_PHYSICS
|
||||||
|
prop.apply_runtime_role(start_role)
|
||||||
|
trace_prop_scale(prop, "after_play_role_applied", stage_mode_name, "RUNNING", camera_zoom)
|
||||||
|
elif node is STICKMAN_RIG_SCRIPT:
|
||||||
|
var rig: StickmanRig = node as StickmanRig
|
||||||
|
rig.reset_simulation_pause()
|
||||||
|
rig.stop_queue()
|
||||||
|
rig.clear_reactive_actions()
|
||||||
|
rig.auto_recover = false
|
||||||
|
rig.begin_terrain_fall()
|
||||||
|
rig.start_queue()
|
||||||
|
elif node is RigidBody2D:
|
||||||
|
(node as RigidBody2D).freeze = false
|
||||||
|
|
||||||
|
|
||||||
|
func pause_run(world_nodes: Array[Node2D]) -> bool:
|
||||||
|
if run_state != STATE_RUNNING:
|
||||||
|
return false
|
||||||
|
event_dispatch_ready = false
|
||||||
|
run_state = STATE_PAUSED
|
||||||
|
resume_baseline_frames = 0
|
||||||
|
for node: Node2D in world_nodes:
|
||||||
|
if node is STICKMAN_RIG_SCRIPT:
|
||||||
|
(node as StickmanRig).pause_simulation()
|
||||||
|
elif node is RigidBody2D:
|
||||||
|
capture_prop_pause_state(node as RigidBody2D)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func resume_run(world_nodes: Array[Node2D], rule_engine: StageRuleEngine) -> bool:
|
||||||
|
if run_state != STATE_PAUSED:
|
||||||
|
return false
|
||||||
|
for body_id: int in prop_pause_states.keys():
|
||||||
|
var snapshot: Dictionary = prop_pause_states[body_id]
|
||||||
|
var body: RigidBody2D = snapshot.get("body") as RigidBody2D
|
||||||
|
if body == null or not is_instance_valid(body) or body.is_queued_for_deletion():
|
||||||
|
continue
|
||||||
|
body.freeze_mode = int(snapshot.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
||||||
|
body.freeze = bool(snapshot.get("freeze", false))
|
||||||
|
body.linear_velocity = snapshot.get("linear_velocity", Vector2.ZERO)
|
||||||
|
body.angular_velocity = float(snapshot.get("angular_velocity", 0.0))
|
||||||
|
body.sleeping = bool(snapshot.get("sleeping", false))
|
||||||
|
prop_pause_states.clear()
|
||||||
|
for node: Node2D in world_nodes:
|
||||||
|
if node is STICKMAN_RIG_SCRIPT:
|
||||||
|
(node as StickmanRig).resume_simulation()
|
||||||
|
run_state = STATE_RUNNING
|
||||||
|
event_dispatch_ready = false
|
||||||
|
resume_baseline_frames = 2
|
||||||
|
if rule_engine != null:
|
||||||
|
rule_engine.reset_runtime_state()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func begin_reset() -> void:
|
||||||
|
event_dispatch_ready = false
|
||||||
|
run_state = STATE_STOPPED
|
||||||
|
resume_baseline_frames = 0
|
||||||
|
paused_edit_autosave_remaining = -1.0
|
||||||
|
prop_pause_states.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func reset_world(
|
||||||
|
world_nodes: Array[Node2D],
|
||||||
|
stage_mode_name: String,
|
||||||
|
camera_zoom: Vector2
|
||||||
|
) -> void:
|
||||||
|
for node: Node2D in world_nodes:
|
||||||
|
if node is STICKMAN_RIG_SCRIPT:
|
||||||
|
var rig: StickmanRig = node as StickmanRig
|
||||||
|
rig.reset_prop_interactions_for_scene_exit()
|
||||||
|
rig.stop_queue()
|
||||||
|
rig.set_terrain_following_enabled(false)
|
||||||
|
rig.snap_to_standing()
|
||||||
|
rig.clear_reactive_actions()
|
||||||
|
rig.reset_simulation_pause()
|
||||||
|
elif node is RigidBody2D:
|
||||||
|
var body: RigidBody2D = node as RigidBody2D
|
||||||
|
if node is PropBlock:
|
||||||
|
var prop: PropBlock = node as PropBlock
|
||||||
|
var authored: Dictionary = authored_state.get(prop.get_instance_id(), {})
|
||||||
|
trace_prop_scale(prop, "before_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom)
|
||||||
|
prop.set_stage_roles(authored.get("roles", prop.stage_roles))
|
||||||
|
prop.end_play_instance_scale()
|
||||||
|
prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT)
|
||||||
|
trace_prop_scale(prop, "after_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom)
|
||||||
|
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||||
|
body.freeze = true
|
||||||
|
body.linear_velocity = Vector2.ZERO
|
||||||
|
body.angular_velocity = 0.0
|
||||||
|
restore_authored_state(stage_mode_name, "STOPPED", camera_zoom)
|
||||||
|
|
||||||
|
|
||||||
|
func finish_reset() -> void:
|
||||||
|
restore_frames_left = 3
|
||||||
|
elapsed_seconds = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func arm_authored_restore() -> void:
|
||||||
|
restore_frames_left = 3
|
||||||
|
|
||||||
|
|
||||||
|
func reset_elapsed() -> void:
|
||||||
|
elapsed_seconds = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func capture_prop_pause_state(body: RigidBody2D) -> Dictionary:
|
||||||
|
var snapshot: Dictionary = {
|
||||||
|
"body": body,
|
||||||
|
"transform": body.global_transform,
|
||||||
|
"linear_velocity": body.linear_velocity,
|
||||||
|
"angular_velocity": body.angular_velocity,
|
||||||
|
"sleeping": body.sleeping,
|
||||||
|
"freeze": body.freeze,
|
||||||
|
"freeze_mode": body.freeze_mode,
|
||||||
|
}
|
||||||
|
prop_pause_states[body.get_instance_id()] = snapshot
|
||||||
|
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||||
|
body.freeze = true
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
func save_object_state(node: Node2D) -> void:
|
||||||
|
var authored_rotation: float = node.global_rotation
|
||||||
|
if node is STICKMAN_RIG_SCRIPT and (node as StickmanRig).get_facing_profile() == StickmanRig.FacingProfile.LEFT:
|
||||||
|
# LEFT is a derived root reflection. Remove it before persisting the
|
||||||
|
# director-authored rotation so a load never applies the mirror twice.
|
||||||
|
authored_rotation = (node.global_transform * Transform2D.FLIP_X).get_rotation()
|
||||||
|
var entry: Dictionary = {
|
||||||
|
"node": node,
|
||||||
|
"position": node.global_position,
|
||||||
|
"rotation": authored_rotation,
|
||||||
|
}
|
||||||
|
if node is STICKMAN_RIG_SCRIPT:
|
||||||
|
entry["facing_profile"] = (node as STICKMAN_RIG_SCRIPT).get_facing_profile()
|
||||||
|
if not node is STICKMAN_RIG_SCRIPT:
|
||||||
|
entry["scale"] = node.scale
|
||||||
|
if node is PropBlock:
|
||||||
|
entry["roles"] = (node as PropBlock).stage_roles.duplicate(true)
|
||||||
|
authored_state[node.get_instance_id()] = entry
|
||||||
|
|
||||||
|
|
||||||
|
func clear_object_state(node: Node2D) -> void:
|
||||||
|
authored_state.erase(node.get_instance_id())
|
||||||
|
|
||||||
|
|
||||||
|
func restore_authored_state(stage_mode_name: String, run_state_name: String, camera_zoom: Vector2) -> void:
|
||||||
|
for id: int in authored_state.keys():
|
||||||
|
var entry: Dictionary = authored_state[id]
|
||||||
|
var node: Node2D = entry.get("node") as Node2D
|
||||||
|
if node == null or not is_instance_valid(node):
|
||||||
|
authored_state.erase(id)
|
||||||
|
continue
|
||||||
|
node.global_position = entry.get("position", node.global_position)
|
||||||
|
node.global_rotation = entry.get("rotation", node.global_rotation)
|
||||||
|
if entry.has("scale"):
|
||||||
|
if node is PropBlock:
|
||||||
|
trace_prop_scale(node as PropBlock, "before_authored_scale_write", stage_mode_name, run_state_name, camera_zoom)
|
||||||
|
node.scale = entry["scale"]
|
||||||
|
if node is PropBlock:
|
||||||
|
trace_prop_scale(node as PropBlock, "after_authored_scale_write", stage_mode_name, run_state_name, camera_zoom)
|
||||||
|
if node is STICKMAN_RIG_SCRIPT and entry.has("facing_profile"):
|
||||||
|
(node as STICKMAN_RIG_SCRIPT).set_facing_profile(int(entry["facing_profile"]))
|
||||||
|
if node is RigidBody2D:
|
||||||
|
var body: RigidBody2D = node as RigidBody2D
|
||||||
|
body.linear_velocity = Vector2.ZERO
|
||||||
|
body.angular_velocity = 0.0
|
||||||
|
if node is PropBlock:
|
||||||
|
var prop: PropBlock = node as PropBlock
|
||||||
|
prop.set_stage_roles(entry.get("roles", prop.stage_roles))
|
||||||
|
prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT)
|
||||||
|
|
||||||
|
|
||||||
|
func trace_changed_prop_scales(
|
||||||
|
world_children: Array[Node],
|
||||||
|
stage_mode_name: String,
|
||||||
|
run_state_name: String,
|
||||||
|
camera_zoom: Vector2
|
||||||
|
) -> void:
|
||||||
|
if not PROP_SCALE_TRACE_ENABLED:
|
||||||
|
return
|
||||||
|
var current_prop_ids: Dictionary = {}
|
||||||
|
for child: Node in world_children:
|
||||||
|
if not child is PropBlock or not is_instance_valid(child):
|
||||||
|
continue
|
||||||
|
var prop: PropBlock = child as PropBlock
|
||||||
|
var instance_id: int = prop.get_instance_id()
|
||||||
|
current_prop_ids[instance_id] = true
|
||||||
|
var snapshot: String = prop_scale_trace_signature(prop, camera_zoom)
|
||||||
|
if String(prop_scale_trace_previous.get(instance_id, "")) != snapshot:
|
||||||
|
trace_prop_scale(prop, "observed_scale_change", stage_mode_name, run_state_name, camera_zoom)
|
||||||
|
for instance_id: int in prop_scale_trace_previous.keys():
|
||||||
|
if not current_prop_ids.has(instance_id):
|
||||||
|
prop_scale_trace_previous.erase(instance_id)
|
||||||
|
|
||||||
|
|
||||||
|
func trace_prop_scale(
|
||||||
|
prop: PropBlock,
|
||||||
|
event: String,
|
||||||
|
stage_mode_name: String,
|
||||||
|
run_state_name: String,
|
||||||
|
camera_zoom: Vector2
|
||||||
|
) -> void:
|
||||||
|
if not PROP_SCALE_TRACE_ENABLED or prop == null or not is_instance_valid(prop):
|
||||||
|
return
|
||||||
|
var snapshot: String = prop_scale_trace_signature(prop, camera_zoom)
|
||||||
|
prop_scale_trace_previous[prop.get_instance_id()] = snapshot
|
||||||
|
print("[PropScaleTrace t=%dms] event=%s mode=%s run_state=%s restore_pending=%d prop=%s role=%s freeze=%s freeze_mode=%d %s" % [
|
||||||
|
Time.get_ticks_msec(),
|
||||||
|
event,
|
||||||
|
stage_mode_name,
|
||||||
|
run_state_name,
|
||||||
|
restore_frames_left,
|
||||||
|
prop.stage_object_id if not prop.stage_object_id.is_empty() else String(prop.name),
|
||||||
|
PropBlock.RuntimeRole.keys()[int(prop.runtime_role)],
|
||||||
|
str(prop.freeze),
|
||||||
|
prop.freeze_mode,
|
||||||
|
snapshot,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
func prop_scale_trace_signature(prop: PropBlock, camera_zoom: Vector2) -> String:
|
||||||
|
var polygon: Polygon2D = prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||||
|
var outline: Line2D = prop.get_node_or_null(NodePath("Outline")) as Line2D
|
||||||
|
var visuals: Node2D = prop.get_node_or_null(NodePath("Visuals")) as Node2D
|
||||||
|
var collision_polygon: CollisionPolygon2D = prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
|
||||||
|
var polygon_local: Vector2 = polygon.scale if polygon != null else Vector2.ONE
|
||||||
|
var polygon_global_x: float = polygon.global_transform.x.length() if polygon != null else 0.0
|
||||||
|
var polygon_global_y: float = polygon.global_transform.y.length() if polygon != null else 0.0
|
||||||
|
var outline_local: Vector2 = outline.scale if outline != null else Vector2.ONE
|
||||||
|
var outline_global_x: float = outline.global_transform.x.length() if outline != null else 0.0
|
||||||
|
var outline_global_y: float = outline.global_transform.y.length() if outline != null else 0.0
|
||||||
|
var visuals_local: Vector2 = visuals.scale if visuals != null else Vector2.ONE
|
||||||
|
var visuals_global_x: float = visuals.global_transform.x.length() if visuals != null else 0.0
|
||||||
|
var visuals_global_y: float = visuals.global_transform.y.length() if visuals != null else 0.0
|
||||||
|
var collision_point: Vector2 = collision_polygon.polygon[0] if collision_polygon != null and not collision_polygon.polygon.is_empty() else Vector2.ZERO
|
||||||
|
return "root_local=%s root_global=(%.5f,%.5f) poly_local=%s poly_global=(%.5f,%.5f) outline_local=%s outline_global=(%.5f,%.5f) visuals_local=%s visuals_global=(%.5f,%.5f) collision_points=%d collision_first=%s play_scale_active=%s play_scale=%s camera_zoom=%s" % [
|
||||||
|
str(prop.scale),
|
||||||
|
prop.global_transform.x.length(),
|
||||||
|
prop.global_transform.y.length(),
|
||||||
|
str(polygon_local),
|
||||||
|
polygon_global_x,
|
||||||
|
polygon_global_y,
|
||||||
|
str(outline_local),
|
||||||
|
outline_global_x,
|
||||||
|
outline_global_y,
|
||||||
|
str(visuals_local),
|
||||||
|
visuals_global_x,
|
||||||
|
visuals_global_y,
|
||||||
|
collision_polygon.polygon.size() if collision_polygon != null else 0,
|
||||||
|
str(collision_point),
|
||||||
|
str(prop.get("_play_scale_active")),
|
||||||
|
str(prop.get("_play_instance_scale")),
|
||||||
|
str(camera_zoom),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func advance_elapsed(delta: float) -> float:
|
||||||
|
if run_state != STATE_RUNNING:
|
||||||
|
return -1.0
|
||||||
|
elapsed_seconds += delta
|
||||||
|
return elapsed_seconds
|
||||||
|
|
||||||
|
|
||||||
|
func consume_resume_baseline_frame() -> Dictionary:
|
||||||
|
if resume_baseline_frames <= 0:
|
||||||
|
return {"active": false, "finished": false}
|
||||||
|
resume_baseline_frames -= 1
|
||||||
|
return {"active": true, "finished": resume_baseline_frames == 0}
|
||||||
|
|
||||||
|
|
||||||
|
func advance_paused_edit_autosave(delta: float) -> bool:
|
||||||
|
if paused_edit_autosave_remaining < 0.0:
|
||||||
|
return false
|
||||||
|
paused_edit_autosave_remaining -= delta
|
||||||
|
if paused_edit_autosave_remaining > 0.0:
|
||||||
|
return false
|
||||||
|
paused_edit_autosave_remaining = -1.0
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func clear_edit_physics_targets() -> void:
|
||||||
|
edit_physics_targets.clear()
|
||||||
|
edit_physics_settle_frames = 0
|
||||||
|
|
||||||
|
|
||||||
|
func add_edit_physics_target(body: RigidBody2D) -> void:
|
||||||
|
edit_physics_targets[body] = body.global_transform
|
||||||
|
|
||||||
|
|
||||||
|
func set_edit_physics_settle_frames(frame_count: int) -> void:
|
||||||
|
edit_physics_settle_frames = frame_count
|
||||||
|
|
||||||
|
|
||||||
|
func sync_pending_editable_physics_transforms(is_edit_mode: bool, gizmo_dragging: bool) -> Array[RigidBody2D]:
|
||||||
|
var synced_bodies: Array[RigidBody2D] = []
|
||||||
|
if edit_physics_targets.is_empty() or not is_edit_mode:
|
||||||
|
return synced_bodies
|
||||||
|
for body: RigidBody2D in edit_physics_targets.keys().duplicate():
|
||||||
|
if body == null or not is_instance_valid(body):
|
||||||
|
edit_physics_targets.erase(body)
|
||||||
|
continue
|
||||||
|
body.global_transform = edit_physics_targets[body]
|
||||||
|
synced_bodies.append(body)
|
||||||
|
if not gizmo_dragging and edit_physics_settle_frames > 0:
|
||||||
|
edit_physics_settle_frames -= 1
|
||||||
|
if edit_physics_settle_frames == 0:
|
||||||
|
edit_physics_targets.clear()
|
||||||
|
return synced_bodies
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://7mkon4j4j1pl
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
class_name StageSceneAdapter
|
||||||
|
extends RefCounted
|
||||||
|
## Maps live stage objects to and from the StageSceneIO document representation.
|
||||||
|
|
||||||
|
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||||
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
|
const STICKMAN_RIG_SCRIPT := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||||
|
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||||
|
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
|
||||||
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func collect(
|
||||||
|
world: Node2D,
|
||||||
|
authored: Dictionary,
|
||||||
|
rules: Array[Dictionary],
|
||||||
|
camera_state: Dictionary,
|
||||||
|
document_metadata: Dictionary
|
||||||
|
) -> Dictionary:
|
||||||
|
var objects: Array[Dictionary] = []
|
||||||
|
var runtime_to_file: Dictionary = {}
|
||||||
|
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
||||||
|
for node: Node in world.get_children():
|
||||||
|
if not node is Node2D:
|
||||||
|
continue
|
||||||
|
var stage_node: Node2D = node as Node2D
|
||||||
|
var type: String = _object_type(stage_node)
|
||||||
|
if type.is_empty():
|
||||||
|
continue
|
||||||
|
counters[type] = int(counters[type]) + 1
|
||||||
|
var file_id: String = "%s_%d" % [type, int(counters[type])]
|
||||||
|
runtime_to_file[stage_node.get_instance_id()] = file_id
|
||||||
|
var authored_state: Dictionary = authored.get(stage_node.get_instance_id(), {})
|
||||||
|
var position: Vector2 = authored_state.get("position", stage_node.global_position)
|
||||||
|
var rotation_degrees: float = rad_to_deg(float(authored_state.get("rotation", stage_node.global_rotation)))
|
||||||
|
var entry: Dictionary = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"type": type,
|
||||||
|
"position": STAGE_SCENE_IO.vector_to_data(position),
|
||||||
|
"rotation": rotation_degrees,
|
||||||
|
}
|
||||||
|
if type == "terrain":
|
||||||
|
entry["asset_id"] = String(stage_node.get("spawn_id"))
|
||||||
|
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
|
||||||
|
elif type == "prop" and stage_node.get_script() == MISSING_PROP_PLACEHOLDER:
|
||||||
|
var original_entry: Dictionary = stage_node.get("original_entry") as Dictionary
|
||||||
|
entry = original_entry.duplicate(true)
|
||||||
|
entry["file_id"] = file_id
|
||||||
|
entry["type"] = "prop"
|
||||||
|
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
|
||||||
|
entry["rotation"] = rotation_degrees
|
||||||
|
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
|
||||||
|
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
|
||||||
|
elif type == "prop":
|
||||||
|
var prop: PropBlock = stage_node as PropBlock
|
||||||
|
entry["asset_id"] = prop.spawn_id
|
||||||
|
entry["stage_object_id"] = prop.stage_object_id
|
||||||
|
entry["roles"] = prop.stage_roles.duplicate(true)
|
||||||
|
var prop_scale: Vector2 = authored_state.get("scale", prop.scale)
|
||||||
|
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
|
||||||
|
entry["material_preset"] = prop.material_preset
|
||||||
|
elif type == "area":
|
||||||
|
var area: TriggerArea = stage_node as TriggerArea
|
||||||
|
entry["scale"] = STAGE_SCENE_IO.vector_to_data(area.scale)
|
||||||
|
entry["size"] = STAGE_SCENE_IO.vector_to_data(area.size)
|
||||||
|
elif stage_node.get_script() == MISSING_STICKMAN_PLACEHOLDER:
|
||||||
|
var original_entry: Dictionary = stage_node.get("original_entry") as Dictionary
|
||||||
|
entry = original_entry.duplicate(true)
|
||||||
|
entry["file_id"] = file_id
|
||||||
|
entry["type"] = "stickman"
|
||||||
|
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
|
||||||
|
entry["rotation"] = rotation_degrees
|
||||||
|
else:
|
||||||
|
var rig: StickmanRig = stage_node as StickmanRig
|
||||||
|
entry["stk_path"] = rig.source_stk_path
|
||||||
|
var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile()))
|
||||||
|
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile]
|
||||||
|
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
|
||||||
|
entry["action_queue"] = queue_result["actions"]
|
||||||
|
if type == "stickman" and entry.has("stk_path"):
|
||||||
|
entry["stk_path"] = CONTENT_PATHS.canonicalize(String(entry.get("stk_path", "")))
|
||||||
|
elif type == "prop" and String(entry.get("asset_id", "")).ends_with(".prop"):
|
||||||
|
entry["asset_id"] = CONTENT_PATHS.canonicalize(String(entry.get("asset_id", "")))
|
||||||
|
objects.append(entry)
|
||||||
|
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(rules, runtime_to_file)
|
||||||
|
var metadata: Dictionary = document_metadata.get("metadata", {}) as Dictionary
|
||||||
|
var scene_name: String = String(document_metadata.get("scene_name", "Untitled"))
|
||||||
|
var camera_position: Vector2 = camera_state.get("position", Vector2.ZERO)
|
||||||
|
var camera_zoom: float = float(camera_state.get("zoom", 0.5))
|
||||||
|
return {
|
||||||
|
"version": STAGE_SCENE_IO.FILE_VERSION,
|
||||||
|
"scene_name": scene_name,
|
||||||
|
"camera": {"position": STAGE_SCENE_IO.vector_to_data(camera_position), "zoom": camera_zoom},
|
||||||
|
"objects": objects,
|
||||||
|
"event_rules": rules_result["rules"],
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary:
|
||||||
|
var file_to_runtime: Dictionary = {}
|
||||||
|
var unavailable: Dictionary = {}
|
||||||
|
var warnings: Array[String] = []
|
||||||
|
var missing_paths: Array[String] = []
|
||||||
|
var loaded_nodes: Array[Node2D] = []
|
||||||
|
var missing_stickmen: int = 0
|
||||||
|
for raw: Variant in data.get("objects", []):
|
||||||
|
if not raw is Dictionary:
|
||||||
|
warnings.append("Malformed object")
|
||||||
|
continue
|
||||||
|
var entry: Dictionary = (raw as Dictionary).duplicate(true)
|
||||||
|
var file_id: String = String(entry.get("file_id", ""))
|
||||||
|
var type: String = String(entry.get("type", ""))
|
||||||
|
if type == "stickman" and entry.has("stk_path"):
|
||||||
|
entry["stk_path"] = CONTENT_PATHS.canonicalize(String(entry.get("stk_path", "")))
|
||||||
|
elif type == "prop" and String(entry.get("asset_id", "")).ends_with(".prop"):
|
||||||
|
entry["asset_id"] = CONTENT_PATHS.canonicalize(String(entry.get("asset_id", "")))
|
||||||
|
if file_id.is_empty() or file_to_runtime.has(file_id) or unavailable.has(file_id):
|
||||||
|
warnings.append("Duplicate or empty file id")
|
||||||
|
continue
|
||||||
|
if not STAGE_SCENE_IO.is_vector_data(entry.get("position")) or not STAGE_SCENE_IO._finite_number(entry.get("rotation", 0.0)):
|
||||||
|
warnings.append("Malformed object transform")
|
||||||
|
continue
|
||||||
|
var position: Vector2 = STAGE_SCENE_IO.data_to_vector(entry.get("position"), Vector2.ZERO)
|
||||||
|
var node: Node2D = null
|
||||||
|
match type:
|
||||||
|
"terrain":
|
||||||
|
node = spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), position)
|
||||||
|
"prop":
|
||||||
|
node = spawner.spawn_prop_asset(String(entry.get("asset_id", "")), position)
|
||||||
|
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
|
||||||
|
var prop_placeholder: Node2D = MISSING_PROP_PLACEHOLDER.new() as Node2D
|
||||||
|
prop_placeholder.configure(entry)
|
||||||
|
prop_placeholder.position = position
|
||||||
|
world.add_child(prop_placeholder)
|
||||||
|
node = prop_placeholder
|
||||||
|
"area":
|
||||||
|
node = spawner.spawn_area(position)
|
||||||
|
"stickman":
|
||||||
|
node = spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), position)
|
||||||
|
if node == null:
|
||||||
|
var placeholder: Node2D = MISSING_STICKMAN_PLACEHOLDER.new() as Node2D
|
||||||
|
placeholder.configure(entry)
|
||||||
|
placeholder.position = position
|
||||||
|
world.add_child(placeholder)
|
||||||
|
node = placeholder
|
||||||
|
unavailable[file_id] = true
|
||||||
|
missing_stickmen += 1
|
||||||
|
var missing_path: String = String(entry.get("stk_path", ""))
|
||||||
|
if not missing_paths.has(missing_path):
|
||||||
|
missing_paths.append(missing_path)
|
||||||
|
if node == null:
|
||||||
|
warnings.append("Unknown object asset")
|
||||||
|
continue
|
||||||
|
node.global_rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
|
||||||
|
if type != "stickman":
|
||||||
|
if entry.has("scale") and not STAGE_SCENE_IO.is_vector_data(entry["scale"]):
|
||||||
|
warnings.append("Malformed object scale")
|
||||||
|
world.remove_child(node)
|
||||||
|
node.queue_free()
|
||||||
|
continue
|
||||||
|
node.scale = STAGE_SCENE_IO.data_to_vector(entry.get("scale"), Vector2.ONE)
|
||||||
|
if node is TriggerArea:
|
||||||
|
var area_size: Vector2 = STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2.ZERO)
|
||||||
|
if not STAGE_SCENE_IO.is_vector_data(entry.get("size")) or area_size.x <= 0.0 or area_size.y <= 0.0:
|
||||||
|
warnings.append("Malformed trigger area")
|
||||||
|
world.remove_child(node)
|
||||||
|
node.queue_free()
|
||||||
|
continue
|
||||||
|
(node as TriggerArea).size = area_size
|
||||||
|
if node is PropBlock:
|
||||||
|
var prop: PropBlock = node as PropBlock
|
||||||
|
prop.stage_object_id = String(entry.get("stage_object_id", ""))
|
||||||
|
var default_roles: Dictionary = PROP_STAGE_ROLES.new_placement(prop.has_usable_grip())
|
||||||
|
if entry.get("roles", null) is Dictionary:
|
||||||
|
prop.set_stage_roles(PROP_STAGE_ROLES.normalize(entry["roles"], default_roles))
|
||||||
|
else:
|
||||||
|
prop.set_stage_roles(PROP_STAGE_ROLES.legacy_placement(prop.has_usable_grip()))
|
||||||
|
var preset_names: Array = PropBlock.MaterialPreset.keys()
|
||||||
|
var raw_preset: Variant = entry.get("material_preset", PropBlock.MaterialPreset.NONE)
|
||||||
|
var preset_index: int = -1
|
||||||
|
if raw_preset is int or raw_preset is float:
|
||||||
|
var numeric_preset: float = float(raw_preset)
|
||||||
|
if is_finite(numeric_preset) and is_equal_approx(numeric_preset, roundf(numeric_preset)):
|
||||||
|
preset_index = int(numeric_preset)
|
||||||
|
else:
|
||||||
|
preset_index = preset_names.find(String(raw_preset))
|
||||||
|
if preset_index >= 0 and preset_index < preset_names.size():
|
||||||
|
prop.material_preset = preset_index
|
||||||
|
else:
|
||||||
|
warnings.append("Invalid prop material preset")
|
||||||
|
if node is StickmanRig:
|
||||||
|
var facing_names: Array = StickmanRig.FacingProfile.keys()
|
||||||
|
var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD")))
|
||||||
|
if facing_index >= 0:
|
||||||
|
(node as StickmanRig).set_facing_profile(facing_index)
|
||||||
|
var raw_queue: Variant = entry.get("action_queue", [])
|
||||||
|
if raw_queue is Array:
|
||||||
|
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
|
||||||
|
for warning: String in queue.get("warnings", []):
|
||||||
|
var character_name: String = String(entry.get("stk_path", "")).get_file()
|
||||||
|
if character_name.is_empty():
|
||||||
|
character_name = file_id
|
||||||
|
warnings.append("%s: %s Open this character's queue to review the skipped action." % [character_name, warning])
|
||||||
|
for action: Dictionary in queue["actions"]:
|
||||||
|
(node as StickmanRig).queue_action(action)
|
||||||
|
else:
|
||||||
|
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
|
||||||
|
loaded_nodes.append(node)
|
||||||
|
file_to_runtime[file_id] = node.get_instance_id()
|
||||||
|
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
|
||||||
|
return {
|
||||||
|
"loaded_nodes": loaded_nodes,
|
||||||
|
"file_to_runtime": file_to_runtime,
|
||||||
|
"unavailable": unavailable,
|
||||||
|
"warnings": warnings,
|
||||||
|
"missing_stickmen": missing_stickmen,
|
||||||
|
"missing_paths": missing_paths,
|
||||||
|
"decoded_rules": decoded_rules,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _object_type(node: Node2D) -> String:
|
||||||
|
if node is TerrainBlock:
|
||||||
|
return "terrain"
|
||||||
|
if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER:
|
||||||
|
return "prop"
|
||||||
|
if node is STICKMAN_RIG_SCRIPT or node.get_script() == MISSING_STICKMAN_PLACEHOLDER:
|
||||||
|
return "stickman"
|
||||||
|
if node is TriggerArea:
|
||||||
|
return "area"
|
||||||
|
return ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cj0p4105ehlop
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
class_name StageSpeechDraftStore
|
||||||
|
extends RefCounted
|
||||||
|
## Owns the durable speech draft and its queue/scene recovery origin.
|
||||||
|
|
||||||
|
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||||
|
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||||
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
|
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||||
|
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||||
|
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||||
|
|
||||||
|
const DRAFT_PATH: String = "user://speech_composer_draft.json"
|
||||||
|
const DRAFT_DEBOUNCE: float = 0.75
|
||||||
|
const DRAFT_SCHEMA: int = 1
|
||||||
|
|
||||||
|
var _world: Node2D
|
||||||
|
var _composer: Control
|
||||||
|
var origin: Dictionary = {}
|
||||||
|
var draft_dirty: bool = false
|
||||||
|
var draft_timer: float = -1.0
|
||||||
|
|
||||||
|
|
||||||
|
func configure(world: Node2D, composer: Control) -> void:
|
||||||
|
_world = world
|
||||||
|
_composer = composer
|
||||||
|
|
||||||
|
|
||||||
|
func reset_session() -> void:
|
||||||
|
draft_timer = -1.0
|
||||||
|
draft_dirty = false
|
||||||
|
origin.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func arm_draft_save() -> void:
|
||||||
|
draft_dirty = true
|
||||||
|
draft_timer = DRAFT_DEBOUNCE
|
||||||
|
|
||||||
|
|
||||||
|
func update(delta: float) -> void:
|
||||||
|
if draft_timer < 0.0:
|
||||||
|
return
|
||||||
|
draft_timer -= delta
|
||||||
|
if draft_timer <= 0.0:
|
||||||
|
draft_timer = -1.0
|
||||||
|
persist_draft_now()
|
||||||
|
|
||||||
|
|
||||||
|
func capture_origin(actor: StickmanRig, context: String, scene_path: String, scene_fingerprint: String, editor_state: Dictionary) -> Dictionary:
|
||||||
|
var result: Dictionary = {
|
||||||
|
"context": context,
|
||||||
|
"scene_path": CONTENT_PATHS.canonicalize(scene_path),
|
||||||
|
"scene_fingerprint": scene_fingerprint,
|
||||||
|
"actor_file_id": speech_actor_file_id(actor),
|
||||||
|
"actor_stk_path": CONTENT_PATHS.canonicalize(actor.source_stk_path) if actor != null and is_instance_valid(actor) else "",
|
||||||
|
"action_editor_kind": String(editor_state.get("kind", "")),
|
||||||
|
"action_editor_index": int(editor_state.get("index", -1)),
|
||||||
|
"restore_queue": bool(editor_state.get("restore_queue", false)),
|
||||||
|
"queue_index": -1,
|
||||||
|
"queue_action": {},
|
||||||
|
"insert_after": bool(editor_state.get("insert_after", false)),
|
||||||
|
}
|
||||||
|
if context != "action_editor" or actor == null or not is_instance_valid(actor):
|
||||||
|
return result
|
||||||
|
var editor_rig: StickmanRig = editor_state.get("rig") as StickmanRig
|
||||||
|
var action_id: int = int(editor_state.get("action_id", -1))
|
||||||
|
if String(editor_state.get("kind", "")) == "queue_edit" and editor_rig == actor and action_id >= 0:
|
||||||
|
var queue_index: int = actor.index_for_action_id(action_id)
|
||||||
|
result["queue_index"] = queue_index
|
||||||
|
result["queue_action"] = queue_action_wire_at(actor, queue_index)
|
||||||
|
elif String(editor_state.get("kind", "")) == "queue_insert" and editor_rig == actor:
|
||||||
|
var insert_index: int = int(editor_state.get("index", -1))
|
||||||
|
var target_action_id: int = int(editor_state.get("target_action_id", -1))
|
||||||
|
if target_action_id >= 0:
|
||||||
|
insert_index = actor.index_for_action_id(target_action_id)
|
||||||
|
result["queue_index"] = insert_index
|
||||||
|
result["queue_action"] = queue_action_wire_at(actor, insert_index)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func validate_origin(origin_value: Dictionary, current_scene_path: String, current_scene_fingerprint: String) -> Dictionary:
|
||||||
|
var canonical: Dictionary = canonicalize_origin(origin_value)
|
||||||
|
if current_scene_path.is_empty() or String(canonical.get("scene_path", "")) != current_scene_path:
|
||||||
|
return {}
|
||||||
|
var scene_fingerprint_matches: bool = String(canonical.get("scene_fingerprint", "")) == current_scene_fingerprint
|
||||||
|
var actor: StickmanRig = find_speech_actor(String(canonical.get("actor_file_id", "")), String(canonical.get("actor_stk_path", "")))
|
||||||
|
if actor == null:
|
||||||
|
return {}
|
||||||
|
var context: String = String(canonical.get("context", ""))
|
||||||
|
if context == "direct":
|
||||||
|
return {"actor": actor, "context": "direct"} if scene_fingerprint_matches else {}
|
||||||
|
if context != "action_editor":
|
||||||
|
return {}
|
||||||
|
var editor_kind: String = String(canonical.get("action_editor_kind", ""))
|
||||||
|
if editor_kind == "queue_add":
|
||||||
|
return {"actor": actor, "context": "action_editor", "kind": editor_kind} if scene_fingerprint_matches else {}
|
||||||
|
if editor_kind != "queue_edit" and editor_kind != "queue_insert":
|
||||||
|
return {}
|
||||||
|
var queue_index: int = int(canonical.get("queue_index", -1))
|
||||||
|
var expected: Dictionary = canonical.get("queue_action", {})
|
||||||
|
if queue_index < 0 or expected.is_empty() or queue_action_wire_at(actor, queue_index) != expected:
|
||||||
|
return {}
|
||||||
|
if editor_kind == "queue_edit":
|
||||||
|
var action_id: int = actor.action_id_at(queue_index)
|
||||||
|
if action_id < 0 or _queue_action_consumed(actor.get_action_status(action_id)):
|
||||||
|
return {}
|
||||||
|
return {"actor": actor, "context": "action_editor", "kind": editor_kind, "queue_index": queue_index}
|
||||||
|
|
||||||
|
|
||||||
|
func speech_actor_file_id(actor: StickmanRig) -> String:
|
||||||
|
if actor == null or not is_instance_valid(actor) or _world == null:
|
||||||
|
return ""
|
||||||
|
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
||||||
|
for child: Node in _world.get_children():
|
||||||
|
if not child is Node2D:
|
||||||
|
continue
|
||||||
|
var node: Node2D = child as Node2D
|
||||||
|
var object_type: String = speech_object_type(node)
|
||||||
|
if object_type.is_empty():
|
||||||
|
continue
|
||||||
|
counters[object_type] = int(counters[object_type]) + 1
|
||||||
|
if node == actor:
|
||||||
|
return "%s_%d" % [object_type, int(counters[object_type])]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func speech_object_type(node: Node2D) -> String:
|
||||||
|
if node is TerrainBlock:
|
||||||
|
return "terrain"
|
||||||
|
if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER:
|
||||||
|
return "prop"
|
||||||
|
if node is StickmanRig or node.get_script() == MISSING_STICKMAN_PLACEHOLDER:
|
||||||
|
return "stickman"
|
||||||
|
if node is TriggerArea:
|
||||||
|
return "area"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func find_speech_actor(file_id: String, source_path: String) -> StickmanRig:
|
||||||
|
if file_id.is_empty() or _world == null:
|
||||||
|
return null
|
||||||
|
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
|
||||||
|
for child: Node in _world.get_children():
|
||||||
|
if not child is Node2D:
|
||||||
|
continue
|
||||||
|
var node: Node2D = child as Node2D
|
||||||
|
var object_type: String = speech_object_type(node)
|
||||||
|
if object_type.is_empty():
|
||||||
|
continue
|
||||||
|
counters[object_type] = int(counters[object_type]) + 1
|
||||||
|
if "%s_%d" % [object_type, int(counters[object_type])] != file_id:
|
||||||
|
continue
|
||||||
|
if not node is StickmanRig:
|
||||||
|
return null
|
||||||
|
var actor: StickmanRig = node as StickmanRig
|
||||||
|
return actor if actor.source_stk_path == source_path else null
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func queue_action_wire_at(actor: StickmanRig, index: int) -> Dictionary:
|
||||||
|
if actor == null or not is_instance_valid(actor) or index < 0:
|
||||||
|
return {}
|
||||||
|
var queue: Array[Dictionary] = actor.get_queue()
|
||||||
|
if index >= queue.size():
|
||||||
|
return {}
|
||||||
|
var encoded: Dictionary = STAGE_SCENE_IO.encode_queue([queue[index]])
|
||||||
|
var actions: Array = encoded.get("actions", [])
|
||||||
|
return actions[0] if not actions.is_empty() and actions[0] is Dictionary else {}
|
||||||
|
|
||||||
|
|
||||||
|
func canonicalize_origin(origin_value: Dictionary) -> Dictionary:
|
||||||
|
var canonical: Dictionary = origin_value.duplicate(true)
|
||||||
|
for key: String in ["scene_path", "actor_stk_path"]:
|
||||||
|
if canonical.has(key):
|
||||||
|
canonical[key] = CONTENT_PATHS.canonicalize(String(canonical.get(key, "")))
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
|
func speech_action_matches_queue_edit(draft: Dictionary, actor: StickmanRig, queue_index: int) -> bool:
|
||||||
|
if actor == null or not is_instance_valid(actor):
|
||||||
|
return false
|
||||||
|
var queue: Array[Dictionary] = actor.get_queue()
|
||||||
|
if queue_index < 0 or queue_index >= queue.size() or String(queue[queue_index].get("type", "")) != "speak":
|
||||||
|
return false
|
||||||
|
var draft_payload: Dictionary = SPEECH_PAYLOAD.normalize(draft)
|
||||||
|
var saved_payload: Dictionary = SPEECH_PAYLOAD.normalize(queue[queue_index])
|
||||||
|
draft_payload.erase("legacy_fixed")
|
||||||
|
saved_payload.erase("legacy_fixed")
|
||||||
|
return draft_payload == saved_payload
|
||||||
|
|
||||||
|
|
||||||
|
func inspect_recovery(current_scene_path: String, current_scene_fingerprint: String) -> Dictionary:
|
||||||
|
if current_scene_path.is_empty() or not has_draft_file():
|
||||||
|
return {"available": false, "clear_matching_edit": false}
|
||||||
|
var document: Dictionary = read_draft_document()
|
||||||
|
var origin_value: Variant = document.get("origin", null)
|
||||||
|
if not origin_value is Dictionary:
|
||||||
|
return {"available": false, "clear_matching_edit": false}
|
||||||
|
var validated: Dictionary = validate_origin(origin_value as Dictionary, current_scene_path, current_scene_fingerprint)
|
||||||
|
if validated.is_empty():
|
||||||
|
return {"available": false, "clear_matching_edit": false}
|
||||||
|
var draft_value: Variant = document.get("draft", null)
|
||||||
|
if draft_value is Dictionary and String(validated.get("context", "")) == "action_editor" and String(validated.get("kind", "")) == "queue_edit":
|
||||||
|
if speech_action_matches_queue_edit(draft_value as Dictionary, validated["actor"] as StickmanRig, int(validated["queue_index"])):
|
||||||
|
return {"available": false, "clear_matching_edit": true}
|
||||||
|
return {"available": true, "clear_matching_edit": false}
|
||||||
|
|
||||||
|
|
||||||
|
func restore_empty_recovered_bubble_text(recovered: Dictionary, saved_action: Dictionary) -> Dictionary:
|
||||||
|
var result: Dictionary = recovered.duplicate(true)
|
||||||
|
var saved: Dictionary = SPEECH_PAYLOAD.normalize(saved_action)
|
||||||
|
var saved_bubbles: Array = saved.get("bubbles", [])
|
||||||
|
var recovered_bubbles_value: Variant = result.get("bubbles", null)
|
||||||
|
if recovered_bubbles_value is Array:
|
||||||
|
var recovered_bubbles: Array = recovered_bubbles_value
|
||||||
|
for index: int in mini(recovered_bubbles.size(), saved_bubbles.size()):
|
||||||
|
if not recovered_bubbles[index] is Dictionary or not saved_bubbles[index] is Dictionary:
|
||||||
|
continue
|
||||||
|
var bubble: Dictionary = recovered_bubbles[index]
|
||||||
|
if not String(bubble.get("text", "")).strip_edges().is_empty():
|
||||||
|
continue
|
||||||
|
var saved_text: String = String((saved_bubbles[index] as Dictionary).get("text", ""))
|
||||||
|
if saved_text.strip_edges().is_empty():
|
||||||
|
continue
|
||||||
|
bubble["text"] = saved_text
|
||||||
|
recovered_bubbles[index] = bubble
|
||||||
|
result["bubbles"] = recovered_bubbles
|
||||||
|
if not recovered_bubbles.is_empty() and recovered_bubbles[0] is Dictionary:
|
||||||
|
result["text"] = String((recovered_bubbles[0] as Dictionary).get("text", ""))
|
||||||
|
elif String(result.get("text", "")).strip_edges().is_empty():
|
||||||
|
result["text"] = String(saved.get("text", ""))
|
||||||
|
elif String(result.get("text", "")).strip_edges().is_empty():
|
||||||
|
result["text"] = String(saved.get("text", ""))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func read_draft_document() -> Dictionary:
|
||||||
|
if not FileAccess.file_exists(DRAFT_PATH) and FileAccess.file_exists(DRAFT_PATH + ".bak"):
|
||||||
|
DirAccess.rename_absolute(ProjectSettings.globalize_path(DRAFT_PATH + ".bak"), ProjectSettings.globalize_path(DRAFT_PATH))
|
||||||
|
if not FileAccess.file_exists(DRAFT_PATH):
|
||||||
|
return {}
|
||||||
|
var file: FileAccess = FileAccess.open(DRAFT_PATH, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return {}
|
||||||
|
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||||
|
file.close()
|
||||||
|
if not parsed is Dictionary or int((parsed as Dictionary).get("schema", -1)) != DRAFT_SCHEMA:
|
||||||
|
return {}
|
||||||
|
var document: Dictionary = (parsed as Dictionary).duplicate(true)
|
||||||
|
var origin_value: Variant = document.get("origin", {})
|
||||||
|
if origin_value is Dictionary:
|
||||||
|
document["origin"] = canonicalize_origin(origin_value as Dictionary)
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
func speech_payload_for_json(action: Dictionary) -> Dictionary:
|
||||||
|
var payload: Dictionary = action.duplicate(true)
|
||||||
|
var placement: Dictionary = payload.get("placement", {}) if payload.get("placement", {}) is Dictionary else {}
|
||||||
|
if String(placement.get("mode", "auto")) == "manual" and placement.get("center", null) is Vector2:
|
||||||
|
placement["center"] = STAGE_SCENE_IO.vector_to_data(placement["center"])
|
||||||
|
payload["placement"] = placement
|
||||||
|
var bubbles: Array = payload.get("bubbles", [])
|
||||||
|
for index: int in bubbles.size():
|
||||||
|
if not bubbles[index] is Dictionary:
|
||||||
|
continue
|
||||||
|
var bubble: Dictionary = bubbles[index]
|
||||||
|
var bubble_placement: Variant = bubble.get("placement", null)
|
||||||
|
if bubble_placement is Dictionary:
|
||||||
|
var bubble_data: Dictionary = bubble_placement as Dictionary
|
||||||
|
var bubble_position: Variant = bubble_data.get("center", null)
|
||||||
|
if String(bubble_data.get("mode", "auto")) == "manual" and bubble_position is Vector2:
|
||||||
|
bubble_data["center"] = STAGE_SCENE_IO.vector_to_data(bubble_position)
|
||||||
|
bubbles[index] = bubble
|
||||||
|
if not bubbles.is_empty():
|
||||||
|
payload["bubbles"] = bubbles
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
func persist_draft_now() -> void:
|
||||||
|
if _composer == null or not _composer.visible or not draft_dirty:
|
||||||
|
return
|
||||||
|
var document: Dictionary = {
|
||||||
|
"schema": DRAFT_SCHEMA,
|
||||||
|
"modified_unix": Time.get_unix_time_from_system(),
|
||||||
|
"draft": speech_payload_for_json(_composer.call("get_draft")),
|
||||||
|
"selected_bubble": int(_composer.call("selected_bubble_index")),
|
||||||
|
"origin": canonicalize_origin(origin),
|
||||||
|
}
|
||||||
|
var tmp_path: String = DRAFT_PATH + ".tmp"
|
||||||
|
var bak_path: String = DRAFT_PATH + ".bak"
|
||||||
|
var file: FileAccess = FileAccess.open(tmp_path, FileAccess.WRITE)
|
||||||
|
if file == null:
|
||||||
|
push_warning("Speech composer draft could not be written.")
|
||||||
|
return
|
||||||
|
file.store_string(JSON.stringify(document, "\t", false))
|
||||||
|
file.flush()
|
||||||
|
file.close()
|
||||||
|
var tmp_abs: String = ProjectSettings.globalize_path(tmp_path)
|
||||||
|
var path_abs: String = ProjectSettings.globalize_path(DRAFT_PATH)
|
||||||
|
var bak_abs: String = ProjectSettings.globalize_path(bak_path)
|
||||||
|
if FileAccess.file_exists(bak_path):
|
||||||
|
DirAccess.remove_absolute(bak_abs)
|
||||||
|
if FileAccess.file_exists(DRAFT_PATH) and DirAccess.rename_absolute(path_abs, bak_abs) != OK:
|
||||||
|
DirAccess.remove_absolute(tmp_abs)
|
||||||
|
push_warning("Speech composer draft backup failed; keeping the previous draft.")
|
||||||
|
return
|
||||||
|
if DirAccess.rename_absolute(tmp_abs, path_abs) != OK:
|
||||||
|
if FileAccess.file_exists(bak_path):
|
||||||
|
DirAccess.rename_absolute(bak_abs, path_abs)
|
||||||
|
push_warning("Speech composer draft replacement failed.")
|
||||||
|
return
|
||||||
|
if FileAccess.file_exists(bak_path):
|
||||||
|
DirAccess.remove_absolute(bak_abs)
|
||||||
|
draft_dirty = false
|
||||||
|
|
||||||
|
|
||||||
|
func clear_draft_file() -> void:
|
||||||
|
draft_timer = -1.0
|
||||||
|
draft_dirty = false
|
||||||
|
for path: String in [DRAFT_PATH, DRAFT_PATH + ".tmp", DRAFT_PATH + ".bak"]:
|
||||||
|
var absolute: String = ProjectSettings.globalize_path(path)
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
DirAccess.remove_absolute(absolute)
|
||||||
|
|
||||||
|
|
||||||
|
func has_draft_file() -> bool:
|
||||||
|
return FileAccess.file_exists(DRAFT_PATH) or FileAccess.file_exists(DRAFT_PATH + ".bak")
|
||||||
|
|
||||||
|
|
||||||
|
func _queue_action_consumed(status: int) -> bool:
|
||||||
|
return status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bxd7eblfc2yoh
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
uid://buutqpkalyet7
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
class_name StageTargetCaptureSession
|
||||||
|
extends RefCounted
|
||||||
|
## Owns a single pending world-target capture and its preview calculation.
|
||||||
|
|
||||||
|
const STICKMAN_ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||||
|
|
||||||
|
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION }
|
||||||
|
|
||||||
|
var kind: int = Kind.NONE
|
||||||
|
var hint: String = ""
|
||||||
|
var resolve_callback: Callable = Callable()
|
||||||
|
var cancel_callback: Callable = Callable()
|
||||||
|
var throw_preview_rig: StickmanRig
|
||||||
|
var throw_preview_prefix: Array[Dictionary] = []
|
||||||
|
var throw_preview_cache: Dictionary = {}
|
||||||
|
var set_down_capture_rig: StickmanRig
|
||||||
|
var set_down_preview_prefix: Array[Dictionary] = []
|
||||||
|
var set_down_preview_cache: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func begin(kind_value: int, hint_value: String, on_resolve: Callable, on_cancel: Callable) -> void:
|
||||||
|
kind = kind_value
|
||||||
|
hint = hint_value
|
||||||
|
resolve_callback = on_resolve
|
||||||
|
cancel_callback = on_cancel
|
||||||
|
throw_preview_rig = null
|
||||||
|
throw_preview_prefix.clear()
|
||||||
|
throw_preview_cache.clear()
|
||||||
|
set_down_capture_rig = null
|
||||||
|
set_down_preview_prefix.clear()
|
||||||
|
set_down_preview_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func begin_throw(hint_value: String, rig: StickmanRig, on_resolve: Callable, on_cancel: Callable, queue_prefix: Array[Dictionary]) -> void:
|
||||||
|
begin(Kind.THROW_POSITION, hint_value, on_resolve, on_cancel)
|
||||||
|
throw_preview_rig = rig if rig != null and is_instance_valid(rig) else null
|
||||||
|
throw_preview_prefix = queue_prefix.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
func begin_set_down(hint_value: String, rig: StickmanRig, on_resolve: Callable, on_cancel: Callable, queue_prefix: Array[Dictionary]) -> void:
|
||||||
|
begin(Kind.SET_DOWN_POSITION, hint_value, on_resolve, on_cancel)
|
||||||
|
set_down_capture_rig = rig if rig != null and is_instance_valid(rig) else null
|
||||||
|
set_down_preview_prefix = queue_prefix.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
func finish() -> void:
|
||||||
|
kind = Kind.NONE
|
||||||
|
hint = ""
|
||||||
|
resolve_callback = Callable()
|
||||||
|
cancel_callback = Callable()
|
||||||
|
throw_preview_rig = null
|
||||||
|
throw_preview_prefix.clear()
|
||||||
|
throw_preview_cache.clear()
|
||||||
|
set_down_capture_rig = null
|
||||||
|
set_down_preview_prefix.clear()
|
||||||
|
set_down_preview_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func update_preview(camera: Camera2D, snap_enabled: bool, grid_size: float) -> void:
|
||||||
|
if kind == Kind.SET_DOWN_POSITION:
|
||||||
|
if set_down_capture_rig == null or not is_instance_valid(set_down_capture_rig):
|
||||||
|
set_down_preview_cache.clear()
|
||||||
|
return
|
||||||
|
var release_target: Vector2 = _snap_to_grid(camera.get_global_mouse_position(), snap_enabled, grid_size)
|
||||||
|
set_down_preview_cache = set_down_capture_rig.preview_prop_set_down(release_target, set_down_preview_prefix)
|
||||||
|
return
|
||||||
|
if kind != Kind.THROW_POSITION or throw_preview_rig == null or not is_instance_valid(throw_preview_rig) or camera == null:
|
||||||
|
throw_preview_cache.clear()
|
||||||
|
return
|
||||||
|
var target: Vector2 = _snap_to_grid(camera.get_global_mouse_position(), snap_enabled, grid_size)
|
||||||
|
throw_preview_cache = throw_preview_rig.preview_prop_throw(target, throw_preview_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_target(world_pos: Vector2, selection: StageSelection, director_visuals: Object, snap_enabled: bool, grid_size: float) -> Dictionary:
|
||||||
|
var value: Variant = null
|
||||||
|
match kind:
|
||||||
|
Kind.WAYPOINT:
|
||||||
|
var waypoint_value: Variant = director_visuals.call("hit_test_waypoint", world_pos)
|
||||||
|
if waypoint_value is Vector2 and (waypoint_value as Vector2).is_finite():
|
||||||
|
value = waypoint_value
|
||||||
|
Kind.AREA:
|
||||||
|
var area_hit: Node2D = selection.hit_test(world_pos)
|
||||||
|
if area_hit is TriggerArea:
|
||||||
|
value = area_hit
|
||||||
|
Kind.PROP:
|
||||||
|
var prop_hit: Node2D = selection.hit_test(world_pos)
|
||||||
|
if prop_hit is PropBlock:
|
||||||
|
value = prop_hit
|
||||||
|
Kind.STICKMAN:
|
||||||
|
var stickman_hit: Node2D = selection.hit_test(world_pos)
|
||||||
|
if stickman_hit is StickmanRig:
|
||||||
|
value = stickman_hit
|
||||||
|
Kind.POSITION, Kind.THROW_POSITION:
|
||||||
|
value = _snap_to_grid(world_pos, snap_enabled, grid_size)
|
||||||
|
Kind.SET_DOWN_POSITION:
|
||||||
|
var release_position: Vector2 = _snap_to_grid(world_pos, snap_enabled, grid_size)
|
||||||
|
if release_position.is_finite() and (not bool(set_down_preview_cache.get("known", false)) or bool(set_down_preview_cache.get("valid", false))):
|
||||||
|
value = release_position
|
||||||
|
if value == null:
|
||||||
|
return {"valid": false, "value": null}
|
||||||
|
return {"valid": true, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
func throw_preview_prefix_for(rig: StickmanRig, before_action_id: int = -1) -> Array[Dictionary]:
|
||||||
|
var result: Array[Dictionary] = []
|
||||||
|
if rig == null or not is_instance_valid(rig):
|
||||||
|
return result
|
||||||
|
var queue_view: Array[Dictionary] = rig.get_queue_view()
|
||||||
|
var limit: int = queue_view.size()
|
||||||
|
if before_action_id >= 0:
|
||||||
|
limit = rig.index_for_action_id(before_action_id)
|
||||||
|
if limit < 0:
|
||||||
|
return result
|
||||||
|
for index: int in mini(limit, queue_view.size()):
|
||||||
|
var entry: Dictionary = queue_view[index]
|
||||||
|
var status: int = int(entry.get("status", STICKMAN_ACTION_RUNNER.QueueStatus.NOT_STARTED))
|
||||||
|
if status == STICKMAN_ACTION_RUNNER.QueueStatus.COMPLETED or status == STICKMAN_ACTION_RUNNER.QueueStatus.INTERRUPTED:
|
||||||
|
continue
|
||||||
|
var action: Dictionary = entry.get("action", {})
|
||||||
|
if action.is_empty():
|
||||||
|
continue
|
||||||
|
result.append({"action": action.duplicate(true), "step": index + 1, "active": status == STICKMAN_ACTION_RUNNER.QueueStatus.ACTIVE})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _snap_to_grid(position: Vector2, snap_enabled: bool, grid_size: float) -> Vector2:
|
||||||
|
if not snap_enabled or grid_size <= 0.0:
|
||||||
|
return position
|
||||||
|
return Vector2(roundf(position.x / grid_size) * grid_size, roundf(position.y / grid_size) * grid_size)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dy7djgj73h5n7
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
class_name StageTerrainPainter
|
||||||
|
extends Node
|
||||||
|
## Owns terrain drag painting, overlap classification, and stroke preview ghosts.
|
||||||
|
|
||||||
|
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
|
||||||
|
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||||
|
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
const TERRAIN_BLOCK := preload("res://scripts/terrain/terrain_block.gd")
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD_PX: float = 6.0
|
||||||
|
|
||||||
|
signal placement_requested(node: Node2D)
|
||||||
|
signal stroke_finished(placed_count: int)
|
||||||
|
|
||||||
|
var _spawner: StageSpawner
|
||||||
|
var _world: Node2D
|
||||||
|
var _camera: Camera2D
|
||||||
|
var _ghost_holder: Node2D
|
||||||
|
var _overlay: StagePlacementOverlay
|
||||||
|
var _spatial_index: StageSpatialIndex
|
||||||
|
|
||||||
|
var _placement_id: String = ""
|
||||||
|
var _dragging: bool = false
|
||||||
|
var _painting: bool = false
|
||||||
|
var _press_world: Vector2 = Vector2.ZERO
|
||||||
|
var _anchor_cell: Vector2i = Vector2i.ZERO
|
||||||
|
var _drag_cells: Array[Vector2i] = []
|
||||||
|
var _last_drag_cells: Array[Vector2i] = []
|
||||||
|
var _ghosts: Array[Node2D] = []
|
||||||
|
var _drag_painted: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func configure(
|
||||||
|
spawner: StageSpawner,
|
||||||
|
world: Node2D,
|
||||||
|
camera: Camera2D,
|
||||||
|
ghost_holder: Node2D,
|
||||||
|
overlay: StagePlacementOverlay,
|
||||||
|
spatial_index: StageSpatialIndex
|
||||||
|
) -> void:
|
||||||
|
_spawner = spawner
|
||||||
|
_world = world
|
||||||
|
_camera = camera
|
||||||
|
_ghost_holder = ghost_holder
|
||||||
|
_overlay = overlay
|
||||||
|
_spatial_index = spatial_index
|
||||||
|
|
||||||
|
|
||||||
|
func begin(placement_id: String, world_pos: Vector2, snap_enabled: bool, grid_size: float) -> void:
|
||||||
|
if _spawner == null or not _spawner.is_terrain_id(placement_id):
|
||||||
|
return
|
||||||
|
_placement_id = placement_id
|
||||||
|
if snap_enabled:
|
||||||
|
world_pos = _snap_to_grid(world_pos, grid_size)
|
||||||
|
_free_terrain_ghosts()
|
||||||
|
_drag_painted.clear()
|
||||||
|
_dragging = true
|
||||||
|
_painting = false
|
||||||
|
_press_world = world_pos
|
||||||
|
_anchor_cell = world_to_terrain_cell(world_pos, _placement_id)
|
||||||
|
|
||||||
|
|
||||||
|
func update(world_pos: Vector2, placement_id: String) -> void:
|
||||||
|
if not _dragging:
|
||||||
|
return
|
||||||
|
_placement_id = placement_id
|
||||||
|
if not _painting:
|
||||||
|
var zoom: float = _camera.zoom.x if _camera != null else 1.0
|
||||||
|
if (world_pos - _press_world).length() * zoom < DRAG_THRESHOLD_PX:
|
||||||
|
return
|
||||||
|
_painting = true
|
||||||
|
var target: Vector2i = world_to_terrain_cell(world_pos, _placement_id)
|
||||||
|
if Input.is_key_pressed(KEY_SHIFT):
|
||||||
|
var delta: Vector2i = target - _anchor_cell
|
||||||
|
if absi(delta.x) >= absi(delta.y):
|
||||||
|
target = Vector2i(_anchor_cell.x + delta.x, _anchor_cell.y)
|
||||||
|
else:
|
||||||
|
target = Vector2i(_anchor_cell.x, _anchor_cell.y + delta.y)
|
||||||
|
_drag_cells = bresenham_cells(_anchor_cell, target)
|
||||||
|
refresh_terrain_ghosts()
|
||||||
|
if _overlay != null:
|
||||||
|
if target != _anchor_cell:
|
||||||
|
_overlay.set_terrain_guide(terrain_cell_center(_anchor_cell, _placement_id), terrain_cell_center(target, _placement_id))
|
||||||
|
else:
|
||||||
|
_overlay.clear_terrain_guide()
|
||||||
|
|
||||||
|
|
||||||
|
func commit(placement_id: String, snap_enabled: bool, grid_size: float) -> void:
|
||||||
|
if not _dragging:
|
||||||
|
return
|
||||||
|
_placement_id = placement_id
|
||||||
|
var placed_count: int = 0
|
||||||
|
if not _painting:
|
||||||
|
var click_pos: Vector2 = _snap_to_grid(_press_world, grid_size) if snap_enabled else _press_world
|
||||||
|
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
|
||||||
|
if click_node != null:
|
||||||
|
placement_requested.emit(click_node)
|
||||||
|
placed_count += 1
|
||||||
|
for cell: Vector2i in _drag_cells:
|
||||||
|
if classify_cell(cell, _placement_id) != 1:
|
||||||
|
continue
|
||||||
|
var node: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
|
||||||
|
if node == null:
|
||||||
|
continue
|
||||||
|
placement_requested.emit(node)
|
||||||
|
placed_count += 1
|
||||||
|
_drag_painted[cell] = true
|
||||||
|
_reset_stroke()
|
||||||
|
stroke_finished.emit(placed_count)
|
||||||
|
|
||||||
|
|
||||||
|
func cancel() -> void:
|
||||||
|
_reset_stroke()
|
||||||
|
|
||||||
|
|
||||||
|
func is_dragging() -> bool:
|
||||||
|
return _dragging
|
||||||
|
|
||||||
|
|
||||||
|
func is_painting() -> bool:
|
||||||
|
return _painting
|
||||||
|
|
||||||
|
|
||||||
|
func get_drag_cells() -> Array[Vector2i]:
|
||||||
|
return _drag_cells
|
||||||
|
|
||||||
|
|
||||||
|
func get_drag_painted_cells() -> Dictionary:
|
||||||
|
return _drag_painted
|
||||||
|
|
||||||
|
|
||||||
|
func get_ghosts() -> Array[Node2D]:
|
||||||
|
return _ghosts
|
||||||
|
|
||||||
|
|
||||||
|
func clear_terrain_ghosts() -> void:
|
||||||
|
_free_terrain_ghosts()
|
||||||
|
|
||||||
|
|
||||||
|
func terrain_stride(placement_id: String) -> Vector2:
|
||||||
|
var size: Vector2 = _spawner.get_template_aabb(placement_id).size
|
||||||
|
if size.x <= 0.0001 or size.y <= 0.0001:
|
||||||
|
return Vector2(STAGE_SPAWNER.TERRAIN_GRID_SIZE, STAGE_SPAWNER.TERRAIN_GRID_SIZE)
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
func world_to_terrain_cell(pos: Vector2, placement_id: String) -> Vector2i:
|
||||||
|
var stride: Vector2 = terrain_stride(placement_id)
|
||||||
|
return Vector2i(int(roundf(pos.x / stride.x)), int(roundf(pos.y / stride.y)))
|
||||||
|
|
||||||
|
|
||||||
|
func terrain_cell_center(cell: Vector2i, placement_id: String) -> Vector2:
|
||||||
|
var stride: Vector2 = terrain_stride(placement_id)
|
||||||
|
return Vector2(cell.x * stride.x, cell.y * stride.y)
|
||||||
|
|
||||||
|
|
||||||
|
func block_aabb(cell: Vector2i, placement_id: String) -> Rect2:
|
||||||
|
var local: Rect2 = _spawner.get_template_aabb(placement_id)
|
||||||
|
return Rect2(terrain_cell_center(cell, placement_id) + local.position, local.size)
|
||||||
|
|
||||||
|
|
||||||
|
func classify_cell(cell: Vector2i, placement_id: String) -> int:
|
||||||
|
if _drag_painted.has(cell):
|
||||||
|
return 2
|
||||||
|
var aabb: Rect2 = block_aabb(cell, placement_id)
|
||||||
|
var candidates: Array[Node2D] = _spatial_index.candidates_for_aabb(aabb)
|
||||||
|
var has_same: bool = false
|
||||||
|
for node: Node2D in candidates:
|
||||||
|
if not aabb_overlaps(aabb, STAGE_SELECTION.get_world_aabb(node)):
|
||||||
|
continue
|
||||||
|
if node is TERRAIN_BLOCK and (node as TerrainBlock).spawn_id == placement_id:
|
||||||
|
has_same = true
|
||||||
|
continue
|
||||||
|
return 3
|
||||||
|
return 2 if has_same else 1
|
||||||
|
|
||||||
|
|
||||||
|
static func aabb_overlaps(a: Rect2, b: Rect2) -> bool:
|
||||||
|
return a.position.x < b.end.x and b.position.x < a.end.x \
|
||||||
|
and a.position.y < b.end.y and b.position.y < a.end.y
|
||||||
|
|
||||||
|
|
||||||
|
static func bresenham_cells(a: Vector2i, b: Vector2i) -> Array[Vector2i]:
|
||||||
|
var cells: Array[Vector2i] = []
|
||||||
|
var x0: int = a.x
|
||||||
|
var y0: int = a.y
|
||||||
|
var x1: int = b.x
|
||||||
|
var y1: int = b.y
|
||||||
|
var dx: int = absi(x1 - x0)
|
||||||
|
var dy: int = -absi(y1 - y0)
|
||||||
|
var sx: int = 1 if x0 < x1 else -1
|
||||||
|
var sy: int = 1 if y0 < y1 else -1
|
||||||
|
var error: int = dx + dy
|
||||||
|
while true:
|
||||||
|
cells.append(Vector2i(x0, y0))
|
||||||
|
if x0 == x1 and y0 == y1:
|
||||||
|
break
|
||||||
|
var doubled_error: int = 2 * error
|
||||||
|
if doubled_error >= dy:
|
||||||
|
error += dy
|
||||||
|
x0 += sx
|
||||||
|
if doubled_error <= dx:
|
||||||
|
error += dx
|
||||||
|
y0 += sy
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_terrain_ghosts() -> void:
|
||||||
|
if _drag_cells == _last_drag_cells:
|
||||||
|
return
|
||||||
|
_last_drag_cells = _drag_cells.duplicate()
|
||||||
|
_free_terrain_ghosts()
|
||||||
|
if _placement_id.is_empty() or not _spawner.is_terrain_id(_placement_id):
|
||||||
|
return
|
||||||
|
for cell: Vector2i in _drag_cells:
|
||||||
|
var state: int = classify_cell(cell, _placement_id)
|
||||||
|
var ghost: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
|
||||||
|
if ghost == null:
|
||||||
|
continue
|
||||||
|
if ghost.get_parent() == _world:
|
||||||
|
_world.remove_child(ghost)
|
||||||
|
_ghost_holder.add_child(ghost)
|
||||||
|
_configure_ghost_collision(ghost)
|
||||||
|
match state:
|
||||||
|
1:
|
||||||
|
ghost.modulate = Color(0.3, 1.0, 0.4, 0.5)
|
||||||
|
2:
|
||||||
|
ghost.modulate = Color(1.0, 1.0, 1.0, 0.15)
|
||||||
|
3:
|
||||||
|
ghost.modulate = Color(1.0, 0.3, 0.3, 0.5)
|
||||||
|
_ghosts.append(ghost)
|
||||||
|
|
||||||
|
|
||||||
|
func _free_terrain_ghosts() -> void:
|
||||||
|
for ghost: Node2D in _ghosts:
|
||||||
|
if ghost != null and is_instance_valid(ghost):
|
||||||
|
ghost.queue_free()
|
||||||
|
_ghosts.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _reset_stroke() -> void:
|
||||||
|
_dragging = false
|
||||||
|
_painting = false
|
||||||
|
_press_world = Vector2.ZERO
|
||||||
|
_drag_cells.clear()
|
||||||
|
_last_drag_cells.clear()
|
||||||
|
_drag_painted.clear()
|
||||||
|
_free_terrain_ghosts()
|
||||||
|
if _overlay != null:
|
||||||
|
_overlay.clear_terrain_guide()
|
||||||
|
|
||||||
|
|
||||||
|
func _configure_ghost_collision(ghost: Node2D) -> void:
|
||||||
|
if ghost is RigidBody2D:
|
||||||
|
var body: RigidBody2D = ghost as RigidBody2D
|
||||||
|
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||||
|
body.freeze = true
|
||||||
|
body.collision_layer = 0
|
||||||
|
body.collision_mask = 0
|
||||||
|
elif ghost is StaticBody2D:
|
||||||
|
var static_body: StaticBody2D = ghost as StaticBody2D
|
||||||
|
static_body.collision_layer = 0
|
||||||
|
static_body.collision_mask = 0
|
||||||
|
elif ghost is STICKMAN_RIG:
|
||||||
|
var rig: StickmanRig = ghost as StickmanRig
|
||||||
|
var skeleton: Skeleton2D = rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
|
||||||
|
if skeleton != null and skeleton.modification_stack != null:
|
||||||
|
skeleton.modification_stack.enabled = false
|
||||||
|
var animation_player: AnimationPlayer = rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
|
||||||
|
if animation_player != null:
|
||||||
|
animation_player.stop()
|
||||||
|
rig.set_collision_bodies_enabled(false)
|
||||||
|
|
||||||
|
|
||||||
|
func _snap_to_grid(pos: Vector2, grid_size: float) -> Vector2:
|
||||||
|
if grid_size <= 0.0:
|
||||||
|
return pos
|
||||||
|
return Vector2(roundf(pos.x / grid_size) * grid_size, roundf(pos.y / grid_size) * grid_size)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dlaa2syr0pq50
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
class_name StageThemeController
|
||||||
|
extends RefCounted
|
||||||
|
## Owns the resolved Sandbox Stage theme values and theme resource loading.
|
||||||
|
|
||||||
|
const SANDBOX_THEME_SCRIPT := preload("res://scripts/stage/sandbox_theme.gd")
|
||||||
|
|
||||||
|
var theme: Dictionary = {}
|
||||||
|
var sandbox_theme: SandboxTheme
|
||||||
|
var grid_default: float = 15.0
|
||||||
|
var accent_edit: Color = Color("#22c6ff")
|
||||||
|
var accent_direct: Color = Color("#ffb300")
|
||||||
|
var accent_play: Color = Color("#33dd77")
|
||||||
|
var guide_line_color: Color = Color("#22c6ff")
|
||||||
|
var action_popup_font_size: int = 24
|
||||||
|
var action_popup_emoji_size: int = 22
|
||||||
|
var tooltip_font_size: int = 18
|
||||||
|
var status_pill_font_size: int = 16
|
||||||
|
var ui_font: Font
|
||||||
|
var emoji_font: Font
|
||||||
|
var ui_font_bold: Font
|
||||||
|
var ui_font_italic: Font
|
||||||
|
var queue_panel_font_size: int = 18
|
||||||
|
var rule_panel_font_size: int = 18
|
||||||
|
var action_editor_font_size: int = 18
|
||||||
|
var rule_editor_font_size: int = 18
|
||||||
|
var panel_row_font_size: int = 16
|
||||||
|
var panel_title_font_size: int = 18
|
||||||
|
var panel_title_bold: bool = true
|
||||||
|
var rule_label_bold: bool = false
|
||||||
|
var badge_bold: bool = true
|
||||||
|
var action_popup_bold: bool = false
|
||||||
|
var action_popup_italic: bool = false
|
||||||
|
var font_sizes: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func load_theme(source_path: String, minimum_grid_size: float, maximum_grid_size: float) -> void:
|
||||||
|
sandbox_theme = SANDBOX_THEME_SCRIPT.new() as SandboxTheme
|
||||||
|
sandbox_theme.load_from(source_path)
|
||||||
|
theme = {}
|
||||||
|
_build_font_sizes()
|
||||||
|
if not FileAccess.file_exists(source_path):
|
||||||
|
push_warning("SandboxStage: theme file '%s' not found; using defaults." % source_path)
|
||||||
|
return
|
||||||
|
var file: FileAccess = FileAccess.open(source_path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return
|
||||||
|
var json: Variant = JSON.parse_string(file.get_as_text())
|
||||||
|
file.close()
|
||||||
|
if not json is Dictionary:
|
||||||
|
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
|
||||||
|
return
|
||||||
|
theme = sandbox_theme.raw_config()
|
||||||
|
var fonts_value: Variant = theme.get("fonts", {})
|
||||||
|
var fonts: Dictionary = fonts_value as Dictionary if fonts_value is Dictionary else {}
|
||||||
|
action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
|
||||||
|
action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22))
|
||||||
|
tooltip_font_size = int(fonts.get("tooltip_font_size", 18))
|
||||||
|
status_pill_font_size = int(fonts.get("status_pill_font_size", 16))
|
||||||
|
queue_panel_font_size = int(fonts.get("queue_panel_font_size", 18))
|
||||||
|
rule_panel_font_size = int(fonts.get("rule_panel_font_size", 18))
|
||||||
|
action_editor_font_size = int(fonts.get("action_editor_font_size", 18))
|
||||||
|
rule_editor_font_size = int(fonts.get("rule_editor_font_size", 18))
|
||||||
|
panel_row_font_size = int(fonts.get("panel_row_font_size", 16))
|
||||||
|
panel_title_font_size = int(fonts.get("panel_title_font_size", 18))
|
||||||
|
panel_title_bold = bool(fonts.get("panel_title_bold", true))
|
||||||
|
rule_label_bold = bool(fonts.get("rule_label_bold", false))
|
||||||
|
badge_bold = bool(fonts.get("badge_bold", true))
|
||||||
|
ui_font = _load_font(String(fonts.get("ui_font", "")))
|
||||||
|
emoji_font = _load_font(String(fonts.get("emoji_font", "")))
|
||||||
|
ui_font_bold = _resolve_style_font(String(fonts.get("ui_font_bold", "")), ui_font, true)
|
||||||
|
ui_font_italic = _resolve_style_font(String(fonts.get("ui_font_italic", "")), ui_font, false)
|
||||||
|
action_popup_bold = false
|
||||||
|
action_popup_italic = false
|
||||||
|
var action_popup_value: Variant = fonts.get("action_popup", null)
|
||||||
|
if action_popup_value is Dictionary:
|
||||||
|
var action_popup: Dictionary = action_popup_value as Dictionary
|
||||||
|
if action_popup.has("size"):
|
||||||
|
action_popup_font_size = int(action_popup["size"])
|
||||||
|
action_popup_bold = bool(action_popup.get("bold", false))
|
||||||
|
action_popup_italic = bool(action_popup.get("italic", false))
|
||||||
|
var grid_value: Variant = theme.get("grid", {})
|
||||||
|
var grid: Dictionary = grid_value as Dictionary if grid_value is Dictionary else {}
|
||||||
|
grid_default = clampf(float(grid.get("snap_size", 15.0)), minimum_grid_size, maximum_grid_size)
|
||||||
|
var colors_value: Variant = theme.get("mode_colors", {})
|
||||||
|
var colors: Dictionary = colors_value as Dictionary if colors_value is Dictionary else {}
|
||||||
|
accent_edit = parse_color(String(colors.get("edit_accent", "")), accent_edit)
|
||||||
|
accent_direct = parse_color(String(colors.get("direct_accent", "")), accent_direct)
|
||||||
|
accent_play = parse_color(String(colors.get("play_accent", "")), accent_play)
|
||||||
|
guide_line_color = parse_color(String(colors.get("guide_line", "")), guide_line_color)
|
||||||
|
if String((json as Dictionary).get("version", "")) == SANDBOX_THEME_SCRIPT.SCHEMA_VERSION:
|
||||||
|
action_popup_font_size = sandbox_theme.integer("fonts.sizes.action_popup", action_popup_font_size, 8, 96)
|
||||||
|
action_popup_emoji_size = sandbox_theme.integer("fonts.sizes.action_popup_emoji", action_popup_emoji_size, 8, 96)
|
||||||
|
tooltip_font_size = sandbox_theme.integer("fonts.sizes.tooltip", tooltip_font_size, 8, 96)
|
||||||
|
status_pill_font_size = sandbox_theme.integer("fonts.sizes.status_pill", status_pill_font_size, 8, 96)
|
||||||
|
queue_panel_font_size = sandbox_theme.integer("fonts.sizes.queue_panel", queue_panel_font_size, 8, 96)
|
||||||
|
rule_panel_font_size = sandbox_theme.integer("fonts.sizes.rule_panel", rule_panel_font_size, 8, 96)
|
||||||
|
action_editor_font_size = sandbox_theme.integer("fonts.sizes.action_editor", action_editor_font_size, 8, 96)
|
||||||
|
rule_editor_font_size = sandbox_theme.integer("fonts.sizes.rule_editor", rule_editor_font_size, 8, 96)
|
||||||
|
panel_row_font_size = sandbox_theme.integer("fonts.sizes.panel_row", panel_row_font_size, 8, 96)
|
||||||
|
panel_title_font_size = sandbox_theme.integer("fonts.sizes.panel_title", panel_title_font_size, 8, 96)
|
||||||
|
accent_edit = sandbox_theme.color("mode_colors.edit", accent_edit)
|
||||||
|
accent_direct = sandbox_theme.color("mode_colors.direct", accent_direct)
|
||||||
|
accent_play = sandbox_theme.color("mode_colors.play", accent_play)
|
||||||
|
guide_line_color = sandbox_theme.color("mode_colors.guide", guide_line_color)
|
||||||
|
grid_default = sandbox_theme.number("grid.snap_size", grid_default, minimum_grid_size, maximum_grid_size)
|
||||||
|
_build_font_sizes()
|
||||||
|
|
||||||
|
|
||||||
|
func build_font_sizes() -> void:
|
||||||
|
_build_font_sizes()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_font_sizes() -> void:
|
||||||
|
font_sizes = {
|
||||||
|
"queue_panel": queue_panel_font_size,
|
||||||
|
"rule_panel": rule_panel_font_size,
|
||||||
|
"action_editor": action_editor_font_size,
|
||||||
|
"rule_editor": rule_editor_font_size,
|
||||||
|
"panel_row": panel_row_font_size,
|
||||||
|
"panel_title": panel_title_font_size,
|
||||||
|
"panel_title_bold": panel_title_bold,
|
||||||
|
"bold_font": ui_font_bold,
|
||||||
|
"italic_font": ui_font_italic,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_style_font(path: String, base: Font, bold: bool) -> Font:
|
||||||
|
return _resolve_style_font(path, base, bold)
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_style_font(path: String, base: Font, bold: bool) -> Font:
|
||||||
|
if not path.is_empty():
|
||||||
|
var loaded: Font = _load_font(path)
|
||||||
|
if loaded != null:
|
||||||
|
return loaded
|
||||||
|
if base == null or not bold:
|
||||||
|
return base
|
||||||
|
var variation: FontVariation = FontVariation.new()
|
||||||
|
variation.base_font = base
|
||||||
|
variation.variation_embolden = 0.9
|
||||||
|
return variation
|
||||||
|
|
||||||
|
|
||||||
|
static func parse_color(html: String, fallback: Color) -> Color:
|
||||||
|
var value: String = html.strip_edges()
|
||||||
|
if value.begins_with("#"):
|
||||||
|
value = value.substr(1)
|
||||||
|
if value.is_empty():
|
||||||
|
return fallback
|
||||||
|
return Color.from_string(value, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
func load_font(path: String) -> Font:
|
||||||
|
return _load_font(path)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_font(path: String) -> Font:
|
||||||
|
if path.is_empty():
|
||||||
|
return null
|
||||||
|
if not ResourceLoader.exists(path):
|
||||||
|
push_warning("SandboxStage: theme font '%s' not found; using fallback." % path)
|
||||||
|
return null
|
||||||
|
var resource: Resource = ResourceLoader.load(path)
|
||||||
|
if resource is Font:
|
||||||
|
return resource as Font
|
||||||
|
push_warning("SandboxStage: theme font '%s' is not a Font; using fallback." % path)
|
||||||
|
return null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bj5hbnq564akd
|
||||||
@@ -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()]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cbdfvuscs7b47
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b6wxhc8fh5o15
|
||||||
Reference in New Issue
Block a user