feat: add Phase 20a stage identities and overlap selection
@@ -21,7 +21,7 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | ✅ Resolved | Phase 11: `create_walk.gd` deleted; new `scripts/tools/create_animations.gd` (`@tool extends EditorScript`) bakes `walk_left`/`walk_right` (same keyframes) and a one‑shot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (2026‑08‑27) |
|
| 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | ✅ Resolved | Phase 11: `create_walk.gd` deleted; new `scripts/tools/create_animations.gd` (`@tool extends EditorScript`) bakes `walk_left`/`walk_right` (same keyframes) and a one‑shot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (2026‑08‑27) |
|
||||||
| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still re‑adds the proxy as soon as `RECOVERING` begins (`state_changed` handler), while `Body/*` is already visible — the static box can pop in around the standing figure before the stand‑up completes. Consider delaying re‑addition until after recovery finishes (`ANIMATED`). (2026‑08‑27) |
|
| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still re‑adds the proxy as soon as `RECOVERING` begins (`state_changed` handler), while `Body/*` is already visible — the static box can pop in around the standing figure before the stand‑up completes. Consider delaying re‑addition until after recovery finishes (`ANIMATED`). (2026‑08‑27) |
|
||||||
| 11 | **Stage Freeze Abstraction** — Sandbox Stage EDIT‑mode freezing is type‑specific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`‑based sensors) will need another case in `scripts/editors/stage/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a duck‑typed `set_simulating(bool)` interface once more physical object kinds appear. (2026‑08‑27) |
|
| 11 | **Stage Freeze Abstraction** — Sandbox Stage EDIT‑mode freezing is type‑specific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`‑based sensors) will need another case in `scripts/editors/stage/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a duck‑typed `set_simulating(bool)` interface once more physical object kinds appear. (2026‑08‑27) |
|
||||||
| 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (2026‑08‑27) |
|
| 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Phase 20a specifies AABB broad phase plus visible-geometry narrow phase for characters and props, with a small screen-space line tolerance, to make overlapping-object choices match the clicked art. Terrain, scenery, and areas retain AABB selection in that bounded phase; keep this debt open until implementation and the remaining object types are reviewed. See `docs/specs/phase_20a_stage_identities_and_names.md`. (2026‑08‑27; spec update 2026-10-25) |
|
||||||
| 13 | **Slope-aware walking physics & dynamic obstacle avoidance (deferred)** — Phase 3a bakes a real navigation mesh (per-`TerrainBlock` polygon decomposition into a code-built `NavigationRegion2D`) and `walk_to` follows `NavigationAgent2D` paths, but the figure walks the path with an upright pose (no tilt to the slope, no physics sliding) and `avoidance_enabled = false`, so it can path through props and other stickmen. | Medium | Open | A stickman crossing a ramp/stair follows the sloped footprint but looks flat-footed, and does not avoid moving props or each other. Future: tilt/rotate the figure to the path slope and enable RVO avoidance (`avoidance_enabled`, avoidance layers, `velocity` handling) once content/props/other stickmen are registered as obstacles. (2026‑08‑29) |
|
| 13 | **Slope-aware walking physics & dynamic obstacle avoidance (deferred)** — Phase 3a bakes a real navigation mesh (per-`TerrainBlock` polygon decomposition into a code-built `NavigationRegion2D`) and `walk_to` follows `NavigationAgent2D` paths, but the figure walks the path with an upright pose (no tilt to the slope, no physics sliding) and `avoidance_enabled = false`, so it can path through props and other stickmen. | Medium | Open | A stickman crossing a ramp/stair follows the sloped footprint but looks flat-footed, and does not avoid moving props or each other. Future: tilt/rotate the figure to the path slope and enable RVO avoidance (`avoidance_enabled`, avoidance layers, `velocity` handling) once content/props/other stickmen are registered as obstacles. (2026‑08‑29) |
|
||||||
| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the visible-AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) |
|
| 14 | **Phase 4 event engine is O(rigs×props + areas×movables) per physics frame** — `sandbox_stage.gd` `_update_area_entry()` / `_update_stickman_prop_collision()` run all-pairs geometric tests every physics frame in PLAY. | Low | Open | Fine at sandbox scale, but degrades quadratically with dozens of dynamic objects. A cell-keyed spatial broadphase (e.g. `_grid_cells`) can cull candidate pairs before the visible-AABB/feet-point tests. Phase 4b implemented that index (see #16, now Resolved) but the event engine does **not yet query it** — #14 remains Open until `_update_area_entry()` / `_update_stickman_prop_collision()` use it. (2026‑08‑30 / note updated 2026‑09‑02) |
|
||||||
| 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)** — `StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `docs/archive/plans/PHASE_4b_SPEC.md` §3. (2026-09-02) |
|
| 15 | **Walk arrival jitter (mode re-evaluated per frame + arrival-radius mismatch)** — `StickmanRig._update_walking()` re-computes `_walk_mode` from `is_target_reachable()` every physics frame, and finishes via `is_navigation_finished()` (`NAV_TARGET_DESIRED_DISTANCE` 12 px, feet-space) *or* the `ARRIVE_DISTANCE` 8 px root-space guard. Near the nav-mesh boundary (clicked waypoints often sit just off placed terrain) `is_target_reachable()` can flip, swapping `root_target` between the nav path point and the raw waypoint — two points with a small vertical offset → up/down jitter. | Medium | ✅ Resolved | Phase 4b: `StickmanRig._update_walking()` now **latches `_walk_mode` once per walk** (probes up to `LATCH_PROBE_MAX_FRAMES` = 6 after the map syncs, latching `"nav"` when reachable or `"direct"` on the bound) so it can never flip between frames, unifies arrival on the **final target** at `ARRIVE_DISTANCE` 8 px (snap-on-arrive), steers straight at the final target when within `2 × ARRIVE_DISTANCE`, and re-asserts the standing markers one extra physics frame after stop (`_settle_walk_markers()`) to clear any residual body-bob. Exactly one `arrive` fires; `global_position` is unchanged after arrival. See `docs/archive/plans/PHASE_4b_SPEC.md` §3. (2026-09-02) |
|
||||||
@@ -91,6 +91,7 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| 79 | **Editor chrome has two existing token sources and scene-level literals** — PropTheme owns current Prop UI values while Stickman scenes hardcode bars, labels, and borders. | Medium | Spec prepared; implementation pending | Add one validated `EditorVisualTheme` source for shared semantic chrome and editor-local `Theme` instances. Retain PropTheme for prop material/primitive/canvas values and keep Stickman canvas drawing tokens in a separate namespace. Migrate legacy Prop `ui` accessors deliberately so a chrome role has one final authority; use SandboxTheme values as a visual reference without a runtime dependency or Stage inspector component reuse. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
| 79 | **Editor chrome has two existing token sources and scene-level literals** — PropTheme owns current Prop UI values while Stickman scenes hardcode bars, labels, and borders. | Medium | Spec prepared; implementation pending | Add one validated `EditorVisualTheme` source for shared semantic chrome and editor-local `Theme` instances. Retain PropTheme for prop material/primitive/canvas values and keep Stickman canvas drawing tokens in a separate namespace. Migrate legacy Prop `ui` accessors deliberately so a chrome role has one final authority; use SandboxTheme values as a visual reference without a runtime dependency or Stage inspector component reuse. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
||||||
| 80 | **Stickman panel nodes currently own both document shapes and local view state** — rebuilding a panel for a focused layout would reset selection, zoom, and pan and risk save/preview drift. | Medium | Spec prepared; implementation pending | Move the same ten BodyPartPanel instances between Overview and Focus hosts, retain one permanent WholeStickmanPreview sibling, and defer switching while a drag or popup is active. The controller owns session-only view choice and active part; manually chosen views survive resize, and narrow Overview remains scrollable. A future document-model extraction is warranted only if new editor features make live-node ownership limiting. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
| 80 | **Stickman panel nodes currently own both document shapes and local view state** — rebuilding a panel for a focused layout would reset selection, zoom, and pan and risk save/preview drift. | Medium | Spec prepared; implementation pending | Move the same ten BodyPartPanel instances between Overview and Focus hosts, retain one permanent WholeStickmanPreview sibling, and defer switching while a drag or popup is active. The controller owns session-only view choice and active part; manually chosen views survive resize, and narrow Overview remains scrollable. A future document-model extraction is warranted only if new editor features make live-node ownership limiting. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
||||||
| 81 | **Stickman authored transforms lack a complete dirty-state boundary** — preview translation emits `part_moved`, while rotation and scale currently have no commit signal; Save/Load/Clear do not track unsaved work. | Medium | Spec prepared; implementation pending | Add a controller-observable transform commit path for position, rotation, and scale, plus `_dirty`/`_current_path` and Save/Discard Changes/Cancel transactions for replacement and close. Keep Overview/Focus, guide, grid, selection, and zoom/pan as session UI state that never marks dirty. Preserve the `.stk` format. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
| 81 | **Stickman authored transforms lack a complete dirty-state boundary** — preview translation emits `part_moved`, while rotation and scale currently have no commit signal; Save/Load/Clear do not track unsaved work. | Medium | Spec prepared; implementation pending | Add a controller-observable transform commit path for position, rotation, and scale, plus `_dirty`/`_current_path` and Save/Discard Changes/Cancel transactions for replacement and close. Keep Overview/Focus, guide, grid, selection, and zoom/pan as session UI state that never marks dirty. Preserve the `.stk` format. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
|
||||||
|
| 82 | **Phase 20a introduces persistent stage identity alongside temporary save IDs** — props already use persistent `stage_object_id`, while stickmen currently depend on runtime instance IDs and each save regenerates `file_id` by World order. | Medium | Spec prepared; implementation pending | Keep `file_id` for the current rule remap, retain existing prop IDs, and add a persistent character ID plus scene-unique display names independent of assets. Migrate legacy character IDs deterministically from their pre-migration `file_id`; preserve IDs and names through missing-asset placeholders. A scene-scoped World scan for name allocation is simpler than a mutable registry at current scene sizes. Phase 20b–20e may consume these IDs for authored links; this spec does not implement those links. See `docs/specs/phase_20a_stage_identities_and_names.md`. (2026-10-25) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -194,4 +195,6 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
|
|
||||||
| 2026-10-22 | Prepared and revised the Phase 19 Plain Background architecture specification (`docs/specs/phase_19_plain_background.md`) for final user sign-off. The accepted composed Stage backdrop view serves live Stage and previews; scene v1.7 persists active kind, last Sky ID, and confirmed plain color while migrating all older scenes as Sky. Color drafts stay out of Save/autosave, and theme-backed grid and two-tone gizmo overlays address light fills without changing art. Per user feedback, the current-color chip is the sole custom-picker entry, the separate More colors button is removed, and the custom picker uses a simple Preview label without a temporary-state helper message. Logged #78 for the chooser extension and rendered verification gates. No runtime code or tests changed. |
|
| 2026-10-22 | Prepared and revised the Phase 19 Plain Background architecture specification (`docs/specs/phase_19_plain_background.md`) for final user sign-off. The accepted composed Stage backdrop view serves live Stage and previews; scene v1.7 persists active kind, last Sky ID, and confirmed plain color while migrating all older scenes as Sky. Color drafts stay out of Save/autosave, and theme-backed grid and two-tone gizmo overlays address light fills without changing art. Per user feedback, the current-color chip is the sole custom-picker entry, the separate More colors button is removed, and the custom picker uses a simple Preview label without a temporary-state helper message. Logged #78 for the chooser extension and rendered verification gates. No runtime code or tests changed. |
|
||||||
|
|
||||||
|
| 2026-10-25 | Prepared the Phase 20a identities, names, and overlap-selection architecture specification (`docs/specs/phase_20a_stage_identities_and_names.md`) for user sign-off. It specifies persistent character/prop IDs and scene-unique names alongside the existing temporary rule `file_id`, deterministic legacy migration to scene v1.8, shared EDIT name inspectors, and a visible-geometry click check with a transient overlap preview. Updated open #12 for the bounded character/prop precision plan and added #82 for the identity and migration trade-off. No runtime code or tests changed. |
|
||||||
|
|
||||||
_End of Log_
|
_End of Log_
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ The Sandbox Stage establishes a useful baseline for hierarchy, contrast, child-f
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Sandbox Stage | SandboxTheme loads validated tokens from [sandbox_theme.json](../../sandbox_theme.json). Stage inspector controls live under [scripts/shared/stage_ui](../../scripts/shared/stage_ui/). | Use as the product reference for semantic colors, readable type, focus, action roles, and reusable theme access. |
|
| Sandbox Stage | SandboxTheme loads validated tokens from [sandbox_theme.json](../../sandbox_theme.json). Stage inspector controls live under [scripts/shared/stage_ui](../../scripts/shared/stage_ui/). | Use as the product reference for semantic colors, readable type, focus, action roles, and reusable theme access. |
|
||||||
| Stage inspector | The approved [Stage panel UI system](../specs/stage_panel_ui_system.md) targets in-stage selected-object cards. | Do not insert StageInspectorPanel into editor inspectors by default. Borrow fitting rules and state behavior; use its components only if the layout and ownership match. |
|
| Stage inspector | The approved [Stage panel UI system](../specs/stage_panel_ui_system.md) targets in-stage selected-object cards. | Do not insert StageInspectorPanel into editor inspectors by default. Borrow fitting rules and state behavior; use its components only if the layout and ownership match. |
|
||||||
|
| Stage object names and overlap chooser | [Phase 20a](../specs/phase_20a_stage_identities_and_names_ui_ux.md) defines the name-and-Rename inspector header and the anchored **Objects here** chooser. | Reuse the rename interaction for other renameable objects; reuse the chooser shell and state tokens for similar non-modal object pickers. Follow the click-versus-drag and transform-handle priority in §4.6. Keep object-specific naming rules with the owning feature. |
|
||||||
| Stage and editor dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) draws the shared confirmation card over the active scene while retaining native dialog signals. The style reads `popups.confirmation` from SandboxTheme. | Use this shared card for Prop and Stickman confirmation, recovery, and short action dialogs. Native file picker content remains native. |
|
| Stage and editor dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) draws the shared confirmation card over the active scene while retaining native dialog signals. The style reads `popups.confirmation` from SandboxTheme. | Use this shared card for Prop and Stickman confirmation, recovery, and short action dialogs. Native file picker content remains native. |
|
||||||
| Shared editor chrome | [EditorVisualTheme](../../scripts/shared/editor_ui/editor_visual_theme.gd) loads validated tokens from [editor_visual_theme.json](../../editor_visual_theme.json) and builds each editor's local control Theme. `EditorSelectionDropdown` and `EditorContextMenu` supply shared selection and canvas-menu styling. | Use shared semantic tokens and controls for matching editor workflows. Keep editor-specific document behavior in its owner. |
|
| Shared editor chrome | [EditorVisualTheme](../../scripts/shared/editor_ui/editor_visual_theme.gd) loads validated tokens from [editor_visual_theme.json](../../editor_visual_theme.json) and builds each editor's local control Theme. `EditorSelectionDropdown` and `EditorContextMenu` supply shared selection and canvas-menu styling. | Use shared semantic tokens and controls for matching editor workflows. Keep editor-specific document behavior in its owner. |
|
||||||
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json) for material, geometry, and canvas defaults; editor chrome comes from `EditorVisualTheme`. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep Prop artwork/material values and canvas contrast in `PropTheme`; use shared editor tokens for chrome, inspector states, and selection dropdown styling. |
|
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json) for material, geometry, and canvas defaults; editor chrome comes from `EditorVisualTheme`. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep Prop artwork/material values and canvas contrast in `PropTheme`; use shared editor tokens for chrome, inspector states, and selection dropdown styling. |
|
||||||
@@ -98,6 +99,7 @@ For drawing handles, keep a contrasting backing/halo where the canvas can contai
|
|||||||
- **Editor inspector:** favor a persistent column beside the canvas, with labeled groups and vertical scrolling. The Prop Editor already uses a split container and scrollable inspector. Separate sections with rounded cards, readable headings, colorful section icons, and concise helper copy; keep related values grouped in compact tiles. Reuse Stage's hierarchy and state clarity, but do not force the floating Stage card structure into this column.
|
- **Editor inspector:** favor a persistent column beside the canvas, with labeled groups and vertical scrolling. The Prop Editor already uses a split container and scrollable inspector. Separate sections with rounded cards, readable headings, colorful section icons, and concise helper copy; keep related values grouped in compact tiles. Reuse Stage's hierarchy and state clarity, but do not force the floating Stage card structure into this column.
|
||||||
- **Temporary targeting step:** the Stage capture bar is for a short-lived click-on-stage task. An editor can borrow the prompt-plus-visible-Cancel contract when it has a similar temporary canvas capture step, but should not copy the bar just because it is visible in Stage.
|
- **Temporary targeting step:** the Stage capture bar is for a short-lived click-on-stage task. An editor can borrow the prompt-plus-visible-Cancel contract when it has a similar temporary canvas capture step, but should not copy the bar just because it is visible in Stage.
|
||||||
- **Modal dialog:** follow [Dialog Design Principles](../specs/dialog_design_principles.md). Keep a labeled **Cancel**, **Done**, or **Back** action; use one visually clear primary action; preserve the clicked control's anchor when content grows; cap height and scroll content on short viewports.
|
- **Modal dialog:** follow [Dialog Design Principles](../specs/dialog_design_principles.md). Keep a labeled **Cancel**, **Done**, or **Back** action; use one visually clear primary action; preserve the clicked control's anchor when content grows; cap height and scroll content on short viewports.
|
||||||
|
- **Anchored object chooser:** use the non-modal chooser pattern in section 4.6 for resolving overlapping canvas targets. Keep it visually distinct from a centered dialog and from a persistent inspector.
|
||||||
- **Canvas tools:** keep mode and document controls outside the draw area where possible. Put contextual handles and temporary prompts near the object only if they do not obscure the task.
|
- **Canvas tools:** keep mode and document controls outside the draw area where possible. Put contextual handles and temporary prompts near the object only if they do not obscure the task.
|
||||||
|
|
||||||
### 4.2 Reuse through tokens and small components
|
### 4.2 Reuse through tokens and small components
|
||||||
@@ -136,6 +138,26 @@ Use the Sandbox Stage confirmation card for child-facing confirmation, recovery,
|
|||||||
|
|
||||||
Treat the card as one shared product pattern. Do not create editor-local confirmation palettes or button-role variants.
|
Treat the card as one shared product pattern. Do not create editor-local confirmation palettes or button-role variants.
|
||||||
|
|
||||||
|
### 4.5 Renameable object inspector header
|
||||||
|
|
||||||
|
When a selected object has a user-editable name, show that name once in the inspector header beside the object's type icon. Put a clearly labeled **Rename** button beside it; the title itself should not be an undisclosed rename hotspot. Truncate a long title with an ellipsis and expose the full value as a tooltip. Keep the header and Rename action visible when the inspector body is collapsed.
|
||||||
|
|
||||||
|
Activating **Rename** reveals a prefilled, single-line field anchored directly below the header. Provide a prominent **Done** action and a neutral **Cancel** action, both with child-sized targets. Enter commits and Escape cancels. Keep validation beside the field, explain the problem in plain language, and offer a concrete suggestion when possible. A failed rename keeps the draft visible. Cancel restores the inspector's previous expanded or collapsed state; a successful rename updates the header and any visible object labels.
|
||||||
|
|
||||||
|
Use this interaction for other renameable objects when their inspector can support the same compact header and inline edit. Keep validation, uniqueness, and persistence rules in the owning feature; this visual pattern does not prescribe domain rules. Store shared header, edit, button, focus, and spacing values in the owning UI theme rather than inventing local styling for each object type.
|
||||||
|
|
||||||
|
### 4.6 Anchored object chooser
|
||||||
|
|
||||||
|
Use an anchored chooser when a click lands on multiple real canvas targets and the child must choose which one to act on. The Stage's **Objects here** popup is the implementation reference. It is a temporary, non-modal popup near the click, not an inspector or a centered confirmation dialog.
|
||||||
|
|
||||||
|
- Resolve selection clicks and movement gestures together. When the already-selected object is one of the overlapping targets, check its rotate and resize handles first on pointer press; a handle gesture starts that transform immediately. A press on the selected object's visible body becomes a drag after movement exceeds the screen-space threshold (8 logical pixels in Stage). If released before the threshold, open the chooser and preserve the prior selection. Measure movement in screen space so zoom does not change the gesture; other canvas workflows can tune the threshold while keeping this ordering.
|
||||||
|
|
||||||
|
- Give the popup a dark raised shell with a clear 2 px border, a 12–16 px corner radius, a restrained local shadow, and 12–16 px of inner space. Keep the stage visible; do not dim the canvas behind this short selection task.
|
||||||
|
- Use full-width rows at least 44 px high, preferably around 56 px, with about 8 px between them. Keep the candidate's icon and name together. The selected candidate keeps a teal fill, cyan outline, and a visible **Selected** label; hover and keyboard focus each remain easy to distinguish from selection.
|
||||||
|
- Keep a rounded, neutral **Cancel** button in a separate footer, at least 48 px high. Escape may also cancel. An outside click closes the chooser and is consumed so it cannot select the stage underneath.
|
||||||
|
- Keep the clicked art visible where possible, fit the popup to safe viewport margins, and cap the list height so longer candidate lists scroll. Keep pointer hover and keyboard focus behavior equivalent when they preview a candidate.
|
||||||
|
- Define shell, row, selected, hover, focus, badge, spacing, and Cancel values as theme tokens. The Stage implementation uses `SandboxTheme.popups.anchored_chooser`; other workflows should reuse the visual contract through their own appropriate theme owner.
|
||||||
|
|
||||||
## 5. Editor flows and state maps
|
## 5. Editor flows and state maps
|
||||||
|
|
||||||
The following maps describe the current interaction architecture at a level useful for future visual work. They are not proposed additions to file formats.
|
The following maps describe the current interaction architecture at a level useful for future visual work. They are not proposed additions to file formats.
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Action Reactions — Product Design Brief
|
||||||
|
|
||||||
|
> **Status:** Accepted product decisions captured from the design conversation; not a final architecture or implementation specification.
|
||||||
|
> **Audience:** UI/UX, architecture, and implementation.
|
||||||
|
> **Purpose:** Preserve the agreed child-facing behavior and identify the remaining product and technical questions before implementation.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a child connect a specific moment in an authored action to a purposeful response from one or more characters. A reaction can contain ordered steps, combine compatible actions such as walking and speaking, and itself become the source of another reaction. The same scene events can also be observed by stage-level handlers such as sound or lighting cues.
|
||||||
|
|
||||||
|
The child is directing a planned scene. Reaction links are intentional links to authored action instances, not broad reusable rules such as “whenever Milo walks.”
|
||||||
|
|
||||||
|
## Accepted child flow
|
||||||
|
|
||||||
|
1. In **DIRECT**, the child selects a character. The Director inspector appears only while a character is selected. Clicking outside dismisses it; there is no close button. This is an intentional departure from the shared Stage inspector shell’s fixed Close control. The Phase 20a **EDIT** naming inspector is a separate workflow and retains the shared shell's labeled Close control.
|
||||||
|
2. The inspector keeps the three quick actions **Move**, **Say**, and **Wait** visible.
|
||||||
|
3. An authored action card can be opened to reveal its management choices. From a specific action, the child chooses **Make someone react to this…**.
|
||||||
|
4. The **When** choice is contextual to that action. The child does not browse one long list of generic events.
|
||||||
|
5. The child chooses who responds from a visual, searchable character roster, with on-stage picking available where useful. Each additional responder has a separate **Then** lane.
|
||||||
|
6. **Then** is an ordered set of numbered steps. Actions placed together in one step start together; the next step waits until the current step’s actions finish.
|
||||||
|
7. Reactions to reactions use the same flow. They appear as separate linked cards, not as a nested graph. The source action shows that it starts reactions; browsing all reactions supports search and grouping by their **When** event.
|
||||||
|
|
||||||
|
Numbered steps and paired actions should fit the shared Stage inspector visual system, not introduce a parallel popup style. The shell should retain its safe placement and accessible, clearly labeled controls. The agreed outside-click dismissal is a deliberate exception to the shell’s usual Close button.
|
||||||
|
|
||||||
|
## Stage character names
|
||||||
|
|
||||||
|
- A placed character can be renamed from its inspector when selected in **Edit** mode. This is a new stickman inspector; the current Edit-mode inspector UI is for props and scenery.
|
||||||
|
- The name belongs to that character instance in the scene. Renaming it does not rename or modify the reusable stickman asset.
|
||||||
|
- Character and prop instance names share one scene-wide, case-insensitive uniqueness rule. Placing or duplicating characters that share an asset default name assigns a distinct suffix, such as **Milo**, **Milo 2**, and **Milo 3**.
|
||||||
|
- Action and reaction cards, event descriptions, and responder choices use the current stage-character name.
|
||||||
|
- A character’s saved identity is independent of its display name, so renaming it does not retarget its actions or reactions.
|
||||||
|
|
||||||
|
## Stage prop names
|
||||||
|
|
||||||
|
- A placed prop can be renamed from its selected-prop inspector in **Edit** mode using a **Name on stage** field. The name belongs to the scene instance and does not modify the reusable prop asset.
|
||||||
|
- Duplicate props receive a distinct default name, such as **Blue Door** and **Blue Door 2**. Renaming an instance does not change its saved identity or retarget existing links.
|
||||||
|
- Target choices and action/reaction descriptions show the instance name so a child can distinguish props of the same asset. Where helpful, target pickers should also show a thumbnail or highlight the exact prop on stage.
|
||||||
|
- A Move To action can be described as **Walk to Blue Door** only when it is linked to that specific prop instance. A free-position target remains a place, optionally described as **Place 2 · by Blue Door**. Do not infer prop identity from coordinate proximity.
|
||||||
|
|
||||||
|
## Choosing overlapping stage objects
|
||||||
|
|
||||||
|
- A normal click on one object selects it immediately. A click that hits multiple objects opens **Objects here** and lists only objects whose visible geometry is actually under the click; an object merely nearby or inside another object's bounding-box corner is not a candidate.
|
||||||
|
- Keep the previous selection and inspector as-is while the chooser is open. Hovering or keyboard-focusing a row previews that exact candidate on the stage without committing selection.
|
||||||
|
- Choosing a row commits that object and opens its inspector. Cancel, Escape, or outside dismissal closes the chooser and keeps the previous selection. The click that dismisses the popup must not also select an object behind it.
|
||||||
|
- Rows use the current scene instance name and a clear character/prop icon. The accompanying approved mockup is [Objects here overlap picker](action_reactions_mockups/06_edit_overlap_picker.png).
|
||||||
|
|
||||||
|
## DIRECT mode action previews
|
||||||
|
|
||||||
|
- A selected character’s route is labeled with its authored movement type, such as **Walk** or **Run**. The destination marker identifies the named target, such as **Blue Door**. Do not use a generic “planned walk” label; show only movement types the editor supports.
|
||||||
|
- A **Say** step is represented on the stage by a single speech-bubble marker at the action’s starting point. It signals that speech will happen without displaying a bubble count or keeping multiple dialogue bubbles on the stage. Any step number identifies the action’s place in the plan, not the number of speech bubbles.
|
||||||
|
- Tapping a numbered movement flag selects its existing plan step and reveals **Change place**; selecting alone never moves the destination, and flags are not draggable by default. **Change place** enters destination capture with a clear prompt and visible **Cancel**. The old flag stays faintly visible while the candidate route and flag follow the cursor. Choosing a new point or exact named prop commits the change; **Cancel** or **Esc** restores the original destination.
|
||||||
|
## Action moments
|
||||||
|
|
||||||
|
Only moments relevant to the selected authored action are shown. “Starts” is omitted from Drop and Set Down labels because the start timing is implied.
|
||||||
|
|
||||||
|
| Authored action or event | Reaction moment |
|
||||||
|
| --- | --- |
|
||||||
|
| **Move To** | When the character begins walking, or when they reach that action’s named endpoint. The endpoint is specific to that Move To action; there is no generic “walk finishes” choice. |
|
||||||
|
| **Say** | When that exact line begins (default), or after that line finishes. The line-start event occurs when the bubble/line appears, so a reaction can start while the speaker is still walking. |
|
||||||
|
| **Wait** | When that exact wait finishes (default), or when it begins. A “finished waiting” reaction only fires if the wait completes. |
|
||||||
|
| **Pick Up** | When the prop is successfully picked up and reaches the character’s hand. |
|
||||||
|
| **Use** | When the character successfully uses the selected prop/use spot. |
|
||||||
|
| **Drop** | When the character begins the authored Drop action. |
|
||||||
|
| **Set Down** | When the character begins the authored Set Down action. |
|
||||||
|
| **Throw action** | A separate event for when a character throws a prop. This is distinct from a prop touching a character. The event fires when the character begins the authored Throw action. |
|
||||||
|
| **Moving prop touches character** | A separate scene event for a moving prop touching a character, such as “When a prop touches Ava.” It is not tied back to, or tracked as, the authored Throw action that may have launched it. Fire once when contact begins, not every physics frame; if contact ends and a new touch occurs, that is a new event. |
|
||||||
|
| **Ragdoll** | **When Milo falls.** No separate settled/lying-still moment for now. Landing, bouncing, and prop contact are distinct physical events. |
|
||||||
|
| **Recover** | When Milo is back on his feet, after the Recover action completes. |
|
||||||
|
|
||||||
|
## Reaction priority and action flow
|
||||||
|
|
||||||
|
- A reaction begins as soon as its selected source moment occurs. It does not wait for the source character’s other action in the same step to finish. For example, Milo can **Walk to Red Door + Say “Help!”** together; Ava’s reaction starts when that exact Say line appears, while Milo keeps walking.
|
||||||
|
- A reaction takes priority for its responder. If Ava is doing an incompatible action, that action pauses. Compatible actions may continue together, such as adding speech while a walk continues.
|
||||||
|
- Ava completes all numbered steps in the reaction before returning to her paused authored plan. Actions grouped in one step start together; the step ends when all its actions finish. Ava resumes her paused action and then continues her original queue.
|
||||||
|
- A **Follow Milo** action tracks Milo’s live position. In the agreed example, Ava follows while Milo walks, and the follow ends when Milo reaches the door and Ava catches up.
|
||||||
|
- If a new reaction for Ava triggers while she is already reacting, the new reaction takes over and cancels the unfinished steps of the current reaction. Completed actions are not undone. The original authored plan remains paused and resumes after the new reaction finishes. The saved earlier reaction remains available for later plays; there is no queue of superseded reactions.
|
||||||
|
- The user does not want extra explanatory UI copy for the basic priority/resume behavior.
|
||||||
|
- Do not show loop warnings for ordinary authored reaction chains. Links refer to specific authored actions, so a finite chain remains finite unless the child deliberately authors more links. The moving-prop contact event is independently repeatable per distinct contact, as described above.
|
||||||
|
|
||||||
|
## Event listeners beyond characters
|
||||||
|
|
||||||
|
One scene event can start a linked character reaction and notify external handlers from the same occurrence. Handlers are not limited to characters: they may drive stage-wide effects such as a sound or lighting cue. For example, Milo’s specific “Help!” line can start Ava’s reaction and notify a stage handler.
|
||||||
|
|
||||||
|
## Editing and deletion
|
||||||
|
|
||||||
|
- Editing the destination of an existing Move To action keeps reactions attached to that authored action and updates the displayed trigger location.
|
||||||
|
- Deleting an action with dependent reactions warns that reactions will also be removed. Do not enumerate all affected reactions. Deleting it removes dangling reactions, including follow-up reactions downstream. If there are no dependent reactions, use a normal confirmation to delete the action.
|
||||||
|
- If editing an action removes the moment a reaction depends on (for example, changing a Walk to a Say), use the same confirmation style as deleting an action with reactions. Explain that reactions will also be removed; on confirmation, delete the dangling reactions and downstream reactions. Canceling keeps the original action and reactions. Never silently retarget a reaction to an unrelated moment.
|
||||||
|
|
||||||
|
## Existing visual guidance and intentional departures
|
||||||
|
|
||||||
|
- Use the established Stage inspector visual system and its shared theme tokens for the inspector shell and controls.
|
||||||
|
- The no-close-button, outside-click dismissal behavior is an intentional departure from the Stage panel system’s fixed Close button, chosen by the user for this inspector.
|
||||||
|
- The user chose a sparse reaction flow and simple delete warning. Do not add an itemized dependent-reaction list to the delete confirmation.
|
||||||
|
- A persistent actor-specific inspector context and actor-named reopen affordance were proposed during UI/UX review; their final layout is not part of the decisions captured here.
|
||||||
|
|
||||||
|
## Architecture follow-up before implementation
|
||||||
|
|
||||||
|
The product decisions require an architecture specification for:
|
||||||
|
|
||||||
|
- Stable authored action identity across save/load, so links remain attached to the exact action rather than row position or action type.
|
||||||
|
- Stable stage-character and prop-instance identities independent of display names, so renames do not retarget actions or reactions and duplicate instances remain distinct.
|
||||||
|
- A Move To target linked to a prop instance must retain that identity separately from its approach position; free-position targets remain distinct from prop-linked targets.
|
||||||
|
- Event routing that can notify both reactions and external/stage handlers from one event, including the moving-prop contact event.
|
||||||
|
- Grouped concurrent actions, multi-step Then execution, reaction priority, interruption and resumption, replacement of an active reaction, and live actor following.
|
||||||
|
- Distinguishing authored Drop and Set Down events from incidental prop release during ragdoll or other runtime behavior.
|
||||||
|
|
||||||
|
These are implementation constraints to carry into the architecture phase; this brief does not claim the current runtime already supports them.
|
||||||
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,54 @@
|
|||||||
|
# Phase 20 — Action Plans and Reactions
|
||||||
|
|
||||||
|
This roadmap keeps the proposed Phase 20 slices together so they are available when each slice is scoped. The slice boundaries are proposals and can be refined before their specs are written.
|
||||||
|
|
||||||
|
## Source of product decisions
|
||||||
|
|
||||||
|
- [Action and reaction design brief](action_reactions_design_brief.md) — accepted user-facing decisions and architecture requirements.
|
||||||
|
- [Action visualization mockups](action_reactions_mockups/action_visuals_contact_sheet.png) — current DIRECT-mode visual direction.
|
||||||
|
|
||||||
|
Do not duplicate detailed decisions here. Add new decisions to the design brief and update this roadmap only when the slice boundaries change.
|
||||||
|
|
||||||
|
## Proposed slices
|
||||||
|
|
||||||
|
### Phase 20a — Stage identities and names
|
||||||
|
|
||||||
|
**Status:** User-verified on `feature/phase-20-action-reactions`; automated checks, production interaction run, UIUX visual review, and user manual checks passed. The branch remains unmerged. See the [Phase 20a implementation and verification spec](../specs/phase_20a_stage_identities_and_names.md) and [UI/UX spec](../specs/phase_20a_stage_identities_and_names_ui_ux.md).
|
||||||
|
|
||||||
|
- Rename placed characters and props from their selected Edit-mode inspectors.
|
||||||
|
- Give duplicate instances distinct scene names while keeping those display names separate from reusable assets.
|
||||||
|
- Add stable character and prop instance identities that survive save/load and renaming.
|
||||||
|
- Make character and prop names unique across the scene, ignoring case.
|
||||||
|
- For overlapping clicks, show only objects whose visible geometry is hit; preview candidates from the chooser and preserve the prior selection until a row is chosen. The approved mockup is `action_reactions_mockups/06_edit_overlap_picker.png`.
|
||||||
|
- Make those identities available to later action targets and reaction responder choices.
|
||||||
|
|
||||||
|
### Phase 20b — Action plans and DIRECT authoring
|
||||||
|
|
||||||
|
- Author each character's ordered action plan, including actions grouped to run together.
|
||||||
|
- Author movement destinations as either a free place or a specific named prop instance.
|
||||||
|
- Support selecting a route marker, deliberately changing its destination, and the agreed DIRECT preview labels and speech marker.
|
||||||
|
- Establish the action identity and serialization needed for event links in later slices.
|
||||||
|
|
||||||
|
### Phase 20c — Event-specific reaction authoring
|
||||||
|
|
||||||
|
- Let a child choose a specific authored action event and choose one or more responding characters.
|
||||||
|
- Author ordered **Then** steps, including actions that start together and actions such as **Follow**.
|
||||||
|
- Apply the agreed warnings and cleanup when deleting an action or editing away its reaction trigger.
|
||||||
|
|
||||||
|
### Phase 20d — Reaction runtime and priority
|
||||||
|
|
||||||
|
- Run reactions at their selected event moments, with reaction priority over the responder's authored plan.
|
||||||
|
- Pause and resume compatible or interrupted actions as agreed; replace an active reaction when a higher-priority reaction triggers.
|
||||||
|
- Execute multi-step reactions and live following, with saved action and reaction links remaining stable.
|
||||||
|
|
||||||
|
### Phase 20e — Prop events and stage-wide handlers
|
||||||
|
|
||||||
|
- Add the authored Throw-start event and the separate moving-prop-touches-character event.
|
||||||
|
- Route one event occurrence to both character reactions and external stage handlers, such as sound or lighting cues.
|
||||||
|
- Keep contact events distinct from the authored Throw action that may have caused them.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
For each slice, prepare the relevant UI/UX and architecture specs and review them with the user. Before handing a slice back for user verification, pass three gates: automated behavior/persistence assertions, production-path interaction checks, and rendered visual review by UIUX against the style guide and approved mockups. Capture the actual GUI states reached by the interactions; a headless pass, debug print, or screenshot file alone is not proof of the whole flow. Fix findings and rerun the affected checks before sign-off. If an environment prevents a required check, report the specific limitation and the remaining reproducible user check.
|
||||||
|
|
||||||
|
Apply the same gates to 20a through 20e. The detailed 20a scenarios are in [its verification plan](../specs/phase_20a_stage_identities_and_names.md#verification-plan); later slice specs should define their own observable scenarios and captures for action authoring, event links, reaction timing, and prop/stage handlers.
|
||||||
@@ -27,6 +27,12 @@ Use this guide when creating or revising child-facing dialogs, popups, and short
|
|||||||
- Preserve clear hover, pressed, selected, focused, and disabled states. Do not use color as the only indication of a state.
|
- Preserve clear hover, pressed, selected, focused, and disabled states. Do not use color as the only indication of a state.
|
||||||
- Adapt the arrangement to each workflow while keeping this shared visual language. The Choose Scenery dialog in `scripts/shared/ui/asset_selector.gd` and its `popups.asset_selector` theme tokens are the current implementation reference.
|
- Adapt the arrangement to each workflow while keeping this shared visual language. The Choose Scenery dialog in `scripts/shared/ui/asset_selector.gd` and its `popups.asset_selector` theme tokens are the current implementation reference.
|
||||||
|
|
||||||
|
## Anchored object chooser
|
||||||
|
|
||||||
|
An anchored chooser resolves a temporary canvas choice, such as picking between objects under one click. It is non-modal and remains visually attached to the stage context; it does not use the dim backdrop or centered card used by a confirmation dialog. Follow the rounded shell, row states, selected badge, footer Cancel, safe-viewport fit, and outside-click behavior in [Editor Visual Style Guide, section 4.6](../guides/editor_visual_style_guide.md#46-anchored-object-chooser). The current Stage implementation is **Objects here** and its tokens live in `SandboxTheme.popups.anchored_chooser`.
|
||||||
|
|
||||||
|
Keep **Cancel** visible even when Escape is supported. Outside dismissal consumes that click so the stage beneath the popup does not receive it. Keep previews transient until the child activates a candidate.
|
||||||
|
|
||||||
## Stable position during dynamic resizing
|
## Stable position during dynamic resizing
|
||||||
|
|
||||||
- When a dialog changes size after a tab, filter, or other in-place choice, preserve its current anchor so the clicked control stays under the pointer.
|
- When a dialog changes size after a tab, filter, or other in-place choice, preserve its current anchor so the clicked control stays under the pointer.
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Phase 20a — Stage identities, names, and overlap selection
|
||||||
|
|
||||||
|
**Status:** User-verified; identity, interaction, and UIUX visual gates passed. No merge or commit has been made.
|
||||||
|
|
||||||
|
**UI authority:** [Phase 20a UI/UX](phase_20a_stage_identities_and_names_ui_ux.md). The [Phase 20 design brief](../plans/action_reactions_design_brief.md) records the product decisions. This slice supplies stable identities and readable names for placed characters and props, and makes overlapping object selection match the approved mockup. Action plans, reaction links, and runtime event routing belong to Phases 20b–20e.
|
||||||
|
|
||||||
|
## Current system and boundaries
|
||||||
|
|
||||||
|
- `StageSceneAdapter.collect()` assigns `file_id` from each object's type and current World order on every save. Existing rule encoding maps `Object.get_instance_id()` through those temporary IDs. Keep that conversion intact. A `file_id` is not persistent object identity.
|
||||||
|
- `PropBlock.stage_object_id` already persists and targets prop actions. `StickmanRig` lacks the equivalent. Neither placed type has an instance display name. The selected-prop card currently shows the asset library name; there is no EDIT character card.
|
||||||
|
- `StageSelection.hit_test_all()` currently tests World AABBs and returns candidates in front-to-back order. `_begin_click_select()` and the scenery click path select the frontmost object before opening **Objects here**. `StageModalViews` builds an anchored `PopupPanel` whose labels use asset/node names. The same popup also resolves multiple props during DIRECT Use-target capture.
|
||||||
|
- Selection remains in `StageSelection`; Stage inspectors and `StageGizmos` render it. The new chooser preview must remain separate from that selected state.
|
||||||
|
|
||||||
|
## Identity and name contract
|
||||||
|
|
||||||
|
Persist these two **object entry** fields for every `type == "stickman"` or `type == "prop"` in `.scene`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "stickman_1",
|
||||||
|
"type": "stickman",
|
||||||
|
"stage_object_id": "stickman_172...",
|
||||||
|
"stage_name": "Milo 2",
|
||||||
|
"stk_path": "res://content/stickmen/milo.stk"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`stage_object_id` is an opaque, nonempty, scene-unique string. It is assigned once per placed instance and does not change on rename, move, save, load, or asset repair. A duplicated placed instance receives a new ID. Keep existing prop IDs exactly, including values already referenced by queue/rule prop actions. A `stage_name` is a child-facing scene label, independent of `Node.name`, the `.stk` name, the `.prop` library name, and the asset path. Its trimmed, nonempty value is unique across **both** characters and props using a case-insensitive comparison. A capitalization-only rename of the same object is valid.
|
||||||
|
|
||||||
|
`file_id` remains the per-save key for the current `StageSceneIO.encode_rules()/decode_rules()` runtime-ID remap. Do not replace it in Phase 20a. Existing prop action payloads keep using `target_prop_id == stage_object_id`. Later phases may use the common persistent IDs for actor, responder, and movement target references without using display names as keys.
|
||||||
|
|
||||||
|
### Typed live APIs
|
||||||
|
|
||||||
|
| Class / file | Contract and responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `StickmanRig` — `scripts/characters/stickman/stickman_rig.gd` | Add `var stage_object_id: String = ""` and `var stage_name: String = ""`. These are placed-instance state; the stickman factory does not write them to reusable `.stk` content. |
|
||||||
|
| `PropBlock` — `scripts/props/prop_block.gd` | Retain `stage_object_id`; add `var stage_name: String = ""`. Prop library templates remain unchanged. |
|
||||||
|
| `MissingStickmanPlaceholder` / `MissingPropPlaceholder` | Read both fields from `original_entry`, retain them while the asset is absent, and write their current values back over the cloned entry during collection. Asset repair must carry both values to the restored node. |
|
||||||
|
| `StageObjectIdentity` — proposed `scripts/stage/stage_object_identity.gd`, `class_name StageObjectIdentity extends RefCounted` | Scene-scoped allocation and lookup, not an autoload. Typed public functions: `func get_id(node: Node2D) -> String`, `func get_name(node: Node2D) -> String`, `func assign_new(node: Node2D, base_name: String, world: Node2D) -> void`, `func next_free_name(base_name: String, world: Node2D, exclude: Node2D = null) -> String`, `func validate_name(requested: String, world: Node2D, exclude: Node2D = null) -> Dictionary`, and `func rename(node: Node2D, requested: String, world: Node2D) -> Dictionary`. The result shape is `{ "ok": bool, "name": String, "suggestion": String, "reason": StringName }`, with `reason` `&""`, `&"blank"`, or `&"duplicate"`. Only named characters and props participate in the name search. |
|
||||||
|
| `SandboxStage` — `scripts/editors/stage/sandbox_stage.gd` | Own one identity helper for the active World. Call it after a real placement, before placement feedback; never assign IDs to ghosts. Handle rename requests, mark the document dirty on success, refresh the selected inspector and visible chooser labels, and preserve selection. Use the same helper when an existing placement is duplicated. |
|
||||||
|
|
||||||
|
Additional typed touchpoints:
|
||||||
|
|
||||||
|
| Existing or new class | Phase 20a interface |
|
||||||
|
| --- | --- |
|
||||||
|
| `class_name StageSceneIO extends RefCounted` | `const FILE_VERSION: String = "1.8"`; keep `static func read_file(path: String) -> Dictionary` and `_validate_root(data: Dictionary) -> Dictionary` as the non-destructive migration/validation boundary. |
|
||||||
|
| `class_name StageSceneAdapter extends RefCounted` | Keep `func collect(...) -> Dictionary` and `func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary`; include both object fields in each direction. |
|
||||||
|
| `class_name StageSelection extends RefCounted` | Keep `func hit_test_all(world_pos: Vector2) -> Array[Node2D]`; add a private typed visible-hit helper, such as `func _visible_hit(node: Node2D, world_pos: Vector2, screen_tolerance_px: float) -> bool`. |
|
||||||
|
| `class_name StageGizmos extends Node2D` | Add `func set_candidate_preview(node: Node2D) -> void`; a null node clears the preview. The candidate field is transient and distinct from selected targets and ordinary hover. |
|
||||||
|
| `class_name StageModalViews extends RefCounted` | Keep `signal object_selected(node: Node2D)`; add `signal candidate_preview_changed(node: Node2D)` and `signal objects_here_dismissed()`. `show_objects_here(...) -> void` retains the candidate list and prior-selection list. |
|
||||||
|
| `class_name StagePropSettingsView extends Control` | Add `signal rename_requested(node: Node2D, requested_name: String)`; show the stage name once in the shared header beside Rename; keep its existing prop role/spot signals and shared shell. The prop body has no duplicate static name row. |
|
||||||
|
| New `class_name StageCharacterSettingsView extends Control` in `scripts/editors/stage/stage_character_settings_view.gd` | `signal rename_requested(node: Node2D, requested_name: String)`; `func show_character(rig: StickmanRig, safe_rect: Rect2) -> void`; `func hide_card(reset_selection: bool = false) -> void`. Own the compact name-and-Rename header and inline naming presentation only. |
|
||||||
|
|
||||||
|
`StageObjectIdentity` should inspect the current World when allocating or validating. Scene sizes are small, and a scan avoids a second mutable registry that can drift after deletion, load, or replacement. The existing prop ID generator may be moved behind `assign_new`; it must check both named types and missing placeholders for collisions. Name suffixing chooses the smallest available positive suffix at or above 2 without renaming existing instances. A terminal ` <number>` suffix from a duplicated instance is interpreted as the same base, so duplicating **Milo 2** among **Milo** and **Milo 2** offers **Milo 3**, rather than **Milo 2 2**. Deleting **Milo 2** can make that suffix available to a future placement. Compare with `to_lower()` after trimming outer spaces; preserve the child's chosen spelling and internal spaces.
|
||||||
|
|
||||||
|
Use the Stickman Library entry's `name` as a new character's base name; that entry is derived from the `.stk` `stickman_name`, falling back to the `.stk` basename. Use the Prop Library's `name`/`label`, falling back to the asset basename or **Prop**, as a new prop's base name. If the chosen base is empty after trimming, use **Character** or **Prop**. Repeated placement and duplication allocate IDs and names from the current scene, without a naming dialog. An asset rename never silently changes already placed `stage_name` values.
|
||||||
|
|
||||||
|
### Save, migration, and validation
|
||||||
|
|
||||||
|
Advance `StageSceneIO.FILE_VERSION` from `1.7` to `1.8`. Extend `StageSceneAdapter.collect()/apply()` to write and restore both fields for real and missing objects. Assign restored values before `_finalize_placed_node()` can generate a fresh prop ID. Save and autosave use the same adapter, so both retain identity. Existing `file_id` generation and rule mapping continue unchanged.
|
||||||
|
|
||||||
|
For scene versions `1.0`–`1.7`, preserve all current migrations, then fill the two new fields in object order. Keep every existing nonempty prop `stage_object_id`. For each legacy character, derive a deterministic ID from its pre-migration `file_id` (for example `stickman:<file_id>`) and add a deterministic suffix only if it collides with another persistent ID. Do this once at the `1.8` migration boundary; after saving, the stored ID is authoritative. Derive initial names from each asset's familiar name where available, then allocate scene-wide suffixes in document order. The migration's base-name resolver can read the existing Stickman/Prop Library metadata but must not instantiate or modify World nodes. An unavailable asset uses its saved path basename as the base and retains the resulting name and ID in its placeholder. With unchanged asset metadata, opening the same old file before its first save must yield the same identities and names each time.
|
||||||
|
|
||||||
|
The current migration contains version-specific Sky/Plain Background and wall-tint conditions. When adding `1.7` to accepted legacy versions, ensure it does **not** receive pre-1.7 background defaults or pre-1.6 wall-color rewrites. Add the new identity/name pass after the existing older-version transformations.
|
||||||
|
|
||||||
|
For a version `1.8` document, `StageSceneIO._validate_root()` requires nonempty string IDs and trimmed, nonempty string names for character and prop entries; rejects duplicate IDs across both types and case-insensitive duplicate names; and does not silently repair ambiguous current-format references. Validate before `StageSceneAdapter.apply()` clears the current World, preserving the existing non-destructive read boundary. The legacy migration allocates missing fields before this validation. A successful manual rename trims outer spaces, permits capitalization-only edits, and returns `blank` or `duplicate` with a suggested free name without altering the saved or live name. A successful rename marks the scene dirty once; Cancel and invalid input do not.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Asset[Reusable asset name] -->|placement base| Allocate[StageObjectIdentity]
|
||||||
|
Allocate --> Live[Placed stage_object_id + stage_name]
|
||||||
|
Live --> Adapter[StageSceneAdapter collect/apply]
|
||||||
|
Adapter --> File[.scene v1.8 object entry]
|
||||||
|
Live --> Card[EDIT inspector and Objects here labels]
|
||||||
|
Live --> Later[20b–20e stable links]
|
||||||
|
FileID[file_id per save] --> Rules[Existing rule ID remap]
|
||||||
|
```
|
||||||
|
|
||||||
|
## EDIT inspector flow
|
||||||
|
|
||||||
|
Adopt the shared `StageInspectorPanel` shell, `SandboxTheme.stage_panels` type/spacing/button tokens, safe rectangle, and fixed header. Show the selected object's name once in the header beside a visible **Rename** button. The prop inspector uses a disclosure chevron: **▴** collapses the settings body into the same named header and **▾** expands it. The character inspector stays as a compact name-and-Rename bar because naming is its only EDIT setting. Both compact headers remain one line; long names use an ellipsis and keep the full text in a tooltip. Clicking empty stage space clears selection and removes the bar. Activating Rename opens a prefilled inline `LineEdit` with explicit Done/Cancel controls and nearby validation feedback. Do not make the title itself an undisclosed rename hotspot. `StagePanelChoiceGroup` and `StagePanelToggleRow` remain available for choice and switch content; keep the modal **Objects here** layout under `StageModalViews` and `SandboxTheme.control_theme()`, since its point-click popup behavior differs from the persistent inspector.
|
||||||
|
|
||||||
|
The view owns only a draft name and inline error presentation. It emits intent, such as `signal rename_requested(node: Node2D, requested_name: String)`. `SandboxStage` validates and mutates via `StageObjectIdentity`, then refreshes the view and labels. On blank/duplicate results, keep the field and focus in place and show the UI spec's explanation and suggestion. Enter requests Done; Escape cancels the draft; selection change cancels it. Multiple selection shows the approved helper instead of an editable field. The header and inspector anchor stay stable while the body grows. Do not use `Node.name` as a shortcut for stage names.
|
||||||
|
|
||||||
|
The placement ghost displays `next_free_name()` as **Click to place …**, and the actual placed node displays brief **… placed** feedback. Only actual placement commits an ID or name. Deletion releases a name naturally when the node leaves World; no tombstone/name counter is needed. Existing objects are never renumbered.
|
||||||
|
|
||||||
|
## Visible-geometry hit model
|
||||||
|
|
||||||
|
`StageSelection.hit_test_all(world_pos: Vector2) -> Array[Node2D]` remains the single source for both immediate click selection and **Objects here**. Use `get_world_aabb()` as a broad phase, expanded only by a small tolerance converted from screen pixels with camera zoom. For `PropBlock` and `StickmanRig`, run a second pass against the actual visible polygons and stroked lines in their transformed visual subtree. Use `Geometry2D.is_point_in_polygon()` for filled `Polygon2D` shapes; for `Line2D`, compare distance to each transformed segment against half its rendered width plus an approximately 6–8 logical-pixel screen tolerance. Respect node transforms, rotation, scale, `is_visible_in_tree()`, and effective opacity. If a custom prop is visibly composed from multiple shapes, inspect the shapes rather than treating its collision hull or union AABB as the visible hit. A transparent gap or a door outside the click's visible area is not a candidate. Missing-asset markers use their drawn marker rectangle. Keep existing AABB behavior for terrain/scenery/areas in this slice, so their selection remains covered by debt item #12.
|
||||||
|
|
||||||
|
Preserve front-to-back sorting after the exact filter. The small line tolerance keeps thin limbs selectable, but it should not pull in a visually separate neighboring object. Hover and click should use the same hit model where practical so the cue predicts the candidate list. Do not replace the precise visible hit with a physics point query: an animated `StickmanRig` has no comparable physics body, and a prop's collision hull can differ from its rendered shape.
|
||||||
|
|
||||||
|
## Overlap chooser and transform gesture precedence
|
||||||
|
|
||||||
|
```text
|
||||||
|
EDIT left press → StageSelection.hit_test_all(point)
|
||||||
|
0 hits: existing empty-click / box-select behavior
|
||||||
|
1 hit: select immediately (existing drag handling remains)
|
||||||
|
2+ hits: defer the choice until movement or release
|
||||||
|
selected candidate + pointer moves ≥ 8 screen px → drag current selection, no chooser
|
||||||
|
selected gizmo handle → rotate/resize immediately, no chooser
|
||||||
|
pointer released before threshold → show Objects here, selection and inspector unchanged
|
||||||
|
row hover/focus → transient candidate preview
|
||||||
|
row click → validate candidate → select_only(candidate) → close
|
||||||
|
Cancel/Escape/outside → clear preview → close, no selection write
|
||||||
|
```
|
||||||
|
|
||||||
|
Use screen-space distance so the drag threshold feels the same at each camera zoom. The selected object under the press is the drag anchor even if it is behind another visible candidate; dragging it moves the current selection as usual. Keep rotate and prop resize handle hit tests ahead of overlap disambiguation. If no already-selected candidate is under the press, a multi-hit gesture remains a chooser click. After a chooser commit in EDIT, show the brief teaching toast **Drag to move · Click to choose**; do not show it for DIRECT Use or scenery selection.
|
||||||
|
|
||||||
|
Add `StageModalViews` signals `candidate_preview_changed(node: Node2D)` (`null` clears), `object_selected(node: Node2D)` (existing commit signal), and `objects_here_dismissed()`. Row hover and keyboard focus emit the same preview; pointer exit, Cancel, popup hide, and invalidated candidates clear it. The popup receives stage-name labels and a **Selected** badge for candidates in the previous selection. Rows stay at least 44 px high, preferably around 56 px, with bounded scrolling and a visible Cancel action.
|
||||||
|
|
||||||
|
`SandboxStage` owns `_objects_here_candidates: Array[Node2D]` for the one open chooser and routes preview to a new transient `StageGizmos.set_candidate_preview(node: Node2D)` API. This preview is separate from `_targets` and `_hovered`; it draws the EDIT-cyan visible outline/silhouette above overlap art and a nearby cyan-outlined rounded pill containing only the candidate's **name**. The pill appears while a chooser row is hovered or focused; do not add a “Preview:” prefix. The normal selected outline remains visible. Clear candidate preview when the chooser closes, selection changes, mode changes, or the candidate is deleted. Row commit checks the node is still valid, selectable, and in the stored candidate set before calling `select_only()` once. The chooser should snapshot the prior selection solely to display **Selected** badges; it need not restore a snapshot because opening and previewing never mutate `StageSelection`.
|
||||||
|
|
||||||
|
Give the popup a full-viewport transparent input guard below its anchored card, or an equivalent centralized consumed-event path. The guard closes on an outside press and consumes that press, so it cannot also run Stage click selection. Escape and Cancel call the same close path. `popup_hide` is a final cleanup path and should not commit selection. The EDIT click path and scenery overlap path must remove their eager `select_only(hit)` calls before opening the chooser. Shift and multi-select behavior can continue its existing direct-toggle path when no chooser is requested.
|
||||||
|
|
||||||
|
The DIRECT Use-target picker already filters `hit_test_all()` down to props and calls `_select_use_target_prop()` after row choice. Retain that prop-only filter and target-commit path. Store a chooser context (`&"edit_select"` or `&"direct_use_prop"`) in `SandboxStage` when opening the shared popup; route its existing `object_selected(node)` signal according to that context. This avoids mixing the EDIT selection commit with a DIRECT target commit or opening an EDIT inspector during capture. Both contexts use the same precise hit list and stage-name labels. Dismissing a DIRECT chooser leaves the pending target capture active and unchanged.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Child
|
||||||
|
participant Stage as SandboxStage
|
||||||
|
participant Hits as StageSelection
|
||||||
|
participant Popup as StageModalViews
|
||||||
|
participant Gizmos as StageGizmos
|
||||||
|
Child->>Stage: Press shared visible point
|
||||||
|
Stage->>Hits: hit_test_all(point)
|
||||||
|
Hits-->>Stage: [Milo, Ava]
|
||||||
|
alt Selected candidate under pointer, drag reaches 8 screen px
|
||||||
|
Stage->>Gizmos: begin_translate_drag(selected candidate)
|
||||||
|
Child->>Stage: Move and release
|
||||||
|
Stage->>Gizmos: drag_to(point), end_drag()
|
||||||
|
Stage-->>Child: Keep selection; no chooser
|
||||||
|
else Pointer released before 8 screen px
|
||||||
|
Stage->>Popup: show_objects_here(candidates, prior selection)
|
||||||
|
Child->>Popup: Hover or focus Ava row
|
||||||
|
Popup->>Stage: candidate_preview_changed(Ava)
|
||||||
|
Stage->>Gizmos: set_candidate_preview(Ava)
|
||||||
|
Child->>Popup: Click Ava row
|
||||||
|
Popup->>Stage: object_selected(Ava)
|
||||||
|
Stage->>Hits: select_only(Ava)
|
||||||
|
Popup->>Stage: objects_here_dismissed()
|
||||||
|
Stage->>Gizmos: set_candidate_preview(null)
|
||||||
|
end
|
||||||
|
Note over Stage,Gizmos: Rotate and resize handles take priority on press
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance outcomes and observation path
|
||||||
|
|
||||||
|
1. Place two copies of a character and two copies of a prop with the same asset bases. The ghost announces the next free name; feedback confirms the placed name. Each object gets its own persistent ID; names are distinct across both types regardless of capitalization. Asset files and names do not change.
|
||||||
|
2. Select one character or prop in EDIT. Its inspector header shows the stage name once beside Rename. The prop disclosure collapses and expands the body while preserving the named header; the character inspector stays compact. A valid rename updates the header and future chooser labels, marks the scene dirty, and keeps its ID. Blank or conflicting names stay in the field with a suggestion. Cancel, Escape, and selection changes abandon the draft. A multiple selection offers no name editor.
|
||||||
|
3. Save, close, and reload. Observe the same names and IDs on both real objects and any missing-asset markers. A prior-version scene loads with deterministic generated character IDs and unique names; saving it writes v1.8. Existing prop targets and rule references still resolve through their respective IDs.
|
||||||
|
4. Put Milo and Ava over one click point, with a Blue Door nearby but outside that clicked visible geometry. The chooser lists Milo and Ava only. Hover or focus Ava: the exact Ava preview and name pill appear while prior selection and inspector remain. Cancel, Escape, or an outside click clears preview and leaves prior selection intact; that outside click causes no second stage selection. Choosing Ava selects her once and opens her inspector.
|
||||||
|
5. With an overlapping object selected, press its visible body and drag past 8 screen pixels: move the selected object or current selection without opening the chooser. Release before the threshold: open the chooser and preserve the previous selection. Rotate or resize from the selected gizmo handle: begin that transform immediately without opening the chooser.
|
||||||
|
6. Click where only one visible object is hit: select immediately. Click inside an object's AABB but outside all visible character/prop art: it is not selected. Thin visible limbs remain selectable with the documented tolerance. In DIRECT Use target capture, multiple touching props still offer a prop-only chooser and choosing one advances that target capture; dismissing it leaves capture pending.
|
||||||
|
|
||||||
|
A repeatable manual review is: create **Milo**, **Milo 2**, and **Blue Door**; rename the first to **Max**; overlap Max and Milo 2; position Blue Door close to, but outside, the test point; save and reopen; then run the click, preview, Cancel, and commit observations above. Record IDs from the saved `.scene` before and after reload to confirm they remain byte-for-byte equal while `file_id` continues to serve the existing rule remap. The user reports completing the sign-off workflows for names and persistence, overlap selection and movement, and the DIRECT **Use…** picker; see the User verification record below.
|
||||||
|
|
||||||
|
## Verification plan
|
||||||
|
|
||||||
|
Headless assertions, a production-path interaction run, and a render-capable visual review cover different failure classes; a successful launch or debug print alone does not satisfy a gate. The report below records the completed gates, including user verification at the end.
|
||||||
|
|
||||||
|
### 1. Automated behavior and persistence
|
||||||
|
|
||||||
|
- Add focused assertions for `StageObjectIdentity`: allocation for both types, cross-type case-insensitive conflicts, suffix selection, capitalization-only rename, blank/duplicate rejection with suggestions, duplicate placement receiving a fresh ID, and freeing a name when an object is deleted.
|
||||||
|
- Exercise `StageSceneIO` migrations from each supported version `1.0`–`1.7` to `1.8`. Assert deterministic character IDs/names, exact retention of existing prop IDs, and rejection of duplicate/empty IDs and names in current-format documents before the live World is cleared.
|
||||||
|
- Exercise production `StageSceneAdapter.collect()/apply()` round trips for real and missing-asset characters and props. Assert that rename, save/load, and asset repair preserve instance IDs and names while `file_id` continues to serve rule remapping.
|
||||||
|
- Cover the hit model with visible polygon interiors, points outside polygon art but inside the AABB, thin limb lines, transforms, visibility, camera zoom, and front-to-back ordering. A nearby object outside the click's geometry must not enter the candidates.
|
||||||
|
|
||||||
|
### 2. Production-path interaction
|
||||||
|
|
||||||
|
- Run a GUI-capable Stage harness that instantiates `SandboxStage`, creates the overlap arrangement from acceptance outcome 4, and drives the same click, pointer, focus, keyboard, and button routes a child uses. Use Godot input-event injection or desktop UI automation where supported; do not substitute direct calls to `select_only()` or popup callbacks for the interactions being verified.
|
||||||
|
- Assert selection and inspector state before opening the chooser, during row preview, after row commit, and after Cancel, Escape, and outside dismissal. Confirm candidate preview always clears on close, stale candidates cannot be committed, and the outside-dismissal press is consumed instead of selecting the stage object behind the popup.
|
||||||
|
- Drive the name editor through Done, Enter, Cancel, Escape, invalid-name feedback, and selection changes. Assert document dirty state and saved values change only after a valid commit.
|
||||||
|
- Exercise the shared DIRECT Use-target picker separately. Verify it remains prop-only, commits through the target-capture route, and leaves capture pending after dismissal.
|
||||||
|
|
||||||
|
### 3. Rendered visual and UIUX review
|
||||||
|
|
||||||
|
- Capture rendered PNGs from the same production-path flow in a render-capable GUI session. Include: prop inspector; character inspector using the colorful `break.stk` art; name editing with a duplicate-name message; overlap chooser with the previous selection visible; hover preview; keyboard-focus preview; Cancel/Escape/outside-dismissal result; and the nearby Blue Door omitted from candidates. Include a crowded chooser/scroll state, a long name in the collapsed prop header at 920×560, and a character near the top chrome to verify the rotation handle moves below the rectangle while retaining its 44 × 44 hit target.
|
||||||
|
- Capture an ordered set of states (or a short recording where available) so UIUX can judge how the controls respond across the flow, not only isolated static layouts. Ask UIUX to compare the rendered pixels with `docs/guides/editor_visual_style_guide.md`, `docs/specs/stage_panel_ui_system.md`, the approved UI/UX spec, and `docs/plans/action_reactions_mockups/06_edit_overlap_picker.png`.
|
||||||
|
- Have UIUX report concrete visual findings for hierarchy, theme-token use, target size, readable names, focus/hover/selection distinction, anchoring, and clipping. Address findings and recapture the affected states before considering visual review complete.
|
||||||
|
- Inspect the actual rendered pixels. A capture file existing or the scene launching does not prove the visual result. Follow the same visual-capture conventions used by `tests/phase19_gui_visual_capture.gd` and `tests/phase19_depth_inspector_visual_capture.gd`.
|
||||||
|
|
||||||
|
The verification report should list the commands and scenarios run, assertion results, capture locations, UIUX findings and resolutions, and any unverified outcome with its specific environment limitation. Keep debug prints or temporary overlays as diagnostic aids; they are not substitutes for assertions or rendered review. If GUI input or rendering cannot be established after trying an available route, mark the affected outcomes unverified and leave a precise reproducible user check. The popup interaction can follow the existing test precedent in `tests/test_phase4b_popup_anchor.gd`.
|
||||||
|
|
||||||
|
## Architectural decisions and trade-offs
|
||||||
|
|
||||||
|
- **Separate persistent ID, display name, and save-local `file_id`:** preserves current prop targeting and old rule serialization while preparing exact references for later action/reaction work. It adds two string fields per named object.
|
||||||
|
- **Scene-scoped helper over an autoload or name registry:** avoids stale indices after deletion and keeps preview scenes isolated. A World scan is sufficient for the expected child-authored scene size; index it later only if profiling shows a need.
|
||||||
|
- **Visible-geometry narrow phase for characters/props:** prevents false chooser rows and aligns one-hit selection with the mockup. Drawing and hit extraction must agree on transform and visibility; the line tolerance protects touchability. Terrain/scenery precision remains a separate issue in debt #12.
|
||||||
|
- **One chooser with explicit context:** reuses the established popup for EDIT and DIRECT while keeping each commit side effect explicit. The transient preview has no authority to mutate scene or selection state.
|
||||||
|
|
||||||
|
## Verification report
|
||||||
|
|
||||||
|
### Automated behavior and persistence
|
||||||
|
|
||||||
|
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase20a_identity.gd` — **200 checks passed**. Covers identity/name allocation and validation, deterministic migrations from 1.0–1.7, current-format rejection, real and missing-object collection/apply, and a file save/read round trip.
|
||||||
|
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase20a_hit_testing.gd` — **12 checks passed**. Covers polygon interior and empty AABB space, front-to-back order, transformed props, line touch tolerance at two zooms, and hidden/transparent shapes.
|
||||||
|
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_content_paths.gd` — **17 checks passed**. The old-path fixture now uses the pre-identity 1.7 schema plus its required background fields, then verifies migration, loading, and canonical asset paths.
|
||||||
|
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase4b_stage.gd` — **53 assertions passed**. Restored the documented bare-Escape fallback from DIRECT to EDIT after the existing regression exposed its absence.
|
||||||
|
- `git diff --check` — passed.
|
||||||
|
|
||||||
|
### Production-path interaction and rendered review
|
||||||
|
|
||||||
|
- `tests/phase20a_gui_visual_capture.gd` ran in a render-capable Godot 4.7.1 session. **104 production-input assertions passed** and 25 captures were saved at 1280×720 and 920×560. Scenarios include keyboard-driven naming and dirty state, duplicate-name feedback, prop and character overlaps with Blue Door excluded outside the clicked geometry, chooser preview/commit/Cancel/Escape/outside dismissal, DIRECT Use target progression, crowded-list scrolling at a smaller viewport, long-name one-line inspector headers, a top-edge rotation handle, and an actual rotation drag through the Stage input path.
|
||||||
|
- Captures are in `tests/phase20a_captures/`; key states include `prop_inspector.png`, `character_inspector.png`, `prop_inspector_collapsed.png`, `prop_inspector_collapsed_long_name_small_viewport.png`, `break_character_long_name_top_handle_small_viewport.png`, `character_duplicate_name_feedback.png`, `character_overlap_chooser.png`, `character_overlap_preview.png`, `character_overlap_committed.png`, `direct_use_chooser.png`, `direct_use_target_selected.png`, `placement_name_prompt.png`, `placement_feedback.png`, and both `crowded_chooser_*_small_viewport.png` states.
|
||||||
|
- UIUX compared the current captures against `docs/guides/editor_visual_style_guide.md`, the amended shared Stage panel spec, this UI/UX spec, and the approved overlap mockup. **Visual review accepted.** The long prop title truncates to one line while keeping the full name in its tooltip; the Break art reads clearly inside the stronger outline; the compact rotate handle moves below the selection near the top chrome and remains operable through its 44 px hit target. The preview-name pill may move toward the upper-right at 920×560 when space is tight; the cyan candidate outline remains distinct.
|
||||||
|
- The follow-up name-pill refinement uses `tests/phase20a_popup_style_preview.gd` to render the actual Stage chooser, overlapping characters, and selected **Blue Door** inspector without invoking any scene-recovery action. Its **14 assertions passed**. It refreshed `character_overlap_preview.png`, `overlap_hover_preview.png`, `overlap_candidate_preview.png`, and `overlap_chooser_popup_style.png` at 1280×720. The candidate label now contains the name alone inside a rounded cyan-outlined pill. UIUX reviewed `character_overlap_preview.png` and approved the pill's alignment, separation from the Selected badge, and spacing from the open inspector.
|
||||||
|
- The same render-capable Stage harness also drives overlap presses, pointer motion, and releases through production input. **32 assertions passed**: a press waits; movement below 8 px still resolves as a click; release opens the chooser without changing selection; dragging the selected object moves it without opening the chooser; and rotate/resize handles start their transforms without opening it. This checks the gesture decision alongside the actual popup styling and layout.
|
||||||
|
- A later full `tests/phase20a_gui_visual_capture.gd` attempt did not reach its first placement step because a pre-existing scene-recovery dialog was open. No recovery button was activated; the recovery copy was left intact. The isolated visual harness above verified the updated chooser and name pill.
|
||||||
|
- An additional run of `tests/test_phase4_final_bugs.gd` ended with **16 failures of 80 checks** across action-editor/payload assertions, synthetic headless prop-drag input, overlay speech assertions, left-idle mirroring checks, and a source-text contract. This broader script is outside the Phase 20a acceptance set and those failures were not changed here. The new below-box rotation behavior itself passes the render-capable production-input check above.
|
||||||
|
|
||||||
|
The Windows Godot runs print a root certificate-store warning; the render-capable run also reported shader-cache warnings. Neither prevented execution or capture. Missing-asset warnings in the identity suite are expected because that test deliberately verifies editable placeholders.
|
||||||
|
|
||||||
|
### User verification
|
||||||
|
|
||||||
|
- The user reports manually testing the remaining sign-off workflows and that they look good: stage names and persistence, overlapping selection and movement, and the DIRECT **Use…** prop-target chooser. The user had also reviewed the EDIT-mode appearance.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Phase 20a — Stage identities and names UI/UX
|
||||||
|
|
||||||
|
**Status:** Updated from user feedback; current inspector and selection-cue captures received UIUX approval. The implementation and test report are in `phase_20a_stage_identities_and_names.md`.
|
||||||
|
|
||||||
|
## Current behavior
|
||||||
|
|
||||||
|
- EDIT has a prop inspector, but selected characters have no inspector or stage-name field.
|
||||||
|
- The current **Objects here** popup is opened for multiple AABB candidates, after the frontmost object has already been selected. Its rows use a prop library name or a character's node name.
|
||||||
|
- `StageSelection.hit_test_all()` currently treats the world-space AABB as the hit test, so empty corners of a rotated or irregular object's bounds can appear as candidates.
|
||||||
|
|
||||||
|
## Name controls
|
||||||
|
|
||||||
|
- Each placed character and prop receives a distinct scene display name automatically. The asset's familiar name is the base: for example, **Milo**, **Milo 2**, and **Blue Door**, **Blue Door 2**. Placing an object does not open a naming dialog or stop repeat placement. Existing instances keep their names when another instance is added or removed.
|
||||||
|
- Character and prop names share one scene-wide, case-insensitive uniqueness rule.
|
||||||
|
- Show the selected character or prop name once, in the inspector header beside its icon. Add a visible **Rename** button there; do not make the title text itself a hidden rename hotspot.
|
||||||
|
- A selected prop's header also has a 48 px disclosure button. **▴** collapses the settings body into the same named one-line bar; **▾** expands it. Keep the name and **Rename** visible when collapsed. Do not replace the selected prop name with a generic **Settings** button.
|
||||||
|
- A selected character stays as a compact name-and-**Rename** bar because naming is its only EDIT inspector setting. It has no disclosure control. Compact prop and character headers stay on one line; long names truncate with an ellipsis while the full name remains available as a tooltip. Clicking empty stage space clears the selection and removes the bar.
|
||||||
|
- Activating **Rename** reveals a prefilled single-line editor with **Done** and **Cancel**. Enter commits; Escape cancels. Show duplicate/blank feedback under the editor. Keep the header anchored as the card grows. Changing selection cancels an uncommitted draft.
|
||||||
|
- Trim outer spaces on commit. Compare the requested name case-insensitively against all other named character and prop instances. Blank or duplicate names remain in the field with a nearby kid-friendly explanation and a suggested free name. A capitalization-only change to the same object's current name is allowed. A successful rename updates its inspector header and chooser label without changing its instance identity.
|
||||||
|
- Do not offer name editing while multiple objects are selected.
|
||||||
|
|
||||||
|
## Overlapping-object chooser
|
||||||
|
|
||||||
|
- On a normal EDIT click, retain the AABB check as a broad phase, then check the clicked point against the candidate's visible geometry. Only actual geometry hits are selectable or listed. Do not show a nearby object merely because it is within another object's bounds.
|
||||||
|
- Apply visible-geometry filtering to both immediate single-object selection and chooser candidates so there is one consistent hit model.
|
||||||
|
- When a normal EDIT press hits two or more objects, wait briefly to tell a click from a drag. If the pointer is released without moving past the 8 px screen-space drag threshold, open the anchored, scrolling **Objects here** popup without changing the current selection or inspector. If the current selection includes an object under the pointer and the child drags past the threshold, drag that selected object (or the whole current selection) and do not open the chooser. The selected object wins as the drag target even when another candidate is drawn in front.
|
||||||
|
- Test gizmo handles before overlap selection. Pressing a selected object's rotate or resize handle starts that transform immediately, even when other objects are visible under the handle; do not open **Objects here** for a handle gesture.
|
||||||
|
- Each row has a character/prop icon and its unique stage name. Mark any candidate that is already in the prior selection with a distinct **Selected** badge.
|
||||||
|
- Hovering or keyboard-focusing a row previews that exact object with a bright EDIT-cyan outline or silhouette above overlapping art and a nearby cyan-outlined rounded pill containing only the candidate name, such as **Milo**. The name pill appears only while that row is hovered or focused; do not prepend “Preview:”. This transient cue does not update selection or inspector. Leaving the row clears it.
|
||||||
|
- Clicking a row commits selection once and opens that object's inspector. **Cancel**, Escape, and outside dismissal clear the preview and preserve the previous selection, including a multi-object selection. The dismissal click must not also select the stage object beneath it.
|
||||||
|
- After a child chooses an overlapping object in EDIT, briefly show **Drag to move · Click to choose**. This teaches that a drag manipulates the selected object while a click opens the chooser again.
|
||||||
|
- Anchor the popup near the click while keeping the clicked art visible. Limit its height and scroll when many objects truly share the hit point. Use large rows, at least 44 px high and preferably around 56 px, with readable names.
|
||||||
|
- Give the chooser a rounded dark shell with a visible 2 px border, 12–16 px corners, a local shadow, and 12 px content inset. Space the 56 px candidate rows by 8 px. Keep **Cancel** in its own rounded neutral footer button at least 48 px high. This is a non-modal picker: leave the stage undimmed, and consume outside clicks after dismissing it.
|
||||||
|
- The shared popup also serves DIRECT prop target capture. Preserve the existing prop-only candidate filter and target-commit semantics there while using the same stage-name labels and exact hit results.
|
||||||
|
- Make the regular selected-object outline a more pronounced light rectangle with a dark backing. Replace the large blue rotate ring with a compact curved-arrow rotate handle attached above the rectangle; retain an approximately 44 × 44 logical-pixel hit target and keep prop corner resize handles. The transient chooser preview remains EDIT cyan and visually distinct.
|
||||||
|
- Keep the rotation handle reachable near the top chrome. If the 44 × 44 hit target would overlap the toolbar or mode pill, place the handle below the selected object's rectangle with its connector attached to the lower edge.
|
||||||
|
|
||||||
|
## Canvas feedback and child-facing copy
|
||||||
|
|
||||||
|
- During repeated placement, show the next automatic name beside the placement ghost, such as **Click to place Milo 2**. After placement, briefly show **Milo 2 placed** beside the object. Keep names off the canvas at rest to avoid visual clutter.
|
||||||
|
- Retain the normal selected-object cue while a chooser preview is active; the candidate preview must be visually distinct and must not transfer selection.
|
||||||
|
- Use the approved overlap mockup at `../plans/action_reactions_mockups/06_edit_overlap_picker.png` as the visual reference.
|
||||||
|
|
||||||
|
| State | Copy |
|
||||||
|
|---|---|
|
||||||
|
| Name in inspector | The stage name appears once in the header |
|
||||||
|
| Start editing | **Rename** |
|
||||||
|
| Save or discard edit | **Done** / **Cancel** |
|
||||||
|
| Blank name | **Give this one a name.** |
|
||||||
|
| Duplicate name | **Milo is already here. Try Milo 3.** |
|
||||||
|
| Multiple selection | **Select one character or prop to change its name.** |
|
||||||
|
| Placement prompt | **Click to place Milo 2** |
|
||||||
|
| Placement feedback | **Milo 2 placed** |
|
||||||
|
| Chooser title | **Objects here** · **Choose one** |
|
||||||
|
| Previously selected row | **Selected** |
|
||||||
|
| Candidate name pill | **Milo** (name only; no “Preview:” prefix) |
|
||||||
|
| After choosing an overlapping object | **Drag to move · Click to choose** |
|
||||||
|
| Dismiss chooser | **Cancel** |
|
||||||
|
|
||||||
|
## Theme and accessibility
|
||||||
|
|
||||||
|
- Reuse `StageInspectorPanel` and `SandboxTheme.stage_panels` for selected-object inspector shell, header Rename action, disclosure behavior, type, spacing, buttons, focus, and safe placement. Use `SandboxTheme.control_theme()` for the chooser. Extend `stage_panels` tokens only when the shared system lacks a needed value.
|
||||||
|
- Use `SandboxTheme.popups.anchored_chooser` for the chooser shell, rows, selected badge, state borders, spacing, and Cancel button. Reuse the general name-and-Rename header and inline editing contract in the [Editor Visual Style Guide](../guides/editor_visual_style_guide.md#45-renameable-object-inspector-header) when adding renameable object inspectors. Object-specific validation remains owned by the identity system.
|
||||||
|
- Use the EDIT cyan from `mode_colors.edit` for candidate preview and active EDIT feedback. Do not use color alone: preserve a clear outline/silhouette, focus state, and textual preview name.
|
||||||
|
- Use a rounded dark fill with a cyan outline for the candidate name pill; keep its background, corner radius, border width, and padding in `SandboxTheme.overlays.gizmos.candidate_name_pill_*` tokens.
|
||||||
|
- Keep controls large and keyboard-operable. Hover and keyboard focus produce the same candidate preview. Use explicit **Rename**, **Done**, and **Cancel** labels; the prop disclosure control exposes **Expand settings** / **Collapse settings** tooltips.
|
||||||
|
|
||||||
|
## Intentional distinctions
|
||||||
|
|
||||||
|
- The selected prop inspector uses a named collapse bar instead of **Close → Settings**. The selected character is already a one-line name bar. Clicking empty stage space removes either inspector by clearing selection. The separate Director/action inspector's outside-click behavior remains scoped to that workflow.
|
||||||
|
- The chooser is a popup for resolving a point click, not a persistent side panel. It does not select the frontmost object before the child chooses.
|
||||||
|
- Visible geometry improves on the current AABB-only candidate behavior. The AABB remains a fast broad phase; the visible hit check and a small screen-space tolerance for thin lines define the actual candidates.
|
||||||
|
|
||||||
|
## Visual verification
|
||||||
|
|
||||||
|
UIUX reviewed the earlier Final6 captures against the style guide, shared Stage panel spec, and approved overlap mockup. The chooser placement, short and crowded lists, naming flow, placement feedback, preview, dismissal, commit, and DIRECT Use capture were accepted. That review predates the current inspector/selection refinement.
|
||||||
|
|
||||||
|
The current revision strengthens the rectangular selection outline, replaces the large rotation ring with a compact curved-arrow handle, puts Rename beside the single name in each inspector header, and changes the prop inspector's Close → Settings state to a named disclosure bar. The character inspector stays compact because naming is its only setting. UIUX reviewed the regenerated captures and approved the one-line long-name header, the Break artwork outline, and the below-box rotate handle near the top chrome. At 920×560, the overlap preview-name pill may move toward the upper-right edge when the click point and inspector leave little room.
|
||||||
|
|
||||||
|
The follow-up candidate label now shows only the character name in a rounded cyan-outlined pill. UIUX reviewed the refreshed [character overlap preview](../../tests/phase20a_captures/character_overlap_preview.png) with the **Blue Door** inspector open and approved its alignment and separation from the chooser's **Selected** badge and the inspector.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Stage selection panel UI system — architecture specification
|
# Stage selection panel UI system — architecture specification
|
||||||
|
|
||||||
> **Status:** Approved for implementation. **Scope:** In-stage selection and inspector cards, beginning with the placed-prop settings card. Modal dialogs, the Prop Editor, and the Stickman Editor are later candidates, outside this change.
|
> **Status:** Approved baseline, amended by Phase 20a user feedback. **Scope:** In-stage selection and inspector cards, beginning with the placed-prop settings card. The Phase 20a amendment below replaces the original selected-prop **Close → Settings** interaction for the prop and character naming inspectors. Other callers can retain Close when that fits their workflow. Modal dialogs, the Prop Editor, and the Stickman Editor are outside this change.
|
||||||
|
|
||||||
## Purpose and current reference
|
## Purpose and current reference
|
||||||
|
|
||||||
The placed-prop card is the visual reference for future Stage selection panels. Its **selected prop name is the header** beside an icon and Close button; there is no separate “Prop settings” title. Its depth and motion options show selection through a colored surface and border, with **no checkmark in the label**. Large full-row switches show ON/OFF and keep the switch in the same position when help appears. The header stays fixed while the content scrolls.
|
The placed-prop card is the visual reference for future Stage selection panels. Its **selected prop name is the header** beside an icon; there is no separate “Prop settings” title. Its depth and motion options show selection through a colored surface and border, with **no checkmark in the label**. Large full-row switches show ON/OFF and keep the switch in the same position when help appears. The header stays fixed while the content scrolls. Phase 20a adds a visible Rename action and replaces the prop card's Close/dismiss state with a disclosure chevron; the name remains visible in both expanded and collapsed states.
|
||||||
|
|
||||||
Today `SandboxStage` builds and styles that card in `sandbox_stage.gd`. `PropSettingsToggleRow` and its drawn switch implement one reusable control, but carry prop-specific names. `SandboxTheme` has `stage_prop_settings` tokens. `QueuePanel`, `RulePanel`, and action dialogs also create local style overrides, but they are popup workflows and do not join this first migration.
|
Today `SandboxStage` builds and styles that card in `sandbox_stage.gd`. `PropSettingsToggleRow` and its drawn switch implement one reusable control, but carry prop-specific names. `SandboxTheme` has `stage_prop_settings` tokens. `QueuePanel`, `RulePanel`, and action dialogs also create local style overrides, but they are popup workflows and do not join this first migration.
|
||||||
|
|
||||||
@@ -24,13 +24,15 @@ Targeting and placement steps in EDIT and DIRECT use a temporary capture bar, se
|
|||||||
|
|
||||||
## Child-facing contract
|
## Child-facing contract
|
||||||
|
|
||||||
- A selection card has a recognizable icon, the selected object's name, and a large Close button. Long names wrap to at most two lines and expose their full name in a tooltip; Close never overlaps the title.
|
- A selection card has a recognizable icon and the selected object's name. Long names wrap to at most two lines and expose their full name in a tooltip; header actions never overlap the title.
|
||||||
|
- When renaming is available, show a visible **Rename** button beside the name. Do not make the title text itself a hidden rename hotspot. Rename opens a prefilled inline field with visible **Done** and **Cancel** actions; validation feedback stays beside the field.
|
||||||
|
- A caller may configure the final header button as a large Close action or, for a collapsible inspector, as a disclosure button. The disclosure uses **▾** to expand and **▴** to collapse, with **Expand settings** / **Collapse settings** tooltips. Collapsing keeps the icon, name, and Rename button visible on one line; long names use an ellipsis and keep the full value in the title tooltip.
|
||||||
- Content uses the same heading, choice, switch, helper, spacing, corners, focus, hover, selected, and disabled styles. Each setting can still supply its own words, optional illustration, and reason for being unavailable.
|
- Content uses the same heading, choice, switch, helper, spacing, corners, focus, hover, selected, and disabled styles. Each setting can still supply its own words, optional illustration, and reason for being unavailable.
|
||||||
- Choice labels stay stable when selected. Selection is shown by fill and border; text does not gain a checkmark or change width.
|
- Choice labels stay stable when selected. Selection is shown by fill and border; text does not gain a checkmark or change width.
|
||||||
- A switch is clickable anywhere in its row. Showing or hiding help may change the row height, but the switch and ON/OFF label stay anchored at the top, so a second click at the same pointer position hits the same control.
|
- A switch is clickable anywhere in its row. Showing or hiding help may change the row height, but the switch and ON/OFF label stay anchored at the top, so a second click at the same pointer position hits the same control.
|
||||||
- A card fits its visible content on a tall stage. On a short stage, only the body scrolls; the title and Close remain reachable. Focus and selected states remain distinct from hover.
|
- A card fits its visible content on a tall stage. On a short stage, only the body scrolls; the title and header actions remain reachable. Focus and selected states remain distinct from hover.
|
||||||
- After Close, a compact **Settings** button appears at the card's usual top-right position for the selected object. It reopens the card without requiring the child to deselect and reselect the object.
|
- A Close-enabled, non-collapsible caller may use its existing dismissal/reopen behavior. Phase 20a's selected prop inspector collapses to its named header instead of showing a generic **Settings** button. The character inspector stays as a compact name-and-Rename bar because renaming is its only content. Clicking empty stage space clears selection and removes either bar.
|
||||||
- Keyboard users can Tab to choices, switches, and Close; Space/Enter activates the focused control. Disabled rows explain why. Text and colored states both convey ON/OFF; color alone is not the only cue.
|
- Keyboard users can Tab to choices, switches, Rename, disclosure, and any Close action; Space/Enter activates the focused control. Disabled rows explain why. Text and colored states both convey ON/OFF; color alone is not the only cue.
|
||||||
|
|
||||||
## Component hierarchy
|
## Component hierarchy
|
||||||
|
|
||||||
@@ -42,7 +44,8 @@ CanvasLayer (owned by SandboxStage)
|
|||||||
├─ Header : HBoxContainer
|
├─ Header : HBoxContainer
|
||||||
│ ├─ Icon : Label
|
│ ├─ Icon : Label
|
||||||
│ ├─ Title : Label (selected item name)
|
│ ├─ Title : Label (selected item name)
|
||||||
│ └─ Close : Button
|
│ ├─ Rename : Button (optional)
|
||||||
|
│ └─ Disclosure or Close : Button (caller-configured)
|
||||||
└─ ScrollContainer (vertical only)
|
└─ ScrollContainer (vertical only)
|
||||||
└─ Body : VBoxContainer (caller adds sections and controls)
|
└─ Body : VBoxContainer (caller adds sections and controls)
|
||||||
├─ Section heading : Label
|
├─ Section heading : Label
|
||||||
@@ -58,7 +61,7 @@ The shell owns layout and presentation. The caller owns the body composition and
|
|||||||
|
|
||||||
| Target | Public contract | Responsibility |
|
| Target | Public contract | Responsibility |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `scripts/shared/stage_ui/stage_inspector_panel.gd`, `class_name StageInspectorPanel extends PanelContainer` | `signal close_requested`; `func apply_theme(stage_theme: SandboxTheme) -> void`; `func set_header(title: String, icon_text: String = "") -> void`; `func body() -> VBoxContainer`; `func set_safe_rect(rect: Rect2) -> void`; `func request_fit() -> void`; `func reset_scroll() -> void` | Fixed header, scroll body, viewport fit, local theme. Emits Close; does not mutate selection or hide itself. `request_fit()` coalesces repeated calls into one deferred fit. |
|
| `scripts/shared/stage_ui/stage_inspector_panel.gd`, `class_name StageInspectorPanel extends PanelContainer` | `signal close_requested`; `signal rename_requested`; `signal collapsed_changed(collapsed: bool)`; `func set_rename_enabled(enabled: bool) -> void`; `func set_collapsible(enabled: bool, collapsed: bool = false) -> void`; `func set_close_visible(enabled: bool) -> void`; `func set_body_visible(enabled: bool) -> void`; `func set_collapsed(collapsed: bool, notify: bool = true) -> void`; `func set_title_single_line(enabled: bool) -> void`; plus `apply_theme`, `set_header`, `body`, `set_safe_rect`, `request_fit`, `reset_scroll` | Fixed header, optional Rename and Close/disclosure actions, scroll body, viewport fit, local theme. Compact headers use one-line ellipsis with a full-name tooltip. Emits intent; does not mutate selection or hide itself. `request_fit()` coalesces repeated calls into one deferred fit. |
|
||||||
| `scripts/shared/stage_ui/stage_panel_choice_group.gd`, `class_name StagePanelChoiceGroup extends VBoxContainer` | `signal choice_selected(id: StringName)`; `func add_choice(id: StringName, caption: String, cue: Control = null) -> Button`; `func select_no_signal(id: StringName) -> void`; `func clear_no_signal() -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Exclusive choices with stable captions and native focus/activation. `clear_no_signal()` supports migrated prop draw order. Repressing the selected option leaves it selected. An optional cue is decorative, not clickable. |
|
| `scripts/shared/stage_ui/stage_panel_choice_group.gd`, `class_name StagePanelChoiceGroup extends VBoxContainer` | `signal choice_selected(id: StringName)`; `func add_choice(id: StringName, caption: String, cue: Control = null) -> Button`; `func select_no_signal(id: StringName) -> void`; `func clear_no_signal() -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Exclusive choices with stable captions and native focus/activation. `clear_no_signal()` supports migrated prop draw order. Repressing the selected option leaves it selected. An optional cue is decorative, not clickable. |
|
||||||
| `scripts/shared/stage_ui/stage_panel_toggle_row.gd`, `class_name StagePanelToggleRow extends Button` | `signal preferred_height_changed`; inherited `signal toggled(toggled_on: bool)`; `func configure(caption: String) -> void`; `func set_help_text(text: String) -> void`; `func set_disabled_state(value: bool, reason: String = "") -> void`; `func set_checked_no_signal(value: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | One focusable full-row toggle, large drawn switch, ON/OFF label, help layout. Stores `_normal_help` and `_disabled_reason` separately; displayed help derives from `disabled`. Re-enabling restores normal help and never leaves a stale disabled reason. Child labels and switch ignore pointer events. |
|
| `scripts/shared/stage_ui/stage_panel_toggle_row.gd`, `class_name StagePanelToggleRow extends Button` | `signal preferred_height_changed`; inherited `signal toggled(toggled_on: bool)`; `func configure(caption: String) -> void`; `func set_help_text(text: String) -> void`; `func set_disabled_state(value: bool, reason: String = "") -> void`; `func set_checked_no_signal(value: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | One focusable full-row toggle, large drawn switch, ON/OFF label, help layout. Stores `_normal_help` and `_disabled_reason` separately; displayed help derives from `disabled`. Re-enabling restores normal help and never leaves a stale disabled reason. Child labels and switch ignore pointer events. |
|
||||||
| `scripts/shared/stage_ui/stage_panel_switch_visual.gd`, `class_name StagePanelSwitchVisual extends Control` | `func set_state(enabled: bool, unavailable: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Vector switch drawing only. No input or business signals. Reuses the current 64 × 34 track and 26 px knob. |
|
| `scripts/shared/stage_ui/stage_panel_switch_visual.gd`, `class_name StagePanelSwitchVisual extends Control` | `func set_state(enabled: bool, unavailable: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Vector switch drawing only. No input or business signals. Reuses the current 64 × 34 track and 26 px knob. |
|
||||||
@@ -77,11 +80,15 @@ flowchart LR
|
|||||||
Stage --> Save[Save placement, reconcile, mark dirty]
|
Stage --> Save[Save placement, reconcile, mark dirty]
|
||||||
Stage --> Refresh[Refresh controls without signals]
|
Stage --> Refresh[Refresh controls without signals]
|
||||||
Refresh --> Fit[Panel request_fit, deferred once]
|
Refresh --> Fit[Panel request_fit, deferred once]
|
||||||
Close[Close pressed] --> CloseSignal[StageInspectorPanel.close_requested]
|
Rename[Rename pressed] --> RenameSignal[StageInspectorPanel.rename_requested]
|
||||||
|
RenameSignal --> Caller[Caller opens inline name editor]
|
||||||
|
Disclosure[Disclosure pressed] --> CollapseSignal[StageInspectorPanel.collapsed_changed]
|
||||||
|
CollapseSignal --> Fit[Refit named header or expanded body]
|
||||||
|
Close[Optional Close pressed] --> CloseSignal[StageInspectorPanel.close_requested]
|
||||||
CloseSignal --> Stage
|
CloseSignal --> Stage
|
||||||
```
|
```
|
||||||
|
|
||||||
Signal payloads are `StringName` for a choice ID and `bool` for a toggle. The group ID is UI-local and maps explicitly to the existing prop role string; no UI object or dictionary is serialized. Refresh calls `select_no_signal()` / `set_checked_no_signal()`, avoiding another mutation signal. The panel emits `close_requested`; `SandboxStage` records the currently selected object as dismissed and keeps its card hidden through unrelated refreshes. `SandboxStage` shows a compact Settings button at the panel anchor while dismissed. A different selection or pressing Settings clears that dismissal. Closing does not undo a saved setting.
|
Choice payloads are `StringName` IDs and toggle payloads are `bool`. The group ID is UI-local and maps explicitly to the existing prop role string; no UI object or dictionary is serialized. Refresh calls `select_no_signal()` / `set_checked_no_signal()`, avoiding another mutation signal. Rename and disclosure signals carry presentation intent only. A caller that exposes Close may retain its own dismissal/reopen policy; a collapsible inspector keeps its selected name in the header and uses `collapsed_changed` to synchronize any caller state. Collapsing does not change selection or undo a saved setting.
|
||||||
|
|
||||||
### Example prop adapter
|
### Example prop adapter
|
||||||
|
|
||||||
@@ -125,8 +132,8 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
|
|||||||
2. Switch and ON/OFF remain at a fixed top alignment while help grows below the caption. Control height comes from wrapped content and a minimum, never from vertically centering the switch in a changing row.
|
2. Switch and ON/OFF remain at a fixed top alignment while help grows below the caption. Control height comes from wrapped content and a minimum, never from vertically centering the switch in a changing row.
|
||||||
3. Selected, hovered, focused, and disabled styles each have a visible state. Focus remains visible over selected fill. Disabled rows retain readable text and show their reason in the row. If no reason exists, the caller must provide one before disabling a child-facing setting.
|
3. Selected, hovered, focused, and disabled styles each have a visible state. Focus remains visible over selected fill. Disabled rows retain readable text and show their reason in the row. If no reason exists, the caller must provide one before disabling a child-facing setting.
|
||||||
4. Choice state is apparent through fill and border; its label stays unchanged. The toggle also has ON/OFF text and knob position, so it does not depend on color alone.
|
4. Choice state is apparent through fill and border; its label stays unchanged. The toggle also has ON/OFF text and knob position, so it does not depend on color alone.
|
||||||
5. The title can wrap to two lines; Close reserves its width. Full title is available as tooltip. The shell recomputes height after wrapping and keeps Close outside the scroll area.
|
5. The title can wrap to two lines; visible Rename and disclosure/Close actions reserve their widths. Full title is available as tooltip. The shell recomputes height after wrapping and keeps all header controls outside the scroll area.
|
||||||
6. The card consumes pointer events inside its rectangle so a settings click cannot select a stage object behind it. Escape follows the Stage's existing panel dismissal convention. Focus moves into the card when opened from keyboard and returns to the originating Stage control on Close when one exists.
|
6. The card consumes pointer events inside its rectangle so a settings click cannot select a stage object behind it. Escape collapses an expanded collapsible card; a Close-enabled non-collapsible caller may use its existing dismissal convention. Focus moves into the card when opened from keyboard.
|
||||||
|
|
||||||
## Migration sequence
|
## Migration sequence
|
||||||
|
|
||||||
@@ -137,10 +144,10 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
1. The prop card retains the current selected-name header, fixed Close, no text checkmarks, front/behind cues, role semantics, and saved scene format. Legacy draw order still shows no selected depth until a child chooses one.
|
1. The prop card retains the selected-name header, no text checkmarks, front/behind cues, role semantics, and saved scene format. Under the Phase 20a amendment, it shows Rename and a disclosure chevron; the name remains visible when collapsed. Legacy draw order still shows no selected depth until a child chooses one.
|
||||||
2. At the same viewport and theme, the migrated prop card's width, spacing, typography, colors, switch size, and selected states match the current card. Long prop names and wrapped labels cannot collide with Close or the switch.
|
2. At the same viewport and theme, the migrated prop card's width, spacing, typography, colors, switch size, and selected states match the current card. Long prop names and wrapped labels cannot collide with Close or the switch.
|
||||||
3. Switching a row On and Off at the same pointer position works twice without moving the switch. Help appears beneath the caption. A disabled reason disappears on re-enable and normal help returns.
|
3. Switching a row On and Off at the same pointer position works twice without moving the switch. Help appears beneath the caption. A disabled reason disappears on re-enable and normal help returns.
|
||||||
4. On short viewports, every body control is reachable by scroll while header and Close stay visible. Scroll position remains stable during a setting change and resets for a different selected prop. After Close, the card stays hidden while that same object remains selected, including after role or viewport refreshes; a compact Settings button remains available at the card anchor to reopen it. Changing selection shows the new selection's card.
|
4. On short viewports, every body control is reachable by scroll while the header actions stay visible. Scroll position remains stable during a setting change and resets for a different selected prop. Collapsing keeps the selected prop name and Rename action visible; expanding restores the body. Changing selection shows the new selection's card.
|
||||||
5. Pointer, touch, and keyboard activation each change a setting once. Disabled controls cannot mutate a role, and focus remains visible on both selected and neutral choices.
|
5. Pointer, touch, and keyboard activation each change a setting once. Disabled controls cannot mutate a role, and focus remains visible on both selected and neutral choices.
|
||||||
6. The next Stage inspector can create a shell, a choice group, and a toggle row using only `SandboxTheme` and shared APIs, with no copied panel geometry or stylebox code.
|
6. The next Stage inspector can create a shell, a choice group, and a toggle row using only `SandboxTheme` and shared APIs, with no copied panel geometry or stylebox code.
|
||||||
|
|
||||||
@@ -153,6 +160,6 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
|
|||||||
|
|
||||||
## Decision and next adopter
|
## Decision and next adopter
|
||||||
|
|
||||||
1. **Panel dismissal — decided:** Closing a selection card keeps it dismissed while the same object remains selected. Changing selection or pressing the compact Settings button shows it again. The implementation tracks this in `SandboxStage`, separate from the shared shell.
|
1. **Panel visibility — amended by Phase 20a:** For the placed-prop inspector, the child collapses/expands the body with a disclosure while the named header stays visible. Character naming uses a compact header only. Callers with a different workflow may retain Close and own any dismissal/reopen behavior in `SandboxStage`.
|
||||||
2. **Next adopter — recommended:** Apply the shared kit to the next in-stage selected-object inspector as it is built. Existing queue/rule popups have different focus and sizing behavior and can be evaluated in a later pass.
|
2. **Next adopter — recommended:** Apply the shared kit to the next in-stage selected-object inspector as it is built. Existing queue/rule popups have different focus and sizing behavior and can be evaluated in a later pass.
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,58 @@
|
|||||||
"padding": 12
|
"padding": 12
|
||||||
},
|
},
|
||||||
"action_menu": { "font_size": 24 },
|
"action_menu": { "font_size": 24 },
|
||||||
|
"anchored_chooser": {
|
||||||
|
"preferred_width": 360,
|
||||||
|
"edge_inset": 8,
|
||||||
|
"safe_top": 120,
|
||||||
|
"safe_bottom": 48,
|
||||||
|
"content_inset": 12,
|
||||||
|
"heading_font_size": 20,
|
||||||
|
"row_height": 56,
|
||||||
|
"row_gap": 8,
|
||||||
|
"header_gap": 8,
|
||||||
|
"footer_gap": 8,
|
||||||
|
"max_rows_height": 320,
|
||||||
|
"minimum_rows_height": 116,
|
||||||
|
"row_font_size": 20,
|
||||||
|
"row_text_color": "#F5F7FA",
|
||||||
|
"row_surface": "#171B24",
|
||||||
|
"row_border_color": "#FFFFFF55",
|
||||||
|
"selected_surface": "#12394A",
|
||||||
|
"hover_surface": "#203849",
|
||||||
|
"accent_color": "#22C6FF",
|
||||||
|
"focus_color": "#8BD9FF",
|
||||||
|
"corner_radius": 14,
|
||||||
|
"border_width": 2,
|
||||||
|
"bg_color": "#202633FA",
|
||||||
|
"border_color": "#667386",
|
||||||
|
"padding": 0,
|
||||||
|
"shadow_size": 6,
|
||||||
|
"shadow_color": "#00000080",
|
||||||
|
"shadow_offset": [0, 4],
|
||||||
|
"row_corner_radius": 12,
|
||||||
|
"row_border_width": 1,
|
||||||
|
"selected_border_width": 2,
|
||||||
|
"hover_border_width": 2,
|
||||||
|
"focus_border_width": 3,
|
||||||
|
"row_padding_x": 12,
|
||||||
|
"row_padding_y": 6,
|
||||||
|
"badge_corner_radius": 8,
|
||||||
|
"badge_surface": "#12394A",
|
||||||
|
"badge_text_color": "#F5F7FA",
|
||||||
|
"badge_font_size": 16,
|
||||||
|
"badge_padding_x": 8,
|
||||||
|
"badge_padding_y": 2,
|
||||||
|
"cancel_width": 120,
|
||||||
|
"cancel_height": 48,
|
||||||
|
"cancel_corner_radius": 10,
|
||||||
|
"cancel_border_width": 2,
|
||||||
|
"cancel_font_size": 20,
|
||||||
|
"cancel_surface": "#293242",
|
||||||
|
"cancel_hover_surface": "#354154",
|
||||||
|
"cancel_border_color": "#718096",
|
||||||
|
"cancel_text_color": "#F5F7FA"
|
||||||
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"surface_color_size": [560, 640],
|
"surface_color_size": [560, 640],
|
||||||
"surface_color_preview_size": [420, 140],
|
"surface_color_preview_size": [420, 140],
|
||||||
@@ -348,7 +400,12 @@
|
|||||||
"selection_color": "#F5F7FAE6",
|
"selection_color": "#F5F7FAE6",
|
||||||
"hover_color": "#FFE66DCC",
|
"hover_color": "#FFE66DCC",
|
||||||
"rotate_color": "#4DB9FFE6",
|
"rotate_color": "#4DB9FFE6",
|
||||||
"backing_extra_width": 3.0
|
"backing_extra_width": 3.0,
|
||||||
|
"candidate_name_pill_bg_color": "#182432F2",
|
||||||
|
"candidate_name_pill_corner_radius": 8.0,
|
||||||
|
"candidate_name_pill_border_width": 3.0,
|
||||||
|
"candidate_name_pill_padding_x": 12.0,
|
||||||
|
"candidate_name_pill_padding_y": 8.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgets": {
|
"widgets": {
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ extends Node2D
|
|||||||
|
|
||||||
var original_entry: Dictionary = {}
|
var original_entry: Dictionary = {}
|
||||||
var missing_stk_path: String = ""
|
var missing_stk_path: String = ""
|
||||||
|
var stage_object_id: String = ""
|
||||||
|
var stage_name: String = ""
|
||||||
|
|
||||||
const MARKER_SIZE := Vector2(260.0, 52.0)
|
const MARKER_SIZE := Vector2(260.0, 52.0)
|
||||||
|
|
||||||
func configure(entry: Dictionary) -> void:
|
func configure(entry: Dictionary) -> void:
|
||||||
original_entry = entry.duplicate(true)
|
original_entry = entry.duplicate(true)
|
||||||
missing_stk_path = String(entry.get("stk_path", ""))
|
missing_stk_path = String(entry.get("stk_path", ""))
|
||||||
|
stage_object_id = String(entry.get("stage_object_id", ""))
|
||||||
|
stage_name = String(entry.get("stage_name", ""))
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
func get_area_rect() -> Rect2:
|
func get_area_rect() -> Rect2:
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ var action_queue: Array[Dictionary]:
|
|||||||
|
|
||||||
## Original .stk asset path, assigned by StageSpawner for scene persistence.
|
## Original .stk asset path, assigned by StageSpawner for scene persistence.
|
||||||
var source_stk_path: String = ""
|
var source_stk_path: String = ""
|
||||||
|
## Persistent per-stage-instance identity and display name. Reusable .stk
|
||||||
|
## assets never serialize these fields.
|
||||||
|
var stage_object_id: String = ""
|
||||||
|
var stage_name: String = ""
|
||||||
|
|
||||||
## Authored nonvisual subsystem children. They establish explicit ownership
|
## Authored nonvisual subsystem children. They establish explicit ownership
|
||||||
## boundaries while this facade preserves the long-standing public API.
|
## boundaries while this facade preserves the long-standing public API.
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const STAGE_SPATIAL_INDEX := preload("res://scripts/stage/stage_spatial_index.gd
|
|||||||
const STAGE_TERRAIN_PAINTER := preload("res://scripts/editors/stage/stage_terrain_painter.gd")
|
const STAGE_TERRAIN_PAINTER := preload("res://scripts/editors/stage/stage_terrain_painter.gd")
|
||||||
const STAGE_CHROME_SCRIPT := preload("res://scripts/editors/stage/stage_chrome.gd")
|
const STAGE_CHROME_SCRIPT := preload("res://scripts/editors/stage/stage_chrome.gd")
|
||||||
const STAGE_PROP_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/stage/stage_prop_settings_view.gd")
|
const STAGE_PROP_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/stage/stage_prop_settings_view.gd")
|
||||||
|
const STAGE_CHARACTER_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/stage/stage_character_settings_view.gd")
|
||||||
|
const STAGE_OBJECT_IDENTITY_SCRIPT := preload("res://scripts/stage/stage_object_identity.gd")
|
||||||
const STAGE_USE_SPOT_OVERLAY_SCRIPT := preload("res://scripts/editors/stage/stage_use_spot_overlay.gd")
|
const STAGE_USE_SPOT_OVERLAY_SCRIPT := preload("res://scripts/editors/stage/stage_use_spot_overlay.gd")
|
||||||
const STAGE_USE_TARGET_CAPTURE_BAR_SCRIPT := preload("res://scripts/editors/stage/stage_use_target_capture_bar.gd")
|
const STAGE_USE_TARGET_CAPTURE_BAR_SCRIPT := preload("res://scripts/editors/stage/stage_use_target_capture_bar.gd")
|
||||||
const STAGE_SCENE_ADAPTER_SCRIPT := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
|
const STAGE_SCENE_ADAPTER_SCRIPT := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
|
||||||
@@ -82,8 +84,6 @@ const RULE_EDITOR := preload("res://scenes/editors/stage/rule_editor.tscn")
|
|||||||
const WAYPOINT_CONTEXT := preload("res://scripts/stage/waypoint_context.gd")
|
const WAYPOINT_CONTEXT := preload("res://scripts/stage/waypoint_context.gd")
|
||||||
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.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")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enums
|
# Enums
|
||||||
@@ -257,6 +257,7 @@ var _suppressed_contact_pairs: Dictionary:
|
|||||||
return {} if _rule_engine == null else _rule_engine.get_suppressed_contact_pairs()
|
return {} if _rule_engine == null else _rule_engine.get_suppressed_contact_pairs()
|
||||||
|
|
||||||
var _spawner: StageSpawner
|
var _spawner: StageSpawner
|
||||||
|
var _object_identity: StageObjectIdentity
|
||||||
var _selection: StageSelection
|
var _selection: StageSelection
|
||||||
var _gizmos: StageGizmos
|
var _gizmos: StageGizmos
|
||||||
var _stage_input_ready: bool = false
|
var _stage_input_ready: bool = false
|
||||||
@@ -344,12 +345,25 @@ var _asset_browser: StageAssetBrowser
|
|||||||
var _modal_views: StageModalViews
|
var _modal_views: StageModalViews
|
||||||
var _chrome: StageChrome
|
var _chrome: StageChrome
|
||||||
var _prop_settings_view: StagePropSettingsView
|
var _prop_settings_view: StagePropSettingsView
|
||||||
|
var _character_settings_view: StageCharacterSettingsView
|
||||||
|
var _placement_name_prompt: Label
|
||||||
|
var _placement_name_prompt_panel: PanelContainer
|
||||||
|
var _objects_here_candidates: Array[Node2D] = []
|
||||||
|
var _objects_here_context: StringName = &""
|
||||||
|
var _objects_here_hit_position: Vector2 = Vector2.ZERO
|
||||||
|
var _placement_feedback_label: Label
|
||||||
|
var _placement_feedback_panel: PanelContainer
|
||||||
|
var _placement_feedback_node: Node2D
|
||||||
|
var _placement_feedback_timer: float = 0.0
|
||||||
|
var _last_pointer_viewport_position: Vector2 = Vector2.ZERO
|
||||||
var _edit_wobble_preview_prop: PropBlock
|
var _edit_wobble_preview_prop: PropBlock
|
||||||
var _use_spot_overlay: StageUseSpotOverlay
|
var _use_spot_overlay: StageUseSpotOverlay
|
||||||
var _use_target_capture_bar: StageUseTargetCaptureBar
|
var _use_target_capture_bar: StageUseTargetCaptureBar
|
||||||
var _placement_overlay = null # StagePlacementOverlay (preloaded)
|
var _placement_overlay = null # StagePlacementOverlay (preloaded)
|
||||||
|
|
||||||
var _placement_id: String = ""
|
var _placement_id: String = ""
|
||||||
|
var _placement_name_cache_key: String = ""
|
||||||
|
var _placement_name_cache_value: String = ""
|
||||||
|
|
||||||
var _stickman_library: StickmanLibrary:
|
var _stickman_library: StickmanLibrary:
|
||||||
get:
|
get:
|
||||||
@@ -403,9 +417,6 @@ var _palette_dock: PanelContainer:
|
|||||||
var _prop_settings_card: Control:
|
var _prop_settings_card: Control:
|
||||||
get:
|
get:
|
||||||
return null if _prop_settings_view == null else _prop_settings_view.get_card()
|
return null if _prop_settings_view == null else _prop_settings_view.get_card()
|
||||||
var _prop_settings_reopen_button: Button:
|
|
||||||
get:
|
|
||||||
return null if _prop_settings_view == null else _prop_settings_view.get_reopen_button()
|
|
||||||
var _prop_settings_fields: VBoxContainer:
|
var _prop_settings_fields: VBoxContainer:
|
||||||
get:
|
get:
|
||||||
return null if _prop_settings_view == null else _prop_settings_view.get_fields()
|
return null if _prop_settings_view == null else _prop_settings_view.get_fields()
|
||||||
@@ -421,12 +432,6 @@ var _prop_settings_motion_group: Control:
|
|||||||
var _prop_settings_controls: Dictionary:
|
var _prop_settings_controls: Dictionary:
|
||||||
get:
|
get:
|
||||||
return {} if _prop_settings_view == null else _prop_settings_view.get_controls()
|
return {} if _prop_settings_view == null else _prop_settings_view.get_controls()
|
||||||
var _prop_settings_active_key: String:
|
|
||||||
get:
|
|
||||||
return "" if _prop_settings_view == null else _prop_settings_view.get_active_selection_key()
|
|
||||||
var _prop_settings_dismissed_key: String:
|
|
||||||
get:
|
|
||||||
return "" if _prop_settings_view == null else _prop_settings_view.get_dismissed_selection_key()
|
|
||||||
var _objects_here_popup: PopupPanel:
|
var _objects_here_popup: PopupPanel:
|
||||||
get:
|
get:
|
||||||
return null if _modal_views == null else _modal_views.get_objects_here_popup()
|
return null if _modal_views == null else _modal_views.get_objects_here_popup()
|
||||||
@@ -539,6 +544,11 @@ var _pan_last: Vector2 = Vector2.ZERO
|
|||||||
var _space_key_down: bool = false
|
var _space_key_down: bool = false
|
||||||
var _space_press_owned: bool = false
|
var _space_press_owned: bool = false
|
||||||
|
|
||||||
|
var _pending_overlap_candidates: Array[Node2D] = []
|
||||||
|
var _pending_overlap_drag_target: Node2D = null
|
||||||
|
var _pending_overlap_world_position: Vector2 = Vector2.ZERO
|
||||||
|
var _pending_overlap_screen_position: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
var _box_selecting: bool = false
|
var _box_selecting: bool = false
|
||||||
var _box_start: Vector2 = Vector2.ZERO
|
var _box_start: Vector2 = Vector2.ZERO
|
||||||
var _box_rect: Rect2 = Rect2()
|
var _box_rect: Rect2 = Rect2()
|
||||||
@@ -548,6 +558,8 @@ var _ghost_holder: Node2D = null
|
|||||||
|
|
||||||
## Phase 4b terrain drag-painting state.
|
## Phase 4b terrain drag-painting state.
|
||||||
const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0
|
const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0
|
||||||
|
## An overlap press becomes a selected-object drag after this screen-space move.
|
||||||
|
const OVERLAP_SELECT_DRAG_THRESHOLD_PX: float = 8.0
|
||||||
|
|
||||||
## Compatibility views retained for existing stage tools and tests.
|
## Compatibility views retained for existing stage tools and tests.
|
||||||
var _terrain_dragging: bool:
|
var _terrain_dragging: bool:
|
||||||
@@ -643,7 +655,6 @@ var _direct_move_target: Vector2:
|
|||||||
var _direct_move_last_style: Dictionary:
|
var _direct_move_last_style: Dictionary:
|
||||||
get:
|
get:
|
||||||
return {} if _director_controller == null else _director_controller.get_direct_move_last_style()
|
return {} if _director_controller == null else _director_controller.get_direct_move_last_style()
|
||||||
var _next_stage_prop_serial: int = 0
|
|
||||||
var _speak_dialog: AcceptDialog = null
|
var _speak_dialog: AcceptDialog = null
|
||||||
var _speak_edit: LineEdit = null
|
var _speak_edit: LineEdit = null
|
||||||
var _speak_stop_current_check: CheckBox = null
|
var _speak_stop_current_check: CheckBox = null
|
||||||
@@ -1327,6 +1338,7 @@ func _ready() -> void:
|
|||||||
_load_settings()
|
_load_settings()
|
||||||
|
|
||||||
_spawner = STAGE_SPAWNER.new(_world)
|
_spawner = STAGE_SPAWNER.new(_world)
|
||||||
|
_object_identity = STAGE_OBJECT_IDENTITY_SCRIPT.new() as StageObjectIdentity
|
||||||
_spatial_index = STAGE_SPATIAL_INDEX.new()
|
_spatial_index = STAGE_SPATIAL_INDEX.new()
|
||||||
_asset_browser = STAGE_ASSET_BROWSER_SCRIPT.new() as StageAssetBrowser
|
_asset_browser = STAGE_ASSET_BROWSER_SCRIPT.new() as StageAssetBrowser
|
||||||
add_child(_asset_browser)
|
add_child(_asset_browser)
|
||||||
@@ -1379,6 +1391,7 @@ func _ready() -> void:
|
|||||||
_update_scene_chrome()
|
_update_scene_chrome()
|
||||||
_handle_cli_startup(OS.get_cmdline_user_args())
|
_handle_cli_startup(OS.get_cmdline_user_args())
|
||||||
_check_speech_recovery()
|
_check_speech_recovery()
|
||||||
|
_last_pointer_viewport_position = get_viewport().get_mouse_position()
|
||||||
_stage_input_ready = true
|
_stage_input_ready = true
|
||||||
|
|
||||||
|
|
||||||
@@ -1400,6 +1413,7 @@ func _process(delta: float) -> void:
|
|||||||
_use_spot_overlay.show_edit(_spot_edit_prop, _spot_edit_ghost_world, "Click a place on this prop" if _spot_edit_id.is_empty() else "Click the spot's new place", _spot_selected_id, _spot_edit_id)
|
_use_spot_overlay.show_edit(_spot_edit_prop, _spot_edit_ghost_world, "Click a place on this prop" if _spot_edit_id.is_empty() else "Click the spot's new place", _spot_selected_id, _spot_edit_id)
|
||||||
elif _ghost != null and is_instance_valid(_ghost):
|
elif _ghost != null and is_instance_valid(_ghost):
|
||||||
_update_ghost_position()
|
_update_ghost_position()
|
||||||
|
_update_placement_feedback(delta)
|
||||||
_update_cursor_coords()
|
_update_cursor_coords()
|
||||||
_update_action_overlay()
|
_update_action_overlay()
|
||||||
_sync_awaiting_cursor()
|
_sync_awaiting_cursor()
|
||||||
@@ -1453,6 +1467,28 @@ func _update_throw_preview_cache() -> void:
|
|||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if _hosted_prop_editor != null or not _stage_input_ready:
|
if _hosted_prop_editor != null or not _stage_input_ready:
|
||||||
return
|
return
|
||||||
|
if event is InputEventMouseMotion:
|
||||||
|
_last_pointer_viewport_position = (event as InputEventMouseMotion).position
|
||||||
|
elif event is InputEventMouseButton:
|
||||||
|
_last_pointer_viewport_position = (event as InputEventMouseButton).position
|
||||||
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup) and _objects_here_popup.visible:
|
||||||
|
if event is InputEventMouseButton:
|
||||||
|
var chooser_click: InputEventMouseButton = event as InputEventMouseButton
|
||||||
|
var popup_rect: Rect2 = Rect2(Vector2(_objects_here_popup.position), Vector2(_objects_here_popup.size))
|
||||||
|
if chooser_click.pressed and not popup_rect.has_point(chooser_click.position):
|
||||||
|
_modal_views.dismiss_objects_here()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
elif event is InputEventKey:
|
||||||
|
var chooser_key: InputEventKey = event as InputEventKey
|
||||||
|
if chooser_key.pressed and not chooser_key.echo and chooser_key.keycode == KEY_ESCAPE:
|
||||||
|
_modal_views.dismiss_objects_here()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
if event is InputEventKey and (event as InputEventKey).pressed and not (event as InputEventKey).echo and (event as InputEventKey).keycode == KEY_ESCAPE and not _pending_overlap_candidates.is_empty():
|
||||||
|
_clear_pending_overlap_gesture()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
if _speech_session != null:
|
if _speech_session != null:
|
||||||
_speech_session.set_camera_panning(_panning)
|
_speech_session.set_camera_panning(_panning)
|
||||||
if _speech_session.handle_input(event):
|
if _speech_session.handle_input(event):
|
||||||
@@ -1651,6 +1687,8 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||||||
_action_editor.hide()
|
_action_editor.hide()
|
||||||
elif _rule_editor != null and _rule_editor.visible:
|
elif _rule_editor != null and _rule_editor.visible:
|
||||||
_rule_editor.hide()
|
_rule_editor.hide()
|
||||||
|
else:
|
||||||
|
set_mode(StageMode.EDIT)
|
||||||
elif _placement_id != "":
|
elif _placement_id != "":
|
||||||
set_placement_mode("")
|
set_placement_mode("")
|
||||||
elif not _selection.get_selected().is_empty():
|
elif not _selection.get_selected().is_empty():
|
||||||
@@ -1827,8 +1865,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
|||||||
else:
|
else:
|
||||||
_place_at(world_pos)
|
_place_at(world_pos)
|
||||||
else:
|
else:
|
||||||
_begin_click_select(world_pos, mb.shift_pressed)
|
_begin_click_select(world_pos, mb.shift_pressed, mb.position)
|
||||||
else:
|
else:
|
||||||
|
if _resolve_pending_overlap_click():
|
||||||
|
return
|
||||||
if _gizmos.is_dragging():
|
if _gizmos.is_dragging():
|
||||||
_gizmos.end_drag()
|
_gizmos.end_drag()
|
||||||
elif _box_selecting:
|
elif _box_selecting:
|
||||||
@@ -1851,7 +1891,9 @@ func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
|||||||
if _scenery_edit_mode:
|
if _scenery_edit_mode:
|
||||||
_handle_scenery_edit_motion(world_pos)
|
_handle_scenery_edit_motion(world_pos)
|
||||||
return
|
return
|
||||||
if _gizmos.is_dragging():
|
if _try_begin_pending_overlap_drag(mm.position, world_pos):
|
||||||
|
_director_visuals.mark_dirty()
|
||||||
|
elif _gizmos.is_dragging():
|
||||||
_gizmos.drag_to(world_pos)
|
_gizmos.drag_to(world_pos)
|
||||||
_director_visuals.mark_dirty()
|
_director_visuals.mark_dirty()
|
||||||
elif _terrain_dragging:
|
elif _terrain_dragging:
|
||||||
@@ -1883,6 +1925,7 @@ func set_mode(mode: StageMode) -> void:
|
|||||||
return
|
return
|
||||||
if _plain_color_draft_active:
|
if _plain_color_draft_active:
|
||||||
_close_selector()
|
_close_selector()
|
||||||
|
_clear_pending_overlap_gesture()
|
||||||
_cancel_surface_color_preview()
|
_cancel_surface_color_preview()
|
||||||
if _scenery_controller != null:
|
if _scenery_controller != null:
|
||||||
if _scenery_controller.is_group_preview_pending():
|
if _scenery_controller.is_group_preview_pending():
|
||||||
@@ -1891,6 +1934,7 @@ func set_mode(mode: StageMode) -> void:
|
|||||||
_scenery_controller.exit_tool()
|
_scenery_controller.exit_tool()
|
||||||
if _scenery_edit_mode:
|
if _scenery_edit_mode:
|
||||||
_exit_scenery_edit_mode()
|
_exit_scenery_edit_mode()
|
||||||
|
_dismiss_active_objects_here()
|
||||||
_hide_scenery_capture_bar()
|
_hide_scenery_capture_bar()
|
||||||
var previous_mode: StageMode = current_mode
|
var previous_mode: StageMode = current_mode
|
||||||
_cancel_edit_wobble_preview()
|
_cancel_edit_wobble_preview()
|
||||||
@@ -1914,6 +1958,7 @@ func set_mode(mode: StageMode) -> void:
|
|||||||
_sync_run_controls()
|
_sync_run_controls()
|
||||||
_apply_cursor()
|
_apply_cursor()
|
||||||
_refresh_prop_settings_card()
|
_refresh_prop_settings_card()
|
||||||
|
_refresh_character_settings_card()
|
||||||
mode_changed.emit(int(mode))
|
mode_changed.emit(int(mode))
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
|
|
||||||
@@ -2164,6 +2209,10 @@ func _place_at(world_pos: Vector2) -> void:
|
|||||||
if node == null:
|
if node == null:
|
||||||
return
|
return
|
||||||
_finalize_placed_node(node)
|
_finalize_placed_node(node)
|
||||||
|
var placed_name: String = _object_identity.get_name(node)
|
||||||
|
if not placed_name.is_empty():
|
||||||
|
_show_toast("%s placed" % placed_name)
|
||||||
|
_show_placement_feedback(node, placed_name)
|
||||||
if node is TerrainBlock:
|
if node is TerrainBlock:
|
||||||
_nav_dirty = true
|
_nav_dirty = true
|
||||||
_rebuild_grid_cells()
|
_rebuild_grid_cells()
|
||||||
@@ -2176,10 +2225,12 @@ func _place_at(world_pos: Vector2) -> void:
|
|||||||
## Common post-spawn wiring: freeze kinematic props in EDIT, save authored state,
|
## Common post-spawn wiring: freeze kinematic props in EDIT, save authored state,
|
||||||
## and connect the Phase 3a/4 signals for content/stickmen/props.
|
## and connect the Phase 3a/4 signals for content/stickmen/props.
|
||||||
func _finalize_placed_node(node: Node2D) -> void:
|
func _finalize_placed_node(node: Node2D) -> void:
|
||||||
|
if node is PropBlock:
|
||||||
|
_object_identity.assign_new(node, StageObjectIdentity.asset_base_name("prop", String((node as PropBlock).spawn_id)), _world)
|
||||||
|
elif node is STICKMAN_RIG:
|
||||||
|
_object_identity.assign_new(node, StageObjectIdentity.asset_base_name("stickman", String((node as StickmanRig).source_stk_path)), _world)
|
||||||
if node is PropBlock:
|
if node is PropBlock:
|
||||||
var placed_prop := node as PropBlock
|
var placed_prop := node as PropBlock
|
||||||
if placed_prop.stage_object_id.is_empty():
|
|
||||||
placed_prop.stage_object_id = _new_stage_prop_id()
|
|
||||||
placed_prop.ensure_default_grip()
|
placed_prop.ensure_default_grip()
|
||||||
if placed_prop.stage_roles.is_empty():
|
if placed_prop.stage_roles.is_empty():
|
||||||
placed_prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(placed_prop.has_usable_grip()))
|
placed_prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(placed_prop.has_usable_grip()))
|
||||||
@@ -2216,24 +2267,6 @@ func _finalize_placed_node(node: Node2D) -> void:
|
|||||||
prop.separated.connect(_on_prop_separated.bind(prop))
|
prop.separated.connect(_on_prop_separated.bind(prop))
|
||||||
|
|
||||||
|
|
||||||
func _new_stage_prop_id() -> String:
|
|
||||||
while true:
|
|
||||||
_next_stage_prop_serial += 1
|
|
||||||
var candidate := "prop_%d_%d_%d" % [Time.get_ticks_usec(), randi(), _next_stage_prop_serial]
|
|
||||||
if not _stage_prop_id_in_use(candidate):
|
|
||||||
return candidate
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
func _stage_prop_id_in_use(stage_id: String) -> bool:
|
|
||||||
for child: Node in _world.get_children():
|
|
||||||
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
|
|
||||||
return true
|
|
||||||
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == stage_id:
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
func _resolve_prop_by_stage_id(stage_id: String) -> PropBlock:
|
func _resolve_prop_by_stage_id(stage_id: String) -> PropBlock:
|
||||||
if stage_id.is_empty():
|
if stage_id.is_empty():
|
||||||
return null
|
return null
|
||||||
@@ -2293,6 +2326,7 @@ func _use_failure_message(reason_code: String) -> String:
|
|||||||
func _spawn_ghost() -> void:
|
func _spawn_ghost() -> void:
|
||||||
_free_ghost()
|
_free_ghost()
|
||||||
if _placement_id == "":
|
if _placement_id == "":
|
||||||
|
_update_placement_name_prompt(Vector2.ZERO)
|
||||||
return
|
return
|
||||||
var ghost := _spawner.spawn(_placement_id, Vector2.ZERO)
|
var ghost := _spawner.spawn(_placement_id, Vector2.ZERO)
|
||||||
if ghost == null:
|
if ghost == null:
|
||||||
@@ -2337,17 +2371,116 @@ func _free_ghost() -> void:
|
|||||||
if _ghost != null and is_instance_valid(_ghost):
|
if _ghost != null and is_instance_valid(_ghost):
|
||||||
_ghost.queue_free()
|
_ghost.queue_free()
|
||||||
_ghost = null
|
_ghost = null
|
||||||
|
if _placement_id.is_empty() or not _is_named_placement():
|
||||||
|
_update_placement_name_prompt(Vector2.ZERO)
|
||||||
|
|
||||||
|
|
||||||
func _update_ghost_position() -> void:
|
func _update_ghost_position() -> void:
|
||||||
if _ghost == null or not is_instance_valid(_ghost):
|
if _ghost == null or not is_instance_valid(_ghost):
|
||||||
return
|
return
|
||||||
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
|
var canvas_transform: Transform2D = get_viewport().get_canvas_transform()
|
||||||
|
var pointer_world_position: Vector2 = canvas_transform.affine_inverse() * _last_pointer_viewport_position
|
||||||
|
var pos: Vector2 = pointer_world_position + _spawner.get_spawn_offset(_placement_id)
|
||||||
if _is_terrain_placement():
|
if _is_terrain_placement():
|
||||||
pos = _snap_to_grid(pos) if _snap_enabled else pos
|
pos = _snap_to_grid(pos) if _snap_enabled else pos
|
||||||
elif _snap_enabled:
|
elif _snap_enabled:
|
||||||
pos = _snap_to_grid(pos)
|
pos = _snap_to_grid(pos)
|
||||||
_ghost.position = pos
|
_ghost.position = pos
|
||||||
|
_update_placement_name_prompt(_last_pointer_viewport_position)
|
||||||
|
|
||||||
|
|
||||||
|
func _is_named_placement() -> bool:
|
||||||
|
return _placement_id == "prop" or _placement_id == "stickman"
|
||||||
|
|
||||||
|
|
||||||
|
func _placement_base_name() -> String:
|
||||||
|
var asset_key: String = ""
|
||||||
|
if _placement_id == "prop":
|
||||||
|
asset_key = "prop:" + _spawner.get_selected_prop_id()
|
||||||
|
elif _placement_id == "stickman":
|
||||||
|
asset_key = "stickman:" + _spawner.get_selected_stickman_path()
|
||||||
|
if asset_key.is_empty():
|
||||||
|
_placement_name_cache_key = ""
|
||||||
|
_placement_name_cache_value = ""
|
||||||
|
return ""
|
||||||
|
if asset_key == _placement_name_cache_key:
|
||||||
|
return _placement_name_cache_value
|
||||||
|
_placement_name_cache_key = asset_key
|
||||||
|
var object_type: String = "prop" if _placement_id == "prop" else "stickman"
|
||||||
|
var asset_path: String = _spawner.get_selected_prop_id() if object_type == "prop" else _spawner.get_selected_stickman_path()
|
||||||
|
_placement_name_cache_value = StageObjectIdentity.asset_base_name(object_type, asset_path)
|
||||||
|
return _placement_name_cache_value
|
||||||
|
|
||||||
|
|
||||||
|
func _update_placement_name_prompt(screen_position: Vector2) -> void:
|
||||||
|
if _placement_name_prompt == null or _placement_name_prompt_panel == null:
|
||||||
|
return
|
||||||
|
if current_mode != StageMode.EDIT or _ghost == null or not is_instance_valid(_ghost) or not _is_named_placement():
|
||||||
|
_placement_name_prompt_panel.visible = false
|
||||||
|
return
|
||||||
|
var next_name: String = _object_identity.next_free_name(_placement_base_name(), _world, _ghost)
|
||||||
|
_placement_name_prompt.text = "Click to place %s" % next_name
|
||||||
|
_placement_name_prompt_panel.size = _placement_name_prompt_panel.get_combined_minimum_size()
|
||||||
|
var viewport: Rect2 = get_viewport_rect()
|
||||||
|
var position: Vector2 = screen_position + Vector2(16.0, 18.0)
|
||||||
|
position.x = clampf(position.x, 8.0, maxf(8.0, viewport.size.x - _placement_name_prompt_panel.size.x - 8.0))
|
||||||
|
position.y = clampf(position.y, 8.0, maxf(8.0, viewport.size.y - _placement_name_prompt_panel.size.y - 8.0))
|
||||||
|
_placement_name_prompt_panel.position = position
|
||||||
|
_placement_name_prompt_panel.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _show_placement_feedback(node: Node2D, stage_name: String) -> void:
|
||||||
|
if _placement_feedback_label == null or node == null:
|
||||||
|
return
|
||||||
|
_placement_feedback_node = node
|
||||||
|
_placement_feedback_label.text = "%s placed" % stage_name
|
||||||
|
_placement_feedback_timer = 1.6
|
||||||
|
_placement_feedback_panel.visible = true
|
||||||
|
_position_placement_feedback()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_placement_feedback(delta: float) -> void:
|
||||||
|
if _placement_feedback_timer <= 0.0:
|
||||||
|
return
|
||||||
|
_placement_feedback_timer = maxf(0.0, _placement_feedback_timer - delta)
|
||||||
|
if _placement_feedback_node == null or not is_instance_valid(_placement_feedback_node) or _placement_feedback_node.is_queued_for_deletion():
|
||||||
|
_placement_feedback_timer = 0.0
|
||||||
|
if _placement_feedback_timer <= 0.0:
|
||||||
|
_placement_feedback_panel.visible = false
|
||||||
|
_placement_feedback_node = null
|
||||||
|
else:
|
||||||
|
_position_placement_feedback()
|
||||||
|
|
||||||
|
|
||||||
|
func _position_placement_feedback() -> void:
|
||||||
|
if _placement_feedback_label == null or _placement_feedback_node == null or not is_instance_valid(_placement_feedback_node):
|
||||||
|
return
|
||||||
|
var viewport: Rect2 = get_viewport_rect()
|
||||||
|
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(_placement_feedback_node)
|
||||||
|
var screen_bounds: Rect2 = _world_rect_to_viewport_rect(bounds)
|
||||||
|
_placement_feedback_panel.size = _placement_feedback_panel.get_combined_minimum_size()
|
||||||
|
var position: Vector2 = Vector2(
|
||||||
|
screen_bounds.get_center().x - _placement_feedback_panel.size.x * 0.5,
|
||||||
|
screen_bounds.position.y - _placement_feedback_panel.size.y - 12.0
|
||||||
|
)
|
||||||
|
position.x = clampf(position.x, 8.0, maxf(8.0, viewport.size.x - _placement_feedback_panel.size.x - 8.0))
|
||||||
|
position.y = clampf(position.y, 8.0, maxf(8.0, viewport.size.y - _placement_feedback_panel.size.y - 8.0))
|
||||||
|
_placement_feedback_panel.position = position
|
||||||
|
|
||||||
|
|
||||||
|
func _world_rect_to_viewport_rect(world_rect: Rect2) -> Rect2:
|
||||||
|
var transform: Transform2D = get_viewport().get_canvas_transform()
|
||||||
|
var corners: Array[Vector2] = [
|
||||||
|
world_rect.position,
|
||||||
|
Vector2(world_rect.end.x, world_rect.position.y),
|
||||||
|
world_rect.end,
|
||||||
|
Vector2(world_rect.position.x, world_rect.end.y),
|
||||||
|
]
|
||||||
|
var first_point: Vector2 = transform * corners[0]
|
||||||
|
var screen_rect: Rect2 = Rect2(first_point, Vector2.ZERO)
|
||||||
|
for corner_index: int in range(1, corners.size()):
|
||||||
|
screen_rect = screen_rect.expand(transform * corners[corner_index])
|
||||||
|
return screen_rect
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Terrain drag-painting (Phase 4b)
|
# Terrain drag-painting (Phase 4b)
|
||||||
@@ -2469,7 +2602,8 @@ func _add_node_to_grid_cells(node: Node2D) -> void:
|
|||||||
# Selection
|
# Selection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
|
func _begin_click_select(world_pos: Vector2, shift: bool, screen_pos: Vector2) -> void:
|
||||||
|
_clear_pending_overlap_gesture()
|
||||||
var candidates: Array[Node2D] = _selection.hit_test_all(world_pos)
|
var candidates: Array[Node2D] = _selection.hit_test_all(world_pos)
|
||||||
var hit: Node2D = candidates[0] if not candidates.is_empty() else null
|
var hit: Node2D = candidates[0] if not candidates.is_empty() else null
|
||||||
if hit != null:
|
if hit != null:
|
||||||
@@ -2478,8 +2612,10 @@ func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
|
|||||||
if _selection.is_selected(hit):
|
if _selection.is_selected(hit):
|
||||||
_gizmos.begin_translate_drag(hit, world_pos)
|
_gizmos.begin_translate_drag(hit, world_pos)
|
||||||
elif candidates.size() > 1:
|
elif candidates.size() > 1:
|
||||||
_selection.select_only(hit)
|
_pending_overlap_candidates = candidates.duplicate()
|
||||||
_show_objects_here_popup(candidates)
|
_pending_overlap_drag_target = _selected_overlap_drag_target(candidates)
|
||||||
|
_pending_overlap_world_position = world_pos
|
||||||
|
_pending_overlap_screen_position = screen_pos
|
||||||
elif _selection.is_selected(hit):
|
elif _selection.is_selected(hit):
|
||||||
# Already selected: keep the whole selection and drag it together.
|
# Already selected: keep the whole selection and drag it together.
|
||||||
_gizmos.begin_translate_drag(hit, world_pos)
|
_gizmos.begin_translate_drag(hit, world_pos)
|
||||||
@@ -2493,6 +2629,58 @@ func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
|
|||||||
_gizmos.set_box_rect(_box_rect)
|
_gizmos.set_box_rect(_box_rect)
|
||||||
|
|
||||||
|
|
||||||
|
func _selected_overlap_drag_target(candidates: Array[Node2D]) -> Node2D:
|
||||||
|
if _selection == null:
|
||||||
|
return null
|
||||||
|
var primary: Node2D = _selection.get_primary()
|
||||||
|
if primary != null and candidates.has(primary):
|
||||||
|
return primary
|
||||||
|
for selected: Node2D in _selection.get_selected():
|
||||||
|
if candidates.has(selected):
|
||||||
|
return selected
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _try_begin_pending_overlap_drag(screen_pos: Vector2, world_pos: Vector2) -> bool:
|
||||||
|
if _pending_overlap_candidates.is_empty() or _pending_overlap_drag_target == null:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(_pending_overlap_drag_target) or _pending_overlap_drag_target.is_queued_for_deletion():
|
||||||
|
_pending_overlap_drag_target = null
|
||||||
|
return false
|
||||||
|
if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
|
||||||
|
return false
|
||||||
|
if screen_pos.distance_to(_pending_overlap_screen_position) < OVERLAP_SELECT_DRAG_THRESHOLD_PX:
|
||||||
|
return false
|
||||||
|
var drag_target: Node2D = _pending_overlap_drag_target
|
||||||
|
var drag_origin: Vector2 = _pending_overlap_world_position
|
||||||
|
_clear_pending_overlap_gesture()
|
||||||
|
_gizmos.begin_translate_drag(drag_target, drag_origin)
|
||||||
|
_gizmos.drag_to(world_pos)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_pending_overlap_click() -> bool:
|
||||||
|
if _pending_overlap_candidates.is_empty():
|
||||||
|
return false
|
||||||
|
var world_position: Vector2 = _pending_overlap_world_position
|
||||||
|
var candidates: Array[Node2D] = _selection.hit_test_all(world_position)
|
||||||
|
_clear_pending_overlap_gesture()
|
||||||
|
if candidates.size() > 1:
|
||||||
|
_show_objects_here_popup(candidates, &"edit_select", world_position)
|
||||||
|
elif candidates.size() == 1:
|
||||||
|
var hit: Node2D = candidates[0]
|
||||||
|
if not _selection.is_selected(hit):
|
||||||
|
_selection.select_only(hit)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_pending_overlap_gesture() -> void:
|
||||||
|
_pending_overlap_candidates.clear()
|
||||||
|
_pending_overlap_drag_target = null
|
||||||
|
_pending_overlap_world_position = Vector2.ZERO
|
||||||
|
_pending_overlap_screen_position = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
func _finish_box_select(shift: bool) -> void:
|
func _finish_box_select(shift: bool) -> void:
|
||||||
_box_selecting = false
|
_box_selecting = false
|
||||||
_gizmos.set_box_rect(Rect2())
|
_gizmos.set_box_rect(Rect2())
|
||||||
@@ -3040,7 +3228,8 @@ func _refresh_status() -> void:
|
|||||||
if sel.is_empty():
|
if sel.is_empty():
|
||||||
sel_text = "none"
|
sel_text = "none"
|
||||||
elif sel.size() == 1:
|
elif sel.size() == 1:
|
||||||
sel_text = String(sel[0].name)
|
var selected_stage_name: String = "" if _object_identity == null else _object_identity.get_name(sel[0])
|
||||||
|
sel_text = selected_stage_name if not selected_stage_name.is_empty() else "Stage object"
|
||||||
else:
|
else:
|
||||||
sel_text = "%d objects" % sel.size()
|
sel_text = "%d objects" % sel.size()
|
||||||
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
|
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
|
||||||
@@ -3195,7 +3384,11 @@ func _build_ui() -> void:
|
|||||||
_modal_views = STAGE_MODAL_VIEWS_SCRIPT.new() as StageModalViews
|
_modal_views = STAGE_MODAL_VIEWS_SCRIPT.new() as StageModalViews
|
||||||
_modal_views.configure(_sandbox_theme as SandboxTheme, _ui_font)
|
_modal_views.configure(_sandbox_theme as SandboxTheme, _ui_font)
|
||||||
_modal_views.object_selected.connect(_on_objects_here_selected)
|
_modal_views.object_selected.connect(_on_objects_here_selected)
|
||||||
|
_modal_views.candidate_preview_changed.connect(_on_objects_here_candidate_preview_changed)
|
||||||
|
_modal_views.objects_here_dismissed.connect(_on_objects_here_dismissed)
|
||||||
_build_prop_settings_view(ui)
|
_build_prop_settings_view(ui)
|
||||||
|
_build_character_settings_view(ui)
|
||||||
|
_build_placement_name_prompt(ui)
|
||||||
_build_scenery_inspector(ui)
|
_build_scenery_inspector(ui)
|
||||||
_build_set_down_cancel_button(ui)
|
_build_set_down_cancel_button(ui)
|
||||||
_build_use_target_capture_bar(ui)
|
_build_use_target_capture_bar(ui)
|
||||||
@@ -3357,11 +3550,68 @@ func _build_prop_settings_view(ui: CanvasLayer) -> void:
|
|||||||
_prop_settings_view.spot_rename_requested.connect(_on_spot_rename_requested)
|
_prop_settings_view.spot_rename_requested.connect(_on_spot_rename_requested)
|
||||||
_prop_settings_view.spot_erase_requested.connect(_on_spot_erase_requested)
|
_prop_settings_view.spot_erase_requested.connect(_on_spot_erase_requested)
|
||||||
_prop_settings_view.spot_selected.connect(_on_spot_selected)
|
_prop_settings_view.spot_selected.connect(_on_spot_selected)
|
||||||
_prop_settings_view.close_requested.connect(_on_prop_settings_close_requested)
|
_prop_settings_view.rename_requested.connect(_on_prop_rename_requested)
|
||||||
ui.add_child(_prop_settings_view)
|
ui.add_child(_prop_settings_view)
|
||||||
_prop_settings_view.update_layout(_prop_settings_safe_rect())
|
_prop_settings_view.update_layout(_prop_settings_safe_rect())
|
||||||
|
|
||||||
|
|
||||||
|
func _build_character_settings_view(ui: CanvasLayer) -> void:
|
||||||
|
_character_settings_view = STAGE_CHARACTER_SETTINGS_VIEW_SCRIPT.new() as StageCharacterSettingsView
|
||||||
|
_character_settings_view.configure(_sandbox_theme as SandboxTheme)
|
||||||
|
_character_settings_view.rename_requested.connect(_on_character_rename_requested)
|
||||||
|
ui.add_child(_character_settings_view)
|
||||||
|
_character_settings_view.update_layout(_prop_settings_safe_rect())
|
||||||
|
|
||||||
|
|
||||||
|
func _build_placement_name_prompt(ui: CanvasLayer) -> void:
|
||||||
|
_placement_name_prompt_panel = _make_placement_feedback_panel("PlacementNamePromptPanel")
|
||||||
|
_placement_name_prompt_panel.visible = false
|
||||||
|
ui.add_child(_placement_name_prompt_panel)
|
||||||
|
_placement_name_prompt = Label.new()
|
||||||
|
_placement_name_prompt.name = "PlacementNamePrompt"
|
||||||
|
_placement_name_prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_placement_name_prompt.add_theme_font_size_override("font_size", 18)
|
||||||
|
_placement_name_prompt.add_theme_color_override("font_color", Color.WHITE)
|
||||||
|
_placement_name_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
if _ui_font != null:
|
||||||
|
_placement_name_prompt.add_theme_font_override("font", _ui_font)
|
||||||
|
_placement_name_prompt_panel.add_child(_placement_name_prompt)
|
||||||
|
_placement_feedback_panel = _make_placement_feedback_panel("PlacementFeedbackPanel")
|
||||||
|
_placement_feedback_panel.visible = false
|
||||||
|
ui.add_child(_placement_feedback_panel)
|
||||||
|
_placement_feedback_label = Label.new()
|
||||||
|
_placement_feedback_label.name = "PlacementFeedback"
|
||||||
|
_placement_feedback_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_placement_feedback_label.add_theme_font_size_override("font_size", 18)
|
||||||
|
_placement_feedback_label.add_theme_color_override("font_color", Color.WHITE)
|
||||||
|
_placement_feedback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
if _ui_font != null:
|
||||||
|
_placement_feedback_label.add_theme_font_override("font", _ui_font)
|
||||||
|
_placement_feedback_panel.add_child(_placement_feedback_label)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_placement_feedback_panel(panel_name: String) -> PanelContainer:
|
||||||
|
var panel: PanelContainer = PanelContainer.new()
|
||||||
|
panel.name = panel_name
|
||||||
|
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var accent: Color = Color("#22C6FF")
|
||||||
|
var surface: Color = Color("#182432F2")
|
||||||
|
if _sandbox_theme != null:
|
||||||
|
accent = _sandbox_theme.color("mode_colors.edit", accent)
|
||||||
|
surface = _sandbox_theme.color("stage_panels.shell.surface_color", surface)
|
||||||
|
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||||
|
style.bg_color = surface
|
||||||
|
style.border_color = accent
|
||||||
|
style.set_border_width_all(2)
|
||||||
|
style.set_corner_radius_all(10)
|
||||||
|
style.content_margin_left = 12.0
|
||||||
|
style.content_margin_right = 12.0
|
||||||
|
style.content_margin_top = 6.0
|
||||||
|
style.content_margin_bottom = 6.0
|
||||||
|
panel.add_theme_stylebox_override("panel", style)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
func _build_scenery_inspector(ui: CanvasLayer) -> void:
|
func _build_scenery_inspector(ui: CanvasLayer) -> void:
|
||||||
_scenery_inspector = STAGE_INSPECTOR_PANEL_SCRIPT.new() as StageInspectorPanel
|
_scenery_inspector = STAGE_INSPECTOR_PANEL_SCRIPT.new() as StageInspectorPanel
|
||||||
_scenery_inspector.name = "SceneryRepairCard"
|
_scenery_inspector.name = "SceneryRepairCard"
|
||||||
@@ -4157,7 +4407,7 @@ func _handle_use_target_click(world_position: Vector2, screen_position: Vector2)
|
|||||||
_show_toast("Click a prop to use")
|
_show_toast("Click a prop to use")
|
||||||
return
|
return
|
||||||
if prop_candidates.size() > 1:
|
if prop_candidates.size() > 1:
|
||||||
_show_objects_here_popup(prop_candidates)
|
_show_objects_here_popup(prop_candidates, &"direct_use_prop", world_position)
|
||||||
return
|
return
|
||||||
_select_use_target_prop(prop_candidates[0] as PropBlock, screen_position)
|
_select_use_target_prop(prop_candidates[0] as PropBlock, screen_position)
|
||||||
return
|
return
|
||||||
@@ -4560,9 +4810,7 @@ func _refresh_prop_settings_card() -> void:
|
|||||||
return
|
return
|
||||||
var prop: PropBlock = selected[0] as PropBlock
|
var prop: PropBlock = selected[0] as PropBlock
|
||||||
var roles: Dictionary = PROP_STAGE_ROLES.normalize(prop.stage_roles)
|
var roles: Dictionary = PROP_STAGE_ROLES.normalize(prop.stage_roles)
|
||||||
var display_name: String = prop.spawn_id if not prop.spawn_id.is_empty() else "Prop"
|
var display_name: String = prop.stage_name if not prop.stage_name.is_empty() else "Prop"
|
||||||
var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id)
|
|
||||||
display_name = String(entry.get("name", entry.get("label", display_name)))
|
|
||||||
_prop_settings_view.show_prop(prop, display_name, roles, safe_rect, selected, _spot_selected_id)
|
_prop_settings_view.show_prop(prop, display_name, roles, safe_rect, selected, _spot_selected_id)
|
||||||
if not _spot_edit_active and not _use_target_active and _use_spot_overlay != null:
|
if not _spot_edit_active and not _use_target_active and _use_spot_overlay != null:
|
||||||
_use_spot_overlay.show_edit(prop, Vector2.INF, "", _spot_selected_id)
|
_use_spot_overlay.show_edit(prop, Vector2.INF, "", _spot_selected_id)
|
||||||
@@ -4571,11 +4819,45 @@ func _refresh_prop_settings_card() -> void:
|
|||||||
func _prop_settings_safe_rect() -> Rect2:
|
func _prop_settings_safe_rect() -> Rect2:
|
||||||
var viewport: Rect2 = get_viewport_rect()
|
var viewport: Rect2 = get_viewport_rect()
|
||||||
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
|
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 top_offset: float = minf(TOP_BAR_HEIGHT + 120.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 bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - STATUS_BAR_HEIGHT - 12.0)
|
||||||
return Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))
|
return Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_character_settings_card() -> void:
|
||||||
|
if _character_settings_view == null or _selection == null:
|
||||||
|
return
|
||||||
|
var selected: Array[Node2D] = _selection.get_selected()
|
||||||
|
if current_mode != StageMode.EDIT or selected.size() != 1 or not selected[0] is STICKMAN_RIG:
|
||||||
|
_character_settings_view.hide_card(true)
|
||||||
|
return
|
||||||
|
_character_settings_view.show_character(selected[0] as StickmanRig, _prop_settings_safe_rect())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_prop_rename_requested(node: Node2D, requested_name: String) -> void:
|
||||||
|
if current_mode != StageMode.EDIT or _selection == null or _selection.get_selected().size() != 1 or _selection.get_selected()[0] != node:
|
||||||
|
return
|
||||||
|
var previous_name: String = String(node.get("stage_name"))
|
||||||
|
var result: Dictionary = _object_identity.rename(node, requested_name, _world)
|
||||||
|
_prop_settings_view.show_name_result(result)
|
||||||
|
if bool(result.get("ok", false)):
|
||||||
|
if previous_name != String(result.get("name", "")):
|
||||||
|
_mark_scene_dirty()
|
||||||
|
_refresh_prop_settings_card()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_character_rename_requested(node: Node2D, requested_name: String) -> void:
|
||||||
|
if current_mode != StageMode.EDIT or _selection == null or _selection.get_selected().size() != 1 or _selection.get_selected()[0] != node:
|
||||||
|
return
|
||||||
|
var previous_name: String = String(node.get("stage_name"))
|
||||||
|
var result: Dictionary = _object_identity.rename(node, requested_name, _world)
|
||||||
|
_character_settings_view.show_name_result(result)
|
||||||
|
if bool(result.get("ok", false)):
|
||||||
|
if previous_name != String(result.get("name", "")):
|
||||||
|
_mark_scene_dirty()
|
||||||
|
_refresh_character_settings_card()
|
||||||
|
|
||||||
|
|
||||||
func _position_cancel_button_after_prop_choice(after_choice: bool) -> void:
|
func _position_cancel_button_after_prop_choice(after_choice: bool) -> void:
|
||||||
if _set_down_cancel_button == null:
|
if _set_down_cancel_button == null:
|
||||||
return
|
return
|
||||||
@@ -4584,15 +4866,6 @@ func _position_cancel_button_after_prop_choice(after_choice: bool) -> void:
|
|||||||
_set_down_cancel_button.offset_right = left + 132.0
|
_set_down_cancel_button.offset_right = left + 132.0
|
||||||
|
|
||||||
|
|
||||||
func _on_prop_settings_close_requested() -> void:
|
|
||||||
if _prop_settings_view == null or _prop_settings_active_key.is_empty():
|
|
||||||
return
|
|
||||||
_prop_settings_view.set_dismissed(true)
|
|
||||||
_refresh_prop_settings_card()
|
|
||||||
if _prop_settings_reopen_button != null and _prop_settings_reopen_button.visible:
|
|
||||||
_prop_settings_reopen_button.grab_focus()
|
|
||||||
|
|
||||||
|
|
||||||
func _on_prop_settings_choice_selected(id: StringName, key: String) -> void:
|
func _on_prop_settings_choice_selected(id: StringName, key: String) -> void:
|
||||||
var value: String = String(id)
|
var value: String = String(id)
|
||||||
if key == "depth":
|
if key == "depth":
|
||||||
@@ -5864,8 +6137,7 @@ func _handle_scenery_edit_click(event: InputEventMouseButton, world_position: Ve
|
|||||||
_gizmos.set_box_rect(_box_rect)
|
_gizmos.set_box_rect(_box_rect)
|
||||||
return
|
return
|
||||||
if candidates.size() > 1 and not event.shift_pressed and not _scenery_multi_select:
|
if candidates.size() > 1 and not event.shift_pressed and not _scenery_multi_select:
|
||||||
_selection.select_only(hit)
|
_show_objects_here_popup(candidates, &"scenery_select", world_position)
|
||||||
_show_objects_here_popup(candidates)
|
|
||||||
return
|
return
|
||||||
if event.shift_pressed or _scenery_multi_select:
|
if event.shift_pressed or _scenery_multi_select:
|
||||||
_selection.toggle_selection(hit)
|
_selection.toggle_selection(hit)
|
||||||
@@ -5962,6 +6234,10 @@ func _on_grid_size_changed(value: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _on_selection_changed(nodes: Array[Node2D]) -> void:
|
func _on_selection_changed(nodes: Array[Node2D]) -> void:
|
||||||
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||||
|
_dismiss_active_objects_here()
|
||||||
|
else:
|
||||||
|
_gizmos.set_candidate_preview(null)
|
||||||
_cancel_edit_wobble_preview()
|
_cancel_edit_wobble_preview()
|
||||||
if _scenery_controller != null and _scenery_controller.is_single_transform_active() and not nodes.has(_scenery_controller.get_single_transform_node()):
|
if _scenery_controller != null and _scenery_controller.is_single_transform_active() and not nodes.has(_scenery_controller.get_single_transform_node()):
|
||||||
_scenery_controller.cancel_single_transform()
|
_scenery_controller.cancel_single_transform()
|
||||||
@@ -5975,6 +6251,7 @@ func _on_selection_changed(nodes: Array[Node2D]) -> void:
|
|||||||
object_selected.emit(nodes)
|
object_selected.emit(nodes)
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
_refresh_prop_settings_card()
|
_refresh_prop_settings_card()
|
||||||
|
_refresh_character_settings_card()
|
||||||
if _scenery_edit_mode:
|
if _scenery_edit_mode:
|
||||||
_refresh_scenery_inspector()
|
_refresh_scenery_inspector()
|
||||||
_refresh_scenery_capture_bar()
|
_refresh_scenery_capture_bar()
|
||||||
@@ -6016,20 +6293,106 @@ func _remove_prop_contact_exceptions(prop: PropBlock) -> void:
|
|||||||
(node as PropBlock).remove_collision_exception_with(prop)
|
(node as PropBlock).remove_collision_exception_with(prop)
|
||||||
|
|
||||||
|
|
||||||
func _show_objects_here_popup(candidates: Array[Node2D]) -> void:
|
func _show_objects_here_popup(candidates: Array[Node2D], context: StringName, world_position: Vector2) -> void:
|
||||||
if _modal_views == null:
|
if _modal_views == null or candidates.size() < 2:
|
||||||
return
|
return
|
||||||
var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer
|
var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer
|
||||||
if ui_layer == null:
|
if ui_layer == null:
|
||||||
return
|
return
|
||||||
_modal_views.show_objects_here(ui_layer, candidates, _selection.get_selected(), get_viewport_rect().size, get_viewport().get_mouse_position())
|
_dismiss_active_objects_here()
|
||||||
|
if _placement_feedback_panel != null:
|
||||||
|
_placement_feedback_panel.visible = false
|
||||||
|
_placement_feedback_timer = 0.0
|
||||||
|
_placement_feedback_node = null
|
||||||
|
_objects_here_candidates = candidates.duplicate()
|
||||||
|
_objects_here_context = context
|
||||||
|
_objects_here_hit_position = world_position
|
||||||
|
_gizmos.set_candidate_preview(null)
|
||||||
|
var click_screen_position: Vector2 = get_viewport().get_canvas_transform() * world_position
|
||||||
|
var avoid_rects: Array[Rect2] = []
|
||||||
|
var viewport_size: Vector2 = get_viewport_rect().size
|
||||||
|
avoid_rects.append(Rect2(Vector2.ZERO, Vector2(viewport_size.x, TOP_BAR_HEIGHT + 8.0)))
|
||||||
|
avoid_rects.append(Rect2(Vector2(0.0, viewport_size.y - STATUS_BAR_HEIGHT - 4.0), Vector2(viewport_size.x, STATUS_BAR_HEIGHT + 4.0)))
|
||||||
|
avoid_rects.append(Rect2(Vector2.ZERO, Vector2(120.0, viewport_size.y)))
|
||||||
|
if _mode_badge != null and _mode_badge.visible:
|
||||||
|
avoid_rects.append(_mode_badge.get_global_rect().grow(8.0))
|
||||||
|
if _palette_dock != null and _palette_dock.visible:
|
||||||
|
avoid_rects.append(_palette_dock.get_global_rect().grow(8.0))
|
||||||
|
if _placement_name_prompt_panel != null and _placement_name_prompt_panel.visible:
|
||||||
|
avoid_rects.append(_placement_name_prompt_panel.get_global_rect().grow(8.0))
|
||||||
|
if context == &"direct_use_prop" and _use_target_capture_bar != null and _use_target_capture_bar.visible:
|
||||||
|
avoid_rects.append(_use_target_capture_bar.get_global_rect().grow(8.0))
|
||||||
|
if _prop_settings_view != null:
|
||||||
|
var prop_card: Control = _prop_settings_view.get_card()
|
||||||
|
if prop_card != null and prop_card.visible:
|
||||||
|
avoid_rects.append(prop_card.get_global_rect().grow(8.0))
|
||||||
|
if _character_settings_view != null:
|
||||||
|
var character_card: Control = _character_settings_view.get_card()
|
||||||
|
if character_card != null and character_card.visible:
|
||||||
|
avoid_rects.append(character_card.get_global_rect().grow(8.0))
|
||||||
|
for candidate: Node2D in _objects_here_candidates:
|
||||||
|
var candidate_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(candidate)
|
||||||
|
if candidate_bounds.has_area():
|
||||||
|
avoid_rects.append(_world_rect_to_viewport_rect(candidate_bounds).grow(8.0))
|
||||||
|
_modal_views.show_objects_here(
|
||||||
|
ui_layer,
|
||||||
|
_objects_here_candidates,
|
||||||
|
_selection.get_selected().duplicate(),
|
||||||
|
get_viewport_rect().size,
|
||||||
|
click_screen_position,
|
||||||
|
avoid_rects
|
||||||
|
)
|
||||||
|
var popup_rect: Rect2 = _modal_views.get_objects_here_screen_rect()
|
||||||
|
if popup_rect.has_area():
|
||||||
|
avoid_rects.append(popup_rect.grow(8.0))
|
||||||
|
_gizmos.set_candidate_preview_obstacles(avoid_rects)
|
||||||
|
|
||||||
|
|
||||||
func _on_objects_here_selected(node: Node2D) -> void:
|
func _on_objects_here_selected(node: Node2D) -> void:
|
||||||
if node != null and is_instance_valid(node) and node.get_parent() == _world:
|
var context: StringName = _objects_here_context
|
||||||
_selection.select_only(node)
|
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion() or node.get_parent() != _world or not _objects_here_candidates.has(node):
|
||||||
if _use_target_active and _use_target_step == 0 and node is PropBlock:
|
_modal_views.dismiss_objects_here()
|
||||||
call_deferred("_select_use_target_prop", node, get_viewport().get_mouse_position())
|
return
|
||||||
|
var still_hit: bool = _selection.hit_test_all(_objects_here_hit_position).has(node)
|
||||||
|
if not still_hit:
|
||||||
|
_modal_views.dismiss_objects_here()
|
||||||
|
return
|
||||||
|
match context:
|
||||||
|
&"edit_select":
|
||||||
|
_selection.select_only(node)
|
||||||
|
_show_toast("Drag to move · Click to choose")
|
||||||
|
&"scenery_select":
|
||||||
|
_selection.select_only(node)
|
||||||
|
&"direct_use_prop":
|
||||||
|
if current_mode == StageMode.DIRECT and _use_target_active and _use_target_step == 0 and node is PropBlock:
|
||||||
|
_select_use_target_prop(node as PropBlock, get_viewport().get_mouse_position())
|
||||||
|
else:
|
||||||
|
_modal_views.dismiss_objects_here()
|
||||||
|
_:
|
||||||
|
_modal_views.dismiss_objects_here()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_objects_here_candidate_preview_changed(node: Node2D) -> void:
|
||||||
|
if node != null and (not is_instance_valid(node) or not _objects_here_candidates.has(node)):
|
||||||
|
node = null
|
||||||
|
_gizmos.set_candidate_preview(node)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_objects_here_dismissed() -> void:
|
||||||
|
_gizmos.set_candidate_preview(null)
|
||||||
|
_gizmos.set_candidate_preview_obstacles([])
|
||||||
|
_objects_here_candidates.clear()
|
||||||
|
_objects_here_context = &""
|
||||||
|
_objects_here_hit_position = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _dismiss_active_objects_here() -> void:
|
||||||
|
if _modal_views == null:
|
||||||
|
return
|
||||||
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||||
|
_modal_views.dismiss_objects_here()
|
||||||
|
else:
|
||||||
|
_on_objects_here_dismissed()
|
||||||
|
|
||||||
|
|
||||||
func _select_use_target_prop(prop: PropBlock, _screen_position: Vector2) -> void:
|
func _select_use_target_prop(prop: PropBlock, _screen_position: Vector2) -> void:
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
class_name StageCharacterSettingsView
|
||||||
|
extends Control
|
||||||
|
## Presentation-only selected-character name card for the Sandbox Stage.
|
||||||
|
|
||||||
|
const STAGE_INSPECTOR_PANEL := preload("res://scripts/shared/stage_ui/stage_inspector_panel.gd")
|
||||||
|
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
|
||||||
|
signal rename_requested(node: Node2D, requested_name: String)
|
||||||
|
|
||||||
|
var _stage_theme: SandboxTheme
|
||||||
|
var _card: StageInspectorPanel
|
||||||
|
var _name_section: VBoxContainer
|
||||||
|
var _name_edit: LineEdit
|
||||||
|
var _done_button: Button
|
||||||
|
var _cancel_button: Button
|
||||||
|
var _feedback: Label
|
||||||
|
var _character: StickmanRig
|
||||||
|
var _active_selection_key: String = ""
|
||||||
|
var _editing: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func configure(stage_theme: SandboxTheme) -> void:
|
||||||
|
_stage_theme = stage_theme
|
||||||
|
|
||||||
|
|
||||||
|
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_character(rig: StickmanRig, safe_rect: Rect2) -> void:
|
||||||
|
if rig == null or not is_instance_valid(rig):
|
||||||
|
hide_card()
|
||||||
|
return
|
||||||
|
var selection_key: String = str(rig.get_instance_id())
|
||||||
|
if selection_key != _active_selection_key:
|
||||||
|
_active_selection_key = selection_key
|
||||||
|
_editing = false
|
||||||
|
_feedback.visible = false
|
||||||
|
_character = rig
|
||||||
|
_name_edit.text = rig.stage_name
|
||||||
|
_name_section.visible = false
|
||||||
|
_card.set_body_visible(false)
|
||||||
|
_card.visible = true
|
||||||
|
_character = rig
|
||||||
|
_card.set_safe_rect(safe_rect)
|
||||||
|
_card.set_header(rig.stage_name, "🧍")
|
||||||
|
_card.set_rename_enabled(true)
|
||||||
|
_card.set_close_visible(false)
|
||||||
|
if not _editing:
|
||||||
|
_name_edit.text = rig.stage_name
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func show_name_result(result: Dictionary) -> void:
|
||||||
|
if bool(result.get("ok", false)):
|
||||||
|
var accepted_name: String = String(result.get("name", ""))
|
||||||
|
_name_edit.text = accepted_name
|
||||||
|
_feedback.visible = false
|
||||||
|
if _character != null and is_instance_valid(_character):
|
||||||
|
_card.set_header(accepted_name, "🧍")
|
||||||
|
_set_editing(false)
|
||||||
|
else:
|
||||||
|
var reason: StringName = StringName(String(result.get("reason", "")))
|
||||||
|
var suggestion: String = String(result.get("suggestion", ""))
|
||||||
|
if reason == &"blank":
|
||||||
|
_feedback.text = "Give this one a name. Try %s." % suggestion
|
||||||
|
else:
|
||||||
|
_feedback.text = "%s is already here. Try %s." % [String(result.get("name", "")), suggestion]
|
||||||
|
_feedback.visible = true
|
||||||
|
_set_editing(true)
|
||||||
|
_card.request_fit()
|
||||||
|
if not bool(result.get("ok", false)):
|
||||||
|
_name_edit.call_deferred("grab_focus")
|
||||||
|
_name_edit.call_deferred("select_all")
|
||||||
|
|
||||||
|
|
||||||
|
func hide_card(reset_selection: bool = false) -> void:
|
||||||
|
if reset_selection:
|
||||||
|
_active_selection_key = ""
|
||||||
|
_set_editing(false)
|
||||||
|
_feedback.visible = false
|
||||||
|
_character = null
|
||||||
|
if _card != null:
|
||||||
|
_card.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func get_card() -> Control:
|
||||||
|
return _card
|
||||||
|
|
||||||
|
|
||||||
|
func focus_card() -> void:
|
||||||
|
if _card != null and _card.visible:
|
||||||
|
_card.focus_first_control()
|
||||||
|
|
||||||
|
|
||||||
|
func update_layout(safe_rect: Rect2) -> void:
|
||||||
|
if _card != null:
|
||||||
|
_card.set_safe_rect(safe_rect)
|
||||||
|
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
_card = STAGE_INSPECTOR_PANEL.new() as StageInspectorPanel
|
||||||
|
_card.name = "CharacterSettingsCard"
|
||||||
|
_card.visible = false
|
||||||
|
add_child(_card)
|
||||||
|
_card.apply_theme(_stage_theme)
|
||||||
|
_card.set_rename_enabled(true)
|
||||||
|
_card.set_close_visible(false)
|
||||||
|
_card.set_body_visible(false)
|
||||||
|
_card.set_title_single_line(true)
|
||||||
|
_card.rename_requested.connect(_start_editing)
|
||||||
|
var body: VBoxContainer = _card.body()
|
||||||
|
body.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
|
||||||
|
_name_section = VBoxContainer.new()
|
||||||
|
_name_section.name = "StageNameEditor"
|
||||||
|
_name_section.add_theme_constant_override("separation", 6)
|
||||||
|
_name_section.visible = false
|
||||||
|
body.add_child(_name_section)
|
||||||
|
_name_edit = LineEdit.new()
|
||||||
|
_name_edit.name = "StageNameEdit"
|
||||||
|
_name_edit.placeholder_text = "Character name"
|
||||||
|
_name_edit.custom_minimum_size.y = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
|
||||||
|
_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_name_edit.visible = false
|
||||||
|
_name_edit.text_submitted.connect(func(_value: String) -> void: _submit_name())
|
||||||
|
_name_edit.gui_input.connect(_on_name_edit_input)
|
||||||
|
_apply_font(_name_edit)
|
||||||
|
_name_section.add_child(_name_edit)
|
||||||
|
_feedback = Label.new()
|
||||||
|
_feedback.name = "StageNameFeedback"
|
||||||
|
_feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_feedback.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||||
|
_feedback.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
|
||||||
|
_feedback.visible = false
|
||||||
|
_apply_font(_feedback)
|
||||||
|
_name_section.add_child(_feedback)
|
||||||
|
var actions: HBoxContainer = HBoxContainer.new()
|
||||||
|
actions.add_theme_constant_override("separation", 8)
|
||||||
|
_name_section.add_child(actions)
|
||||||
|
_done_button = Button.new()
|
||||||
|
_done_button.name = "DoneStageName"
|
||||||
|
_done_button.text = "Done"
|
||||||
|
_done_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||||
|
_done_button.visible = false
|
||||||
|
_done_button.pressed.connect(_submit_name)
|
||||||
|
_card.style_primary_action_button(_done_button)
|
||||||
|
actions.add_child(_done_button)
|
||||||
|
_cancel_button = Button.new()
|
||||||
|
_cancel_button.name = "CancelStageName"
|
||||||
|
_cancel_button.text = "Cancel"
|
||||||
|
_cancel_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||||
|
_cancel_button.visible = false
|
||||||
|
_cancel_button.pressed.connect(_cancel_editing)
|
||||||
|
_card.style_action_button(_cancel_button, false)
|
||||||
|
actions.add_child(_cancel_button)
|
||||||
|
|
||||||
|
|
||||||
|
func _start_editing() -> void:
|
||||||
|
if _character == null or not is_instance_valid(_character):
|
||||||
|
return
|
||||||
|
_name_edit.text = _character.stage_name
|
||||||
|
_feedback.visible = false
|
||||||
|
_set_editing(true)
|
||||||
|
_name_edit.call_deferred("grab_focus")
|
||||||
|
_name_edit.call_deferred("select_all")
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_editing(editing: bool) -> void:
|
||||||
|
_editing = editing
|
||||||
|
if _name_section == null:
|
||||||
|
return
|
||||||
|
_name_section.visible = editing
|
||||||
|
_card.set_body_visible(editing)
|
||||||
|
_name_edit.visible = editing
|
||||||
|
_done_button.visible = editing
|
||||||
|
_cancel_button.visible = editing
|
||||||
|
|
||||||
|
|
||||||
|
func _submit_name() -> void:
|
||||||
|
if _character != null and is_instance_valid(_character):
|
||||||
|
rename_requested.emit(_character, _name_edit.text)
|
||||||
|
|
||||||
|
|
||||||
|
func _cancel_editing() -> void:
|
||||||
|
if _character != null and is_instance_valid(_character):
|
||||||
|
_name_edit.text = _character.stage_name
|
||||||
|
_feedback.visible = false
|
||||||
|
_set_editing(false)
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_name_edit_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||||
|
_cancel_editing()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_viewport_resized() -> void:
|
||||||
|
var viewport: Rect2 = get_viewport_rect()
|
||||||
|
var top_offset: float = minf(64.0 + 120.0, maxf(12.0, viewport.size.y - 220.0))
|
||||||
|
var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - 38.0 - 12.0)
|
||||||
|
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
|
||||||
|
update_layout(Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0)))
|
||||||
|
|
||||||
|
|
||||||
|
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 _apply_font(control: Control) -> void:
|
||||||
|
if _stage_theme == null:
|
||||||
|
return
|
||||||
|
var font: Font = _stage_theme.font()
|
||||||
|
if font != null:
|
||||||
|
control.add_theme_font_override("font", font)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://fqbgu2edqtpd
|
||||||
@@ -9,11 +9,13 @@ const SPEECH_COMPOSER_SCENE := preload("res://scenes/editors/stage/speech_compos
|
|||||||
const SPEECH_STYLE_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_style_toolbar.gd")
|
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 SPEECH_SHOW_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_show_toolbar.gd")
|
||||||
const WAYPOINT_CONTEXT_SCRIPT := preload("res://scripts/stage/waypoint_context.gd")
|
const WAYPOINT_CONTEXT_SCRIPT := preload("res://scripts/stage/waypoint_context.gd")
|
||||||
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
|
const MISSING_PROP_PLACEHOLDER_SCRIPT := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
const MISSING_STICKMAN_PLACEHOLDER_SCRIPT := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||||
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||||
|
|
||||||
signal object_selected(node: Node2D)
|
signal object_selected(node: Node2D)
|
||||||
|
signal candidate_preview_changed(node: Node2D)
|
||||||
|
signal objects_here_dismissed
|
||||||
|
|
||||||
var speak_dialog: AcceptDialog
|
var speak_dialog: AcceptDialog
|
||||||
var speak_edit: LineEdit
|
var speak_edit: LineEdit
|
||||||
@@ -41,6 +43,8 @@ var speech_show_toolbar: Control
|
|||||||
var speech_recovery_dialog: ConfirmationDialog
|
var speech_recovery_dialog: ConfirmationDialog
|
||||||
var speech_destination_dialog: ConfirmationDialog
|
var speech_destination_dialog: ConfirmationDialog
|
||||||
var _objects_here_popup: PopupPanel
|
var _objects_here_popup: PopupPanel
|
||||||
|
var _objects_here_guard: Control
|
||||||
|
var _objects_here_preview: Node2D
|
||||||
var _speak_box: VBoxContainer
|
var _speak_box: VBoxContainer
|
||||||
var _sandbox_theme: SandboxTheme
|
var _sandbox_theme: SandboxTheme
|
||||||
var _ui_font: Font
|
var _ui_font: Font
|
||||||
@@ -237,12 +241,43 @@ func show_objects_here(
|
|||||||
candidates: Array[Node2D],
|
candidates: Array[Node2D],
|
||||||
selected_nodes: Array[Node2D],
|
selected_nodes: Array[Node2D],
|
||||||
viewport_size: Vector2,
|
viewport_size: Vector2,
|
||||||
pointer_position: Vector2
|
pointer_position: Vector2,
|
||||||
|
avoid_rects: Array[Rect2] = []
|
||||||
) -> void:
|
) -> void:
|
||||||
if parent == null or candidates.size() < 2:
|
if parent == null or candidates.size() < 2:
|
||||||
return
|
return
|
||||||
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||||
_objects_here_popup.queue_free()
|
_objects_here_popup.hide()
|
||||||
|
var content_inset: int = _objects_here_integer("content_inset", 12, 8, 24)
|
||||||
|
var edge_inset: int = _objects_here_integer("edge_inset", 8, 0, 32)
|
||||||
|
var safe_top: int = _objects_here_integer("safe_top", 120, 0, 240)
|
||||||
|
var safe_bottom: int = _objects_here_integer("safe_bottom", 48, 0, 160)
|
||||||
|
var preferred_width: int = _objects_here_integer("preferred_width", 360, 220, 720)
|
||||||
|
var popup_width: int = mini(preferred_width, maxi(1, int(viewport_size.x) - edge_inset * 2))
|
||||||
|
var content_width: int = maxi(1, popup_width - content_inset * 2)
|
||||||
|
var row_height: int = _objects_here_integer("row_height", 56, 44, 96)
|
||||||
|
var row_gap: int = _objects_here_integer("row_gap", 8, 2, 24)
|
||||||
|
var header_gap: int = _objects_here_integer("header_gap", 8, 4, 24)
|
||||||
|
var footer_gap: int = _objects_here_integer("footer_gap", 8, 4, 24)
|
||||||
|
var cancel_height: int = _objects_here_integer("cancel_height", 48, 44, 96)
|
||||||
|
var heading_font_size: int = _objects_here_integer("heading_font_size", 20, 16, 32)
|
||||||
|
var fixed_content_height: float = float(heading_font_size + header_gap + cancel_height + footer_gap + content_inset * 2)
|
||||||
|
var row_content_height: float = float(candidates.size() * row_height + maxi(0, candidates.size() - 1) * row_gap)
|
||||||
|
var safe_popup_height: float = maxf(180.0, viewport_size.y - float(safe_top + safe_bottom))
|
||||||
|
var minimum_rows_height: float = float(_objects_here_integer("minimum_rows_height", 116, 56, 240))
|
||||||
|
var max_rows_height: float = minf(
|
||||||
|
float(_objects_here_integer("max_rows_height", 320, 116, 640)),
|
||||||
|
maxf(minimum_rows_height, safe_popup_height - fixed_content_height)
|
||||||
|
)
|
||||||
|
var rows_height: float = minf(max_rows_height, row_content_height)
|
||||||
|
var popup_height: float = minf(safe_popup_height, fixed_content_height + rows_height)
|
||||||
|
var guard: Control = Control.new()
|
||||||
|
guard.name = "ObjectsHereInputGuard"
|
||||||
|
guard.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
guard.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
guard.gui_input.connect(_on_objects_here_guard_input.bind(guard))
|
||||||
|
parent.add_child(guard)
|
||||||
|
_objects_here_guard = guard
|
||||||
var popup: PopupPanel = PopupPanel.new()
|
var popup: PopupPanel = PopupPanel.new()
|
||||||
popup.name = "ObjectsHere"
|
popup.name = "ObjectsHere"
|
||||||
popup.exclusive = false
|
popup.exclusive = false
|
||||||
@@ -250,67 +285,334 @@ func show_objects_here(
|
|||||||
popup.unfocusable = false
|
popup.unfocusable = false
|
||||||
if _sandbox_theme != null:
|
if _sandbox_theme != null:
|
||||||
popup.theme = _sandbox_theme.control_theme()
|
popup.theme = _sandbox_theme.control_theme()
|
||||||
|
var shell: StyleBoxFlat = _sandbox_theme.stylebox("popups.anchored_chooser") if _sandbox_theme != null else StyleBoxFlat.new()
|
||||||
|
shell.set_corner_radius_all(_objects_here_integer("corner_radius", 14, 8, 24))
|
||||||
|
shell.set_border_width_all(_objects_here_integer("border_width", 2, 1, 4))
|
||||||
|
shell.bg_color = _objects_here_color("popups.anchored_chooser.bg_color", Color("#202633FA"))
|
||||||
|
shell.border_color = _objects_here_color("popups.anchored_chooser.border_color", Color("#667386"))
|
||||||
|
shell.shadow_size = _objects_here_integer("shadow_size", 6, 0, 24)
|
||||||
|
shell.shadow_color = _objects_here_color("popups.anchored_chooser.shadow_color", Color("#00000080"))
|
||||||
|
shell.shadow_offset = _objects_here_vector2("shadow_offset", Vector2(0.0, 4.0))
|
||||||
|
# The child MarginContainer owns the content inset so the shell adds one clean gutter.
|
||||||
|
shell.content_margin_left = 0.0
|
||||||
|
shell.content_margin_right = 0.0
|
||||||
|
shell.content_margin_top = 0.0
|
||||||
|
shell.content_margin_bottom = 0.0
|
||||||
|
popup.add_theme_stylebox_override("panel", shell)
|
||||||
parent.add_child(popup)
|
parent.add_child(popup)
|
||||||
var margin: MarginContainer = MarginContainer.new()
|
var margin: MarginContainer = MarginContainer.new()
|
||||||
|
margin.name = "ObjectsHereMargin"
|
||||||
for side: String in ["left", "right", "top", "bottom"]:
|
for side: String in ["left", "right", "top", "bottom"]:
|
||||||
margin.add_theme_constant_override("margin_" + side, 8)
|
margin.add_theme_constant_override("margin_" + side, content_inset)
|
||||||
popup.add_child(margin)
|
popup.add_child(margin)
|
||||||
var column: VBoxContainer = VBoxContainer.new()
|
var column: VBoxContainer = VBoxContainer.new()
|
||||||
column.add_theme_constant_override("separation", 4)
|
column.name = "ObjectsHereColumn"
|
||||||
|
column.add_theme_constant_override("separation", 0)
|
||||||
margin.add_child(column)
|
margin.add_child(column)
|
||||||
var heading: Label = Label.new()
|
var heading: Label = Label.new()
|
||||||
heading.text = "Objects here · Choose an object"
|
heading.name = "ObjectsHereHeading"
|
||||||
|
heading.text = "Objects here · Choose one"
|
||||||
if _ui_font != null:
|
if _ui_font != null:
|
||||||
heading.add_theme_font_override("font", _ui_font)
|
heading.add_theme_font_override("font", _ui_font)
|
||||||
|
heading.add_theme_font_size_override("font_size", heading_font_size)
|
||||||
|
heading.add_theme_color_override("font_color", _objects_here_color("global.text_color", Color("#F5F7FA")))
|
||||||
column.add_child(heading)
|
column.add_child(heading)
|
||||||
|
var header_spacer: Control = Control.new()
|
||||||
|
header_spacer.custom_minimum_size.y = float(header_gap)
|
||||||
|
header_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
column.add_child(header_spacer)
|
||||||
var scroll: ScrollContainer = ScrollContainer.new()
|
var scroll: ScrollContainer = ScrollContainer.new()
|
||||||
|
scroll.name = "ObjectsHereScroll"
|
||||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
scroll.custom_minimum_size = Vector2(300.0, minf(360.0, float(candidates.size()) * 48.0))
|
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED if row_content_height <= rows_height else ScrollContainer.SCROLL_MODE_AUTO
|
||||||
|
scroll.custom_minimum_size = Vector2(float(content_width), rows_height)
|
||||||
column.add_child(scroll)
|
column.add_child(scroll)
|
||||||
var rows: VBoxContainer = VBoxContainer.new()
|
var rows: VBoxContainer = VBoxContainer.new()
|
||||||
|
rows.name = "ObjectsHereRows"
|
||||||
rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
rows.add_theme_constant_override("separation", row_gap)
|
||||||
scroll.add_child(rows)
|
scroll.add_child(rows)
|
||||||
for node: Node2D in candidates:
|
for candidate_index: int in candidates.size():
|
||||||
|
var node: Node2D = candidates[candidate_index]
|
||||||
|
if node == null or not is_instance_valid(node):
|
||||||
|
continue
|
||||||
var button: Button = Button.new()
|
var button: Button = Button.new()
|
||||||
var selected_mark: String = "✓ " if selected_nodes.has(node) else ""
|
button.name = "ObjectCandidate_%d" % candidate_index
|
||||||
button.text = selected_mark + _object_here_label(node)
|
button.focus_mode = Control.FOCUS_ALL
|
||||||
button.custom_minimum_size = Vector2(300.0, 44.0)
|
button.custom_minimum_size = Vector2(float(content_width), float(row_height))
|
||||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_style_objects_here_candidate(button, selected_nodes.has(node))
|
||||||
|
var row_content: HBoxContainer = HBoxContainer.new()
|
||||||
|
row_content.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
var row_padding_x: float = float(_objects_here_integer("row_padding_x", 12, 4, 24))
|
||||||
|
var row_padding_y: float = float(_objects_here_integer("row_padding_y", 6, 0, 20))
|
||||||
|
row_content.offset_left = row_padding_x
|
||||||
|
row_content.offset_top = row_padding_y
|
||||||
|
row_content.offset_right = -row_padding_x
|
||||||
|
row_content.offset_bottom = -row_padding_y
|
||||||
|
row_content.add_theme_constant_override("separation", 8)
|
||||||
|
row_content.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
button.add_child(row_content)
|
||||||
|
var name_label: Label = Label.new()
|
||||||
|
name_label.text = _object_here_label(node)
|
||||||
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
if _ui_font != null:
|
||||||
|
name_label.add_theme_font_override("font", _ui_font)
|
||||||
|
name_label.add_theme_font_size_override("font_size", _objects_here_font_size())
|
||||||
|
row_content.add_child(name_label)
|
||||||
|
if selected_nodes.has(node):
|
||||||
|
row_content.add_child(_make_selected_candidate_badge())
|
||||||
button.pressed.connect(_on_objects_here_selected.bind(node))
|
button.pressed.connect(_on_objects_here_selected.bind(node))
|
||||||
|
button.mouse_entered.connect(_set_candidate_preview.bind(node))
|
||||||
|
button.mouse_exited.connect(_clear_row_preview.bind(button, node))
|
||||||
|
button.focus_entered.connect(_set_candidate_preview.bind(node))
|
||||||
|
button.focus_exited.connect(_clear_row_preview.bind(button, node))
|
||||||
rows.add_child(button)
|
rows.add_child(button)
|
||||||
var popup_size: Vector2i = Vector2i(
|
var footer_spacer: Control = Control.new()
|
||||||
mini(360, maxi(260, int(viewport_size.x) - 16)),
|
footer_spacer.custom_minimum_size.y = float(footer_gap)
|
||||||
mini(420, maxi(120, int(viewport_size.y) - 16))
|
footer_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
column.add_child(footer_spacer)
|
||||||
|
var cancel: Button = Button.new()
|
||||||
|
cancel.name = "CancelObjectsHere"
|
||||||
|
cancel.text = "Cancel"
|
||||||
|
cancel.custom_minimum_size = Vector2(
|
||||||
|
minf(float(content_width), float(_objects_here_integer("cancel_width", 120, 88, 240))),
|
||||||
|
float(cancel_height)
|
||||||
)
|
)
|
||||||
|
cancel.size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||||
|
_style_objects_here_cancel(cancel)
|
||||||
|
cancel.pressed.connect(_dismiss_objects_here)
|
||||||
|
column.add_child(cancel)
|
||||||
|
var popup_size: Vector2i = Vector2i(popup_width, int(round(popup_height)))
|
||||||
popup.size = popup_size
|
popup.size = popup_size
|
||||||
var position: Vector2i = Vector2i(pointer_position + Vector2(12.0, 12.0))
|
var position: Vector2i = _objects_here_popup_position(pointer_position, viewport_size, popup_size, avoid_rects, edge_inset, safe_top, safe_bottom)
|
||||||
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
|
_objects_here_popup = popup
|
||||||
popup.popup_hide.connect(_on_objects_here_popup_closed.bind(popup))
|
popup.popup_hide.connect(_on_objects_here_popup_closed.bind(popup))
|
||||||
|
popup.close_requested.connect(_dismiss_objects_here)
|
||||||
|
popup.window_input.connect(_on_objects_here_window_input.bind(popup))
|
||||||
popup.position = position
|
popup.position = position
|
||||||
popup.popup()
|
popup.popup()
|
||||||
|
cancel.call_deferred("grab_focus")
|
||||||
|
|
||||||
|
|
||||||
|
func _objects_here_popup_position(
|
||||||
|
pointer_position: Vector2,
|
||||||
|
viewport_size: Vector2,
|
||||||
|
popup_size: Vector2i,
|
||||||
|
avoid_rects: Array[Rect2],
|
||||||
|
edge_inset: int = 8,
|
||||||
|
safe_top: int = 120,
|
||||||
|
safe_bottom: int = 48
|
||||||
|
) -> Vector2i:
|
||||||
|
const POINTER_GAP: float = 12.0
|
||||||
|
var maximum_x: int = maxi(edge_inset, int(viewport_size.x) - popup_size.x - edge_inset)
|
||||||
|
var minimum_y: int = mini(safe_top, maxi(edge_inset, int(viewport_size.y) - popup_size.y - safe_bottom))
|
||||||
|
var maximum_y: int = maxi(minimum_y, int(viewport_size.y) - popup_size.y - safe_bottom)
|
||||||
|
var candidate_positions: Array[Vector2] = [
|
||||||
|
pointer_position + Vector2(POINTER_GAP, POINTER_GAP),
|
||||||
|
Vector2(pointer_position.x - float(popup_size.x) - POINTER_GAP, pointer_position.y + POINTER_GAP),
|
||||||
|
Vector2(pointer_position.x + POINTER_GAP, pointer_position.y - float(popup_size.y) - POINTER_GAP),
|
||||||
|
pointer_position - Vector2(float(popup_size.x) + POINTER_GAP, float(popup_size.y) + POINTER_GAP),
|
||||||
|
]
|
||||||
|
var x_edges: Array[float] = [float(edge_inset), float(maximum_x), pointer_position.x - float(popup_size.x) - POINTER_GAP, pointer_position.x + POINTER_GAP]
|
||||||
|
var y_edges: Array[float] = [float(minimum_y), float(maximum_y), pointer_position.y - float(popup_size.y) - POINTER_GAP, pointer_position.y + POINTER_GAP]
|
||||||
|
for avoid_rect: Rect2 in avoid_rects:
|
||||||
|
if not avoid_rect.has_area():
|
||||||
|
continue
|
||||||
|
var centered_y: float = pointer_position.y - float(popup_size.y) * 0.5
|
||||||
|
var centered_x: float = pointer_position.x - float(popup_size.x) * 0.5
|
||||||
|
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, centered_y))
|
||||||
|
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, centered_y))
|
||||||
|
candidate_positions.append(Vector2(centered_x, avoid_rect.position.y - float(popup_size.y) - POINTER_GAP))
|
||||||
|
candidate_positions.append(Vector2(centered_x, avoid_rect.end.y + POINTER_GAP))
|
||||||
|
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, safe_top))
|
||||||
|
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, safe_top))
|
||||||
|
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, float(maximum_y)))
|
||||||
|
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, float(maximum_y)))
|
||||||
|
x_edges.append(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP)
|
||||||
|
x_edges.append(avoid_rect.end.x + POINTER_GAP)
|
||||||
|
y_edges.append(avoid_rect.position.y - float(popup_size.y) - POINTER_GAP)
|
||||||
|
y_edges.append(avoid_rect.end.y + POINTER_GAP)
|
||||||
|
for candidate_x: float in x_edges:
|
||||||
|
for candidate_y: float in y_edges:
|
||||||
|
candidate_positions.append(Vector2(candidate_x, candidate_y))
|
||||||
|
var pointer_safety_rect: Rect2 = Rect2(pointer_position - Vector2(28.0, 28.0), Vector2(56.0, 56.0))
|
||||||
|
var best_position: Vector2i = Vector2i(edge_inset, edge_inset)
|
||||||
|
var best_overlap_score: float = INF
|
||||||
|
for candidate: Vector2 in candidate_positions:
|
||||||
|
var clamped_position: Vector2i = Vector2i(
|
||||||
|
clampf(candidate.x, float(edge_inset), float(maximum_x)),
|
||||||
|
clampf(candidate.y, float(minimum_y), float(maximum_y))
|
||||||
|
)
|
||||||
|
var popup_rect: Rect2 = Rect2(Vector2(clamped_position), Vector2(popup_size))
|
||||||
|
var pointer_overlap_area: float = popup_rect.intersection(pointer_safety_rect).get_area()
|
||||||
|
var avoid_overlap_area: float = 0.0
|
||||||
|
for avoid_rect: Rect2 in avoid_rects:
|
||||||
|
if avoid_rect.has_area():
|
||||||
|
avoid_overlap_area += popup_rect.intersection(avoid_rect).get_area()
|
||||||
|
var nearest_point: Vector2 = Vector2(
|
||||||
|
clampf(pointer_position.x, popup_rect.position.x, popup_rect.end.x),
|
||||||
|
clampf(pointer_position.y, popup_rect.position.y, popup_rect.end.y)
|
||||||
|
)
|
||||||
|
var overlap_score: float = pointer_overlap_area * 1000000.0 + avoid_overlap_area * 1000000.0 + nearest_point.distance_to(pointer_position)
|
||||||
|
if overlap_score < best_overlap_score:
|
||||||
|
best_overlap_score = overlap_score
|
||||||
|
best_position = clamped_position
|
||||||
|
return best_position
|
||||||
|
|
||||||
|
|
||||||
|
func _style_objects_here_candidate(button: Button, selected: bool) -> void:
|
||||||
|
if button == null:
|
||||||
|
return
|
||||||
|
var accent: Color = _objects_here_color("accent_color", Color("#22C6FF"))
|
||||||
|
var surface: Color = _objects_here_color("row_surface", Color("#171B24"))
|
||||||
|
var selected_surface: Color = _objects_here_color("selected_surface", Color("#12394A"))
|
||||||
|
var hover_surface: Color = _objects_here_color("hover_surface", Color("#203849"))
|
||||||
|
if _sandbox_theme != null:
|
||||||
|
accent = _sandbox_theme.color("mode_colors.edit", accent)
|
||||||
|
button.add_theme_stylebox_override("normal", _objects_here_button_style(
|
||||||
|
selected_surface if selected else surface,
|
||||||
|
accent if selected else _objects_here_color("row_border_color", Color("#FFFFFF55")),
|
||||||
|
_objects_here_integer("selected_border_width", 2, 1, 4) if selected else _objects_here_integer("row_border_width", 1, 0, 4),
|
||||||
|
_objects_here_integer("row_corner_radius", 12, 4, 24)
|
||||||
|
))
|
||||||
|
button.add_theme_stylebox_override("hover", _objects_here_button_style(hover_surface, accent, _objects_here_integer("hover_border_width", 2, 1, 4), _objects_here_integer("row_corner_radius", 12, 4, 24)))
|
||||||
|
button.add_theme_stylebox_override("pressed", _objects_here_button_style(selected_surface, accent, _objects_here_integer("selected_border_width", 2, 1, 4), _objects_here_integer("row_corner_radius", 12, 4, 24)))
|
||||||
|
button.add_theme_stylebox_override("focus", _objects_here_button_style(Color(0.0, 0.0, 0.0, 0.0), _objects_here_color("focus_color", accent), _objects_here_integer("focus_border_width", 3, 2, 5), _objects_here_integer("row_corner_radius", 12, 4, 24)))
|
||||||
|
var text_color: Color = _objects_here_color("row_text_color", Color.WHITE)
|
||||||
|
button.add_theme_color_override("font_color", text_color)
|
||||||
|
button.add_theme_color_override("font_hover_color", text_color)
|
||||||
|
button.add_theme_color_override("font_pressed_color", text_color)
|
||||||
|
button.add_theme_color_override("font_focus_color", text_color)
|
||||||
|
button.add_theme_font_size_override("font_size", _objects_here_font_size())
|
||||||
|
if _ui_font != null:
|
||||||
|
button.add_theme_font_override("font", _ui_font)
|
||||||
|
|
||||||
|
|
||||||
|
func _objects_here_button_style(background: Color, border: Color, border_width: int, corner_radius: int) -> StyleBoxFlat:
|
||||||
|
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||||
|
style.bg_color = background
|
||||||
|
style.border_color = border
|
||||||
|
style.set_border_width_all(border_width)
|
||||||
|
style.set_corner_radius_all(corner_radius)
|
||||||
|
style.content_margin_left = float(_objects_here_integer("row_padding_x", 12, 4, 24))
|
||||||
|
style.content_margin_right = float(_objects_here_integer("row_padding_x", 12, 4, 24))
|
||||||
|
style.content_margin_top = float(_objects_here_integer("row_padding_y", 6, 0, 20))
|
||||||
|
style.content_margin_bottom = float(_objects_here_integer("row_padding_y", 6, 0, 20))
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
func _style_objects_here_cancel(button: Button) -> void:
|
||||||
|
if button == null:
|
||||||
|
return
|
||||||
|
var normal: Color = _objects_here_color("cancel_surface", Color("#293242"))
|
||||||
|
var hover: Color = _objects_here_color("cancel_hover_surface", Color("#354154"))
|
||||||
|
var border: Color = _objects_here_color("cancel_border_color", Color("#718096"))
|
||||||
|
var text_color: Color = _objects_here_color("cancel_text_color", Color("#F5F7FA"))
|
||||||
|
var radius: int = _objects_here_integer("cancel_corner_radius", 10, 4, 24)
|
||||||
|
var border_width: int = _objects_here_integer("cancel_border_width", 2, 1, 4)
|
||||||
|
button.add_theme_stylebox_override("normal", _objects_here_button_style(normal, border, border_width, radius))
|
||||||
|
button.add_theme_stylebox_override("hover", _objects_here_button_style(hover, _objects_here_color("focus_color", border), border_width, radius))
|
||||||
|
button.add_theme_stylebox_override("pressed", _objects_here_button_style(hover, border, border_width, radius))
|
||||||
|
button.add_theme_stylebox_override("focus", _objects_here_button_style(normal, _objects_here_color("focus_color", border), _objects_here_integer("focus_border_width", 3, 2, 5), radius))
|
||||||
|
button.add_theme_color_override("font_color", text_color)
|
||||||
|
button.add_theme_color_override("font_hover_color", text_color)
|
||||||
|
button.add_theme_color_override("font_pressed_color", text_color)
|
||||||
|
button.add_theme_color_override("font_focus_color", text_color)
|
||||||
|
button.add_theme_font_size_override("font_size", _objects_here_integer("cancel_font_size", 20, 16, 32))
|
||||||
|
if _ui_font != null:
|
||||||
|
button.add_theme_font_override("font", _ui_font)
|
||||||
|
|
||||||
|
|
||||||
|
func _objects_here_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||||
|
if _sandbox_theme == null:
|
||||||
|
return clampi(fallback, minimum, maximum)
|
||||||
|
return _sandbox_theme.integer("popups.anchored_chooser." + key, fallback, minimum, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _objects_here_color(path: String, fallback: Color) -> Color:
|
||||||
|
if _sandbox_theme == null:
|
||||||
|
return fallback
|
||||||
|
var resolved_path: String = path if path.contains(".") else "popups.anchored_chooser." + path
|
||||||
|
return _sandbox_theme.color(resolved_path, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
func _objects_here_vector2(key: String, fallback: Vector2) -> Vector2:
|
||||||
|
if _sandbox_theme == null:
|
||||||
|
return fallback
|
||||||
|
var raw: Variant = _sandbox_theme.value("popups.anchored_chooser." + key, null)
|
||||||
|
if raw is Array and (raw as Array).size() == 2:
|
||||||
|
return Vector2(float((raw as Array)[0]), float((raw as Array)[1]))
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
func _make_selected_candidate_badge() -> PanelContainer:
|
||||||
|
var badge: PanelContainer = PanelContainer.new()
|
||||||
|
badge.name = "SelectedCandidateBadge"
|
||||||
|
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var accent: Color = _objects_here_color("accent_color", Color("#22C6FF"))
|
||||||
|
if _sandbox_theme != null:
|
||||||
|
accent = _sandbox_theme.color("mode_colors.edit", accent)
|
||||||
|
var badge_style: StyleBoxFlat = StyleBoxFlat.new()
|
||||||
|
badge_style.bg_color = _objects_here_color("badge_surface", Color("#12394A"))
|
||||||
|
badge_style.border_color = accent
|
||||||
|
badge_style.set_border_width_all(1)
|
||||||
|
badge_style.set_corner_radius_all(_objects_here_integer("badge_corner_radius", 8, 4, 20))
|
||||||
|
badge_style.content_margin_left = float(_objects_here_integer("badge_padding_x", 8, 2, 20))
|
||||||
|
badge_style.content_margin_right = float(_objects_here_integer("badge_padding_x", 8, 2, 20))
|
||||||
|
badge_style.content_margin_top = float(_objects_here_integer("badge_padding_y", 2, 0, 12))
|
||||||
|
badge_style.content_margin_bottom = float(_objects_here_integer("badge_padding_y", 2, 0, 12))
|
||||||
|
badge.add_theme_stylebox_override("panel", badge_style)
|
||||||
|
var label: Label = Label.new()
|
||||||
|
label.text = "Selected"
|
||||||
|
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
label.add_theme_font_size_override("font_size", _objects_here_integer("badge_font_size", 16, 12, 24))
|
||||||
|
label.add_theme_color_override("font_color", _objects_here_color("badge_text_color", Color.WHITE))
|
||||||
|
if _ui_font != null:
|
||||||
|
label.add_theme_font_override("font", _ui_font)
|
||||||
|
badge.add_child(label)
|
||||||
|
return badge
|
||||||
|
|
||||||
|
|
||||||
|
func _objects_here_font_size() -> int:
|
||||||
|
return _objects_here_integer("row_font_size", 20, 14, 40)
|
||||||
|
|
||||||
|
|
||||||
func get_objects_here_popup() -> PopupPanel:
|
func get_objects_here_popup() -> PopupPanel:
|
||||||
return _objects_here_popup
|
return _objects_here_popup
|
||||||
|
|
||||||
|
|
||||||
|
func get_objects_here_screen_rect() -> Rect2:
|
||||||
|
if _objects_here_popup == null or not is_instance_valid(_objects_here_popup) or not _objects_here_popup.visible:
|
||||||
|
return Rect2()
|
||||||
|
return Rect2(Vector2(_objects_here_popup.position), Vector2(_objects_here_popup.size))
|
||||||
|
|
||||||
|
|
||||||
|
func dismiss_objects_here() -> void:
|
||||||
|
_dismiss_objects_here()
|
||||||
|
|
||||||
|
|
||||||
func _object_here_label(node: Node2D) -> String:
|
func _object_here_label(node: Node2D) -> String:
|
||||||
if node is PropBlock:
|
if node is PropBlock:
|
||||||
var prop: PropBlock = node as PropBlock
|
return "📦 %s" % String(node.get("stage_name"))
|
||||||
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:
|
if node is TerrainBlock:
|
||||||
return "🧱 %s · Terrain" % String(node.get("spawn_id"))
|
return "🧱 %s · Terrain" % String(node.get("spawn_id"))
|
||||||
if node is StickmanRig:
|
if node is StickmanRig:
|
||||||
return "🧍 %s · Stickman" % node.name
|
return "🧍 %s" % String(node.get("stage_name"))
|
||||||
|
if node.get_script() == MISSING_PROP_PLACEHOLDER_SCRIPT:
|
||||||
|
return "📦 %s" % String(node.get("stage_name"))
|
||||||
|
if node.get_script() == MISSING_STICKMAN_PLACEHOLDER_SCRIPT:
|
||||||
|
return "🧍 %s" % String(node.get("stage_name"))
|
||||||
return "🎯 %s" % node.name
|
return "🎯 %s" % node.name
|
||||||
|
|
||||||
|
|
||||||
func _on_objects_here_selected(node: Node2D) -> void:
|
func _on_objects_here_selected(node: Node2D) -> void:
|
||||||
|
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
|
||||||
|
_dismiss_objects_here()
|
||||||
|
return
|
||||||
object_selected.emit(node)
|
object_selected.emit(node)
|
||||||
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||||
_objects_here_popup.hide()
|
_objects_here_popup.hide()
|
||||||
@@ -319,10 +621,57 @@ func _on_objects_here_selected(node: Node2D) -> void:
|
|||||||
func _on_objects_here_popup_closed(popup: PopupPanel) -> void:
|
func _on_objects_here_popup_closed(popup: PopupPanel) -> void:
|
||||||
if _objects_here_popup == popup:
|
if _objects_here_popup == popup:
|
||||||
_objects_here_popup = null
|
_objects_here_popup = null
|
||||||
|
candidate_preview_changed.emit(null)
|
||||||
|
_objects_here_preview = null
|
||||||
|
if _objects_here_guard != null and is_instance_valid(_objects_here_guard):
|
||||||
|
_objects_here_guard.queue_free()
|
||||||
|
_objects_here_guard = null
|
||||||
|
objects_here_dismissed.emit()
|
||||||
if is_instance_valid(popup):
|
if is_instance_valid(popup):
|
||||||
popup.queue_free()
|
popup.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_candidate_preview(node: Node2D) -> void:
|
||||||
|
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
|
||||||
|
return
|
||||||
|
_objects_here_preview = node
|
||||||
|
candidate_preview_changed.emit(node)
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_row_preview(button: Button, node: Node2D) -> void:
|
||||||
|
if button != null and is_instance_valid(button) and button.has_focus():
|
||||||
|
return
|
||||||
|
if _objects_here_preview == node:
|
||||||
|
_objects_here_preview = null
|
||||||
|
candidate_preview_changed.emit(null)
|
||||||
|
|
||||||
|
|
||||||
|
func _dismiss_objects_here() -> void:
|
||||||
|
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
|
||||||
|
_objects_here_popup.hide()
|
||||||
|
else:
|
||||||
|
_on_objects_here_popup_closed(null)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_objects_here_guard_input(event: InputEvent, guard: Control) -> void:
|
||||||
|
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
|
||||||
|
guard.get_viewport().set_input_as_handled()
|
||||||
|
_dismiss_objects_here()
|
||||||
|
elif event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||||
|
guard.get_viewport().set_input_as_handled()
|
||||||
|
_dismiss_objects_here()
|
||||||
|
elif guard != null and is_instance_valid(guard):
|
||||||
|
guard.get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_objects_here_window_input(event: InputEvent, _popup: PopupPanel) -> void:
|
||||||
|
if not (event is InputEventKey) or not (event as InputEventKey).pressed or (event as InputEventKey).keycode != KEY_ESCAPE:
|
||||||
|
return
|
||||||
|
if _objects_here_guard != null and is_instance_valid(_objects_here_guard):
|
||||||
|
_objects_here_guard.get_viewport().set_input_as_handled()
|
||||||
|
_dismiss_objects_here()
|
||||||
|
|
||||||
|
|
||||||
func theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
|
func theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
|
||||||
if _sandbox_theme == null:
|
if _sandbox_theme == null:
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -16,13 +16,11 @@ signal spot_move_requested(spot_id: String)
|
|||||||
signal spot_rename_requested(spot_id: String, name: String)
|
signal spot_rename_requested(spot_id: String, name: String)
|
||||||
signal spot_erase_requested(spot_id: String)
|
signal spot_erase_requested(spot_id: String)
|
||||||
signal spot_selected(spot_id: String)
|
signal spot_selected(spot_id: String)
|
||||||
signal close_requested
|
signal rename_requested(node: Node2D, requested_name: String)
|
||||||
|
|
||||||
var _stage_theme: SandboxTheme
|
var _stage_theme: SandboxTheme
|
||||||
var _top_bar_height: float = 64.0
|
var _top_bar_height: float = 64.0
|
||||||
var _status_bar_height: float = 38.0
|
var _status_bar_height: float = 38.0
|
||||||
var _card: StageInspectorPanel
|
var _card: StageInspectorPanel
|
||||||
var _reopen_button: Button
|
|
||||||
var _fields: VBoxContainer
|
var _fields: VBoxContainer
|
||||||
var _notice: Label
|
var _notice: Label
|
||||||
var _depth_group: StagePanelChoiceGroup
|
var _depth_group: StagePanelChoiceGroup
|
||||||
@@ -32,7 +30,14 @@ var _controls: Dictionary = {}
|
|||||||
var _spot_rows: VBoxContainer
|
var _spot_rows: VBoxContainer
|
||||||
var _spot_empty_label: Label
|
var _spot_empty_label: Label
|
||||||
var _active_selection_key: String = ""
|
var _active_selection_key: String = ""
|
||||||
var _dismissed_selection_key: String = ""
|
var _name_node: Node2D
|
||||||
|
var _name_section: VBoxContainer
|
||||||
|
var _name_edit: LineEdit
|
||||||
|
var _name_done_button: Button
|
||||||
|
var _name_cancel_button: Button
|
||||||
|
var _name_feedback: Label
|
||||||
|
var _name_editing: bool = false
|
||||||
|
var _restore_collapsed_after_edit: bool = false
|
||||||
|
|
||||||
|
|
||||||
func configure(stage_theme: SandboxTheme, top_bar_height: float, status_bar_height: float) -> void:
|
func configure(stage_theme: SandboxTheme, top_bar_height: float, status_bar_height: float) -> void:
|
||||||
@@ -62,27 +67,28 @@ func show_prop(
|
|||||||
hide_card()
|
hide_card()
|
||||||
return
|
return
|
||||||
_update_selection_key(selection)
|
_update_selection_key(selection)
|
||||||
var dismissed: bool = _active_selection_key == _dismissed_selection_key
|
_card.visible = true
|
||||||
_card.visible = not dismissed
|
|
||||||
_reopen_button.visible = dismissed
|
|
||||||
if dismissed:
|
|
||||||
return
|
|
||||||
_card.set_safe_rect(safe_rect)
|
_card.set_safe_rect(safe_rect)
|
||||||
_notice.visible = false
|
_notice.visible = false
|
||||||
_fields.visible = true
|
_fields.visible = true
|
||||||
_card.set_header(title, "📦")
|
_card.set_header(title, "📦")
|
||||||
|
_card.set_rename_enabled(true)
|
||||||
|
if _name_node != prop:
|
||||||
|
_name_node = prop
|
||||||
|
_set_name_editing(false)
|
||||||
|
_name_edit.text = prop.stage_name
|
||||||
|
_name_feedback.visible = false
|
||||||
_sync_roles(prop, roles)
|
_sync_roles(prop, roles)
|
||||||
_sync_spots(prop.get_use_spots(), selected_spot_id)
|
_sync_spots(prop.get_use_spots(), selected_spot_id)
|
||||||
|
|
||||||
|
|
||||||
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
|
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
|
||||||
|
_set_name_editing(false)
|
||||||
|
_name_feedback.visible = false
|
||||||
_update_selection_key(selection)
|
_update_selection_key(selection)
|
||||||
var dismissed: bool = _active_selection_key == _dismissed_selection_key
|
_card.visible = true
|
||||||
_card.visible = not dismissed
|
|
||||||
_reopen_button.visible = dismissed
|
|
||||||
if dismissed:
|
|
||||||
return
|
|
||||||
_card.set_safe_rect(safe_rect)
|
_card.set_safe_rect(safe_rect)
|
||||||
|
_card.set_rename_enabled(false)
|
||||||
_notice.visible = true
|
_notice.visible = true
|
||||||
_fields.visible = false
|
_fields.visible = false
|
||||||
_card.set_header("Props", "📦")
|
_card.set_header("Props", "📦")
|
||||||
@@ -90,43 +96,25 @@ func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func hide_card(reset_selection: bool = false) -> void:
|
func hide_card(reset_selection: bool = false) -> void:
|
||||||
|
if reset_selection:
|
||||||
|
_set_name_editing(false)
|
||||||
|
_name_feedback.visible = false
|
||||||
if reset_selection and not _active_selection_key.is_empty():
|
if reset_selection and not _active_selection_key.is_empty():
|
||||||
_update_selection_key([])
|
_update_selection_key([])
|
||||||
if _card != null:
|
if _card != null:
|
||||||
_card.visible = false
|
_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:
|
func update_layout(safe_rect: Rect2) -> void:
|
||||||
if _card == null:
|
if _card == null:
|
||||||
return
|
return
|
||||||
_card.set_safe_rect(safe_rect)
|
_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:
|
func get_card() -> Control:
|
||||||
return _card
|
return _card
|
||||||
|
|
||||||
|
|
||||||
func get_reopen_button() -> Button:
|
|
||||||
return _reopen_button
|
|
||||||
|
|
||||||
|
|
||||||
func get_fields() -> VBoxContainer:
|
func get_fields() -> VBoxContainer:
|
||||||
return _fields
|
return _fields
|
||||||
|
|
||||||
@@ -151,10 +139,6 @@ func get_active_selection_key() -> String:
|
|||||||
return _active_selection_key
|
return _active_selection_key
|
||||||
|
|
||||||
|
|
||||||
func get_dismissed_selection_key() -> String:
|
|
||||||
return _dismissed_selection_key
|
|
||||||
|
|
||||||
|
|
||||||
func focus_card() -> void:
|
func focus_card() -> void:
|
||||||
_focus_card()
|
_focus_card()
|
||||||
|
|
||||||
@@ -165,12 +149,15 @@ func _build() -> void:
|
|||||||
_card.visible = false
|
_card.visible = false
|
||||||
add_child(_card)
|
add_child(_card)
|
||||||
_card.apply_theme(_stage_theme)
|
_card.apply_theme(_stage_theme)
|
||||||
_card.close_requested.connect(_on_card_close_requested)
|
_card.set_collapsible(true)
|
||||||
|
_card.set_rename_enabled(false)
|
||||||
|
_card.rename_requested.connect(_start_name_edit)
|
||||||
|
_card.collapsed_changed.connect(_on_card_collapsed_changed)
|
||||||
var body: VBoxContainer = _card.body()
|
var body: VBoxContainer = _card.body()
|
||||||
body.add_theme_constant_override("separation", 10)
|
body.add_theme_constant_override("separation", 10)
|
||||||
|
|
||||||
_notice = Label.new()
|
_notice = Label.new()
|
||||||
_notice.text = "Select one prop to change its settings."
|
_notice.text = "Select one character or prop to change its name."
|
||||||
_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
_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_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.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
|
||||||
@@ -182,6 +169,7 @@ func _build() -> void:
|
|||||||
_fields.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
|
_fields.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
|
||||||
_fields.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_fields.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
body.add_child(_fields)
|
body.add_child(_fields)
|
||||||
|
_build_name_section(_fields)
|
||||||
_add_heading(_fields, "Where it appears")
|
_add_heading(_fields, "Where it appears")
|
||||||
_depth_group = _make_choice_group("depth")
|
_depth_group = _make_choice_group("depth")
|
||||||
_fields.add_child(_depth_group)
|
_fields.add_child(_depth_group)
|
||||||
@@ -198,15 +186,114 @@ func _build() -> void:
|
|||||||
_add_wobble_section()
|
_add_wobble_section()
|
||||||
_add_use_spots_section()
|
_add_use_spots_section()
|
||||||
|
|
||||||
_reopen_button = Button.new()
|
func _build_name_section(parent: VBoxContainer) -> void:
|
||||||
_reopen_button.name = "ReopenPropSettings"
|
_name_section = VBoxContainer.new()
|
||||||
_reopen_button.text = "Settings"
|
_name_section.name = "StageNameEditor"
|
||||||
_reopen_button.custom_minimum_size = Vector2(148.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
_name_section.add_theme_constant_override("separation", 6)
|
||||||
_reopen_button.focus_mode = Control.FOCUS_ALL
|
_name_section.visible = false
|
||||||
_reopen_button.visible = false
|
parent.add_child(_name_section)
|
||||||
_reopen_button.pressed.connect(_on_reopen_pressed)
|
_name_edit = LineEdit.new()
|
||||||
_card.style_action_button(_reopen_button, true)
|
_name_edit.name = "StageNameEdit"
|
||||||
add_child(_reopen_button)
|
_name_edit.placeholder_text = "Character or prop name"
|
||||||
|
_name_edit.custom_minimum_size.y = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
|
||||||
|
_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_name_edit.visible = false
|
||||||
|
_name_edit.text_submitted.connect(func(_value: String) -> void: _submit_name_edit())
|
||||||
|
_name_edit.gui_input.connect(_on_name_edit_gui_input)
|
||||||
|
_apply_ui_font(_name_edit)
|
||||||
|
_name_section.add_child(_name_edit)
|
||||||
|
_name_feedback = Label.new()
|
||||||
|
_name_feedback.name = "StageNameFeedback"
|
||||||
|
_name_feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_name_feedback.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||||
|
_name_feedback.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
|
||||||
|
_name_feedback.visible = false
|
||||||
|
_apply_ui_font(_name_feedback)
|
||||||
|
_name_section.add_child(_name_feedback)
|
||||||
|
var actions: HBoxContainer = HBoxContainer.new()
|
||||||
|
actions.add_theme_constant_override("separation", 8)
|
||||||
|
_name_section.add_child(actions)
|
||||||
|
_name_done_button = Button.new()
|
||||||
|
_name_done_button.name = "DoneStageName"
|
||||||
|
_name_done_button.text = "Done"
|
||||||
|
_name_done_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||||
|
_name_done_button.visible = false
|
||||||
|
_name_done_button.pressed.connect(_submit_name_edit)
|
||||||
|
_card.style_primary_action_button(_name_done_button)
|
||||||
|
actions.add_child(_name_done_button)
|
||||||
|
_name_cancel_button = Button.new()
|
||||||
|
_name_cancel_button.name = "CancelStageName"
|
||||||
|
_name_cancel_button.text = "Cancel"
|
||||||
|
_name_cancel_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||||
|
_name_cancel_button.visible = false
|
||||||
|
_name_cancel_button.pressed.connect(_cancel_name_edit)
|
||||||
|
_card.style_action_button(_name_cancel_button, false)
|
||||||
|
actions.add_child(_name_cancel_button)
|
||||||
|
|
||||||
|
|
||||||
|
func show_name_result(result: Dictionary) -> void:
|
||||||
|
if bool(result.get("ok", false)):
|
||||||
|
_name_edit.text = String(result.get("name", ""))
|
||||||
|
_name_feedback.visible = false
|
||||||
|
_set_name_editing(false)
|
||||||
|
else:
|
||||||
|
var reason: StringName = StringName(String(result.get("reason", "")))
|
||||||
|
var suggestion: String = String(result.get("suggestion", ""))
|
||||||
|
if reason == &"blank":
|
||||||
|
_name_feedback.text = "Give this one a name. Try %s." % suggestion
|
||||||
|
else:
|
||||||
|
_name_feedback.text = "%s is already here. Try %s." % [String(result.get("name", "")), suggestion]
|
||||||
|
_name_feedback.visible = true
|
||||||
|
_card.request_fit()
|
||||||
|
if not bool(result.get("ok", false)):
|
||||||
|
_name_edit.call_deferred("grab_focus")
|
||||||
|
_name_edit.call_deferred("select_all")
|
||||||
|
|
||||||
|
|
||||||
|
func _start_name_edit() -> void:
|
||||||
|
if _name_node == null or not is_instance_valid(_name_node):
|
||||||
|
return
|
||||||
|
_restore_collapsed_after_edit = _card.is_collapsed()
|
||||||
|
_card.set_collapsed(false)
|
||||||
|
_name_edit.text = String(_name_node.get("stage_name"))
|
||||||
|
_name_feedback.visible = false
|
||||||
|
_set_name_editing(true)
|
||||||
|
_name_edit.call_deferred("grab_focus")
|
||||||
|
_name_edit.call_deferred("select_all")
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_name_editing(editing: bool) -> void:
|
||||||
|
_name_editing = editing
|
||||||
|
if _name_section == null:
|
||||||
|
return
|
||||||
|
_name_section.visible = editing
|
||||||
|
_name_edit.visible = editing
|
||||||
|
_name_done_button.visible = editing
|
||||||
|
_name_cancel_button.visible = editing
|
||||||
|
if not editing and _restore_collapsed_after_edit and _card != null:
|
||||||
|
_restore_collapsed_after_edit = false
|
||||||
|
_card.set_collapsed(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _submit_name_edit() -> void:
|
||||||
|
if _name_node == null or not is_instance_valid(_name_node):
|
||||||
|
return
|
||||||
|
rename_requested.emit(_name_node, _name_edit.text)
|
||||||
|
|
||||||
|
|
||||||
|
func _cancel_name_edit() -> void:
|
||||||
|
if _name_node != null and is_instance_valid(_name_node):
|
||||||
|
_name_edit.text = String(_name_node.get("stage_name"))
|
||||||
|
_name_feedback.visible = false
|
||||||
|
_set_name_editing(false)
|
||||||
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_name_edit_gui_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||||
|
_cancel_name_edit()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
func _add_use_spots_section() -> void:
|
func _add_use_spots_section() -> void:
|
||||||
@@ -449,7 +536,12 @@ func _update_selection_key(selection: Array[Node2D]) -> void:
|
|||||||
if selection_key == _active_selection_key:
|
if selection_key == _active_selection_key:
|
||||||
return
|
return
|
||||||
_active_selection_key = selection_key
|
_active_selection_key = selection_key
|
||||||
_dismissed_selection_key = ""
|
if _name_editing:
|
||||||
|
_name_feedback.visible = false
|
||||||
|
_restore_collapsed_after_edit = false
|
||||||
|
_set_name_editing(false)
|
||||||
|
if _card != null:
|
||||||
|
_card.set_collapsed(false, false)
|
||||||
_card.reset_scroll()
|
_card.reset_scroll()
|
||||||
|
|
||||||
|
|
||||||
@@ -481,17 +573,11 @@ func _on_row_height_changed() -> void:
|
|||||||
_card.request_fit()
|
_card.request_fit()
|
||||||
|
|
||||||
|
|
||||||
func _on_card_close_requested() -> void:
|
func _on_card_collapsed_changed(collapsed: bool) -> void:
|
||||||
close_requested.emit()
|
if collapsed and _name_editing:
|
||||||
|
_name_feedback.visible = false
|
||||||
|
_restore_collapsed_after_edit = false
|
||||||
func _on_reopen_pressed() -> void:
|
_set_name_editing(false)
|
||||||
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:
|
func _focus_card() -> void:
|
||||||
@@ -502,7 +588,7 @@ func _focus_card() -> void:
|
|||||||
func _on_viewport_resized() -> void:
|
func _on_viewport_resized() -> void:
|
||||||
var viewport: Rect2 = get_viewport_rect()
|
var viewport: Rect2 = get_viewport_rect()
|
||||||
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
|
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 top_offset: float = minf(_top_bar_height + 120.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 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))
|
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)
|
update_layout(safe_rect)
|
||||||
|
|||||||
@@ -87,10 +87,12 @@ func collect(
|
|||||||
entry["rotation"] = rotation_degrees
|
entry["rotation"] = rotation_degrees
|
||||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
|
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
|
||||||
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
|
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
|
||||||
|
entry["stage_name"] = String(stage_node.get("stage_name"))
|
||||||
elif type == "prop":
|
elif type == "prop":
|
||||||
var prop: PropBlock = stage_node as PropBlock
|
var prop: PropBlock = stage_node as PropBlock
|
||||||
entry["asset_id"] = prop.spawn_id
|
entry["asset_id"] = prop.spawn_id
|
||||||
entry["stage_object_id"] = prop.stage_object_id
|
entry["stage_object_id"] = prop.stage_object_id
|
||||||
|
entry["stage_name"] = prop.stage_name
|
||||||
entry["roles"] = prop.stage_roles.duplicate(true)
|
entry["roles"] = prop.stage_roles.duplicate(true)
|
||||||
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
|
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
|
||||||
entry["next_use_spot_number"] = prop.next_use_spot_number
|
entry["next_use_spot_number"] = prop.next_use_spot_number
|
||||||
@@ -109,9 +111,13 @@ func collect(
|
|||||||
entry["type"] = "stickman"
|
entry["type"] = "stickman"
|
||||||
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
|
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
|
||||||
entry["rotation"] = rotation_degrees
|
entry["rotation"] = rotation_degrees
|
||||||
|
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
|
||||||
|
entry["stage_name"] = String(stage_node.get("stage_name"))
|
||||||
else:
|
else:
|
||||||
var rig: StickmanRig = stage_node as StickmanRig
|
var rig: StickmanRig = stage_node as StickmanRig
|
||||||
entry["stk_path"] = rig.source_stk_path
|
entry["stk_path"] = rig.source_stk_path
|
||||||
|
entry["stage_object_id"] = rig.stage_object_id
|
||||||
|
entry["stage_name"] = rig.stage_name
|
||||||
var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile()))
|
var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile()))
|
||||||
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile]
|
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile]
|
||||||
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
|
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
|
||||||
@@ -248,6 +254,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
|
|||||||
if node is PropBlock:
|
if node is PropBlock:
|
||||||
var prop: PropBlock = node as PropBlock
|
var prop: PropBlock = node as PropBlock
|
||||||
prop.stage_object_id = String(entry.get("stage_object_id", ""))
|
prop.stage_object_id = String(entry.get("stage_object_id", ""))
|
||||||
|
prop.stage_name = String(entry.get("stage_name", ""))
|
||||||
prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", false)))
|
prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", false)))
|
||||||
var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array)
|
var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array)
|
||||||
prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1)))
|
prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1)))
|
||||||
@@ -270,6 +277,9 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
|
|||||||
else:
|
else:
|
||||||
warnings.append("Invalid prop material preset")
|
warnings.append("Invalid prop material preset")
|
||||||
if node is StickmanRig:
|
if node is StickmanRig:
|
||||||
|
var rig: StickmanRig = node as StickmanRig
|
||||||
|
rig.stage_object_id = String(entry.get("stage_object_id", ""))
|
||||||
|
rig.stage_name = String(entry.get("stage_name", ""))
|
||||||
var facing_names: Array = StickmanRig.FacingProfile.keys()
|
var facing_names: Array = StickmanRig.FacingProfile.keys()
|
||||||
var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD")))
|
var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD")))
|
||||||
if facing_index >= 0:
|
if facing_index >= 0:
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ extends Node2D
|
|||||||
var original_entry: Dictionary = {}
|
var original_entry: Dictionary = {}
|
||||||
var missing_prop_path: String = ""
|
var missing_prop_path: String = ""
|
||||||
var stage_object_id: String = ""
|
var stage_object_id: String = ""
|
||||||
|
var stage_name: String = ""
|
||||||
const MARKER_SIZE := Vector2(180.0, 54.0)
|
const MARKER_SIZE := Vector2(180.0, 54.0)
|
||||||
|
|
||||||
func configure(entry: Dictionary) -> void:
|
func configure(entry: Dictionary) -> void:
|
||||||
original_entry = entry.duplicate(true)
|
original_entry = entry.duplicate(true)
|
||||||
missing_prop_path = String(entry.get("asset_id", ""))
|
missing_prop_path = String(entry.get("asset_id", ""))
|
||||||
stage_object_id = String(entry.get("stage_object_id", ""))
|
stage_object_id = String(entry.get("stage_object_id", ""))
|
||||||
|
stage_name = String(entry.get("stage_name", ""))
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
func get_area_rect() -> Rect2:
|
func get_area_rect() -> Rect2:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ var spawn_id: String = ""
|
|||||||
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
|
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
|
||||||
## spawn_id, this value is unique per instance and survives scene save/load.
|
## spawn_id, this value is unique per instance and survives scene save/load.
|
||||||
var stage_object_id: String = ""
|
var stage_object_id: String = ""
|
||||||
|
var stage_name: String = ""
|
||||||
var interaction_anchors: Dictionary = {}
|
var interaction_anchors: Dictionary = {}
|
||||||
var interaction_reserved_by: int = 0
|
var interaction_reserved_by: int = 0
|
||||||
var interaction_owner_id: int = 0
|
var interaction_owner_id: int = 0
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ extends PanelContainer
|
|||||||
## Reusable shell for an in-stage selection inspector.
|
## Reusable shell for an in-stage selection inspector.
|
||||||
|
|
||||||
signal close_requested
|
signal close_requested
|
||||||
|
signal rename_requested
|
||||||
|
signal collapsed_changed(collapsed: bool)
|
||||||
|
|
||||||
var _stage_theme: SandboxTheme = null
|
var _stage_theme: SandboxTheme = null
|
||||||
var _safe_rect := Rect2()
|
var _safe_rect := Rect2()
|
||||||
@@ -12,9 +14,16 @@ var _layout: VBoxContainer
|
|||||||
var _header: HBoxContainer
|
var _header: HBoxContainer
|
||||||
var _icon: Label
|
var _icon: Label
|
||||||
var _title: Label
|
var _title: Label
|
||||||
|
var _rename_button: Button
|
||||||
var _close_button: Button
|
var _close_button: Button
|
||||||
var _scroll: ScrollContainer
|
var _scroll: ScrollContainer
|
||||||
var _body: VBoxContainer
|
var _body: VBoxContainer
|
||||||
|
var _collapsible: bool = false
|
||||||
|
var _collapsed: bool = false
|
||||||
|
var _close_visible: bool = true
|
||||||
|
var _rename_enabled: bool = false
|
||||||
|
var _body_visible: bool = true
|
||||||
|
var _single_line_title: bool = false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -29,8 +38,13 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||||||
return
|
return
|
||||||
var key_event := event as InputEventKey
|
var key_event := event as InputEventKey
|
||||||
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_ESCAPE:
|
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_ESCAPE:
|
||||||
close_requested.emit()
|
if _collapsible:
|
||||||
get_viewport().set_input_as_handled()
|
if not _collapsed:
|
||||||
|
set_collapsed(true)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
elif _close_visible:
|
||||||
|
close_requested.emit()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
func apply_theme(stage_theme: SandboxTheme) -> void:
|
func apply_theme(stage_theme: SandboxTheme) -> void:
|
||||||
@@ -48,8 +62,9 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
|
|||||||
_margin.add_theme_constant_override("margin_" + side, padding)
|
_margin.add_theme_constant_override("margin_" + side, padding)
|
||||||
_layout.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
|
_layout.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
|
||||||
_header.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
|
_header.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
|
||||||
|
_rename_button.custom_minimum_size = Vector2(92.0, float(_integer("stage_panels.action.min_target_size", 48, 44, 96)))
|
||||||
_close_button.custom_minimum_size = Vector2(
|
_close_button.custom_minimum_size = Vector2(
|
||||||
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
|
float(_integer("stage_panels.action.close_width", 88, 72, 180)) if not _collapsible else float(_integer("stage_panels.action.min_target_size", 48, 44, 96)),
|
||||||
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
|
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
|
||||||
)
|
)
|
||||||
_title.add_theme_font_size_override("font_size", _integer("stage_panels.type.title_size", 24, 12, 48))
|
_title.add_theme_font_size_override("font_size", _integer("stage_panels.type.title_size", 24, 12, 48))
|
||||||
@@ -58,11 +73,14 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
|
|||||||
var ui_font := stage_theme.font()
|
var ui_font := stage_theme.font()
|
||||||
if ui_font != null:
|
if ui_font != null:
|
||||||
_title.add_theme_font_override("font", ui_font)
|
_title.add_theme_font_override("font", ui_font)
|
||||||
|
_rename_button.add_theme_font_override("font", ui_font)
|
||||||
_close_button.add_theme_font_override("font", ui_font)
|
_close_button.add_theme_font_override("font", ui_font)
|
||||||
var emoji_font := stage_theme.emoji_font()
|
var emoji_font := stage_theme.emoji_font()
|
||||||
if emoji_font != null:
|
if emoji_font != null:
|
||||||
_icon.add_theme_font_override("font", emoji_font)
|
_icon.add_theme_font_override("font", emoji_font)
|
||||||
_close_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
|
_close_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
|
||||||
|
_rename_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
|
||||||
|
_style_action_button(_rename_button, false)
|
||||||
_style_action_button(_close_button, false)
|
_style_action_button(_close_button, false)
|
||||||
request_fit()
|
request_fit()
|
||||||
|
|
||||||
@@ -77,6 +95,74 @@ func set_header(title: String, icon_text: String = "") -> void:
|
|||||||
request_fit()
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func set_title_single_line(enabled: bool) -> void:
|
||||||
|
_single_line_title = enabled
|
||||||
|
_apply_title_layout()
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func set_rename_enabled(enabled: bool) -> void:
|
||||||
|
_rename_enabled = enabled
|
||||||
|
if _rename_button == null:
|
||||||
|
return
|
||||||
|
_rename_button.visible = enabled
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func set_collapsible(enabled: bool, collapsed: bool = false) -> void:
|
||||||
|
_collapsible = enabled
|
||||||
|
if _close_button == null:
|
||||||
|
_collapsed = collapsed if enabled else false
|
||||||
|
return
|
||||||
|
_close_button.visible = enabled or _close_visible
|
||||||
|
_close_button.custom_minimum_size.x = float(_integer("stage_panels.action.close_width", 88, 72, 180)) if not enabled else float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
|
||||||
|
if enabled:
|
||||||
|
set_collapsed(collapsed, false)
|
||||||
|
else:
|
||||||
|
_collapsed = false
|
||||||
|
_close_button.text = "Close"
|
||||||
|
_close_button.tooltip_text = "Close inspector"
|
||||||
|
if _scroll != null:
|
||||||
|
_scroll.visible = _body_visible
|
||||||
|
_apply_title_layout()
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func set_close_visible(enabled: bool) -> void:
|
||||||
|
_close_visible = enabled
|
||||||
|
if _close_button == null:
|
||||||
|
return
|
||||||
|
_close_button.visible = _collapsible or enabled
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func set_body_visible(enabled: bool) -> void:
|
||||||
|
_body_visible = enabled
|
||||||
|
if _scroll != null:
|
||||||
|
_scroll.visible = enabled and not _collapsed
|
||||||
|
request_fit()
|
||||||
|
|
||||||
|
|
||||||
|
func set_collapsed(collapsed: bool, notify: bool = true) -> void:
|
||||||
|
if not _collapsible:
|
||||||
|
return
|
||||||
|
var changed: bool = _collapsed != collapsed
|
||||||
|
_collapsed = collapsed
|
||||||
|
if _scroll != null:
|
||||||
|
_scroll.visible = not collapsed and _body_visible
|
||||||
|
if _close_button != null:
|
||||||
|
_close_button.text = "▾" if collapsed else "▴"
|
||||||
|
_close_button.tooltip_text = "Expand settings" if collapsed else "Collapse settings"
|
||||||
|
_apply_title_layout()
|
||||||
|
request_fit()
|
||||||
|
if changed and notify:
|
||||||
|
collapsed_changed.emit(collapsed)
|
||||||
|
|
||||||
|
|
||||||
|
func is_collapsed() -> bool:
|
||||||
|
return _collapsed
|
||||||
|
|
||||||
|
|
||||||
func body() -> VBoxContainer:
|
func body() -> VBoxContainer:
|
||||||
return _body
|
return _body
|
||||||
|
|
||||||
@@ -109,7 +195,10 @@ func focus_first_control() -> void:
|
|||||||
continue
|
continue
|
||||||
control.grab_focus()
|
control.grab_focus()
|
||||||
return
|
return
|
||||||
_close_button.grab_focus()
|
if _rename_button.visible:
|
||||||
|
_rename_button.grab_focus()
|
||||||
|
elif _close_button.visible:
|
||||||
|
_close_button.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func style_action_button(button: Button, prominent: bool = false) -> void:
|
func style_action_button(button: Button, prominent: bool = false) -> void:
|
||||||
@@ -184,27 +273,43 @@ func _build_shell() -> void:
|
|||||||
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
_header.add_child(_icon)
|
_header.add_child(_icon)
|
||||||
_title = Label.new()
|
_title = Label.new()
|
||||||
|
_title.name = "InspectorTitle"
|
||||||
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
_title.max_lines_visible = 2
|
_title.max_lines_visible = 2
|
||||||
|
_title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
_header.add_child(_title)
|
_header.add_child(_title)
|
||||||
|
_apply_title_layout()
|
||||||
|
_rename_button = Button.new()
|
||||||
|
_rename_button.name = "RenameStageObject"
|
||||||
|
_rename_button.text = "Rename"
|
||||||
|
_rename_button.custom_minimum_size = Vector2(92.0, float(_integer("stage_panels.action.min_target_size", 48, 44, 96)))
|
||||||
|
_rename_button.visible = false
|
||||||
|
_rename_button.pressed.connect(func() -> void: rename_requested.emit())
|
||||||
|
_rename_button.visible = _rename_enabled
|
||||||
|
_header.add_child(_rename_button)
|
||||||
_close_button = Button.new()
|
_close_button = Button.new()
|
||||||
_close_button.text = "Close"
|
_close_button.name = "CollapseStageInspector"
|
||||||
|
_close_button.text = "▾" if _collapsible and _collapsed else "▴" if _collapsible else "Close"
|
||||||
|
_close_button.tooltip_text = "Expand settings" if _collapsible and _collapsed else "Collapse settings" if _collapsible else "Close inspector"
|
||||||
|
_close_button.visible = _collapsible or _close_visible
|
||||||
_close_button.custom_minimum_size = Vector2(
|
_close_button.custom_minimum_size = Vector2(
|
||||||
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
|
float(_integer("stage_panels.action.close_width", 88, 72, 180)) if not _collapsible else float(_integer("stage_panels.action.min_target_size", 48, 44, 96)),
|
||||||
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
|
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
|
||||||
)
|
)
|
||||||
_close_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
_close_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
_close_button.focus_mode = Control.FOCUS_ALL
|
_close_button.focus_mode = Control.FOCUS_ALL
|
||||||
_close_button.pressed.connect(func() -> void: close_requested.emit())
|
_close_button.pressed.connect(_on_close_button_pressed)
|
||||||
_header.add_child(_close_button)
|
_header.add_child(_close_button)
|
||||||
|
|
||||||
_scroll = ScrollContainer.new()
|
_scroll = ScrollContainer.new()
|
||||||
|
_scroll.name = "InspectorBodyScroll"
|
||||||
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_scroll.visible = _body_visible and not _collapsed
|
||||||
_layout.add_child(_scroll)
|
_layout.add_child(_scroll)
|
||||||
_body = VBoxContainer.new()
|
_body = VBoxContainer.new()
|
||||||
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
@@ -240,11 +345,12 @@ func _fit_to_content() -> void:
|
|||||||
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
|
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
|
||||||
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
|
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
|
||||||
var body_height := _body.get_combined_minimum_size().y
|
var body_height := _body.get_combined_minimum_size().y
|
||||||
var fixed_height := header_height + gap + padding * 2.0
|
var show_body: bool = not _collapsed and _body_visible
|
||||||
|
var fixed_height := header_height + (gap if show_body else 0.0) + padding * 2.0
|
||||||
var available_height := maxf(72.0, rect.size.y - inset * 2.0)
|
var available_height := maxf(72.0, rect.size.y - inset * 2.0)
|
||||||
var natural_height := fixed_height + body_height
|
var natural_height := fixed_height + (body_height if show_body else 0.0)
|
||||||
var card_height := minf(natural_height, available_height)
|
var card_height := minf(natural_height, available_height)
|
||||||
var scroll_height := maxf(0.0, card_height - fixed_height)
|
var scroll_height := maxf(0.0, card_height - fixed_height) if show_body else 0.0
|
||||||
# Update the scroll minimum before shrinking the PanelContainer. Otherwise its
|
# Update the scroll minimum before shrinking the PanelContainer. Otherwise its
|
||||||
# previous full-content minimum can clamp the new height until another layout.
|
# previous full-content minimum can clamp the new height until another layout.
|
||||||
_scroll.custom_minimum_size.y = scroll_height
|
_scroll.custom_minimum_size.y = scroll_height
|
||||||
@@ -254,6 +360,22 @@ func _fit_to_content() -> void:
|
|||||||
_fit_pending = false
|
_fit_pending = false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_close_button_pressed() -> void:
|
||||||
|
if _collapsible:
|
||||||
|
set_collapsed(not _collapsed)
|
||||||
|
else:
|
||||||
|
close_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_title_layout() -> void:
|
||||||
|
if _title == null:
|
||||||
|
return
|
||||||
|
var one_line: bool = _single_line_title or (_collapsible and _collapsed)
|
||||||
|
_title.autowrap_mode = TextServer.AUTOWRAP_OFF if one_line else TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_title.max_lines_visible = 1 if one_line else 2
|
||||||
|
_title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
|
|
||||||
|
|
||||||
func _style_action_button(button: Button, prominent: bool) -> void:
|
func _style_action_button(button: Button, prominent: bool) -> void:
|
||||||
if button == null:
|
if button == null:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -125,6 +125,63 @@ func _apply_phase19_defaults() -> void:
|
|||||||
if not asset_selector.has(key):
|
if not asset_selector.has(key):
|
||||||
asset_selector[key] = starter_defaults[key]
|
asset_selector[key] = starter_defaults[key]
|
||||||
popups["asset_selector"] = asset_selector
|
popups["asset_selector"] = asset_selector
|
||||||
|
var anchored_chooser_defaults: Dictionary = {
|
||||||
|
"preferred_width": 360,
|
||||||
|
"edge_inset": 8,
|
||||||
|
"safe_top": 120,
|
||||||
|
"safe_bottom": 48,
|
||||||
|
"content_inset": 12,
|
||||||
|
"heading_font_size": 20,
|
||||||
|
"row_height": 56,
|
||||||
|
"row_gap": 8,
|
||||||
|
"header_gap": 8,
|
||||||
|
"footer_gap": 8,
|
||||||
|
"max_rows_height": 320,
|
||||||
|
"minimum_rows_height": 116,
|
||||||
|
"row_font_size": 20,
|
||||||
|
"row_text_color": "#F5F7FA",
|
||||||
|
"row_surface": "#171B24",
|
||||||
|
"row_border_color": "#FFFFFF55",
|
||||||
|
"selected_surface": "#12394A",
|
||||||
|
"hover_surface": "#203849",
|
||||||
|
"accent_color": "#22C6FF",
|
||||||
|
"focus_color": "#8BD9FF",
|
||||||
|
"corner_radius": 14,
|
||||||
|
"border_width": 2,
|
||||||
|
"bg_color": "#202633FA",
|
||||||
|
"border_color": "#667386",
|
||||||
|
"padding": 0,
|
||||||
|
"shadow_size": 6,
|
||||||
|
"shadow_color": "#00000080",
|
||||||
|
"shadow_offset": [0, 4],
|
||||||
|
"row_corner_radius": 12,
|
||||||
|
"row_border_width": 1,
|
||||||
|
"selected_border_width": 2,
|
||||||
|
"hover_border_width": 2,
|
||||||
|
"focus_border_width": 3,
|
||||||
|
"row_padding_x": 12,
|
||||||
|
"row_padding_y": 6,
|
||||||
|
"badge_corner_radius": 8,
|
||||||
|
"badge_surface": "#12394A",
|
||||||
|
"badge_text_color": "#F5F7FA",
|
||||||
|
"badge_font_size": 16,
|
||||||
|
"badge_padding_x": 8,
|
||||||
|
"badge_padding_y": 2,
|
||||||
|
"cancel_width": 120,
|
||||||
|
"cancel_height": 48,
|
||||||
|
"cancel_corner_radius": 10,
|
||||||
|
"cancel_border_width": 2,
|
||||||
|
"cancel_font_size": 20,
|
||||||
|
"cancel_surface": "#293242",
|
||||||
|
"cancel_hover_surface": "#354154",
|
||||||
|
"cancel_border_color": "#718096",
|
||||||
|
"cancel_text_color": "#F5F7FA"
|
||||||
|
}
|
||||||
|
var anchored_chooser: Dictionary = popups.get("anchored_chooser", {}).duplicate(true)
|
||||||
|
for key: String in anchored_chooser_defaults:
|
||||||
|
if not anchored_chooser.has(key):
|
||||||
|
anchored_chooser[key] = anchored_chooser_defaults[key]
|
||||||
|
popups["anchored_chooser"] = anchored_chooser
|
||||||
var ground_finish_defaults: Dictionary = {
|
var ground_finish_defaults: Dictionary = {
|
||||||
"card_width": 880,
|
"card_width": 880,
|
||||||
"max_height_ratio": 0.86,
|
"max_height_ratio": 0.86,
|
||||||
@@ -168,6 +225,11 @@ func _apply_phase19_defaults() -> void:
|
|||||||
"hover_color": "#FFE66DCC",
|
"hover_color": "#FFE66DCC",
|
||||||
"rotate_color": "#4DB9FFE6",
|
"rotate_color": "#4DB9FFE6",
|
||||||
"backing_extra_width": 3.0,
|
"backing_extra_width": 3.0,
|
||||||
|
"candidate_name_pill_bg_color": "#182432F2",
|
||||||
|
"candidate_name_pill_corner_radius": 8.0,
|
||||||
|
"candidate_name_pill_border_width": 3.0,
|
||||||
|
"candidate_name_pill_padding_x": 12.0,
|
||||||
|
"candidate_name_pill_padding_y": 8.0,
|
||||||
}
|
}
|
||||||
for key: String in defaults:
|
for key: String in defaults:
|
||||||
if not gizmos.has(key):
|
if not gizmos.has(key):
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
class_name StageGizmos
|
class_name StageGizmos
|
||||||
extends Node2D
|
extends Node2D
|
||||||
## StageGizmos - Hover highlight, selection outlines and a rotate ring for the
|
## StageGizmos - Hover highlight, selection outlines and a compact rotate handle for the
|
||||||
## Sandbox Stage Builder (Phase 2).
|
## Sandbox Stage Builder (Phase 2).
|
||||||
##
|
##
|
||||||
## Translation is done by dragging an object directly (no move handle); a drag
|
## Translation is done by dragging an object directly (no move handle); a drag
|
||||||
## moves the whole multi-selection. The rotate ring only appears when exactly one
|
## moves the whole multi-selection. The rotate handle only appears when exactly one
|
||||||
## object is selected (rotating a multi-selection isn't useful). Pure drawing +
|
## object is selected (rotating a multi-selection isn't useful). Pure drawing +
|
||||||
## distance-based hit-testing (no Area2D). Dragging drives global_position /
|
## distance-based hit-testing (no Area2D). Dragging drives global_position /
|
||||||
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
|
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
|
||||||
|
|
||||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||||
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
||||||
|
const StickmanRigScript := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
const MissingPropScript := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||||
|
const MissingStickmanScript := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||||
const StageWallPanelScript := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
const StageWallPanelScript := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||||
const StageBackgroundGroundScript := preload("res://scripts/stage/scenery/stage_background_ground.gd")
|
const StageBackgroundGroundScript := preload("res://scripts/stage/scenery/stage_background_ground.gd")
|
||||||
const StageSceneryArtworkScript := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
const StageSceneryArtworkScript := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
||||||
@@ -37,12 +40,14 @@ const SELECTION_COLOR := Color(1.0, 1.0, 1.0, 0.9)
|
|||||||
const HOVER_COLOR := Color(1.0, 0.9, 0.2, 0.5)
|
const HOVER_COLOR := Color(1.0, 0.9, 0.2, 0.5)
|
||||||
const ROTATE_COLOR := Color(0.3, 0.6, 1.0, 0.9)
|
const ROTATE_COLOR := Color(0.3, 0.6, 1.0, 0.9)
|
||||||
|
|
||||||
const SELECTION_WIDTH := 2.0
|
const SELECTION_WIDTH := 3.0
|
||||||
const HOVER_WIDTH := 1.5
|
const HOVER_WIDTH := 1.5
|
||||||
const ROTATE_WIDTH := 2.0
|
const ROTATE_WIDTH := 2.0
|
||||||
|
|
||||||
const ROTATE_HANDLE_MARGIN_PX := 48.0
|
const ROTATE_HANDLE_OFFSET_PX := 24.0
|
||||||
const ROTATE_RING_TOLERANCE_PX := 12.0
|
const ROTATE_HANDLE_HIT_HALF_SIZE_PX := 22.0
|
||||||
|
const ROTATE_ICON_RADIUS_PX := 8.0
|
||||||
|
const ROTATE_HANDLE_SAFE_TOP_PX := 112.0
|
||||||
const SCALE_CROSS_SIZE_PX := 5.0
|
const SCALE_CROSS_SIZE_PX := 5.0
|
||||||
const SCALE_HIT_RADIUS_PX := 12.0
|
const SCALE_HIT_RADIUS_PX := 12.0
|
||||||
const SCENERY_SCALE_HIT_HALF_SIZE_PX := 22.0
|
const SCENERY_SCALE_HIT_HALF_SIZE_PX := 22.0
|
||||||
@@ -69,6 +74,14 @@ var _enabled: bool = true
|
|||||||
var _transform_handles_visible: bool = true
|
var _transform_handles_visible: bool = true
|
||||||
var _targets: Array[Node2D] = []
|
var _targets: Array[Node2D] = []
|
||||||
var _hovered: Node2D = null
|
var _hovered: Node2D = null
|
||||||
|
var _candidate_preview: Node2D = null
|
||||||
|
var _candidate_preview_obstacles: Array[Rect2] = []
|
||||||
|
var _candidate_preview_color: Color = Color("#22C6FF")
|
||||||
|
var _candidate_preview_name_background: Color = Color("#182432F2")
|
||||||
|
var _candidate_preview_name_corner_radius: float = 8.0
|
||||||
|
var _candidate_preview_name_border_width: float = 3.0
|
||||||
|
var _candidate_preview_name_padding: Vector2 = Vector2(12.0, 8.0)
|
||||||
|
var _candidate_preview_name_style: StyleBoxFlat = StyleBoxFlat.new()
|
||||||
var _dragging: Handle = Handle.NONE
|
var _dragging: Handle = Handle.NONE
|
||||||
var _drag_last: Vector2 = Vector2.ZERO
|
var _drag_last: Vector2 = Vector2.ZERO
|
||||||
var _drag_anchor: Node2D = null
|
var _drag_anchor: Node2D = null
|
||||||
@@ -97,6 +110,16 @@ func set_hover(node: Node2D) -> void:
|
|||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func set_candidate_preview(node: Node2D) -> void:
|
||||||
|
_candidate_preview = node if node != null and is_instance_valid(node) else null
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func set_candidate_preview_obstacles(obstacles: Array[Rect2]) -> void:
|
||||||
|
_candidate_preview_obstacles = obstacles.duplicate()
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func set_enabled(enabled: bool) -> void:
|
func set_enabled(enabled: bool) -> void:
|
||||||
_enabled = enabled
|
_enabled = enabled
|
||||||
visible = enabled
|
visible = enabled
|
||||||
@@ -121,6 +144,14 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
|
|||||||
_hover_core_color = stage_theme.color("overlays.gizmos.hover_color", HOVER_COLOR)
|
_hover_core_color = stage_theme.color("overlays.gizmos.hover_color", HOVER_COLOR)
|
||||||
_rotate_backing_color = stage_theme.color("overlays.gizmos.backing_color", Color("#111821E8"))
|
_rotate_backing_color = stage_theme.color("overlays.gizmos.backing_color", Color("#111821E8"))
|
||||||
_rotate_core_color = stage_theme.color("overlays.gizmos.rotate_color", ROTATE_COLOR)
|
_rotate_core_color = stage_theme.color("overlays.gizmos.rotate_color", ROTATE_COLOR)
|
||||||
|
_candidate_preview_color = stage_theme.color("mode_colors.edit", Color("#22C6FF"))
|
||||||
|
_candidate_preview_name_background = stage_theme.color("overlays.gizmos.candidate_name_pill_bg_color", Color("#182432F2"))
|
||||||
|
_candidate_preview_name_corner_radius = stage_theme.number("overlays.gizmos.candidate_name_pill_corner_radius", 8.0, 4.0, 24.0)
|
||||||
|
_candidate_preview_name_border_width = stage_theme.number("overlays.gizmos.candidate_name_pill_border_width", 3.0, 1.0, 8.0)
|
||||||
|
_candidate_preview_name_padding = Vector2(
|
||||||
|
stage_theme.number("overlays.gizmos.candidate_name_pill_padding_x", 12.0, 4.0, 32.0),
|
||||||
|
stage_theme.number("overlays.gizmos.candidate_name_pill_padding_y", 8.0, 2.0, 24.0)
|
||||||
|
)
|
||||||
_outline_extra_width = stage_theme.number("overlays.gizmos.backing_extra_width", 3.0, 1.0, 8.0)
|
_outline_extra_width = stage_theme.number("overlays.gizmos.backing_extra_width", 3.0, 1.0, 8.0)
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
@@ -130,7 +161,7 @@ func set_box_rect(rect: Rect2) -> void:
|
|||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
## Returns ROTATE if the cursor is on the rotate ring (single selection only).
|
## Returns ROTATE if the cursor is on the compact rotate handle (single selection only).
|
||||||
func hit_test(world_pos: Vector2) -> Handle:
|
func hit_test(world_pos: Vector2) -> Handle:
|
||||||
if not _enabled or not _transform_handles_visible or _targets.size() != 1:
|
if not _enabled or not _transform_handles_visible or _targets.size() != 1:
|
||||||
return Handle.NONE
|
return Handle.NONE
|
||||||
@@ -148,9 +179,11 @@ func hit_test(world_pos: Vector2) -> Handle:
|
|||||||
for corner: Vector2 in _corners(rect):
|
for corner: Vector2 in _corners(rect):
|
||||||
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
||||||
return Handle.SCALE
|
return Handle.SCALE
|
||||||
var ring := _rotate_ring_radius(rect)
|
if not _is_scenery_target(target):
|
||||||
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
|
var rotate_handle: Vector2 = _rotate_handle_position(rect)
|
||||||
return Handle.ROTATE
|
var handle_offset: Vector2 = (world_pos - rotate_handle) * _zoom()
|
||||||
|
if absf(handle_offset.x) <= ROTATE_HANDLE_HIT_HALF_SIZE_PX and absf(handle_offset.y) <= ROTATE_HANDLE_HIT_HALF_SIZE_PX:
|
||||||
|
return Handle.ROTATE
|
||||||
return Handle.NONE
|
return Handle.NONE
|
||||||
|
|
||||||
|
|
||||||
@@ -171,7 +204,7 @@ func get_scenery_resize_corner(world_pos: Vector2) -> Vector2:
|
|||||||
return closest
|
return closest
|
||||||
|
|
||||||
|
|
||||||
## Begins a rotate drag (used when the ring is clicked).
|
## Begins a rotate drag (used when the rotate handle is clicked).
|
||||||
func begin_drag(world_pos: Vector2) -> void:
|
func begin_drag(world_pos: Vector2) -> void:
|
||||||
_dragging = hit_test(world_pos)
|
_dragging = hit_test(world_pos)
|
||||||
_drag_last = world_pos
|
_drag_last = world_pos
|
||||||
@@ -264,7 +297,7 @@ func is_dragging() -> bool:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
if _enabled and (_targets.size() > 0 or _hovered != null or _box_rect.size != Vector2.ZERO):
|
if _enabled and (_targets.size() > 0 or _hovered != null or _candidate_preview != null or _box_rect.size != Vector2.ZERO):
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
@@ -274,17 +307,17 @@ func _draw() -> void:
|
|||||||
var zoom := _zoom()
|
var zoom := _zoom()
|
||||||
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
|
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
|
||||||
_draw_halo_rect(_target_bounds(_hovered), _hover_core_color, HOVER_WIDTH / zoom, _hover_backing_color)
|
_draw_halo_rect(_target_bounds(_hovered), _hover_core_color, HOVER_WIDTH / zoom, _hover_backing_color)
|
||||||
|
if _candidate_preview != null and is_instance_valid(_candidate_preview) and not _candidate_preview.is_queued_for_deletion():
|
||||||
|
_draw_candidate_preview(_candidate_preview, zoom)
|
||||||
# A selection outline for every selected object.
|
# A selection outline for every selected object.
|
||||||
for node: Node2D in _targets:
|
for node: Node2D in _targets:
|
||||||
if node != null and is_instance_valid(node):
|
if node != null and is_instance_valid(node):
|
||||||
_draw_halo_rect(_target_bounds(node), _selection_core_color, SELECTION_WIDTH / zoom, _selection_backing_color)
|
_draw_halo_rect(_target_bounds(node), _selection_core_color, SELECTION_WIDTH / zoom, _selection_backing_color)
|
||||||
# Rotate ring only for a single selection.
|
# A clear rectangle signals selection; the compact handle keeps rotation available.
|
||||||
if _transform_handles_visible and _targets.size() == 1 and is_instance_valid(_targets[0]):
|
if _transform_handles_visible and _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||||
var rect: Rect2 = _target_bounds(_targets[0])
|
var rect: Rect2 = _target_bounds(_targets[0])
|
||||||
if not _is_scenery_target(_targets[0]):
|
if not _is_scenery_target(_targets[0]):
|
||||||
var ring_radius: float = _rotate_ring_radius(rect)
|
_draw_rotate_handle(rect, zoom)
|
||||||
draw_arc(rect.get_center(), ring_radius, 0.0, TAU, 64, _rotate_backing_color, ROTATE_WIDTH / zoom + _outline_extra_width / zoom, true)
|
|
||||||
draw_arc(rect.get_center(), ring_radius, 0.0, TAU, 64, _rotate_core_color, ROTATE_WIDTH / zoom, true)
|
|
||||||
if _is_scenery_target(_targets[0]):
|
if _is_scenery_target(_targets[0]):
|
||||||
for corner: Vector2 in _corners(rect):
|
for corner: Vector2 in _corners(rect):
|
||||||
draw_circle(corner, 11.0 / zoom, Color("#111821E8"))
|
draw_circle(corner, 11.0 / zoom, Color("#111821E8"))
|
||||||
@@ -314,8 +347,157 @@ func _draw_halo_rect(rect: Rect2, core_color: Color, core_width: float, backing_
|
|||||||
draw_rect(rect, core_color, false, core_width)
|
draw_rect(rect, core_color, false, core_width)
|
||||||
|
|
||||||
|
|
||||||
func _rotate_ring_radius(rect: Rect2) -> float:
|
func _draw_candidate_preview(root: Node, zoom: float) -> void:
|
||||||
return rect.size.length() * 0.5 + ROTATE_HANDLE_MARGIN_PX / _zoom()
|
if root is CanvasItem and not (root as CanvasItem).is_visible_in_tree():
|
||||||
|
return
|
||||||
|
if root is Polygon2D:
|
||||||
|
var polygon: Polygon2D = root as Polygon2D
|
||||||
|
if polygon.polygon.size() >= 2:
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for point: Vector2 in polygon.polygon:
|
||||||
|
points.append(to_local(polygon.to_global(point)))
|
||||||
|
points.append(points[0])
|
||||||
|
draw_polyline(points, Color(_candidate_preview_color, 0.62), 12.0 / zoom, true)
|
||||||
|
draw_polyline(points, _candidate_preview_color, 4.5 / zoom, true)
|
||||||
|
elif root is Line2D:
|
||||||
|
var line: Line2D = root as Line2D
|
||||||
|
if line.points.size() >= 2:
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for point: Vector2 in line.points:
|
||||||
|
points.append(to_local(line.to_global(point)))
|
||||||
|
var segment_count: int = points.size() - 1 + (1 if line.closed else 0)
|
||||||
|
for index: int in segment_count:
|
||||||
|
draw_line(points[index % points.size()], points[(index + 1) % points.size()], Color(_candidate_preview_color, 0.62), 12.0 / zoom, true)
|
||||||
|
draw_line(points[index % points.size()], points[(index + 1) % points.size()], _candidate_preview_color, 4.5 / zoom, true)
|
||||||
|
for child: Node in root.get_children():
|
||||||
|
_draw_candidate_preview(child, zoom)
|
||||||
|
if root is Node2D and root == _candidate_preview:
|
||||||
|
_draw_candidate_preview_name(root as Node2D, zoom)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_candidate_preview_name(node: Node2D, zoom: float) -> void:
|
||||||
|
var name_text: String = node.name
|
||||||
|
if node is PropBlockScript or node is StickmanRigScript or node.get_script() == MissingPropScript or node.get_script() == MissingStickmanScript:
|
||||||
|
name_text = String(node.get("stage_name"))
|
||||||
|
if name_text.is_empty():
|
||||||
|
name_text = node.name
|
||||||
|
var font: Font = ThemeDB.fallback_font
|
||||||
|
if font == null:
|
||||||
|
return
|
||||||
|
var font_size: int = maxi(20, roundi(20.0 / zoom))
|
||||||
|
var text_size: Vector2 = font.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size)
|
||||||
|
var font_ascent: float = font.get_ascent(font_size)
|
||||||
|
var font_descent: float = font.get_descent(font_size)
|
||||||
|
var bounds: Rect2 = _target_bounds(node)
|
||||||
|
var canvas_transform: Transform2D = get_viewport().get_canvas_transform()
|
||||||
|
var inverse_canvas_transform: Transform2D = canvas_transform.affine_inverse()
|
||||||
|
var candidate_screen_bounds: Rect2 = _world_rect_to_screen_rect(bounds, canvas_transform)
|
||||||
|
var rect_size: Vector2 = Vector2(text_size.x, font_ascent + font_descent) + _candidate_preview_name_padding * 2.0 / zoom
|
||||||
|
var screen_size: Vector2 = rect_size * zoom
|
||||||
|
var target_center: Vector2 = candidate_screen_bounds.get_center()
|
||||||
|
var screen_candidates: Array[Vector2] = [
|
||||||
|
Vector2(target_center.x - screen_size.x * 0.5, candidate_screen_bounds.position.y - screen_size.y - 10.0),
|
||||||
|
Vector2(target_center.x - screen_size.x * 0.5, candidate_screen_bounds.end.y + 10.0),
|
||||||
|
Vector2(candidate_screen_bounds.position.x - screen_size.x - 10.0, target_center.y - screen_size.y * 0.5),
|
||||||
|
Vector2(candidate_screen_bounds.end.x + 10.0, target_center.y - screen_size.y * 0.5),
|
||||||
|
]
|
||||||
|
var viewport_rect: Rect2 = get_viewport().get_visible_rect()
|
||||||
|
var safe_rect: Rect2 = Rect2(Vector2(8.0, 72.0), Vector2(maxf(0.0, viewport_rect.size.x - 16.0), maxf(0.0, viewport_rect.size.y - 120.0)))
|
||||||
|
var max_safe_position: Vector2 = Vector2(
|
||||||
|
maxf(safe_rect.position.x, safe_rect.end.x - screen_size.x),
|
||||||
|
maxf(safe_rect.position.y, safe_rect.end.y - screen_size.y)
|
||||||
|
)
|
||||||
|
screen_candidates.append(safe_rect.position)
|
||||||
|
screen_candidates.append(Vector2(max_safe_position.x, safe_rect.position.y))
|
||||||
|
screen_candidates.append(Vector2(safe_rect.position.x, max_safe_position.y))
|
||||||
|
screen_candidates.append(max_safe_position)
|
||||||
|
for obstacle: Rect2 in _candidate_preview_obstacles:
|
||||||
|
if not obstacle.has_area():
|
||||||
|
continue
|
||||||
|
screen_candidates.append(Vector2(obstacle.position.x - screen_size.x - 8.0, target_center.y - screen_size.y * 0.5))
|
||||||
|
screen_candidates.append(Vector2(obstacle.end.x + 8.0, target_center.y - screen_size.y * 0.5))
|
||||||
|
screen_candidates.append(Vector2(target_center.x - screen_size.x * 0.5, obstacle.position.y - screen_size.y - 8.0))
|
||||||
|
screen_candidates.append(Vector2(target_center.x - screen_size.x * 0.5, obstacle.end.y + 8.0))
|
||||||
|
var best_screen_position: Vector2 = safe_rect.position
|
||||||
|
var best_score: float = INF
|
||||||
|
for candidate_position: Vector2 in screen_candidates:
|
||||||
|
var clamped_position: Vector2 = Vector2(
|
||||||
|
clampf(candidate_position.x, safe_rect.position.x, max_safe_position.x),
|
||||||
|
clampf(candidate_position.y, safe_rect.position.y, max_safe_position.y)
|
||||||
|
)
|
||||||
|
var candidate_rect: Rect2 = Rect2(clamped_position, screen_size)
|
||||||
|
var overlap_area: float = candidate_rect.intersection(candidate_screen_bounds).get_area()
|
||||||
|
for obstacle: Rect2 in _candidate_preview_obstacles:
|
||||||
|
if obstacle.has_area():
|
||||||
|
overlap_area += candidate_rect.intersection(obstacle).get_area()
|
||||||
|
var score: float = overlap_area * 10000.0 + clamped_position.distance_to(target_center) * 0.02
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_screen_position = clamped_position
|
||||||
|
var screen_world_position: Vector2 = inverse_canvas_transform * best_screen_position
|
||||||
|
var rect: Rect2 = Rect2(to_local(screen_world_position), rect_size)
|
||||||
|
_candidate_preview_name_style.bg_color = _candidate_preview_name_background
|
||||||
|
_candidate_preview_name_style.border_color = _candidate_preview_color
|
||||||
|
_candidate_preview_name_style.set_corner_radius_all(maxi(1, roundi(_candidate_preview_name_corner_radius / zoom)))
|
||||||
|
_candidate_preview_name_style.set_border_width_all(maxi(1, roundi(_candidate_preview_name_border_width / zoom)))
|
||||||
|
draw_style_box(_candidate_preview_name_style, rect)
|
||||||
|
var centered_baseline: float = rect_size.y * 0.5 + (font_ascent - font_descent) * 0.5
|
||||||
|
draw_string(
|
||||||
|
font,
|
||||||
|
rect.position + Vector2(_candidate_preview_name_padding.x / zoom, centered_baseline),
|
||||||
|
name_text,
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT,
|
||||||
|
-1.0,
|
||||||
|
font_size,
|
||||||
|
Color.WHITE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _world_rect_to_screen_rect(world_rect: Rect2, canvas_transform: Transform2D) -> Rect2:
|
||||||
|
var points: Array[Vector2] = [
|
||||||
|
world_rect.position,
|
||||||
|
Vector2(world_rect.end.x, world_rect.position.y),
|
||||||
|
world_rect.end,
|
||||||
|
Vector2(world_rect.position.x, world_rect.end.y),
|
||||||
|
]
|
||||||
|
var result: Rect2 = Rect2(canvas_transform * points[0], Vector2.ZERO)
|
||||||
|
for point_index: int in range(1, points.size()):
|
||||||
|
result = result.expand(canvas_transform * points[point_index])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _rotate_handle_position(rect: Rect2) -> Vector2:
|
||||||
|
var zoom: float = _zoom()
|
||||||
|
var above_position := Vector2(rect.get_center().x, rect.position.y - ROTATE_HANDLE_OFFSET_PX / zoom)
|
||||||
|
var screen_position: Vector2 = get_viewport().get_canvas_transform() * above_position
|
||||||
|
if screen_position.y - ROTATE_HANDLE_HIT_HALF_SIZE_PX < ROTATE_HANDLE_SAFE_TOP_PX:
|
||||||
|
return Vector2(rect.get_center().x, rect.end.y + ROTATE_HANDLE_OFFSET_PX / zoom)
|
||||||
|
return above_position
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_rotate_handle(rect: Rect2, zoom: float) -> void:
|
||||||
|
var center: Vector2 = _rotate_handle_position(rect)
|
||||||
|
var below_box: bool = center.y > rect.get_center().y
|
||||||
|
var box_anchor_y: float = rect.end.y if below_box else rect.position.y
|
||||||
|
var handle_anchor_offset: float = -14.0 / zoom if below_box else 14.0 / zoom
|
||||||
|
var connector_start := Vector2(rect.get_center().x, box_anchor_y)
|
||||||
|
var connector_end := center + Vector2(0.0, handle_anchor_offset)
|
||||||
|
draw_line(connector_start, connector_end, _rotate_backing_color, 5.0 / zoom, true)
|
||||||
|
draw_line(connector_start, connector_end, _rotate_core_color, 2.0 / zoom, true)
|
||||||
|
draw_circle(center, 16.0 / zoom, _rotate_backing_color)
|
||||||
|
draw_circle(center, 13.0 / zoom, Color("#252B38"))
|
||||||
|
draw_arc(center, ROTATE_ICON_RADIUS_PX / zoom, deg_to_rad(35.0), deg_to_rad(300.0), 24, _rotate_core_color, 2.5 / zoom, true)
|
||||||
|
var end_angle: float = deg_to_rad(300.0)
|
||||||
|
var radial: Vector2 = Vector2(cos(end_angle), sin(end_angle))
|
||||||
|
var tangent: Vector2 = Vector2(-radial.y, radial.x)
|
||||||
|
var tip: Vector2 = center + radial * (ROTATE_ICON_RADIUS_PX / zoom)
|
||||||
|
var arrow_back: Vector2 = tip - tangent * (4.0 / zoom)
|
||||||
|
var arrow_points := PackedVector2Array([
|
||||||
|
tip,
|
||||||
|
arrow_back + radial * (3.5 / zoom),
|
||||||
|
arrow_back - radial * (3.5 / zoom),
|
||||||
|
])
|
||||||
|
draw_colored_polygon(arrow_points, _rotate_core_color)
|
||||||
|
|
||||||
|
|
||||||
func _snap(v: Vector2) -> Vector2:
|
func _snap(v: Vector2) -> Vector2:
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
class_name StageObjectIdentity
|
||||||
|
extends RefCounted
|
||||||
|
## Scene-scoped identity and display-name allocation for placed stage objects.
|
||||||
|
|
||||||
|
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||||
|
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||||
|
const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||||
|
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
|
||||||
|
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
|
||||||
|
|
||||||
|
var _id_serial: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func get_id(node: Node2D) -> String:
|
||||||
|
if not _is_named_object(node):
|
||||||
|
return ""
|
||||||
|
return String(node.get("stage_object_id"))
|
||||||
|
|
||||||
|
|
||||||
|
func get_name(node: Node2D) -> String:
|
||||||
|
if not _is_named_object(node):
|
||||||
|
return ""
|
||||||
|
return String(node.get("stage_name"))
|
||||||
|
|
||||||
|
|
||||||
|
func assign_new(node: Node2D, base_name: String, world: Node2D) -> void:
|
||||||
|
if not _is_named_object(node) or world == null:
|
||||||
|
return
|
||||||
|
var stage_id: String = get_id(node)
|
||||||
|
if stage_id.strip_edges().is_empty() or _id_in_use(stage_id, world, node):
|
||||||
|
node.set("stage_object_id", _new_id(world, node))
|
||||||
|
var current_name: String = get_name(node).strip_edges()
|
||||||
|
if current_name.is_empty() or _name_in_use(current_name, world, node):
|
||||||
|
var naming_base: String = current_name if not current_name.is_empty() else base_name
|
||||||
|
node.set("stage_name", next_free_name(naming_base, world, node))
|
||||||
|
elif current_name != get_name(node):
|
||||||
|
node.set("stage_name", current_name)
|
||||||
|
|
||||||
|
|
||||||
|
func next_free_name(base_name: String, world: Node2D, exclude: Node2D = null) -> String:
|
||||||
|
var base: String = _base_name(base_name, world, exclude)
|
||||||
|
if not _name_in_use(base, world, exclude):
|
||||||
|
return base
|
||||||
|
var suffix: int = 2
|
||||||
|
while _name_in_use("%s %d" % [base, suffix], world, exclude):
|
||||||
|
suffix += 1
|
||||||
|
return "%s %d" % [base, suffix]
|
||||||
|
|
||||||
|
|
||||||
|
func validate_name(requested: String, world: Node2D, exclude: Node2D = null) -> Dictionary:
|
||||||
|
var trimmed: String = requested.strip_edges()
|
||||||
|
if trimmed.is_empty():
|
||||||
|
return {
|
||||||
|
"ok": false,
|
||||||
|
"name": trimmed,
|
||||||
|
"suggestion": next_free_name(get_name(exclude), world, exclude),
|
||||||
|
"reason": &"blank",
|
||||||
|
}
|
||||||
|
if _name_in_use(trimmed, world, exclude):
|
||||||
|
return {
|
||||||
|
"ok": false,
|
||||||
|
"name": trimmed,
|
||||||
|
"suggestion": next_free_name(trimmed, world, exclude),
|
||||||
|
"reason": &"duplicate",
|
||||||
|
}
|
||||||
|
return {"ok": true, "name": trimmed, "suggestion": "", "reason": &""}
|
||||||
|
|
||||||
|
|
||||||
|
func rename(node: Node2D, requested: String, world: Node2D) -> Dictionary:
|
||||||
|
if not _is_named_object(node):
|
||||||
|
return {"ok": false, "name": "", "suggestion": "", "reason": &"invalid_object"}
|
||||||
|
var result: Dictionary = validate_name(requested, world, node)
|
||||||
|
if bool(result.get("ok", false)):
|
||||||
|
node.set("stage_name", String(result["name"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
static func asset_base_name(object_type: String, asset_path: String) -> String:
|
||||||
|
var fallback: String = asset_path.get_file().get_basename().strip_edges()
|
||||||
|
if object_type == "stickman":
|
||||||
|
var data: Dictionary = STICKMAN_FACTORY.load_stk(asset_path)
|
||||||
|
var name: String = String(data.get("stickman_name", "")).strip_edges()
|
||||||
|
return name if not name.is_empty() else fallback if not fallback.is_empty() else "Character"
|
||||||
|
if object_type == "prop":
|
||||||
|
var entry: Dictionary = PROP_LIBRARY.get_entry(asset_path)
|
||||||
|
var prop_name: String = String(entry.get("name", entry.get("label", ""))).strip_edges()
|
||||||
|
return prop_name if not prop_name.is_empty() else fallback if not fallback.is_empty() else "Prop"
|
||||||
|
return "Character" if object_type == "stickman" else "Prop"
|
||||||
|
|
||||||
|
|
||||||
|
func _base_name(requested: String, world: Node2D, node: Node2D) -> String:
|
||||||
|
var base: String = requested.strip_edges()
|
||||||
|
if base.is_empty():
|
||||||
|
base = "Character" if _is_character(node) else "Prop"
|
||||||
|
var suffix_start: int = base.rfind(" ")
|
||||||
|
if suffix_start > 0:
|
||||||
|
var suffix: String = base.substr(suffix_start + 1)
|
||||||
|
var unsuffixed: String = base.substr(0, suffix_start).strip_edges()
|
||||||
|
if suffix.is_valid_int() and int(suffix) >= 2 and _name_in_use(base, world, node) and _name_in_use(unsuffixed, world, node):
|
||||||
|
base = unsuffixed
|
||||||
|
return base if not base.is_empty() else ("Character" if _is_character(node) else "Prop")
|
||||||
|
|
||||||
|
|
||||||
|
func _new_id(world: Node2D, exclude: Node2D) -> String:
|
||||||
|
while true:
|
||||||
|
_id_serial += 1
|
||||||
|
var candidate: String = "stage_%d_%d_%d" % [Time.get_ticks_usec(), randi(), _id_serial]
|
||||||
|
if not _id_in_use(candidate, world, exclude):
|
||||||
|
return candidate
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _id_in_use(stage_id: String, world: Node2D, exclude: Node2D) -> bool:
|
||||||
|
for child: Node in world.get_children():
|
||||||
|
var node: Node2D = child as Node2D
|
||||||
|
if node == null or node == exclude or not _is_named_object(node):
|
||||||
|
continue
|
||||||
|
if get_id(node) == stage_id:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _name_in_use(stage_name: String, world: Node2D, exclude: Node2D) -> bool:
|
||||||
|
var key: String = stage_name.strip_edges().to_lower()
|
||||||
|
if key.is_empty():
|
||||||
|
return false
|
||||||
|
for child: Node in world.get_children():
|
||||||
|
var node: Node2D = child as Node2D
|
||||||
|
if node == null or node == exclude or not _is_named_object(node):
|
||||||
|
continue
|
||||||
|
if get_name(node).strip_edges().to_lower() == key:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _is_named_object(node: Node2D) -> bool:
|
||||||
|
if node == null:
|
||||||
|
return false
|
||||||
|
return node is STICKMAN_RIG or node is PROP_BLOCK or node.get_script() == MISSING_STICKMAN or node.get_script() == MISSING_PROP
|
||||||
|
|
||||||
|
|
||||||
|
func _is_character(node: Node2D) -> bool:
|
||||||
|
if node == null:
|
||||||
|
return false
|
||||||
|
return node is STICKMAN_RIG or node.get_script() == MISSING_STICKMAN
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8frldld3aq26
|
||||||
@@ -3,7 +3,7 @@ extends RefCounted
|
|||||||
## JSON boundary for Sandbox Stage documents. This class deliberately has no
|
## JSON boundary for Sandbox Stage documents. This class deliberately has no
|
||||||
## scene-tree dependencies, which makes rejected documents non-destructive.
|
## scene-tree dependencies, which makes rejected documents non-destructive.
|
||||||
|
|
||||||
const FILE_VERSION: String = "1.7"
|
const FILE_VERSION: String = "1.8"
|
||||||
const DEFAULT_PLAIN_COLOR: String = "#F5EFE0"
|
const DEFAULT_PLAIN_COLOR: String = "#F5EFE0"
|
||||||
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||||
@@ -12,6 +12,7 @@ const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
|||||||
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||||
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
|
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
|
||||||
|
const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd")
|
||||||
const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
|
const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
|
||||||
const BACKGROUND_FINISHES: Array[String] = ["finish_background_grass", "finish_background_concrete", "finish_background_carpet"]
|
const BACKGROUND_FINISHES: Array[String] = ["finish_background_grass", "finish_background_concrete", "finish_background_carpet"]
|
||||||
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
|
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
|
||||||
@@ -186,13 +187,14 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
|||||||
prop_entry["wobble_on_impact"] = false
|
prop_entry["wobble_on_impact"] = false
|
||||||
_assign_legacy_depths(current.get("objects", []))
|
_assign_legacy_depths(current.get("objects", []))
|
||||||
return current
|
return current
|
||||||
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"]:
|
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]:
|
||||||
return {}
|
return {}
|
||||||
var migrated: Dictionary = data.duplicate(true)
|
var migrated: Dictionary = data.duplicate(true)
|
||||||
if version not in ["1.5", "1.6"]:
|
if version != "1.7":
|
||||||
migrated["sky_id"] = "sky_neutral"
|
if version not in ["1.5", "1.6"]:
|
||||||
migrated["background_kind"] = "sky"
|
migrated["sky_id"] = "sky_neutral"
|
||||||
migrated["plain_color"] = DEFAULT_PLAIN_COLOR
|
migrated["background_kind"] = "sky"
|
||||||
|
migrated["plain_color"] = DEFAULT_PLAIN_COLOR
|
||||||
for raw: Variant in migrated.get("objects", []):
|
for raw: Variant in migrated.get("objects", []):
|
||||||
if not raw is Dictionary:
|
if not raw is Dictionary:
|
||||||
continue
|
continue
|
||||||
@@ -206,15 +208,68 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
|||||||
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
|
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
|
||||||
if not object.has("wobble_on_impact"):
|
if not object.has("wobble_on_impact"):
|
||||||
object["wobble_on_impact"] = false
|
object["wobble_on_impact"] = false
|
||||||
if version not in ["1.5", "1.6"] and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
|
if version not in ["1.5", "1.6", "1.7"] and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
|
||||||
object["finish_id"] = "finish_ground_legacy"
|
object["finish_id"] = "finish_ground_legacy"
|
||||||
if version != "1.6" and String(object.get("type", "")) == "wall_panel":
|
if version not in ["1.6", "1.7"] and String(object.get("type", "")) == "wall_panel":
|
||||||
_migrate_legacy_wall_tint(object)
|
_migrate_legacy_wall_tint(object)
|
||||||
migrated["version"] = FILE_VERSION
|
migrated["version"] = FILE_VERSION
|
||||||
_assign_legacy_depths(migrated.get("objects", []))
|
_assign_legacy_depths(migrated.get("objects", []))
|
||||||
|
_migrate_stage_object_identity(migrated.get("objects", []))
|
||||||
return migrated
|
return migrated
|
||||||
|
|
||||||
|
|
||||||
|
static func _migrate_stage_object_identity(raw_objects: Variant) -> void:
|
||||||
|
if not raw_objects is Array:
|
||||||
|
return
|
||||||
|
var objects: Array = raw_objects
|
||||||
|
var used_ids: Dictionary = {}
|
||||||
|
var reserved_prop_ids: Dictionary = {}
|
||||||
|
var used_names: Dictionary = {}
|
||||||
|
for raw: Variant in objects:
|
||||||
|
if not raw is Dictionary:
|
||||||
|
continue
|
||||||
|
var entry: Dictionary = raw
|
||||||
|
if String(entry.get("type", "")) != "prop":
|
||||||
|
continue
|
||||||
|
var existing_prop_id: String = String(entry.get("stage_object_id", "")).strip_edges()
|
||||||
|
if not existing_prop_id.is_empty():
|
||||||
|
reserved_prop_ids[existing_prop_id] = true
|
||||||
|
for raw: Variant in objects:
|
||||||
|
if not raw is Dictionary:
|
||||||
|
continue
|
||||||
|
var entry: Dictionary = raw
|
||||||
|
var object_type: String = String(entry.get("type", ""))
|
||||||
|
if object_type not in ["stickman", "prop"]:
|
||||||
|
continue
|
||||||
|
var file_id: String = String(entry.get("file_id", ""))
|
||||||
|
var stage_id: String = ""
|
||||||
|
if object_type == "prop":
|
||||||
|
stage_id = String(entry.get("stage_object_id", ""))
|
||||||
|
if stage_id.strip_edges().is_empty():
|
||||||
|
stage_id = "prop:%s" % (file_id if not file_id.is_empty() else str(used_ids.size() + 1))
|
||||||
|
else:
|
||||||
|
stage_id = "stickman:%s" % (file_id if not file_id.is_empty() else str(used_ids.size() + 1))
|
||||||
|
if object_type == "stickman" or String(entry.get("stage_object_id", "")).strip_edges().is_empty():
|
||||||
|
var id_base: String = stage_id
|
||||||
|
var id_suffix: int = 2
|
||||||
|
while used_ids.has(stage_id) or reserved_prop_ids.has(stage_id):
|
||||||
|
stage_id = "%s:%d" % [id_base, id_suffix]
|
||||||
|
id_suffix += 1
|
||||||
|
used_ids[stage_id] = true
|
||||||
|
entry["stage_object_id"] = stage_id
|
||||||
|
var asset_path: String = String(entry.get("stk_path", "")) if object_type == "stickman" else String(entry.get("asset_id", ""))
|
||||||
|
var base_name: String = STAGE_OBJECT_IDENTITY.asset_base_name(object_type, asset_path).strip_edges()
|
||||||
|
if base_name.is_empty():
|
||||||
|
base_name = "Character" if object_type == "stickman" else "Prop"
|
||||||
|
var stage_name: String = base_name
|
||||||
|
var name_suffix: int = 2
|
||||||
|
while used_names.has(stage_name.to_lower()):
|
||||||
|
stage_name = "%s %d" % [base_name, name_suffix]
|
||||||
|
name_suffix += 1
|
||||||
|
used_names[stage_name.to_lower()] = true
|
||||||
|
entry["stage_name"] = stage_name
|
||||||
|
|
||||||
|
|
||||||
static func _migrate_legacy_wall_tint(entry: Dictionary) -> void:
|
static func _migrate_legacy_wall_tint(entry: Dictionary) -> void:
|
||||||
var finish: StringName = StringName(String(entry.get("finish_id", "finish_wall_paint")))
|
var finish: StringName = StringName(String(entry.get("finish_id", "finish_wall_paint")))
|
||||||
if finish not in [&"finish_wall_brick", &"finish_wall_wood"]:
|
if finish not in [&"finish_wall_brick", &"finish_wall_wood"]:
|
||||||
@@ -268,22 +323,35 @@ static func _validate_root(data: Dictionary) -> Dictionary:
|
|||||||
return {"ok": false, "error": "Scene Plain Background color is invalid."}
|
return {"ok": false, "error": "Scene Plain Background color is invalid."}
|
||||||
var seen: Dictionary = {}
|
var seen: Dictionary = {}
|
||||||
var seen_stage_ids: Dictionary = {}
|
var seen_stage_ids: Dictionary = {}
|
||||||
|
var seen_stage_names: Dictionary = {}
|
||||||
for raw: Variant in data["objects"]:
|
for raw: Variant in data["objects"]:
|
||||||
if not raw is Dictionary: continue
|
if not raw is Dictionary: continue
|
||||||
var entry: Dictionary = raw
|
var entry: Dictionary = raw
|
||||||
var file_id := String(entry.get("file_id", ""))
|
var file_id := String(entry.get("file_id", ""))
|
||||||
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
|
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
|
||||||
seen[file_id] = true
|
seen[file_id] = true
|
||||||
if String(entry.get("type", "")) == "prop":
|
var object_type: String = String(entry.get("type", ""))
|
||||||
|
if object_type in ["stickman", "prop"]:
|
||||||
|
var raw_stage_id: Variant = entry.get("stage_object_id", null)
|
||||||
|
if not raw_stage_id is String:
|
||||||
|
return {"ok": false, "error": "Stage object IDs must be unique and non-empty strings."}
|
||||||
|
var stage_id: String = String(raw_stage_id)
|
||||||
|
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
|
||||||
|
return {"ok": false, "error": "Stage object IDs must be unique and non-empty."}
|
||||||
|
seen_stage_ids[stage_id] = true
|
||||||
|
var raw_stage_name: Variant = entry.get("stage_name", null)
|
||||||
|
if not raw_stage_name is String:
|
||||||
|
return {"ok": false, "error": "Stage object names must be trimmed, non-empty strings."}
|
||||||
|
var stage_name: String = String(raw_stage_name)
|
||||||
|
if stage_name.is_empty() or stage_name != stage_name.strip_edges():
|
||||||
|
return {"ok": false, "error": "Stage object names must be trimmed and non-empty."}
|
||||||
|
var name_key: String = stage_name.to_lower()
|
||||||
|
if seen_stage_names.has(name_key):
|
||||||
|
return {"ok": false, "error": "Stage object names must be unique, ignoring capitalization."}
|
||||||
|
seen_stage_names[name_key] = true
|
||||||
|
if object_type == "prop":
|
||||||
if entry.has("wobble_on_impact") and not entry["wobble_on_impact"] is bool:
|
if entry.has("wobble_on_impact") and not entry["wobble_on_impact"] is bool:
|
||||||
return {"ok": false, "error": "Prop wobble_on_impact must be true or false."}
|
return {"ok": false, "error": "Prop wobble_on_impact must be true or false."}
|
||||||
var raw_stage_id: Variant = entry.get("stage_object_id", "")
|
|
||||||
if not raw_stage_id is String:
|
|
||||||
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."}
|
|
||||||
var stage_id := String(raw_stage_id)
|
|
||||||
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
|
|
||||||
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
|
|
||||||
seen_stage_ids[stage_id] = true
|
|
||||||
var use_spot_error: String = PROP_USE_SPOT_DATA.validate_saved(entry.get("use_spots", []), entry.get("next_use_spot_number", 1))
|
var use_spot_error: String = PROP_USE_SPOT_DATA.validate_saved(entry.get("use_spots", []), entry.get("next_use_spot_number", 1))
|
||||||
if not use_spot_error.is_empty():
|
if not use_spot_error.is_empty():
|
||||||
return {"ok": false, "error": use_spot_error}
|
return {"ok": false, "error": use_spot_error}
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
|||||||
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||||
const STAGE_BACKGROUND_GROUND := preload("res://scripts/stage/scenery/stage_background_ground.gd")
|
const STAGE_BACKGROUND_GROUND := preload("res://scripts/stage/scenery/stage_background_ground.gd")
|
||||||
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
||||||
|
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||||
|
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||||
|
const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||||
|
|
||||||
enum SelectionScope { ACTION, SCENERY }
|
enum SelectionScope { ACTION, SCENERY }
|
||||||
|
const VISIBLE_HIT_TOLERANCE_PX: float = 7.0
|
||||||
|
|
||||||
## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds).
|
## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds).
|
||||||
const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
|
const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
|
||||||
@@ -120,10 +125,13 @@ func hit_test_all(world_pos: Vector2) -> Array[Node2D]:
|
|||||||
var node := child as Node2D
|
var node := child as Node2D
|
||||||
if node == null or not _is_selectable(node):
|
if node == null or not _is_selectable(node):
|
||||||
continue
|
continue
|
||||||
if get_world_aabb(node).has_point(world_pos):
|
var broad_bounds: Rect2 = get_world_aabb(node)
|
||||||
|
if _is_named_visual_object(node):
|
||||||
|
broad_bounds = broad_bounds.grow((VISIBLE_HIT_TOLERANCE_PX + 24.0) / maxf(_camera.zoom.x if _camera != null else 1.0, 0.0001))
|
||||||
|
if broad_bounds.has_point(world_pos) and _visible_hit(node, world_pos, VISIBLE_HIT_TOLERANCE_PX):
|
||||||
hits.append(node)
|
hits.append(node)
|
||||||
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos) if _scope == SelectionScope.ACTION else null
|
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos) if _scope == SelectionScope.ACTION else null
|
||||||
if ragdoll_hit != null and not hits.has(ragdoll_hit):
|
if ragdoll_hit != null and not hits.has(ragdoll_hit) and _visible_hit(ragdoll_hit, world_pos, VISIBLE_HIT_TOLERANCE_PX):
|
||||||
hits.append(ragdoll_hit)
|
hits.append(ragdoll_hit)
|
||||||
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
|
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
|
||||||
if a.z_index != b.z_index:
|
if a.z_index != b.z_index:
|
||||||
@@ -241,6 +249,78 @@ func _is_selectable(node: Node) -> bool:
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _is_named_visual_object(node: Node2D) -> bool:
|
||||||
|
return node is PROP_BLOCK or node is STICKMAN_RIG or node.get_script() == MISSING_PROP or node.get_script() == MISSING_STICKMAN
|
||||||
|
|
||||||
|
|
||||||
|
func _visible_hit(node: Node2D, world_pos: Vector2, screen_tolerance_px: float) -> bool:
|
||||||
|
if node == null or not is_instance_valid(node) or not node.is_visible_in_tree():
|
||||||
|
return false
|
||||||
|
if node is PROP_BLOCK or node is STICKMAN_RIG or node.get_script() == MISSING_PROP or node.get_script() == MISSING_STICKMAN:
|
||||||
|
var inherited_opacity: float = _ancestor_opacity(node.get_parent())
|
||||||
|
if inherited_opacity <= 0.01 or _node_opacity(node) <= 0.01:
|
||||||
|
return false
|
||||||
|
if node.has_method("get_area_rect"):
|
||||||
|
return (node.global_transform * (node.call("get_area_rect") as Rect2)).has_point(world_pos)
|
||||||
|
var tolerance_world: float = maxf(screen_tolerance_px, 0.0) / maxf(_camera.zoom.x if _camera != null else 1.0, 0.0001)
|
||||||
|
return _visible_geometry_hit(node, world_pos, tolerance_world, inherited_opacity)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _visible_geometry_hit(root: Node, world_pos: Vector2, tolerance_world: float, inherited_opacity: float) -> bool:
|
||||||
|
var opacity: float = inherited_opacity
|
||||||
|
if root is CanvasItem:
|
||||||
|
var canvas_item: CanvasItem = root as CanvasItem
|
||||||
|
if not canvas_item.is_visible_in_tree():
|
||||||
|
return false
|
||||||
|
opacity *= _node_opacity(canvas_item)
|
||||||
|
if opacity <= 0.01:
|
||||||
|
return false
|
||||||
|
if root is Polygon2D:
|
||||||
|
var polygon: Polygon2D = root as Polygon2D
|
||||||
|
if polygon.color.a * opacity > 0.01 and polygon.polygon.size() >= 3:
|
||||||
|
var local_point: Vector2 = polygon.to_local(world_pos)
|
||||||
|
if Geometry2D.is_point_in_polygon(local_point, polygon.polygon):
|
||||||
|
return true
|
||||||
|
elif root is Line2D:
|
||||||
|
var line: Line2D = root as Line2D
|
||||||
|
if line.default_color.a * opacity > 0.01 and line.points.size() >= 2 and _point_near_line(line, world_pos, tolerance_world):
|
||||||
|
return true
|
||||||
|
for child: Node in root.get_children():
|
||||||
|
if _visible_geometry_hit(child, world_pos, tolerance_world, opacity):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _ancestor_opacity(start: Node) -> float:
|
||||||
|
var opacity: float = 1.0
|
||||||
|
var current: Node = start
|
||||||
|
while current != null:
|
||||||
|
if current is CanvasItem:
|
||||||
|
opacity *= _node_opacity(current as CanvasItem)
|
||||||
|
current = current.get_parent()
|
||||||
|
return opacity
|
||||||
|
|
||||||
|
|
||||||
|
func _node_opacity(item: CanvasItem) -> float:
|
||||||
|
return clampf(item.modulate.a * item.self_modulate.a, 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _point_near_line(line: Line2D, world_pos: Vector2, tolerance_world: float) -> bool:
|
||||||
|
var points: PackedVector2Array = line.points
|
||||||
|
var axis_x_scale: float = line.global_transform.basis_xform(Vector2.RIGHT).length()
|
||||||
|
var axis_y_scale: float = line.global_transform.basis_xform(Vector2.DOWN).length()
|
||||||
|
var half_width: float = line.width * maxf(axis_x_scale, axis_y_scale) * 0.5
|
||||||
|
var threshold_sq: float = (half_width + tolerance_world) * (half_width + tolerance_world)
|
||||||
|
var segment_count: int = points.size() - 1 + (1 if line.closed else 0)
|
||||||
|
for segment_index: int in segment_count:
|
||||||
|
var start: Vector2 = line.to_global(points[segment_index % points.size()])
|
||||||
|
var end: Vector2 = line.to_global(points[(segment_index + 1) % points.size()])
|
||||||
|
if Geometry2D.get_closest_point_to_segment(world_pos, start, end).distance_squared_to(world_pos) <= threshold_sq:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
|
func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
|
||||||
if _world == null or not is_instance_valid(_world):
|
if _world == null or not is_instance_valid(_world):
|
||||||
return null
|
return null
|
||||||
|
|||||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,720 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
|
||||||
|
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||||
|
const STAGE_GIZMOS := preload("res://scripts/stage/stage_gizmos.gd")
|
||||||
|
const CAPTURE_DIR: String = "res://tests/phase20a_captures"
|
||||||
|
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run_capture_scenarios")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_capture_scenarios() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.position = Vector2i(40, 40)
|
||||||
|
root.gui_embed_subwindows = true
|
||||||
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR))
|
||||||
|
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
|
||||||
|
root.add_child(stage)
|
||||||
|
await _wait_frames(8)
|
||||||
|
stage.call("_on_grid_toggled", false)
|
||||||
|
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
||||||
|
spawner.selected_prop_id = "crate"
|
||||||
|
stage.set_placement_mode("prop")
|
||||||
|
var center_screen: Vector2 = _screen_at(stage, Vector2.ZERO)
|
||||||
|
await _send_mouse_motion(center_screen)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(_placement_prompt(stage).visible, "placement prompt is visible")
|
||||||
|
_check(_placement_prompt(stage).text.contains("Click to place Crate"), "placement prompt shows the next automatic name")
|
||||||
|
await _save_capture("placement_name_prompt")
|
||||||
|
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(4)
|
||||||
|
var placed_props: Array[PropBlock] = _placed_props(stage)
|
||||||
|
var chooser: PopupPanel = null
|
||||||
|
_check(placed_props.size() == 1, "first prop is placed through the viewport input path")
|
||||||
|
if placed_props.size() >= 1:
|
||||||
|
_check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name")
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(4)
|
||||||
|
placed_props = _placed_props(stage)
|
||||||
|
_check(placed_props.size() == 2, "second prop is placed through the viewport input path")
|
||||||
|
if placed_props.size() < 2:
|
||||||
|
stage.queue_free()
|
||||||
|
await _wait_frames(2)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
_check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name")
|
||||||
|
_check(placed_props[1].stage_name == "Crate 2", "repeat placement gets the next unique name")
|
||||||
|
await _make_prop_rectangle(placed_props[0], Color("#C46B48"))
|
||||||
|
await _make_prop_rectangle(placed_props[1], Color("#4D86C6"))
|
||||||
|
stage.call("_show_placement_feedback", placed_props[1], placed_props[1].stage_name)
|
||||||
|
stage.set_placement_mode("")
|
||||||
|
await _wait_frames(3)
|
||||||
|
await _save_capture("placement_feedback")
|
||||||
|
await create_timer(1.8).timeout
|
||||||
|
|
||||||
|
# A third prop shares the broad-phase bounds, but its visible outline leaves
|
||||||
|
# the click point empty. The production chooser should exclude it.
|
||||||
|
var outline_decoy: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO)
|
||||||
|
if outline_decoy != null:
|
||||||
|
stage.call("_finalize_placed_node", outline_decoy)
|
||||||
|
await _make_prop_outline(outline_decoy)
|
||||||
|
var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||||
|
var rename_result: Dictionary = identity.rename(outline_decoy, "Outline Decoy", stage.get_node("World") as Node2D)
|
||||||
|
_check(bool(rename_result.get("ok", false)), "outline-only decoy gets a unique label")
|
||||||
|
else:
|
||||||
|
_check(false, "outline-only decoy was spawned")
|
||||||
|
await _wait_frames(4)
|
||||||
|
|
||||||
|
var world: Node2D = stage.get_node("World") as Node2D
|
||||||
|
var character: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-560.0, 0.0))
|
||||||
|
if character != null:
|
||||||
|
stage.call("_finalize_placed_node", character)
|
||||||
|
var selection: StageSelection = stage.get("_selection") as StageSelection
|
||||||
|
selection.select_only(character)
|
||||||
|
await _wait_frames(4)
|
||||||
|
await _save_capture("character_inspector")
|
||||||
|
var character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
|
||||||
|
var rename_button: Button = character_view.find_child("RenameStageObject", true, false) as Button
|
||||||
|
var character_card: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
|
||||||
|
var character_title: Label = character_card.find_child("InspectorTitle", true, false) as Label
|
||||||
|
var character_body: ScrollContainer = character_card.find_child("InspectorBodyScroll", true, false) as ScrollContainer
|
||||||
|
_check(rename_button != null and rename_button.visible and rename_button.text == "Rename", "character inspector has a visible Rename action in its header")
|
||||||
|
_check(character_title != null and character_title.text == character.stage_name, "character name appears once as the inspector heading")
|
||||||
|
_check(character_body != null and not character_body.visible, "character inspector stays a compact name row until editing")
|
||||||
|
if rename_button != null:
|
||||||
|
stage.call("_set_scene_dirty", false)
|
||||||
|
_check(not bool(stage.get("_dirty")), "name editor starts from a clean document")
|
||||||
|
await _click_control(rename_button)
|
||||||
|
await _wait_frames(3)
|
||||||
|
var character_edit: LineEdit = character_view.find_child("StageNameEdit", true, false) as LineEdit
|
||||||
|
_check(character_edit != null and character_edit.visible, "Rename opens the inline editor")
|
||||||
|
await _save_capture("character_name_edit")
|
||||||
|
if character_edit != null:
|
||||||
|
_check(character_edit.has_focus(), "inline name editor receives keyboard focus")
|
||||||
|
await _send_key(KEY_A, false, true)
|
||||||
|
await _type_text("Milo")
|
||||||
|
_check(character_edit.text == "Milo", "injected keyboard input replaces the character name")
|
||||||
|
var done_button: Button = character_view.find_child("DoneStageName", true, false) as Button
|
||||||
|
if done_button != null:
|
||||||
|
await _click_control(done_button)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(character.stage_name == "Milo", "valid name commits from the inline editor")
|
||||||
|
_check(character_title != null and character_title.text == "Milo", "renamed character heading updates immediately")
|
||||||
|
_check(bool(stage.get("_dirty")), "valid rename marks the document dirty")
|
||||||
|
await _save_capture("character_renamed")
|
||||||
|
if rename_button != null:
|
||||||
|
stage.call("_set_scene_dirty", false)
|
||||||
|
_check(not bool(stage.get("_dirty")), "duplicate-name check starts from a clean document")
|
||||||
|
await _click_control(rename_button)
|
||||||
|
await _wait_frames(3)
|
||||||
|
character_edit = character_view.find_child("StageNameEdit", true, false) as LineEdit
|
||||||
|
if character_edit != null:
|
||||||
|
_check(character_edit.has_focus(), "duplicate-name editor receives keyboard focus")
|
||||||
|
await _send_key(KEY_A, false, true)
|
||||||
|
await _type_text(placed_props[0].stage_name)
|
||||||
|
_check(character_edit.text == placed_props[0].stage_name, "injected keyboard input enters the duplicate name")
|
||||||
|
await _send_key(KEY_ENTER)
|
||||||
|
await _wait_frames(3)
|
||||||
|
var feedback: Label = character_view.find_child("StageNameFeedback", true, false) as Label
|
||||||
|
_check(feedback != null and feedback.visible and feedback.text.contains("Crate"), "character duplicate name remains in the editor with feedback")
|
||||||
|
_check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "invalid duplicate rename leaves the name and dirty state unchanged")
|
||||||
|
await _save_capture("character_duplicate_name_feedback")
|
||||||
|
var cancel_name_button: Button = character_view.find_child("CancelStageName", true, false) as Button
|
||||||
|
if cancel_name_button != null:
|
||||||
|
await _click_control(cancel_name_button)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "Cancel discards the draft without marking the scene dirty")
|
||||||
|
else:
|
||||||
|
_check(false, "character asset is spawned for inspector capture")
|
||||||
|
|
||||||
|
placed_props = _placed_props(stage)
|
||||||
|
var first_prop: PropBlock = placed_props[0] if not placed_props.is_empty() else null
|
||||||
|
var second_prop: PropBlock = placed_props[1] if placed_props.size() > 1 else null
|
||||||
|
var selection_after_setup: StageSelection = stage.get("_selection") as StageSelection
|
||||||
|
if first_prop != null:
|
||||||
|
selection_after_setup.select_only(first_prop)
|
||||||
|
await _wait_frames(3)
|
||||||
|
await _save_capture("prop_inspector")
|
||||||
|
var prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
||||||
|
_check(prop_view != null and prop_view.visible, "selected prop inspector is visible")
|
||||||
|
var prop_card: StageInspectorPanel = prop_view.get_card() as StageInspectorPanel
|
||||||
|
var prop_title: Label = prop_card.find_child("InspectorTitle", true, false) as Label
|
||||||
|
var prop_rename_button: Button = prop_card.find_child("RenameStageObject", true, false) as Button
|
||||||
|
var collapse_button: Button = prop_card.find_child("CollapseStageInspector", true, false) as Button
|
||||||
|
_check(prop_title != null and prop_title.text == first_prop.stage_name, "prop name appears once in the inspector heading")
|
||||||
|
_check(_find_label_with_text(prop_view, "Name on stage") == null, "prop inspector does not repeat its name in the body")
|
||||||
|
_check(prop_rename_button != null and prop_rename_button.visible and prop_rename_button.text == "Rename", "prop inspector shares the header Rename action")
|
||||||
|
_check(collapse_button != null and collapse_button.visible and not prop_card.is_collapsed(), "expanded prop inspector has a disclosure control")
|
||||||
|
if collapse_button != null:
|
||||||
|
await _click_control(collapse_button)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(prop_card.visible and prop_card.is_collapsed() and collapse_button.text == "▾", "Close collapses to the named prop header")
|
||||||
|
_check(_find_button_with_text(prop_view, "Settings") == null, "collapsed inspector does not replace the name with a Settings button")
|
||||||
|
await _save_capture("prop_inspector_collapsed")
|
||||||
|
if prop_rename_button != null:
|
||||||
|
await _click_control(prop_rename_button)
|
||||||
|
await _wait_frames(3)
|
||||||
|
var prop_name_edit: LineEdit = prop_view.find_child("StageNameEdit", true, false) as LineEdit
|
||||||
|
_check(not prop_card.is_collapsed() and prop_name_edit != null and prop_name_edit.visible, "Rename expands the inspector and opens its name editor")
|
||||||
|
var prop_cancel_name: Button = prop_view.find_child("CancelStageName", true, false) as Button
|
||||||
|
if prop_cancel_name != null:
|
||||||
|
await _click_control(prop_cancel_name)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(prop_card.is_collapsed(), "Cancel returns to the prior collapsed state")
|
||||||
|
await _click_control(collapse_button)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(not prop_card.is_collapsed() and prop_view.get_fields().visible, "chevron expands the full prop settings body")
|
||||||
|
|
||||||
|
# Click at a true overlap through the stage's normal unhandled input route.
|
||||||
|
# Existing selection remains unchanged while the chooser is open.
|
||||||
|
if first_prop != null and second_prop != null:
|
||||||
|
selection_after_setup.select_only(first_prop)
|
||||||
|
await _wait_frames(2)
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(5)
|
||||||
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||||
|
_check(chooser != null and chooser.visible, "overlap click opens Objects here")
|
||||||
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "opening chooser leaves the previous selection unchanged")
|
||||||
|
var candidates: Array[Node2D] = selection_after_setup.hit_test_all(Vector2.ZERO)
|
||||||
|
_check(candidates.size() == 2 and candidates.has(first_prop) and candidates.has(second_prop), "chooser hit test excludes the empty center of the outline-only decoy")
|
||||||
|
if chooser != null:
|
||||||
|
var chooser_shell: StyleBoxFlat = chooser.get_theme_stylebox("panel") as StyleBoxFlat
|
||||||
|
_check(chooser_shell != null and chooser_shell.corner_radius_top_left >= 12, "overlap chooser shell has the rounded popup treatment")
|
||||||
|
_check(chooser_shell != null and chooser_shell.border_width_left >= 2 and chooser_shell.shadow_size > 0, "overlap chooser shell has a clear border and local shadow")
|
||||||
|
var selected_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||||
|
var selected_candidate_style: StyleBoxFlat = selected_candidate.get_theme_stylebox("normal") as StyleBoxFlat if selected_candidate != null else null
|
||||||
|
_check(selected_candidate_style != null and selected_candidate_style.corner_radius_top_left >= 10, "overlap candidate rows use rounded corners")
|
||||||
|
var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button
|
||||||
|
var cancel_style: StyleBoxFlat = cancel_button.get_theme_stylebox("normal") as StyleBoxFlat if cancel_button != null else null
|
||||||
|
_check(cancel_button != null and cancel_button.custom_minimum_size.y >= 48, "overlap chooser Cancel has a large target")
|
||||||
|
_check(cancel_style != null and cancel_style.corner_radius_top_left >= 8, "overlap chooser Cancel uses the rounded secondary treatment")
|
||||||
|
await _save_capture("overlap_chooser")
|
||||||
|
if chooser != null:
|
||||||
|
var candidate_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||||
|
if candidate_button != null:
|
||||||
|
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||||
|
await _send_mouse_motion(candidate_button.get_screen_position() + candidate_button.size * 0.5, candidate_button.get_window().get_window_id())
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "pointer hover previews the exact overlap candidate")
|
||||||
|
await _save_capture("overlap_hover_preview")
|
||||||
|
await _send_mouse_motion(Vector2(860.0, 360.0), chooser.get_window().get_window_id())
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(gizmos != null and gizmos.get("_candidate_preview") == null, "leaving a candidate row clears hover preview")
|
||||||
|
await _send_key(KEY_TAB, true)
|
||||||
|
await _send_key(KEY_TAB, true)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(candidate_button.has_focus(), "Shift+Tab keyboard navigation focuses the first overlap candidate")
|
||||||
|
_check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "keyboard focus previews the exact candidate")
|
||||||
|
await _save_capture("overlap_candidate_preview")
|
||||||
|
var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button
|
||||||
|
if cancel_button != null:
|
||||||
|
await _click_control(cancel_button)
|
||||||
|
await _wait_frames(4)
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "Cancel closes the chooser")
|
||||||
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Cancel preserves the previous selection")
|
||||||
|
await _save_capture("overlap_cancelled")
|
||||||
|
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(4)
|
||||||
|
await _send_key(KEY_ESCAPE)
|
||||||
|
await _wait_frames(4)
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "Escape closes the chooser")
|
||||||
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Escape preserves the previous selection")
|
||||||
|
var escape_gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||||
|
_check(escape_gizmos == null or escape_gizmos.get("_candidate_preview") == null, "Escape clears the transient candidate preview")
|
||||||
|
await _save_capture("overlap_escape_dismissal")
|
||||||
|
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(4)
|
||||||
|
await _send_click(_screen_at(stage, Vector2(-60.0, -40.0)))
|
||||||
|
await _wait_frames(4)
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "outside dismissal click closes the chooser")
|
||||||
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "outside dismissal consumes the click and preserves selection")
|
||||||
|
await _save_capture("overlap_outside_dismissal")
|
||||||
|
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(4)
|
||||||
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||||
|
if chooser != null:
|
||||||
|
var choose_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||||
|
if choose_button != null:
|
||||||
|
await _click_control(choose_button)
|
||||||
|
await _wait_frames(5)
|
||||||
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == second_prop, "choosing a row commits that object once")
|
||||||
|
await _save_capture("overlap_committed")
|
||||||
|
|
||||||
|
# DIRECT uses the same geometry-filtered chooser with a prop-only list and
|
||||||
|
# target selection does not take over EDIT selection.
|
||||||
|
var direct_use_spot_id: String = second_prop.add_use_spot(Vector2.ZERO) if second_prop != null else ""
|
||||||
|
_check(not direct_use_spot_id.is_empty(), "DIRECT target fixture has a valid Use spot")
|
||||||
|
stage.set_mode(SandboxStage.StageMode.DIRECT)
|
||||||
|
selection_after_setup.select_only(first_prop)
|
||||||
|
stage.call("_begin_use_target_capture", "direct")
|
||||||
|
await _wait_frames(4)
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(4)
|
||||||
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||||
|
_check(chooser != null and chooser.visible, "DIRECT Use opens the shared prop chooser")
|
||||||
|
if chooser != null:
|
||||||
|
var direct_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||||
|
_check(direct_rows != null and direct_rows.get_child_count() == 2, "DIRECT Use chooser contains only the two props that actually cover the point")
|
||||||
|
await _save_capture("direct_use_chooser")
|
||||||
|
var direct_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
||||||
|
if direct_candidate != null:
|
||||||
|
await _send_key(KEY_TAB, true)
|
||||||
|
await _send_key(KEY_TAB, true)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(direct_candidate.has_focus(), "DIRECT picker candidate can receive keyboard focus")
|
||||||
|
await _save_capture("direct_use_preview")
|
||||||
|
await _click_control(direct_candidate)
|
||||||
|
await _wait_frames(4)
|
||||||
|
var target_capture: StageTargetCaptureSession = stage.get("_target_capture") as StageTargetCaptureSession
|
||||||
|
_check(target_capture != null and target_capture.use_target_prop == second_prop and target_capture.use_target_step == 1, "DIRECT chooser advances to Use spot selection for the chosen prop")
|
||||||
|
var direct_capture_prompt: Label = stage.find_child("Prompt", true, false) as Label
|
||||||
|
_check(direct_capture_prompt != null and direct_capture_prompt.text == "Choose a spot on the prop", "valid DIRECT prop selection shows the next-step prompt")
|
||||||
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "DIRECT target selection leaves object selection untouched")
|
||||||
|
await _save_capture("direct_use_target_selected")
|
||||||
|
else:
|
||||||
|
_check(false, "DIRECT target chooser is available for interaction")
|
||||||
|
|
||||||
|
# A director selects between overlapping named characters. A nearby Blue Door
|
||||||
|
# remains visible in the scene but its actual art does not cover the click.
|
||||||
|
stage.set_mode(SandboxStage.StageMode.EDIT)
|
||||||
|
var props_clear_of_character_point: Array[PropBlock] = _placed_props(stage)
|
||||||
|
for prop_index: int in props_clear_of_character_point.size():
|
||||||
|
props_clear_of_character_point[prop_index].position = Vector2(1000.0 + float(prop_index) * 220.0, 700.0)
|
||||||
|
if character != null:
|
||||||
|
character.position = Vector2.ZERO
|
||||||
|
var ava: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", character.position)
|
||||||
|
var character_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||||
|
if ava != null:
|
||||||
|
stage.call("_finalize_placed_node", ava)
|
||||||
|
var ava_result: Dictionary = character_identity.rename(ava, "Ava", stage.get_node("World") as Node2D)
|
||||||
|
_check(bool(ava_result.get("ok", false)), "overlapping second character receives the distinct name Ava")
|
||||||
|
else:
|
||||||
|
_check(false, "overlapping second character is spawned")
|
||||||
|
var blue_door: PropBlock = spawner.spawn_prop_asset("crate", character.position + Vector2(220.0, 0.0))
|
||||||
|
if blue_door != null:
|
||||||
|
stage.call("_finalize_placed_node", blue_door)
|
||||||
|
await _make_blue_door(blue_door)
|
||||||
|
var door_result: Dictionary = character_identity.rename(blue_door, "Blue Door", stage.get_node("World") as Node2D)
|
||||||
|
_check(bool(door_result.get("ok", false)), "nearby prop receives the descriptive name Blue Door")
|
||||||
|
else:
|
||||||
|
_check(false, "nearby Blue Door prop is spawned")
|
||||||
|
if ava != null and blue_door != null:
|
||||||
|
var shared_point: Vector2 = _find_shared_hit_point(stage.get("_selection") as StageSelection, [character, ava])
|
||||||
|
_check(shared_point.is_finite(), "overlapping Milo and Ava share a visible hit point")
|
||||||
|
if shared_point.is_finite():
|
||||||
|
var shared_hits: Array[Node2D] = (stage.get("_selection") as StageSelection).hit_test_all(shared_point)
|
||||||
|
_check(shared_hits.size() == 2 and shared_hits.has(character) and shared_hits.has(ava) and not shared_hits.has(blue_door), "character overlap hit test omits the nearby Blue Door outside visible geometry")
|
||||||
|
selection_after_setup.select_only(first_prop)
|
||||||
|
await _send_click(_screen_at(stage, shared_point))
|
||||||
|
await _wait_frames(5)
|
||||||
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||||
|
_check(chooser != null and chooser.visible, "overlapping Milo and Ava open the production Objects here chooser")
|
||||||
|
if chooser != null:
|
||||||
|
var character_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||||
|
_check(character_rows != null and character_rows.get_child_count() == 2, "character overlap chooser contains Milo and Ava only")
|
||||||
|
var row_names: Array[String] = []
|
||||||
|
if character_rows != null:
|
||||||
|
for row_index: int in character_rows.get_child_count():
|
||||||
|
var row_button: Button = character_rows.get_child(row_index) as Button
|
||||||
|
if row_button == null:
|
||||||
|
continue
|
||||||
|
for label_node: Node in row_button.find_children("*", "Label", true, false):
|
||||||
|
var row_label: Label = label_node as Label
|
||||||
|
if row_label != null:
|
||||||
|
for expected_name: String in ["Milo", "Ava"]:
|
||||||
|
if row_label.text.contains(expected_name) and not row_names.has(expected_name):
|
||||||
|
row_names.append(expected_name)
|
||||||
|
_check(row_names.size() == 2 and row_names.has("Milo") and row_names.has("Ava"), "chooser rows identify the two characters by their stage names")
|
||||||
|
await _save_capture("character_overlap_chooser")
|
||||||
|
var ava_button: Button = _find_candidate_button_for_name(chooser, "Ava")
|
||||||
|
if ava_button != null:
|
||||||
|
await _send_mouse_motion(ava_button.get_screen_position() + ava_button.size * 0.5, ava_button.get_window().get_window_id())
|
||||||
|
await _wait_frames(3)
|
||||||
|
await _save_capture("character_overlap_preview")
|
||||||
|
await _click_control(ava_button)
|
||||||
|
await _wait_frames(4)
|
||||||
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == ava, "choosing Ava commits the character selection")
|
||||||
|
else:
|
||||||
|
_check(false, "Ava row is available for mouse selection")
|
||||||
|
await _save_capture("character_overlap_committed")
|
||||||
|
else:
|
||||||
|
_check(false, "character overlap chooser is available for candidate inspection")
|
||||||
|
for prop_index: int in props_clear_of_character_point.size():
|
||||||
|
props_clear_of_character_point[prop_index].position = Vector2.ZERO
|
||||||
|
else:
|
||||||
|
_check(false, "Milo is available for the character overlap scenario")
|
||||||
|
|
||||||
|
# The larger overlap exercises the chooser's bounded scroll area at a small
|
||||||
|
# viewport while retaining the normal stage click and popup input routes.
|
||||||
|
stage.set_mode(SandboxStage.StageMode.EDIT)
|
||||||
|
for extra_index: int in range(5):
|
||||||
|
var extra_prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO)
|
||||||
|
if extra_prop == null:
|
||||||
|
_check(false, "crowded chooser prop %d is spawned" % extra_index)
|
||||||
|
continue
|
||||||
|
stage.call("_finalize_placed_node", extra_prop)
|
||||||
|
await _make_prop_rectangle(extra_prop, Color.from_hsv(float(extra_index) / 5.0, 0.55, 0.82))
|
||||||
|
root.size = Vector2i(920, 560)
|
||||||
|
await _wait_frames(8)
|
||||||
|
var stage_characters: Array[StickmanRig] = []
|
||||||
|
for child: Node in (stage.get_node("World") as Node2D).get_children():
|
||||||
|
if child is StickmanRig:
|
||||||
|
stage_characters.append(child as StickmanRig)
|
||||||
|
(child as StickmanRig).visible = false
|
||||||
|
if first_prop != null:
|
||||||
|
selection_after_setup.select_only(first_prop)
|
||||||
|
stage.call("_refresh_prop_settings_card")
|
||||||
|
await _wait_frames(4)
|
||||||
|
var prop_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||||
|
var original_prop_name: String = first_prop.stage_name
|
||||||
|
var long_prop_name: String = "Crate with a Very Long Descriptive Stage Name"
|
||||||
|
var long_prop_result: Dictionary = prop_identity.rename(first_prop, long_prop_name, stage.get_node("World") as Node2D)
|
||||||
|
_check(bool(long_prop_result.get("ok", false)), "long prop name is valid for compact-header capture")
|
||||||
|
stage.call("_refresh_prop_settings_card")
|
||||||
|
await _wait_frames(5)
|
||||||
|
var long_prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
||||||
|
var long_prop_card: StageInspectorPanel = long_prop_view.get_card() as StageInspectorPanel
|
||||||
|
var long_prop_title: Label = long_prop_card.find_child("InspectorTitle", true, false) as Label
|
||||||
|
long_prop_card.set_collapsed(true)
|
||||||
|
await _wait_frames(4)
|
||||||
|
_check(long_prop_title != null and long_prop_title.max_lines_visible == 1 and long_prop_title.autowrap_mode == TextServer.AUTOWRAP_OFF, "collapsed prop title stays on one line")
|
||||||
|
_check(long_prop_title != null and long_prop_title.tooltip_text == long_prop_name and long_prop_title.text_overrun_behavior == TextServer.OVERRUN_TRIM_ELLIPSIS, "collapsed prop keeps its full-name tooltip and ellipsis")
|
||||||
|
await _save_capture("prop_inspector_collapsed_long_name_small_viewport")
|
||||||
|
prop_identity.rename(first_prop, original_prop_name, stage.get_node("World") as Node2D)
|
||||||
|
stage.call("_refresh_prop_settings_card")
|
||||||
|
long_prop_card.set_collapsed(false)
|
||||||
|
if character != null:
|
||||||
|
var character_identity_small: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
||||||
|
var original_position: Vector2 = character.global_position
|
||||||
|
var original_name: String = character.stage_name
|
||||||
|
var original_scale: Vector2 = character.scale
|
||||||
|
character.scale = Vector2(0.6, 0.6)
|
||||||
|
var canvas_to_world: Transform2D = stage.get_viewport().get_canvas_transform().affine_inverse()
|
||||||
|
character.visible = true
|
||||||
|
character.global_position = canvas_to_world * Vector2(float(root.size.x) * 0.52, 260.0)
|
||||||
|
var long_character_name: String = "Milo with a Very Long Character Name"
|
||||||
|
character_identity_small.rename(character, long_character_name, stage.get_node("World") as Node2D)
|
||||||
|
selection_after_setup.select_only(character)
|
||||||
|
stage.call("_refresh_character_settings_card")
|
||||||
|
await _wait_frames(7)
|
||||||
|
var compact_character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
|
||||||
|
var compact_character_card: StageInspectorPanel = compact_character_view.get_card() as StageInspectorPanel
|
||||||
|
var compact_character_title: Label = compact_character_card.find_child("InspectorTitle", true, false) as Label
|
||||||
|
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||||
|
var character_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(character)
|
||||||
|
var rotation_handle: Vector2 = gizmos._rotate_handle_position(character_bounds)
|
||||||
|
_check(character.source_stk_path.ends_with("break.stk"), "colorful Break character is present in the visual review")
|
||||||
|
_check(compact_character_title != null and compact_character_title.max_lines_visible == 1 and compact_character_title.tooltip_text == long_character_name, "compact character header stays one line and retains the full-name tooltip")
|
||||||
|
_check(rotation_handle.y > character_bounds.get_center().y, "rotate handle moves below a character near the top chrome")
|
||||||
|
_check(gizmos.hit_test(rotation_handle) == STAGE_GIZMOS.Handle.ROTATE, "lower rotate handle remains inside its hit target")
|
||||||
|
await _save_capture("break_character_long_name_top_handle_small_viewport")
|
||||||
|
var original_rotation: float = character.global_rotation
|
||||||
|
var rotate_press: Vector2 = _screen_at(stage, rotation_handle)
|
||||||
|
await _send_mouse_motion(rotate_press)
|
||||||
|
await _send_mouse_button(rotate_press, true)
|
||||||
|
await _wait_frames(2)
|
||||||
|
_check(gizmos.is_dragging(), "clicking the lower handle starts rotation through the stage input path")
|
||||||
|
var rotation_target: Vector2 = character_bounds.get_center() + (rotation_handle - character_bounds.get_center()).rotated(deg_to_rad(20.0))
|
||||||
|
await _send_mouse_motion(_screen_at(stage, rotation_target))
|
||||||
|
await _send_mouse_button(_screen_at(stage, rotation_target), false)
|
||||||
|
await _wait_frames(3)
|
||||||
|
_check(absf(angle_difference(character.global_rotation, original_rotation + deg_to_rad(20.0))) < 0.03, "dragging the lower handle rotates the selected character")
|
||||||
|
character.global_rotation = original_rotation
|
||||||
|
character.global_position = original_position
|
||||||
|
character.scale = original_scale
|
||||||
|
character_identity_small.rename(character, original_name, stage.get_node("World") as Node2D)
|
||||||
|
stage.call("_refresh_character_settings_card")
|
||||||
|
for stage_character: StickmanRig in stage_characters:
|
||||||
|
stage_character.visible = true
|
||||||
|
selection_after_setup.select_only(first_prop)
|
||||||
|
stage.call("_refresh_prop_settings_card")
|
||||||
|
center_screen = _screen_at(stage, Vector2.ZERO)
|
||||||
|
selection_after_setup.select_only(first_prop)
|
||||||
|
await _send_click(center_screen)
|
||||||
|
await _wait_frames(5)
|
||||||
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
||||||
|
_check(chooser != null and chooser.visible, "crowded overlap opens the chooser at a small viewport")
|
||||||
|
if chooser != null:
|
||||||
|
var crowded_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||||
|
_check(crowded_rows != null and crowded_rows.get_child_count() == 9, "crowded chooser lists seven props and both visible characters")
|
||||||
|
var popup_rect: Rect2 = Rect2(Vector2(chooser.position), Vector2(chooser.size))
|
||||||
|
_check(popup_rect.position.x >= 0.0 and popup_rect.position.y >= 64.0 and popup_rect.end.x <= float(root.size.x) and popup_rect.end.y <= float(root.size.y) - 38.0, "small viewport popup remains inside safe screen bounds")
|
||||||
|
var crowded_scroll: ScrollContainer = chooser.find_child("ObjectsHereScroll", true, false) as ScrollContainer
|
||||||
|
_check(crowded_scroll != null and crowded_scroll.size.y <= 320.0, "crowded chooser bounds its scroll viewport")
|
||||||
|
await _save_capture("crowded_chooser_small_viewport")
|
||||||
|
if crowded_scroll != null:
|
||||||
|
var scroll_pointer: Vector2 = crowded_scroll.get_screen_position() + crowded_scroll.size * 0.5
|
||||||
|
await _send_mouse_wheel_down(scroll_pointer, chooser.get_window().get_window_id())
|
||||||
|
await _wait_frames(4)
|
||||||
|
_check(crowded_scroll.scroll_vertical > 0, "crowded chooser scrolls through production mouse-wheel input")
|
||||||
|
await _save_capture("crowded_chooser_scrolled_small_viewport")
|
||||||
|
await _send_key(KEY_ESCAPE)
|
||||||
|
await _wait_frames(4)
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "Escape closes the crowded small-viewport chooser")
|
||||||
|
|
||||||
|
if _failures == 0:
|
||||||
|
print("PHASE20A_GUI_CAPTURE PASS: production input/render scenarios; captures saved under %s" % CAPTURE_DIR)
|
||||||
|
else:
|
||||||
|
push_error("PHASE20A_GUI_CAPTURE FAIL: %d assertion(s) failed" % _failures)
|
||||||
|
stage.queue_free()
|
||||||
|
await _wait_frames(2)
|
||||||
|
quit(1 if _failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _placed_props(stage: SandboxStage) -> Array[PropBlock]:
|
||||||
|
var result: Array[PropBlock] = []
|
||||||
|
var world: Node2D = stage.get_node("World") as Node2D
|
||||||
|
for child: Node in world.get_children():
|
||||||
|
if child is PropBlock:
|
||||||
|
result.append(child as PropBlock)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _placement_prompt(stage: SandboxStage) -> Label:
|
||||||
|
return stage.find_child("PlacementNamePrompt", true, false) as Label
|
||||||
|
|
||||||
|
|
||||||
|
func _make_prop_rectangle(prop: PropBlock, color: Color) -> void:
|
||||||
|
var points: PackedVector2Array = PackedVector2Array([
|
||||||
|
Vector2(-80.0, -60.0),
|
||||||
|
Vector2(80.0, -60.0),
|
||||||
|
Vector2(80.0, 60.0),
|
||||||
|
Vector2(-80.0, 60.0),
|
||||||
|
])
|
||||||
|
var shapes: Array[Dictionary] = [{
|
||||||
|
"shape_type": "polygon",
|
||||||
|
"points": points,
|
||||||
|
"closed": true,
|
||||||
|
"fill_color": color.to_html(false),
|
||||||
|
"outline_color": "#2D2532",
|
||||||
|
"outline_width": 3.0,
|
||||||
|
}]
|
||||||
|
prop.set_custom_shapes(shapes, points)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_prop_outline(prop: PropBlock) -> void:
|
||||||
|
var bars: Array[PackedVector2Array] = [
|
||||||
|
PackedVector2Array([Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, -50.0), Vector2(-80.0, -50.0)]),
|
||||||
|
PackedVector2Array([Vector2(70.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(70.0, 60.0)]),
|
||||||
|
PackedVector2Array([Vector2(-80.0, 50.0), Vector2(80.0, 50.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0)]),
|
||||||
|
PackedVector2Array([Vector2(-80.0, -60.0), Vector2(-70.0, -60.0), Vector2(-70.0, 60.0), Vector2(-80.0, 60.0)]),
|
||||||
|
]
|
||||||
|
var shapes: Array[Dictionary] = []
|
||||||
|
for points: PackedVector2Array in bars:
|
||||||
|
shapes.append({
|
||||||
|
"shape_type": "polygon",
|
||||||
|
"points": points,
|
||||||
|
"closed": true,
|
||||||
|
"fill_color": "#9B83C5",
|
||||||
|
"outline_color": "#392C4C",
|
||||||
|
"outline_width": 2.0,
|
||||||
|
})
|
||||||
|
var hull_points: PackedVector2Array = PackedVector2Array([
|
||||||
|
Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0),
|
||||||
|
])
|
||||||
|
prop.set_custom_shapes(shapes, Geometry2D.convex_hull(hull_points))
|
||||||
|
|
||||||
|
|
||||||
|
func _make_blue_door(prop: PropBlock) -> void:
|
||||||
|
var door_points: PackedVector2Array = PackedVector2Array([
|
||||||
|
Vector2(-45.0, -85.0),
|
||||||
|
Vector2(45.0, -85.0),
|
||||||
|
Vector2(45.0, 85.0),
|
||||||
|
Vector2(-45.0, 85.0),
|
||||||
|
])
|
||||||
|
var shapes: Array[Dictionary] = [{
|
||||||
|
"shape_type": "polygon",
|
||||||
|
"points": door_points,
|
||||||
|
"closed": true,
|
||||||
|
"fill_color": "#2878D5",
|
||||||
|
"outline_color": "#173C6B",
|
||||||
|
"outline_width": 5.0,
|
||||||
|
}]
|
||||||
|
var knob_points: PackedVector2Array = PackedVector2Array([
|
||||||
|
Vector2(28.0, -8.0), Vector2(34.0, -14.0), Vector2(40.0, -8.0), Vector2(34.0, -2.0),
|
||||||
|
])
|
||||||
|
shapes.append({
|
||||||
|
"shape_type": "polygon",
|
||||||
|
"points": knob_points,
|
||||||
|
"closed": true,
|
||||||
|
"fill_color": "#F7D45C",
|
||||||
|
"outline_color": "#684F1A",
|
||||||
|
"outline_width": 2.0,
|
||||||
|
})
|
||||||
|
prop.set_custom_shapes(shapes, door_points)
|
||||||
|
|
||||||
|
|
||||||
|
func _find_shared_hit_point(selection: StageSelection, nodes: Array[Node2D]) -> Vector2:
|
||||||
|
if selection == null or nodes.size() < 2:
|
||||||
|
return Vector2.INF
|
||||||
|
var shared_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(nodes[0])
|
||||||
|
for node_index: int in range(1, nodes.size()):
|
||||||
|
shared_bounds = shared_bounds.intersection(STAGE_SELECTION.get_world_aabb(nodes[node_index]))
|
||||||
|
if shared_bounds.size.x <= 0.0 or shared_bounds.size.y <= 0.0:
|
||||||
|
return Vector2.INF
|
||||||
|
for y_index: int in range(1, 10):
|
||||||
|
for x_index: int in range(1, 10):
|
||||||
|
var point: Vector2 = shared_bounds.position + Vector2(shared_bounds.size.x * float(x_index) / 10.0, shared_bounds.size.y * float(y_index) / 10.0)
|
||||||
|
var hits: Array[Node2D] = selection.hit_test_all(point)
|
||||||
|
var all_nodes_hit: bool = true
|
||||||
|
for node: Node2D in nodes:
|
||||||
|
if not hits.has(node):
|
||||||
|
all_nodes_hit = false
|
||||||
|
break
|
||||||
|
if all_nodes_hit:
|
||||||
|
return point
|
||||||
|
return Vector2.INF
|
||||||
|
|
||||||
|
|
||||||
|
func _find_candidate_button_for_name(popup: PopupPanel, candidate_name: String) -> Button:
|
||||||
|
var rows: VBoxContainer = popup.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
||||||
|
if rows == null:
|
||||||
|
return null
|
||||||
|
for row_index: int in rows.get_child_count():
|
||||||
|
var button: Button = rows.get_child(row_index) as Button
|
||||||
|
if button == null:
|
||||||
|
continue
|
||||||
|
for label_node: Node in button.find_children("*", "Label", true, false):
|
||||||
|
var label: Label = label_node as Label
|
||||||
|
if label != null and label.text.contains(candidate_name):
|
||||||
|
return button
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _find_button_with_text(parent: Node, text_value: String) -> Button:
|
||||||
|
for child: Node in parent.find_children("*", "Button", true, false):
|
||||||
|
var button: Button = child as Button
|
||||||
|
if button != null and button.text == text_value:
|
||||||
|
return button
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _find_label_with_text(parent: Node, text_value: String) -> Label:
|
||||||
|
for child: Node in parent.find_children("*", "Label", true, false):
|
||||||
|
var label: Label = child as Label
|
||||||
|
if label != null and label.text == text_value:
|
||||||
|
return label
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _screen_at(stage: SandboxStage, world_position: Vector2) -> Vector2:
|
||||||
|
return stage.get_viewport().get_canvas_transform() * world_position
|
||||||
|
|
||||||
|
|
||||||
|
func _click_control(control: Control) -> void:
|
||||||
|
var click_position: Vector2 = control.get_screen_position() + control.size * 0.5
|
||||||
|
var owner_window: Window = control.get_window()
|
||||||
|
print("PHASE20A_CONTROL_CLICK name=%s rect=%s screen=%s window_id=%d window_pos=%s root_window_id=%d" % [
|
||||||
|
control.name,
|
||||||
|
control.get_global_rect(),
|
||||||
|
control.get_screen_position(),
|
||||||
|
owner_window.get_window_id(),
|
||||||
|
owner_window.position,
|
||||||
|
root.get_window_id(),
|
||||||
|
])
|
||||||
|
await _send_click(click_position, owner_window.get_window_id())
|
||||||
|
|
||||||
|
|
||||||
|
func _send_click(position: Vector2, window_id: int = 0) -> void:
|
||||||
|
await _send_mouse_button(position, true, window_id)
|
||||||
|
await _wait_frames(1)
|
||||||
|
await _send_mouse_button(position, false, window_id)
|
||||||
|
await _wait_frames(2)
|
||||||
|
|
||||||
|
|
||||||
|
func _send_mouse_button(position: Vector2, pressed: bool, window_id: int = 0) -> void:
|
||||||
|
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||||
|
event.button_index = MOUSE_BUTTON_LEFT
|
||||||
|
event.position = position
|
||||||
|
event.global_position = position
|
||||||
|
event.window_id = window_id
|
||||||
|
event.pressed = pressed
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _send_mouse_wheel_down(position: Vector2, window_id: int = 0) -> void:
|
||||||
|
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||||
|
event.button_index = MOUSE_BUTTON_WHEEL_DOWN
|
||||||
|
event.position = position
|
||||||
|
event.global_position = position
|
||||||
|
event.window_id = window_id
|
||||||
|
event.pressed = true
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _send_mouse_motion(position: Vector2, window_id: int = 0) -> void:
|
||||||
|
var event: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||||
|
event.position = position
|
||||||
|
event.global_position = position
|
||||||
|
event.window_id = window_id
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _send_key(keycode: Key, with_shift: bool = false, with_ctrl: bool = false) -> void:
|
||||||
|
var event: InputEventKey = InputEventKey.new()
|
||||||
|
event.keycode = keycode
|
||||||
|
event.shift_pressed = with_shift
|
||||||
|
event.ctrl_pressed = with_ctrl
|
||||||
|
event.pressed = true
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
event = InputEventKey.new()
|
||||||
|
event.keycode = keycode
|
||||||
|
event.shift_pressed = with_shift
|
||||||
|
event.ctrl_pressed = with_ctrl
|
||||||
|
event.pressed = false
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _type_text(text_value: String) -> void:
|
||||||
|
for character_index: int in range(text_value.length()):
|
||||||
|
var character: String = text_value.substr(character_index, 1)
|
||||||
|
var event: InputEventKey = InputEventKey.new()
|
||||||
|
event.keycode = OS.find_keycode_from_string(character.to_upper())
|
||||||
|
event.unicode = text_value.unicode_at(character_index)
|
||||||
|
event.shift_pressed = character != character.to_lower()
|
||||||
|
event.pressed = true
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
event = InputEventKey.new()
|
||||||
|
event.keycode = OS.find_keycode_from_string(character.to_upper())
|
||||||
|
event.unicode = text_value.unicode_at(character_index)
|
||||||
|
event.shift_pressed = character != character.to_lower()
|
||||||
|
event.pressed = false
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_frames(count: int) -> void:
|
||||||
|
for _index: int in count:
|
||||||
|
await process_frame
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
|
||||||
|
|
||||||
|
func _save_capture(name: String) -> void:
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var image: Image = root.get_texture().get_image()
|
||||||
|
var path: String = CAPTURE_DIR.path_join("%s.png" % name)
|
||||||
|
var error: Error = image.save_png(ProjectSettings.globalize_path(path))
|
||||||
|
print("PHASE20A_CAPTURE path=%s size=%s error=%d" % [path, image.get_size(), error])
|
||||||
|
_check(error == OK and image.get_size() == root.size, "capture %s is saved at the expected viewport size" % name)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, label: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("PHASE20A_CHECK PASS: %s" % label)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error("PHASE20A_CHECK FAIL: %s" % label)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bpyp47ni2qyf5
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
|
||||||
|
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||||
|
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||||
|
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run_preview")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_preview() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.gui_embed_subwindows = true
|
||||||
|
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
|
||||||
|
root.add_child(stage)
|
||||||
|
await _wait_frames(10)
|
||||||
|
|
||||||
|
# Keep any existing user recovery copy intact while removing its modal from
|
||||||
|
# this visual-only preview. No recovery action or document edit is performed.
|
||||||
|
var recovery_dialog: ConfirmationDialog = stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog
|
||||||
|
if recovery_dialog != null:
|
||||||
|
STAGE_DIALOG_STYLER.hide(recovery_dialog)
|
||||||
|
await _wait_frames(2)
|
||||||
|
|
||||||
|
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
||||||
|
var first: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-18.0, 0.0))
|
||||||
|
var second: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(18.0, 0.0))
|
||||||
|
_check(first != null and second != null, "overlapping character fixtures are available")
|
||||||
|
if first == null or second == null:
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
first.stage_name = "Jimmy"
|
||||||
|
second.stage_name = "Timmy"
|
||||||
|
var crate: PropBlock = spawner.spawn_prop_asset("crate", Vector2(170.0, 190.0))
|
||||||
|
_check(crate != null, "prop inspector fixture is available")
|
||||||
|
if crate == null:
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
crate.stage_name = "Blue Door"
|
||||||
|
var selection: StageSelection = stage.get("_selection") as StageSelection
|
||||||
|
selection.select_only(crate)
|
||||||
|
await _wait_frames(4)
|
||||||
|
var views: StageModalViews = stage.get("_modal_views") as StageModalViews
|
||||||
|
var ui_layer: CanvasLayer = stage.get_node("UI") as CanvasLayer
|
||||||
|
_check(views != null and ui_layer != null, "stage popup UI is available")
|
||||||
|
if views == null or ui_layer == null:
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var avoid_rects: Array[Rect2] = [
|
||||||
|
Rect2(Vector2.ZERO, Vector2(1280.0, 72.0)),
|
||||||
|
Rect2(Vector2(0.0, 682.0), Vector2(1280.0, 38.0)),
|
||||||
|
Rect2(Vector2.ZERO, Vector2(120.0, 720.0)),
|
||||||
|
]
|
||||||
|
var prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
||||||
|
if prop_view != null and prop_view.visible:
|
||||||
|
var prop_card: Control = prop_view.get_card()
|
||||||
|
if prop_card != null:
|
||||||
|
avoid_rects.append(prop_card.get_global_rect().grow(8.0))
|
||||||
|
for character: StickmanRig in [first, second]:
|
||||||
|
var world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(character)
|
||||||
|
if world_bounds.has_area():
|
||||||
|
avoid_rects.append(stage.call("_world_rect_to_viewport_rect", world_bounds).grow(8.0))
|
||||||
|
var click_position: Vector2 = stage.get_viewport().get_canvas_transform() * Vector2.ZERO
|
||||||
|
views.show_objects_here(ui_layer, [first, second], selection.get_selected().duplicate(), Vector2(1280.0, 720.0), click_position, avoid_rects)
|
||||||
|
await _wait_frames(8)
|
||||||
|
var popup: PopupPanel = views.get_objects_here_popup()
|
||||||
|
_check(popup != null and popup.visible, "anchored chooser is visible")
|
||||||
|
if popup != null:
|
||||||
|
var candidate_button: Button = popup.find_child("ObjectCandidate_0", true, false) as Button
|
||||||
|
if candidate_button != null:
|
||||||
|
candidate_button.grab_focus()
|
||||||
|
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||||
|
avoid_rects.append(views.get_objects_here_screen_rect().grow(8.0))
|
||||||
|
if gizmos != null:
|
||||||
|
gizmos.set_candidate_preview_obstacles(avoid_rects)
|
||||||
|
gizmos.set_enabled(true)
|
||||||
|
gizmos.set_candidate_preview(first)
|
||||||
|
await _wait_frames(4)
|
||||||
|
if gizmos != null:
|
||||||
|
var name_pill_style: StyleBoxFlat = gizmos.get("_candidate_preview_name_style") as StyleBoxFlat
|
||||||
|
_check(name_pill_style != null and name_pill_style.corner_radius_top_left >= 8, "candidate name label uses rounded pill corners")
|
||||||
|
_check(name_pill_style != null and name_pill_style.border_width_left >= 3, "candidate name pill keeps a clear cyan outline")
|
||||||
|
var shell: StyleBoxFlat = popup.get_theme_stylebox("panel") as StyleBoxFlat
|
||||||
|
_check(shell != null and shell.corner_radius_top_left >= 12, "chooser shell has rounded corners")
|
||||||
|
_check(shell != null and shell.border_width_left >= 2 and shell.shadow_size > 0, "chooser shell has a border and local shadow")
|
||||||
|
var cancel: Button = popup.find_child("CancelObjectsHere", true, false) as Button
|
||||||
|
var cancel_style: StyleBoxFlat = cancel.get_theme_stylebox("normal") as StyleBoxFlat if cancel != null else null
|
||||||
|
_check(cancel != null and cancel.custom_minimum_size.y >= 48, "Cancel has a large target")
|
||||||
|
_check(cancel_style != null and cancel_style.corner_radius_top_left >= 8, "Cancel has rounded secondary styling")
|
||||||
|
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var capture: Image = root.get_texture().get_image()
|
||||||
|
var capture_names: Array[String] = [
|
||||||
|
"overlap_chooser_popup_style",
|
||||||
|
"character_overlap_preview",
|
||||||
|
"overlap_hover_preview",
|
||||||
|
"overlap_candidate_preview",
|
||||||
|
]
|
||||||
|
for capture_name: String in capture_names:
|
||||||
|
var capture_path: String = "res://tests/phase20a_captures/%s.png" % capture_name
|
||||||
|
var capture_error: Error = capture.save_png(ProjectSettings.globalize_path(capture_path))
|
||||||
|
print("PHASE20A_CAPTURE path=%s size=%s error=%d" % [capture_path, capture.get_size(), capture_error])
|
||||||
|
_check(capture_error == OK, "capture %s is saved" % capture_name)
|
||||||
|
await _verify_overlap_gestures(stage, crate, views, selection)
|
||||||
|
await _wait_frames(2)
|
||||||
|
stage.queue_free()
|
||||||
|
await _wait_frames(2)
|
||||||
|
quit(1 if _failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_overlap_gestures(stage: SandboxStage, selected_prop: PropBlock, views: StageModalViews, selection: StageSelection) -> void:
|
||||||
|
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
||||||
|
var overlapping_prop: PropBlock = spawner.spawn_prop_asset("crate", selected_prop.global_position) as PropBlock
|
||||||
|
_check(overlapping_prop != null, "overlap gesture fixture is available")
|
||||||
|
if overlapping_prop == null:
|
||||||
|
return
|
||||||
|
stage.call("_finalize_placed_node", overlapping_prop)
|
||||||
|
await _wait_frames(4)
|
||||||
|
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
||||||
|
var canvas: Transform2D = stage.get_viewport().get_canvas_transform()
|
||||||
|
var click_world: Vector2 = selected_prop.global_position
|
||||||
|
var click_screen: Vector2 = canvas * click_world
|
||||||
|
var candidates: Array[Node2D] = selection.hit_test_all(click_world)
|
||||||
|
_check(candidates.has(selected_prop) and candidates.has(overlapping_prop), "gesture fixture has two visible objects at the click point")
|
||||||
|
selection.select_only(selected_prop)
|
||||||
|
await _wait_frames(2)
|
||||||
|
if gizmos != null:
|
||||||
|
gizmos.set_snap_size(0.0)
|
||||||
|
|
||||||
|
await _send_mouse_motion(click_screen)
|
||||||
|
await _send_mouse_button(click_screen, true)
|
||||||
|
_check(stage.get("_objects_here_popup") == null and not gizmos.is_dragging(), "overlap press waits without opening chooser or moving")
|
||||||
|
await _send_mouse_motion(click_screen + Vector2(4.0, 0.0), MOUSE_BUTTON_MASK_LEFT)
|
||||||
|
_check(stage.get("_objects_here_popup") == null and not gizmos.is_dragging(), "sub-threshold movement remains a click candidate")
|
||||||
|
await _send_mouse_button(click_screen + Vector2(4.0, 0.0), false)
|
||||||
|
var click_popup: PopupPanel = stage.get("_objects_here_popup") as PopupPanel
|
||||||
|
_check(click_popup != null and click_popup.visible, "release before the threshold opens the overlap chooser")
|
||||||
|
_check(selection.is_selected(selected_prop), "opening the chooser preserves the prior selection")
|
||||||
|
views.dismiss_objects_here()
|
||||||
|
await _wait_frames(2)
|
||||||
|
|
||||||
|
var start_position: Vector2 = selected_prop.global_position
|
||||||
|
var start_screen: Vector2 = canvas * start_position
|
||||||
|
var end_screen: Vector2 = start_screen + Vector2(24.0, 12.0)
|
||||||
|
var expected_position: Vector2 = canvas.affine_inverse() * end_screen
|
||||||
|
await _send_mouse_motion(start_screen)
|
||||||
|
await _send_mouse_button(start_screen, true)
|
||||||
|
await _send_mouse_motion(end_screen, MOUSE_BUTTON_MASK_LEFT)
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "drag past the threshold never opens the overlap chooser")
|
||||||
|
_check(gizmos.is_dragging(), "drag past the threshold begins moving the selected overlap")
|
||||||
|
_check(selected_prop.global_position.is_equal_approx(expected_position), "selected object follows the overlap drag")
|
||||||
|
_check(overlapping_prop.global_position.is_equal_approx(start_position), "unselected overlapping object stays in place")
|
||||||
|
await _send_mouse_button(end_screen, false)
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "drag release does not reopen the chooser")
|
||||||
|
_check(not gizmos.is_dragging(), "drag release finishes the selected-object move")
|
||||||
|
|
||||||
|
# Verify rotate and resize handles remain ahead of overlap selection.
|
||||||
|
selected_prop.global_position = start_position
|
||||||
|
overlapping_prop.global_position = start_position
|
||||||
|
selection.select_only(selected_prop)
|
||||||
|
await _wait_frames(2)
|
||||||
|
var prop_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(selected_prop)
|
||||||
|
var scale_handle: Vector2 = Vector2(prop_bounds.end.x, prop_bounds.position.y)
|
||||||
|
var scale_screen: Vector2 = canvas * scale_handle
|
||||||
|
await _send_mouse_motion(scale_screen)
|
||||||
|
await _send_mouse_button(scale_screen, true)
|
||||||
|
_check(gizmos.is_dragging() and gizmos._dragging == StageGizmos.Handle.SCALE, "overlapped resize handle starts scaling immediately")
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "resize handle does not open the overlap chooser")
|
||||||
|
await _send_mouse_button(scale_screen, false)
|
||||||
|
_check(not gizmos.is_dragging(), "resize handle release completes without opening chooser")
|
||||||
|
|
||||||
|
selected_prop.global_position = start_position
|
||||||
|
await _wait_frames(2)
|
||||||
|
prop_bounds = STAGE_SELECTION.get_world_aabb(selected_prop)
|
||||||
|
var rotate_handle: Vector2 = gizmos._rotate_handle_position(prop_bounds)
|
||||||
|
var rotate_screen: Vector2 = canvas * rotate_handle
|
||||||
|
await _send_mouse_motion(rotate_screen)
|
||||||
|
await _send_mouse_button(rotate_screen, true)
|
||||||
|
_check(gizmos.is_dragging() and gizmos._dragging == StageGizmos.Handle.ROTATE, "overlapped rotate handle starts rotation immediately")
|
||||||
|
_check(stage.get("_objects_here_popup") == null, "rotate handle does not open the overlap chooser")
|
||||||
|
await _send_mouse_button(rotate_screen, false)
|
||||||
|
_check(not gizmos.is_dragging(), "rotate handle release completes without opening chooser")
|
||||||
|
|
||||||
|
|
||||||
|
func _send_mouse_button(position: Vector2, pressed: bool) -> void:
|
||||||
|
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||||
|
event.button_index = MOUSE_BUTTON_LEFT
|
||||||
|
event.position = position
|
||||||
|
event.global_position = position
|
||||||
|
event.pressed = pressed
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _send_mouse_motion(position: Vector2, button_mask: int = 0) -> void:
|
||||||
|
var event: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||||
|
event.position = position
|
||||||
|
event.global_position = position
|
||||||
|
event.button_mask = button_mask
|
||||||
|
Input.parse_input_event(event)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_frames(count: int) -> void:
|
||||||
|
for _index: int in count:
|
||||||
|
await process_frame
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, label: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("PHASE20A_CHECK PASS: %s" % label)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error("PHASE20A_CHECK FAIL: %s" % label)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bhrp36vg8or4m
|
||||||
@@ -57,8 +57,13 @@ func _test_moved_stage_file_paths() -> void:
|
|||||||
func _test_legacy_stage_round_trip() -> void:
|
func _test_legacy_stage_round_trip() -> void:
|
||||||
_cleanup()
|
_cleanup()
|
||||||
var legacy_data: Dictionary = {
|
var legacy_data: Dictionary = {
|
||||||
"version": STAGE_SCENE_IO.FILE_VERSION,
|
# Keep this fixture at the pre-identity schema so the path test also
|
||||||
|
# exercises the existing legacy-stage migration into the current format.
|
||||||
|
"version": "1.7",
|
||||||
"scene_name": "Legacy Asset Paths",
|
"scene_name": "Legacy Asset Paths",
|
||||||
|
"sky_id": "sky_neutral",
|
||||||
|
"background_kind": "sky",
|
||||||
|
"plain_color": "#F5EFE0",
|
||||||
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
|
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
|
||||||
"objects": [
|
"objects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||||
|
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||||
|
|
||||||
|
var _checks: int = 0
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var world := Node2D.new()
|
||||||
|
root.add_child(world)
|
||||||
|
var camera := Camera2D.new()
|
||||||
|
root.add_child(camera)
|
||||||
|
var selection: StageSelection = STAGE_SELECTION.new(world, camera) as StageSelection
|
||||||
|
var triangle: PropBlock = _make_prop("Triangle")
|
||||||
|
world.add_child(triangle)
|
||||||
|
var triangle_points := PackedVector2Array([Vector2(-50.0, -50.0), Vector2(50.0, -50.0), Vector2(0.0, 50.0)])
|
||||||
|
triangle.set_custom_shapes([{
|
||||||
|
"shape_type": "polygon",
|
||||||
|
"points": triangle_points,
|
||||||
|
"closed": true,
|
||||||
|
"fill_color": "#55AAFFFF",
|
||||||
|
"outline_color": "#111111FF",
|
||||||
|
"outline_width": 2.0,
|
||||||
|
}], triangle_points)
|
||||||
|
await process_frame
|
||||||
|
_check(selection.hit_test_all(Vector2.ZERO).has(triangle), "visible polygon interior is selectable")
|
||||||
|
_check(not selection.hit_test_all(Vector2(40.0, 40.0)).has(triangle), "empty point inside polygon AABB is rejected")
|
||||||
|
|
||||||
|
var front: PropBlock = _make_prop("Front")
|
||||||
|
front.position = Vector2.ZERO
|
||||||
|
front.z_index = 2
|
||||||
|
world.add_child(front)
|
||||||
|
front.set_custom_shapes([{
|
||||||
|
"shape_type": "polygon",
|
||||||
|
"points": triangle_points,
|
||||||
|
"closed": true,
|
||||||
|
"fill_color": "#FFAA55FF",
|
||||||
|
"outline_color": "#111111FF",
|
||||||
|
"outline_width": 2.0,
|
||||||
|
}], triangle_points)
|
||||||
|
await process_frame
|
||||||
|
var overlap_hits: Array[Node2D] = selection.hit_test_all(Vector2.ZERO)
|
||||||
|
_check(overlap_hits.size() == 2 and overlap_hits[0] == front and overlap_hits[1] == triangle, "overlap candidates preserve front-to-back draw order")
|
||||||
|
_check(not selection.hit_test_all(Vector2(40.0, 40.0)).has(front), "nearby overlapping prop is excluded outside its visible shape")
|
||||||
|
|
||||||
|
triangle.position = Vector2(200.0, 0.0)
|
||||||
|
triangle.rotation = PI * 0.5
|
||||||
|
triangle.scale = Vector2(2.0, 1.0)
|
||||||
|
await process_frame
|
||||||
|
var transformed_center: Vector2 = triangle.to_global(Vector2.ZERO)
|
||||||
|
var transformed_empty: Vector2 = triangle.to_global(Vector2(40.0, 40.0))
|
||||||
|
_check(selection.hit_test_all(transformed_center).has(triangle), "polygon hit testing follows object translation, rotation, and scale")
|
||||||
|
_check(not selection.hit_test_all(transformed_empty).has(triangle), "transformed empty polygon area remains unselectable")
|
||||||
|
|
||||||
|
var line_prop: PropBlock = PROP_BLOCK.new() as PropBlock
|
||||||
|
line_prop.name = "Thin Line"
|
||||||
|
line_prop.position = Vector2(400.0, 100.0)
|
||||||
|
world.add_child(line_prop)
|
||||||
|
var line_points := PackedVector2Array([Vector2(-20.0, 0.0), Vector2(20.0, 0.0)])
|
||||||
|
var line_shape: Array[Dictionary] = [{
|
||||||
|
"shape_type": "line",
|
||||||
|
"points": line_points,
|
||||||
|
"closed": false,
|
||||||
|
"fill_color": "#00000000",
|
||||||
|
"outline_color": "#FFFFFFFF",
|
||||||
|
"outline_width": 2.0,
|
||||||
|
}]
|
||||||
|
line_prop.set_custom_shapes(line_shape, PackedVector2Array())
|
||||||
|
await process_frame
|
||||||
|
_check(selection.hit_test_all(Vector2(400.0, 105.0)).has(line_prop), "thin visible line remains selectable within the screen-space touch tolerance")
|
||||||
|
_check(not selection.hit_test_all(Vector2(400.0, 109.0)).has(line_prop), "thin line rejects points beyond the touch tolerance")
|
||||||
|
camera.zoom = Vector2(2.0, 2.0)
|
||||||
|
_check(selection.hit_test_all(Vector2(400.0, 104.0)).has(line_prop), "line touch tolerance scales with camera zoom")
|
||||||
|
_check(not selection.hit_test_all(Vector2(400.0, 105.0)).has(line_prop), "zoomed line does not retain an oversized world-space tolerance")
|
||||||
|
camera.zoom = Vector2.ONE
|
||||||
|
line_prop.modulate.a = 0.0
|
||||||
|
_check(not selection.hit_test_all(Vector2(400.0, 100.0)).has(line_prop), "fully transparent line is not selectable")
|
||||||
|
line_prop.modulate.a = 1.0
|
||||||
|
line_prop.visible = false
|
||||||
|
_check(not selection.hit_test_all(Vector2(400.0, 100.0)).has(line_prop), "hidden line is not selectable")
|
||||||
|
|
||||||
|
print("PHASE 20A HIT TESTING: %d checks, %d failures" % [_checks, _failures])
|
||||||
|
world.queue_free()
|
||||||
|
camera.queue_free()
|
||||||
|
await process_frame
|
||||||
|
quit(1 if _failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _make_prop(prop_name: String) -> PropBlock:
|
||||||
|
var prop := PROP_BLOCK.new() as PropBlock
|
||||||
|
prop.name = prop_name
|
||||||
|
return prop
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if condition:
|
||||||
|
print("PASS: " + message)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error("FAIL: " + message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dpyppduges4mi
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd")
|
||||||
|
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||||
|
const STAGE_SCENE_ADAPTER := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
|
||||||
|
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
|
||||||
|
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||||
|
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||||
|
const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||||
|
const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||||
|
|
||||||
|
var _checks: int = 0
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
_test_scene_identity_and_rename()
|
||||||
|
_test_current_format_validation()
|
||||||
|
_test_legacy_migrations()
|
||||||
|
await _test_adapter_identity_round_trip()
|
||||||
|
print("PHASE 20A IDENTITY: %d checks, %d failures" % [_checks, _failures])
|
||||||
|
quit(1 if _failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
_checks += 1
|
||||||
|
if condition:
|
||||||
|
print("PASS: " + message)
|
||||||
|
else:
|
||||||
|
_failures += 1
|
||||||
|
push_error("FAIL: " + message)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_scene_identity_and_rename() -> void:
|
||||||
|
print("\n--- Scene identity and naming ---")
|
||||||
|
var world := Node2D.new()
|
||||||
|
var identity: StageObjectIdentity = STAGE_OBJECT_IDENTITY.new() as StageObjectIdentity
|
||||||
|
var milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig
|
||||||
|
var milo_two: StickmanRig = STICKMAN_RIG.new() as StickmanRig
|
||||||
|
var prop: PropBlock = PROP_BLOCK.new() as PropBlock
|
||||||
|
world.add_child(milo)
|
||||||
|
world.add_child(milo_two)
|
||||||
|
world.add_child(prop)
|
||||||
|
|
||||||
|
identity.assign_new(milo, "Milo", world)
|
||||||
|
identity.assign_new(milo_two, "Milo", world)
|
||||||
|
identity.assign_new(prop, "milo", world)
|
||||||
|
_check(not identity.get_id(milo).is_empty(), "first placed character gets a persistent ID")
|
||||||
|
_check(identity.get_id(milo_two) != identity.get_id(milo), "duplicate character gets a fresh persistent ID")
|
||||||
|
_check(identity.get_id(prop) != identity.get_id(milo) and identity.get_id(prop) != identity.get_id(milo_two), "prop ID is unique across both object types")
|
||||||
|
_check(identity.get_name(milo) == "Milo", "first stage name keeps its familiar base")
|
||||||
|
_check(identity.get_name(milo_two) == "Milo 2", "duplicate stage name receives the smallest free suffix")
|
||||||
|
_check(identity.get_name(prop) == "milo 3", "character and prop names share case-insensitive uniqueness")
|
||||||
|
|
||||||
|
var capitalization: Dictionary = identity.validate_name(" MILO ", world, milo)
|
||||||
|
_check(bool(capitalization.get("ok", false)) and String(capitalization.get("name", "")) == "MILO", "capitalization-only rename trims outer spaces and is valid")
|
||||||
|
var original_id: String = identity.get_id(milo)
|
||||||
|
var duplicate: Dictionary = identity.rename(milo, "mIlO 2", world)
|
||||||
|
_check(not bool(duplicate.get("ok", true)) and duplicate.get("reason", &"") == &"duplicate", "duplicate name is rejected case-insensitively")
|
||||||
|
var suggestion: String = String(duplicate.get("suggestion", ""))
|
||||||
|
var suggestion_check: Dictionary = identity.validate_name(suggestion, world, milo)
|
||||||
|
_check(not suggestion.is_empty() and bool(suggestion_check.get("ok", false)), "duplicate name supplies an available suggestion")
|
||||||
|
var blank: Dictionary = identity.rename(milo, " ", world)
|
||||||
|
_check(not bool(blank.get("ok", true)) and blank.get("reason", &"") == &"blank", "blank name is rejected")
|
||||||
|
_check(identity.get_name(milo) == "Milo", "invalid rename leaves the current name unchanged")
|
||||||
|
var renamed: Dictionary = identity.rename(milo, " MAX ", world)
|
||||||
|
_check(bool(renamed.get("ok", false)) and identity.get_name(milo) == "MAX", "valid rename stores the trimmed scene name")
|
||||||
|
_check(identity.get_id(milo) == original_id, "renaming preserves the persistent object ID")
|
||||||
|
|
||||||
|
world.remove_child(milo_two)
|
||||||
|
milo_two.free()
|
||||||
|
identity.rename(milo, "Milo", world)
|
||||||
|
var next_milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig
|
||||||
|
world.add_child(next_milo)
|
||||||
|
identity.assign_new(next_milo, "Milo", world)
|
||||||
|
_check(identity.get_name(next_milo) == "Milo 2", "deleting an instance releases its stage name")
|
||||||
|
world.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_current_format_validation() -> void:
|
||||||
|
print("\n--- Current scene identity validation ---")
|
||||||
|
var valid: Dictionary = _current_document([
|
||||||
|
_object("stickman_1", "stickman", "char-1", "Milo"),
|
||||||
|
_object("prop_1", "prop", "prop-1", "Blue Door"),
|
||||||
|
])
|
||||||
|
_check(bool(STAGE_SCENE_IO._validate_root(valid).get("ok", false)), "unique current-format character and prop identities validate")
|
||||||
|
|
||||||
|
var duplicate_ids: Dictionary = valid.duplicate(true)
|
||||||
|
(duplicate_ids["objects"][1] as Dictionary)["stage_object_id"] = "char-1"
|
||||||
|
_check(not bool(STAGE_SCENE_IO._validate_root(duplicate_ids).get("ok", true)), "current-format duplicate IDs across types are rejected")
|
||||||
|
var empty_id: Dictionary = valid.duplicate(true)
|
||||||
|
(empty_id["objects"][0] as Dictionary)["stage_object_id"] = " "
|
||||||
|
_check(not bool(STAGE_SCENE_IO._validate_root(empty_id).get("ok", true)), "current-format empty IDs are rejected")
|
||||||
|
var duplicate_names: Dictionary = valid.duplicate(true)
|
||||||
|
(_other_named_object(duplicate_names))["stage_name"] = "mIlO"
|
||||||
|
_check(not bool(STAGE_SCENE_IO._validate_root(duplicate_names).get("ok", true)), "current-format duplicate names ignoring capitalization are rejected")
|
||||||
|
var whitespace_name: Dictionary = valid.duplicate(true)
|
||||||
|
(_empty_names_object(whitespace_name))["stage_name"] = " Milo "
|
||||||
|
_check(not bool(STAGE_SCENE_IO._validate_root(whitespace_name).get("ok", true)), "current-format names with outer whitespace are rejected")
|
||||||
|
var missing_name: Dictionary = valid.duplicate(true)
|
||||||
|
(_empty_names_object(missing_name)).erase("stage_name")
|
||||||
|
_check(not bool(STAGE_SCENE_IO._validate_root(missing_name).get("ok", true)), "current-format objects missing a name are rejected")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_legacy_migrations() -> void:
|
||||||
|
print("\n--- Legacy scene migrations ---")
|
||||||
|
for version: String in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]:
|
||||||
|
var source: Dictionary = _legacy_document(version)
|
||||||
|
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(source)
|
||||||
|
var migrated_again: Dictionary = STAGE_SCENE_IO._migrate_to_current(source.duplicate(true))
|
||||||
|
_check(String(migrated.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "%s migrates to current version" % version)
|
||||||
|
var objects: Array = migrated.get("objects", [])
|
||||||
|
var repeat_objects: Array = migrated_again.get("objects", [])
|
||||||
|
_check(objects.size() == 3 and repeat_objects.size() == 3, "%s preserves all named object entries" % version)
|
||||||
|
var stage_ids: Dictionary = {}
|
||||||
|
var stage_names: Dictionary = {}
|
||||||
|
for object_index: int in objects.size():
|
||||||
|
var entry: Dictionary = objects[object_index] as Dictionary
|
||||||
|
var repeat_entry: Dictionary = repeat_objects[object_index] as Dictionary
|
||||||
|
var stage_id: String = String(entry.get("stage_object_id", ""))
|
||||||
|
var stage_name: String = String(entry.get("stage_name", ""))
|
||||||
|
_check(not stage_id.is_empty(), "%s object %d receives a persistent ID" % [version, object_index + 1])
|
||||||
|
_check(not stage_name.strip_edges().is_empty(), "%s object %d receives a nonempty name" % [version, object_index + 1])
|
||||||
|
_check(not stage_ids.has(stage_id), "%s object %d receives a unique ID" % [version, object_index + 1])
|
||||||
|
_check(not stage_names.has(stage_name.to_lower()), "%s object %d receives a case-insensitively unique name" % [version, object_index + 1])
|
||||||
|
stage_ids[stage_id] = true
|
||||||
|
stage_names[stage_name.to_lower()] = true
|
||||||
|
_check(stage_id == String(repeat_entry.get("stage_object_id", "")) and stage_name == String(repeat_entry.get("stage_name", "")), "%s migration is deterministic for object %d" % [version, object_index + 1])
|
||||||
|
var prop_entry: Dictionary = objects[2] as Dictionary
|
||||||
|
var expected_prop_id: String = "prop_1" if version == "1.0" else "legacy-prop-id"
|
||||||
|
_check(String(prop_entry.get("stage_object_id", "")) == expected_prop_id, "%s preserves the existing prop ID" % version)
|
||||||
|
var validation: Dictionary = STAGE_SCENE_IO._validate_root(migrated)
|
||||||
|
_check(bool(validation.get("ok", false)), "%s migrated document passes current-format validation" % version)
|
||||||
|
|
||||||
|
var collision_source: Dictionary = _legacy_document("1.7")
|
||||||
|
var collision_objects: Array = collision_source["objects"]
|
||||||
|
(collision_objects[2] as Dictionary)["stage_object_id"] = "stickman:stickman_1"
|
||||||
|
var collision_migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(collision_source)
|
||||||
|
var collision_validation: Dictionary = STAGE_SCENE_IO._validate_root(collision_migrated)
|
||||||
|
_check(String(((collision_migrated["objects"] as Array)[2] as Dictionary).get("stage_object_id", "")) == "stickman:stickman_1", "a saved prop ID is retained when a legacy character ID would collide")
|
||||||
|
_check(bool(collision_validation.get("ok", false)), "legacy ID collision is resolved without producing invalid current-format data")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_adapter_identity_round_trip() -> void:
|
||||||
|
print("\n--- Production adapter identity round-trip ---")
|
||||||
|
var source_world := Node2D.new()
|
||||||
|
root.add_child(source_world)
|
||||||
|
var source_spawner: StageSpawner = STAGE_SPAWNER.new(source_world) as StageSpawner
|
||||||
|
var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter
|
||||||
|
var real_character: StickmanRig = source_spawner.spawn_stickman_asset("res://content/stickmen/basic.stk", Vector2(-180.0, 0.0))
|
||||||
|
var real_prop: PropBlock = source_spawner.spawn_prop_asset("crate", Vector2(180.0, 0.0))
|
||||||
|
_check(real_character != null and real_prop != null, "production spawner creates real character and prop fixtures")
|
||||||
|
if real_character == null or real_prop == null:
|
||||||
|
source_world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
return
|
||||||
|
real_character.stage_object_id = "stage-char-real"
|
||||||
|
real_character.stage_name = "Milo"
|
||||||
|
real_prop.stage_object_id = "stage-prop-real"
|
||||||
|
real_prop.stage_name = "Blue Crate"
|
||||||
|
var missing_character_entry: Dictionary = {
|
||||||
|
"file_id": "legacy-missing-character",
|
||||||
|
"type": "stickman",
|
||||||
|
"stage_object_id": "stage-char-missing",
|
||||||
|
"stage_name": "Ava",
|
||||||
|
"stk_path": "res://content/stickmen/phase20a_missing_character.stk",
|
||||||
|
"position": {"x": -360.0, "y": 0.0},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"action_queue": [],
|
||||||
|
}
|
||||||
|
var missing_character: Node2D = MISSING_STICKMAN.new() as Node2D
|
||||||
|
missing_character.configure(missing_character_entry)
|
||||||
|
source_world.add_child(missing_character)
|
||||||
|
var missing_prop_entry: Dictionary = {
|
||||||
|
"file_id": "legacy-missing-prop",
|
||||||
|
"type": "prop",
|
||||||
|
"stage_object_id": "stage-prop-missing",
|
||||||
|
"stage_name": "Secret Door",
|
||||||
|
"asset_id": "res://content/props/phase20a_missing_prop.prop",
|
||||||
|
"position": {"x": 360.0, "y": 0.0},
|
||||||
|
"rotation": 0.0,
|
||||||
|
"scale": {"x": 1.0, "y": 1.0},
|
||||||
|
"use_spots": [],
|
||||||
|
"next_use_spot_number": 1,
|
||||||
|
}
|
||||||
|
var missing_prop: Node2D = MISSING_PROP.new() as Node2D
|
||||||
|
missing_prop.configure(missing_prop_entry)
|
||||||
|
source_world.add_child(missing_prop)
|
||||||
|
await process_frame
|
||||||
|
var document: Dictionary = adapter.collect(
|
||||||
|
source_world,
|
||||||
|
{},
|
||||||
|
[],
|
||||||
|
{"position": Vector2(32.0, -64.0), "zoom": 0.75},
|
||||||
|
{"scene_name": "Identity Round Trip"}
|
||||||
|
)
|
||||||
|
var serialized: Dictionary = {}
|
||||||
|
for entry_value: Variant in document.get("objects", []):
|
||||||
|
if entry_value is Dictionary:
|
||||||
|
var entry: Dictionary = entry_value
|
||||||
|
serialized[String(entry.get("stage_object_id", ""))] = entry
|
||||||
|
for identity_pair in [
|
||||||
|
["stage-char-real", "Milo"],
|
||||||
|
["stage-prop-real", "Blue Crate"],
|
||||||
|
["stage-char-missing", "Ava"],
|
||||||
|
["stage-prop-missing", "Secret Door"],
|
||||||
|
]:
|
||||||
|
var entry: Dictionary = serialized.get(identity_pair[0], {}) as Dictionary
|
||||||
|
_check(String(entry.get("stage_name", "")) == identity_pair[1], "collect preserves name '%s' for %s" % [identity_pair[1], identity_pair[0]])
|
||||||
|
_check(not entry.is_empty(), "collect preserves identity %s" % identity_pair[0])
|
||||||
|
# write_file_safe uses absolute-path rename operations after writing its temporary file.
|
||||||
|
var save_path: String = ProjectSettings.globalize_path("res://tests/.phase20a_identity_round_trip.scene")
|
||||||
|
var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(save_path, document)
|
||||||
|
_check(bool(write_result.get("ok", false)), "scene writer saves the collected identity document: %s" % String(write_result.get("error", "")))
|
||||||
|
var read_result: Dictionary = STAGE_SCENE_IO.read_file(save_path)
|
||||||
|
_check(bool(read_result.get("ok", false)), "scene reader loads the saved identity document")
|
||||||
|
var disk_document: Dictionary = read_result.get("data", {}) as Dictionary
|
||||||
|
var apply_document: Dictionary = disk_document if not disk_document.is_empty() else document
|
||||||
|
var disk_objects_by_id: Dictionary = {}
|
||||||
|
for disk_entry_value: Variant in apply_document.get("objects", []):
|
||||||
|
if disk_entry_value is Dictionary:
|
||||||
|
var disk_entry: Dictionary = disk_entry_value as Dictionary
|
||||||
|
disk_objects_by_id[String(disk_entry.get("stage_object_id", ""))] = disk_entry
|
||||||
|
for identity_pair in [
|
||||||
|
["stage-char-real", "Milo"],
|
||||||
|
["stage-prop-real", "Blue Crate"],
|
||||||
|
["stage-char-missing", "Ava"],
|
||||||
|
["stage-prop-missing", "Secret Door"],
|
||||||
|
]:
|
||||||
|
var disk_entry: Dictionary = disk_objects_by_id.get(identity_pair[0], {}) as Dictionary
|
||||||
|
_check(String(disk_entry.get("stage_name", "")) == identity_pair[1], "disk round-trip retains name '%s'" % identity_pair[1])
|
||||||
|
_check(not disk_entry.is_empty(), "disk round-trip retains ID %s" % identity_pair[0])
|
||||||
|
|
||||||
|
var target_world := Node2D.new()
|
||||||
|
root.add_child(target_world)
|
||||||
|
var target_spawner: StageSpawner = STAGE_SPAWNER.new(target_world) as StageSpawner
|
||||||
|
var applied: Dictionary = adapter.apply(apply_document, target_world, target_spawner)
|
||||||
|
var restored_by_id: Dictionary = {}
|
||||||
|
for node_value: Variant in applied.get("loaded_nodes", []):
|
||||||
|
var node: Node2D = node_value as Node2D
|
||||||
|
if node != null:
|
||||||
|
restored_by_id[String(node.get("stage_object_id"))] = node
|
||||||
|
for identity_pair in [
|
||||||
|
["stage-char-real", "Milo"],
|
||||||
|
["stage-prop-real", "Blue Crate"],
|
||||||
|
["stage-char-missing", "Ava"],
|
||||||
|
["stage-prop-missing", "Secret Door"],
|
||||||
|
]:
|
||||||
|
var restored: Node2D = restored_by_id.get(identity_pair[0]) as Node2D
|
||||||
|
_check(restored != null and String(restored.get("stage_name")) == identity_pair[1], "apply restores ID and name for %s" % identity_pair[0])
|
||||||
|
_check(restored_by_id["stage-char-missing"].get_script() == MISSING_STICKMAN, "missing character asset restores as its editable placeholder")
|
||||||
|
_check(restored_by_id["stage-prop-missing"].get_script() == MISSING_PROP, "missing custom prop restores as its editable placeholder")
|
||||||
|
var adapter_warnings: Array = applied.get("warnings", []) as Array
|
||||||
|
_check(adapter_warnings.is_empty(), "adapter reports no malformed-object warnings during identity round-trip")
|
||||||
|
target_world.queue_free()
|
||||||
|
source_world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(save_path))
|
||||||
|
|
||||||
|
|
||||||
|
func _empty_names_object(document: Dictionary) -> Dictionary:
|
||||||
|
return (document["objects"] as Array)[0] as Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _other_named_object(document: Dictionary) -> Dictionary:
|
||||||
|
return (document["objects"] as Array)[1] as Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _object(file_id: String, object_type: String, stage_id: String, stage_name: String) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"file_id": file_id,
|
||||||
|
"type": object_type,
|
||||||
|
"stage_object_id": stage_id,
|
||||||
|
"stage_name": stage_name,
|
||||||
|
"position": {"x": 0.0, "y": 0.0},
|
||||||
|
"rotation": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _current_document(objects: Array) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"version": STAGE_SCENE_IO.FILE_VERSION,
|
||||||
|
"scene_name": "Identity Test",
|
||||||
|
"sky_id": "sky_neutral",
|
||||||
|
"background_kind": "sky",
|
||||||
|
"plain_color": "#F5EFE0",
|
||||||
|
"camera": {"position": {"x": 0.0, "y": 0.0}, "zoom": 0.5},
|
||||||
|
"objects": objects,
|
||||||
|
"event_rules": [],
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _legacy_document(version: String) -> Dictionary:
|
||||||
|
var prop: Dictionary = _object("prop_1", "prop", "legacy-prop-id", "Ignored")
|
||||||
|
prop["asset_id"] = "res://content/props/briefcase.prop"
|
||||||
|
prop["scale"] = {"x": 1.0, "y": 1.0}
|
||||||
|
prop["material_preset"] = 0
|
||||||
|
if version == "1.0":
|
||||||
|
prop.erase("stage_object_id")
|
||||||
|
prop.erase("stage_name")
|
||||||
|
var first_character: Dictionary = _object("stickman_1", "stickman", "", "")
|
||||||
|
first_character["stk_path"] = "res://content/stickmen/test.stk"
|
||||||
|
first_character["action_queue"] = []
|
||||||
|
first_character.erase("stage_object_id")
|
||||||
|
first_character.erase("stage_name")
|
||||||
|
var second_character: Dictionary = _object("stickman_2", "stickman", "", "")
|
||||||
|
second_character["stk_path"] = "res://content/stickmen/test.stk"
|
||||||
|
second_character["action_queue"] = []
|
||||||
|
second_character.erase("stage_object_id")
|
||||||
|
second_character.erase("stage_name")
|
||||||
|
var document: Dictionary = _current_document([first_character, second_character, prop])
|
||||||
|
document["version"] = version
|
||||||
|
return document
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cphn6t44v67pk
|
||||||
@@ -116,11 +116,12 @@ func _test_stage_prop_drag_and_rotation() -> void:
|
|||||||
|
|
||||||
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
|
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
|
||||||
var center: Vector2 = rect.get_center()
|
var center: Vector2 = rect.get_center()
|
||||||
var radius: float = stage._gizmos._rotate_ring_radius(rect)
|
var rotate_handle: Vector2 = stage._gizmos._rotate_handle_position(rect)
|
||||||
press.position = canvas * (center + Vector2(radius, 0.0))
|
var starting_vector: Vector2 = rotate_handle - center
|
||||||
|
press.position = canvas * rotate_handle
|
||||||
stage._handle_world_click(press)
|
stage._handle_world_click(press)
|
||||||
_check(stage._gizmos.is_dragging(), "prop rotation ring begins a rotate drag")
|
_check(stage._gizmos.is_dragging(), "prop rotation handle begins a rotate drag")
|
||||||
motion.position = canvas * (center + Vector2(0.0, radius))
|
motion.position = canvas * (center + starting_vector.rotated(PI * 0.5))
|
||||||
motion.relative = motion.position - press.position
|
motion.relative = motion.position - press.position
|
||||||
stage._input(motion)
|
stage._input(motion)
|
||||||
release.position = motion.position
|
release.position = motion.position
|
||||||
|
|||||||