Add visual harmonization tests for editor UI components and ground finish chooser
- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency. - Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values. - Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage. - Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
This commit is contained in:
@@ -88,6 +88,9 @@ This document tracks known technical debt, optimization opportunities, and minor
|
||||
| 76 | **Camera-aware Sky redraws vector motifs while framing changes** — the former static Sky drew motifs only when the preset or viewport changed. | Low | Planned | The Phase 19 Sky addition keeps one screen-space `StageSkyView`, redraws only when a bound camera's effective framing changes, and caches reusable cloud/moon curve samples where practical. Measure sustained pan/zoom cost in a rendered Stage before adding a raster or shader path. The fixed reference frame and bounded response can saturate at distant camera positions; that is an accepted composition trade-off, not scene state to persist. See `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md#phase-19-addition-camera-aware-sky-details`. (2026-10-21) |
|
||||
| 77 | **Starter surfaces guarantee a finite pan envelope, not arbitrary camera framing** — the two bundled sets use a fixed authored width to keep their scene data simple. | Low | Implemented; visual review pending | The authored envelope is a 1,280-logical-pixel viewport at zoom 0.5, ±1,000 world units of travel, and 100 units of margin per side. User review prompted an opaque Town Grass Background ground and separate scale-1 tile rows. Twenty-five 192-unit tiles cover the 4,760-unit minimum; Asphalt has 27 tiles (5,184 units) so the road extends one tile beyond the sidewalk at both ends. Navigation merges contiguous tile tops and routes the 32-unit curb step at the exposed ends without merging Ground objects or colliders. The focused suite passes 515 assertions, including the curb step and PLAY landing. The camera remains unrestricted, so wider viewports, farther pans, and zoomed-out shots can reveal finite ends. Rendered seam review remains pending. See docs/specs/phase_19_starter_scene_composition.md. (2026-10-21)
|
||||
| 78 | **Plain Background adds a second backdrop kind while Sky remains a separate renderer** — live Stage, starter previews, and chooser cards must agree on kind and remembered color. | Medium | Spec prepared; implementation pending | Phase 19 composes one shared `StageBackgroundView` from the existing Sky view and an opaque fill. Scene v1.7 stores active kind, last Sky ID, and confirmed plain color; all older scenes migrate as Sky. `SandboxStage` owns the transient custom-picker draft, which must never enter Save or autosave. The existing chooser's four-Sky layout and 42 px compact swatches need targeted extension, and grid/gizmo contrast needs theme-backed treatment for light and dark fills. Rendered pan/zoom/resize, legacy migration, and every draft-cancel exit remain verification gates. See `docs/specs/phase_19_plain_background.md`. (2026-10-22) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# Editor Visual Style Guide
|
||||
|
||||
**Scope:** Establish a practical visual and interaction direction for the Prop Editor and Stickman Editor, using the Sandbox Stage UI as a product reference.
|
||||
**Status:** Guidance for future editor design and implementation. It documents current patterns and gaps; it does not assert that the editors already share the Stage theme or components.
|
||||
|
||||
## 1. Product direction
|
||||
|
||||
Both editors should feel like approachable creative workspaces in the same Stickman Studio product:
|
||||
|
||||
- Keep the canvas or preview as the main work surface. Put tools and settings around it, not on top of the artwork unless a temporary task requires an overlay.
|
||||
- Make the current task and mode easy to recognize. Use direct labels such as **Arrange**, **Reshape**, **Attach**, **Save**, and **Cancel**.
|
||||
- Prefer visible feedback on the object over abstract state: selection outlines, vertex handles, previews, mode coloring, and status text.
|
||||
- Preserve a stable layout while the child works. Opening an inspector section, showing help, or changing a mode should not make the active control jump away from the pointer.
|
||||
- Make recovery and destructive actions predictable. Show what is about to happen and keep a visible way to cancel.
|
||||
- Share a visual language, not necessarily identical panel layouts. A vector canvas, a modal dialog, an in-stage inspector card, and a stickman-part panel have different jobs.
|
||||
|
||||
The Sandbox Stage establishes a useful baseline for hierarchy, contrast, child-facing language, control states, safe viewport behavior, and theme tokens. Its in-stage inspector and capture bar are Stage patterns; they should be adapted only when an editor workflow has the same interaction needs.
|
||||
|
||||
## 2. Current implementation map
|
||||
|
||||
| Surface | Current implementation | Reuse implication |
|
||||
| --- | --- | --- |
|
||||
| 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 dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) styles child-facing dialogs hosted by the Stage overlay. | Reuse its visual contract. Its overlay host and lifecycle are Stage-specific unless deliberately generalized. |
|
||||
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json); the controller creates a local inspector Theme. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep values token-driven and scoped. Current Prop colors and target sizes differ from Stage tokens. |
|
||||
| Stickman Editor | [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd) coordinates ten BodyPartPanels and the Whole Stickman preview. The scene currently uses fixed top-bar offsets and a three-column workspace. | The canvas/preview model is established, but it has no apparent SandboxTheme or PropTheme integration. Harmonization is future work. |
|
||||
| Child-facing dialogs | [Dialog Design Principles](../specs/dialog_design_principles.md) documents the rounded, labeled-action pattern and stable resizing behavior. | Apply these rules to editor dialogs, adapting ownership and host behavior to each editor. |
|
||||
|
||||
The Prop Editor user guide at [prop_editor_user_guide.md](prop_editor_user_guide.md) describes its current controls and layout. Use it to avoid documenting proposed changes as existing behavior.
|
||||
|
||||
## 3. Shared visual system
|
||||
|
||||
### 3.1 Semantic color roles
|
||||
|
||||
Use color to reinforce meaning consistently. Keep labels, shapes, icons, or state text alongside color so meaning survives grayscale, low contrast, and color-vision differences.
|
||||
|
||||
| Role | Sandbox Stage reference | Editor guidance |
|
||||
| --- | --- | --- |
|
||||
| Main background | global.bg_color #171B24F2 | Use a dark neutral around the work area; avoid competing with authored art. |
|
||||
| Raised surface | global.surface_color #252B38FA | Use for toolbars, inspector shells, and cards. Distinguish adjacent surfaces by value, border, or spacing. |
|
||||
| Primary text | global.text_color #F5F7FA | Use for titles, control names, and important values. |
|
||||
| Secondary text | global.muted_text_color #AAB2C0 | Use for helper text and secondary status; keep readable at the chosen size. |
|
||||
| EDIT accent | mode_colors.edit #22C6FF | Suitable for active edit mode, selection accents, and editor guidance. It is not the current PropTheme accent. |
|
||||
| DIRECT accent | mode_colors.direct #FFB300 | Reserved for the Stage's directing mode. Do not use as a generic warning color in editors. |
|
||||
| PLAY accent | mode_colors.play #33DD77 | Reserved for Stage playback state. |
|
||||
| Primary action | Stage dialog/action blue #087DBA | Use for the single most important forward action in a dialog. |
|
||||
| Destructive action | Stage danger #E66B75 with dark red surfaces | Use only for destructive actions; pair with explicit wording and a cancel path. |
|
||||
| Focus | Stage dialog focus #8BD9FF | Give keyboard focus an outline that remains visible over selected, hovered, and dark states. |
|
||||
| Canvas grid | Stage tokens vary with canvas treatment; Prop canvas currently uses #FFFFFF26. | Keep grid lines subtle. The work, selection, and handles must dominate the grid. |
|
||||
|
||||
These values are current Stage reference values, not a mandate to paste literal hex values into editor controls. Resolve colors through validated theme accessors. If an editor needs a warmer or quieter local accent, define a semantic editor token and document its mapping to Stage meaning.
|
||||
|
||||
### 3.2 Current token differences to resolve during harmonization
|
||||
|
||||
The current palettes are related but not at parity:
|
||||
|
||||
- PropTheme uses ui.colors.accent **#4FA3FF**, while SandboxTheme EDIT uses **#22C6FF**.
|
||||
- PropTheme defaults its button and row minimum heights to **36 px** and its mode button minimum height to **56 px**. Stage action targets are **48 px**, and child-facing UI guidance calls for at least **44 × 44 logical pixels**.
|
||||
- PropTheme's canvas is #1F1F1F; its inspector token is #262C39. The Stage uses #171B24F2 / #252B38FA for global background and surface.
|
||||
- The older Stickman Editor has no apparent editor theme integration. Its scene uses 32 px menu/top bars, a 28 px status bar, 4 px preview corners, and a 14 px preview title; body-part headings are around 13–14 px. It defines preview surface and borders directly in the scene.
|
||||
|
||||
Treat these as migration work, not evidence that the editors already match the Stage. Before implementation, decide whether to harmonize the accent, minimum targets, border/focus states, and surface roles. Preserve canvas contrast where it serves drawing readability.
|
||||
|
||||
### 3.3 Type, spacing, and shape
|
||||
|
||||
Use these Stage examples as starting points, then tune them for the denser editor workspace:
|
||||
|
||||
- Stage inspector hierarchy: title **24 px**, section **20 px**, body **20 px**, helper **16 px**.
|
||||
- Stage inspector shell: **12 px** padding, **8 px** header gap, **12 px** section gap, **10 px** corner radius.
|
||||
- Stage choice row: **56 px** minimum height; Stage toggle row: **64 px** minimum, increasing when helper text is visible.
|
||||
- Large child-facing dialogs: title about **28–30 px**, body about **20 px**, button about **20 px**, **2 px** borders, **12–18 px** corner radii, and generous footer spacing.
|
||||
- Stage generally uses dark surfaces with a restrained border. The dialog guide's stronger borders and rounder controls are for modal emphasis, not a requirement to put every editor widget inside a thick outline.
|
||||
|
||||
For compact inspectors, shorten the hierarchy only after checking it at the intended window size. Prefer a clear label and value over shrinking type until it becomes difficult to read. Use container spacing and alignment rather than per-control position nudges.
|
||||
|
||||
### 3.4 State styling contract
|
||||
|
||||
Every interactive control should have distinct, inspectable states:
|
||||
|
||||
| State | Expected feedback |
|
||||
| --- | --- |
|
||||
| Neutral | Stable surface and legible label; no implied selection. |
|
||||
| Hover | Small surface or border change. Must not resemble selected or focused. |
|
||||
| Selected / active | Persistent fill, border, pressed state, or explicit status. Keep the label unchanged when possible. |
|
||||
| Keyboard focus | High-contrast focus ring or border that is visible even over selected fill. |
|
||||
| Disabled | Control cannot activate, remains readable, and explains why when the reason is not obvious. |
|
||||
| Dragging / live preview | Immediate object feedback; indicate the active handle or preview and make cancellation discoverable. |
|
||||
| Error / invalid | Explain the problem near the affected task and offer a concrete next step. Do not rely on red alone. |
|
||||
| Saved / dirty | Show a concise, persistent status such as **Saved** / **Unsaved changes** where it helps prevent lost work. |
|
||||
|
||||
For drawing handles, keep a contrasting backing/halo where the canvas can contain arbitrary colors. The Prop canvas's two-tone selection halo and the Stage gizmo backing are useful precedents. Selection should be readable at all zoom levels; keep handle sizes in screen space when scaling the canvas.
|
||||
|
||||
## 4. Layout and reuse rules
|
||||
|
||||
### 4.1 Use the right pattern for the job
|
||||
|
||||
- **In-stage selected-object inspector:** follow StageInspectorPanel, choice groups, and toggle rows in the [Stage UI spec](../specs/stage_panel_ui_system.md). Its title/Close header, fixed header with scrolling body, and safe-rect fitting are specific to a floating selection card.
|
||||
- **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. Reuse spacing, hierarchy, colors, and control states; 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
1. Put repeated colors, font sizes, minimum sizes, spacing, radii, border widths, and state colors in a validated theme source.
|
||||
2. Use typed accessors with sensible fallbacks. Avoid repeating raw values across script-built controls and .tscn files.
|
||||
3. Apply per-control StyleBox overrides. Do not mutate a cached/shared Theme to style one editor or one accent variant.
|
||||
4. Extract a reusable control only after the behavior or presentation appears in at least two appropriate contexts. Keep domain rules and document mutations in the editor controller.
|
||||
5. Prefer native Button, ButtonGroup, CheckBox, OptionButton, and LineEdit semantics. Custom-drawn controls should separate visual drawing from input and expose a clear typed API.
|
||||
6. Keep canvas-specific rendering tokens separate from chrome tokens. Grid, artwork, shape fill, selection halo, vertex handles, and preview ghosts have different contrast jobs.
|
||||
7. Do not serialize UI state unless it is part of the authored document. Tool preference, document content, preview-only state, and persistence should be deliberately separated.
|
||||
|
||||
The current code already demonstrates local theme ownership in Prop Editor (_ui_theme) and Stage token validation/cache behavior in SandboxTheme. This guide recommends extending those strengths; it does not claim the editor themes are currently unified.
|
||||
|
||||
### 4.3 Responsive workspace behavior
|
||||
|
||||
- Preserve canvas priority. When space is constrained, inspector content scrolls before drawing tools or essential canvas controls are clipped.
|
||||
- Keep splitters draggable and give both sides a useful minimum. Prop Editor has the outer canvas/inspector split and an Attach preview split; these are good workspace patterns to retain.
|
||||
- Use container-driven rows (FlowContainer, HBoxContainer, VBoxContainer) instead of fixed offsets for control groups that can wrap or grow with theme fonts.
|
||||
- Keep top actions and mode controls reachable at smaller window sizes. Wrap or stack controls only when necessary, and preserve their order and mode meaning.
|
||||
- Keep toolbars, status bars, and dialogs inside safe margins. A dynamic dialog should grow from a stable anchor and scroll rather than jump or recenter after each choice.
|
||||
- For Stickman Editor, the current scene uses fixed top bar offsets and a three-column layout. A responsive pass should specifically validate narrower windows, custom fonts, longer translations, and panel resizing before claiming parity with Prop Editor's split workspace.
|
||||
|
||||
## 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.
|
||||
|
||||
### 5.1 Prop Editor flow
|
||||
|
||||
Current primary flow, based on [prop_editor.gd](../../scripts/editors/prop/prop_editor.gd) and [prop_canvas.gd](../../scripts/editors/prop/prop_canvas.gd):
|
||||
|
||||
~~~mermaid
|
||||
flowchart TD
|
||||
Open[Open Prop Editor] --> Recovery{Recover autosave?}
|
||||
Recovery -->|Restore| Dirty[Restore document as dirty]
|
||||
Recovery -->|Discard / none| New[New or loaded document]
|
||||
Dirty --> Arrange[Arrange shapes]
|
||||
New --> Arrange
|
||||
Arrange --> Reshape[Reshape vertices]
|
||||
Arrange --> Attach[Attach anchors and preview]
|
||||
Reshape --> Arrange
|
||||
Attach --> Arrange
|
||||
Attach --> Reshape
|
||||
Arrange --> Save[Save / Save As]
|
||||
Reshape --> Save
|
||||
Attach --> Save
|
||||
Save --> Clean[Saved status; autosave cleared]
|
||||
Arrange --> Exit{New / close with changes?}
|
||||
Reshape --> Exit
|
||||
Attach --> Exit
|
||||
Exit -->|Save| Save
|
||||
Exit -->|Discard| Close[Close or start new]
|
||||
Exit -->|Cancel| CurrentMode[Return to current mode]
|
||||
~~~
|
||||
|
||||
Presentation rules for this flow:
|
||||
|
||||
- Arrange, Reshape, and Attach are persistent tool modes. Keep the active mode visually selected and keep mode labels stable.
|
||||
- Mode changes may change available controls and inspector sections. Explain missing controls through a short label or helper only when the reason is not obvious from the mode.
|
||||
- Shape/anchor edits change the document and should visibly change dirty status. Preview motion, visibility toggles, and other session-only controls should not imply the document was edited.
|
||||
- Saving should confirm completion in the status area or title state. Errors should identify the failed file action and retain the child's work.
|
||||
- New and close with unsaved changes offer explicit **Save**, **Discard**, and **Cancel** outcomes. For actions that could replace work, name the consequence in the prompt.
|
||||
- In Attach mode, show the selected anchor and its editable handles clearly on the canvas, and keep the private stickman/prop preview visually separate from the authoring surface.
|
||||
|
||||
### 5.2 Stickman Editor flow
|
||||
|
||||
Current primary flow, based on [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd), [body_part_panel.gd](../../scripts/editors/stickman/body_part_panel.gd), and [whole_stickman_preview.gd](../../scripts/editors/stickman/whole_stickman_preview.gd):
|
||||
|
||||
~~~mermaid
|
||||
flowchart LR
|
||||
LoadOrStart[Load or start stickman] --> Parts[Choose a body-part drawing panel]
|
||||
Parts --> Draw[Draw and color one or more shapes]
|
||||
Draw --> Select[Select shape or vertex]
|
||||
Select --> Preview[Inspect whole-stickman assembly]
|
||||
Preview --> Guide[Toggle pose guide / reset views]
|
||||
Guide --> Parts
|
||||
Parts --> Save[Save stickman]
|
||||
Save --> Continue[Continue editing]
|
||||
LoadOrStart --> Clear[Clear with confirmation]
|
||||
Clear --> Parts
|
||||
~~~
|
||||
|
||||
Presentation rules for this flow:
|
||||
|
||||
- Make the active body-part panel and active shape unambiguous. Selecting a shape in one panel clears selection in the others.
|
||||
- Keep local drawing context and the assembled Whole Stickman preview distinct but visually related. A change in one body part should have a prompt, visible effect in the assembly preview.
|
||||
- Use the pose guide as quiet construction help beneath selection gizmos. Guide colors distinguish left/right limbs; the guide must not capture pointer input or obscure active edits.
|
||||
- Keep color editing and recent colors close to the relevant part, and confirm a color only through the explicit dialog action.
|
||||
- Keep grid/snap and guide visibility available as tool preferences, visually separated from authored shape color and geometry.
|
||||
|
||||
### 5.3 Reusable dialog transaction
|
||||
|
||||
For either editor, the expected child-facing sequence is:
|
||||
|
||||
1. State the action in the title and one short sentence.
|
||||
2. Show the effect or consequence when it may be surprising.
|
||||
3. Present one primary action, any needed destructive action, and a visible **Cancel**.
|
||||
4. Keep focus in the dialog, preserve the current anchor during resize, and return focus to the invoking control after close where practical.
|
||||
5. On success, close the dialog and update the affected editor state once. On cancel, leave the document unchanged.
|
||||
|
||||
This is a target interaction contract for editor dialogs. Existing controls should be checked individually before being treated as compliant.
|
||||
|
||||
## 6. Interaction, feedback, and child-facing language
|
||||
|
||||
### 6.1 Input and feedback
|
||||
|
||||
- Support mouse and touch-size targets; target at least **44 × 44 logical pixels** for child-facing actions. Prefer Stage's **48 px** action target where the layout allows.
|
||||
- Keep keyboard navigation visible and complete: Tab reaches meaningful controls, Enter/Space activates them, Escape cancels temporary tasks, and focus does not disappear behind a selected style.
|
||||
- Keep right-click and keyboard shortcuts as optional accelerators. A visible menu item or button should remain available for important operations.
|
||||
- For dragging, preview changes continuously, show which handle is active, commit on release, and provide a clear cancel path when the operation is consequential or multi-step.
|
||||
- Avoid hit-test surprises: decorative labels/icons ignore mouse input; a large row toggle has one activation target; a canvas guide is visual only.
|
||||
- Preserve the world point under the cursor during zoom. Keep selected gizmos and handles visible above artwork and grid.
|
||||
- Use both visual and textual feedback for snap, mode, selected state, disabled reason, save state, and errors.
|
||||
|
||||
The Stage panel's full-row toggle and stable help layout are useful behaviors when a row switch is appropriate. In an editor inspector, apply the same principles without copying the Stage card's floating shell.
|
||||
|
||||
### 6.2 Copy and labels
|
||||
|
||||
Prefer concise, concrete verbs and familiar terms:
|
||||
|
||||
| Prefer | Avoid |
|
||||
| --- | --- |
|
||||
| **Arrange**, **Reshape**, **Attach** | Technical labels such as “Transform mode” or “Edit points mode” as the main child-facing label |
|
||||
| **Add Shape**, **Erase Shape**, **Create Point**, **Remove Point** | Ambiguous labels such as “Apply” when the result can be named |
|
||||
| **Save**, **Save As…**, **Load**, **Cancel** | Icon-only actions with no tooltip or accessible label |
|
||||
| “This shape was reshaped.” | A disabled Radius field with no explanation |
|
||||
| “Choose where this prop attaches.” | Internal terms such as “anchor payload” |
|
||||
| “Unsaved changes. Save before starting a new prop?” | Generic “Are you sure?” without naming the consequence |
|
||||
|
||||
Emoji can help distinguish common actions, as used by Prop Editor menus, but should be an optional cue rather than the only meaning. Keep the text label understandable if an emoji font is unavailable. Use the same wording for the same action in menu, dialog, toolbar, tooltip, and user guide.
|
||||
|
||||
### 6.3 Icons and emoji
|
||||
|
||||
- Use one icon or emoji to help scan a familiar action; do not put emoji on every label by default.
|
||||
- Keep the text label as the source of meaning. Provide a tooltip or accessible name for icon-only canvas tools.
|
||||
- Use icons consistently for the same action across menus, toolbars, empty states, and dialogs. Avoid changing icon meaning between editors.
|
||||
- Avoid using an emoji's color as the only state cue; fonts and platforms may render it differently.
|
||||
- The Prop Editor already uses emoji in File and mode labels. The Stickman Editor currently uses plain menu labels. Treat a visual icon pass as a recommendation that should be applied consistently, not as a current shared convention.
|
||||
|
||||
### 6.4 Empty, loading, and error states
|
||||
|
||||
The current editors have different levels of state guidance. The following table separates known behavior from recommended presentation:
|
||||
|
||||
| State | Current reference | Recommended presentation |
|
||||
| --- | --- | --- |
|
||||
| New/empty Prop canvas | Arrange has an add-shape context menu and an Arrange-only empty-canvas hint; Reshape does not offer adding a shape. | Keep the canvas mostly clear. Show a short Arrange hint only when it helps discover the next action; hide it as soon as artwork exists. Do not show a dead-end hint in Reshape or Attach. |
|
||||
| Empty Prop inspector selection | Inspector sections depend on mode and selected shape/anchor. | State what to select or create when the empty inspector would otherwise look broken. Keep the message short and do not add a second competing empty-canvas prompt. |
|
||||
| Prop load/recovery | Load failures use an error dialog; autosave recovery offers Restore or Discard. | Name the file/action in the message, preserve the current artwork on failure, and keep Restore/Discard outcomes distinct. Loading feedback should not imply that the document is already saved. |
|
||||
| Stickman panel before drawing | The ten body-part panels are the main workspace; the panel script owns drawing and selection behavior. | Give each blank panel a concise, low-contrast drawing cue if discovery testing shows it is needed. Remove it on first shape so it does not compete with artwork. |
|
||||
| Stickman load/clear | Load and save use file dialogs; Clear has a confirmation dialog; error dialogs report failures. | Keep the current document intact on a failed load. State the consequence in Clear confirmation and offer a visible cancel path. |
|
||||
| Save / dirty state | Prop Editor has status/title and autosave behavior; Stickman Editor status bar reports cursor coordinates and snap state. | Make save status easy to find in both editors. Do not treat autosave as a user-visible successful save unless the file was actually saved to the chosen destination. |
|
||||
|
||||
Do not add spinners or blocking overlays for synchronous work without evidence that the operation takes long enough to need them. If an operation becomes asynchronous, keep progress near the task and preserve access to cancellation where safe.
|
||||
|
||||
### 6.5 Motion and reduced motion
|
||||
|
||||
- Use motion to show cause and effect: a brief selection flash, a panel entering, or a successful action cue. Keep it short and do not delay the next input.
|
||||
- Do not animate every hover or value change. Motion should help the child understand an edit, not decorate idle controls.
|
||||
- Keep persistent mode and selection state visible after any transition ends.
|
||||
- Respect a reduced-motion preference for nonessential transitions and flashes. Provide an equally clear static state.
|
||||
- The current code has short visual effects such as the Prop canvas's new-point flash and Stage completion effects. A unified reduced-motion preference is not established by the referenced theme/specs; treat it as a future product-level decision.
|
||||
|
||||
## 7. Theme and implementation checklist
|
||||
|
||||
Before adding or revising an editor control:
|
||||
|
||||
### Design
|
||||
|
||||
- [ ] Is the control's job clear and is it in the correct workspace area?
|
||||
- [ ] Does it follow the correct pattern: persistent inspector, modal dialog, canvas tool, or temporary capture?
|
||||
- [ ] Are neutral, hover, selected, focused, disabled, dragging, and error states defined?
|
||||
- [ ] Does selected state remain distinct from hover and focus?
|
||||
- [ ] Are colors backed by labels, icons, position, or shape cues where appropriate?
|
||||
- [ ] Are labels child-readable and consistent with the existing action vocabulary?
|
||||
- [ ] Can the layout handle smaller windows, longer names, and larger font presets?
|
||||
|
||||
### Theme
|
||||
|
||||
- [ ] Are colors, type sizes, spacing, radii, borders, and target sizes tokens rather than repeated literals?
|
||||
- [ ] Does the token use a semantic name rather than a control-specific unexplained hex?
|
||||
- [ ] Does it have a validated accessor, fallback, and range clamp where applicable?
|
||||
- [ ] Are per-control style changes local and isolated from a shared cached Theme?
|
||||
- [ ] Is the difference from Stage tokens intentional and documented?
|
||||
|
||||
### Interaction
|
||||
|
||||
- [ ] Can pointer, touch, and keyboard users reach and activate the control?
|
||||
- [ ] Does a disabled control explain why, if not self-evident?
|
||||
- [ ] Does temporary work have an obvious Cancel/undo path?
|
||||
- [ ] Does a control's position remain stable while help, validation, or mode content changes?
|
||||
- [ ] Does changing a tool preference avoid marking the document dirty unless that is intentional?
|
||||
- [ ] Does success/failure produce visible feedback without hiding the canvas or losing authored work?
|
||||
|
||||
### Architecture
|
||||
|
||||
- [ ] Does the editor controller own document mutation, persistence, and dirty state?
|
||||
- [ ] Does a reusable control emit a typed intent and display a supplied value?
|
||||
- [ ] Is new reuse actually shared by more than one appropriate UI context?
|
||||
- [ ] Are UI preferences, preview-only state, and document data kept separate?
|
||||
- [ ] Are new theme values represented in both default config and committed JSON?
|
||||
- [ ] Are existing user guides/specs updated only after the behavior is verified?
|
||||
|
||||
### Implementation recipe
|
||||
|
||||
For a new editor UI element, use this sequence:
|
||||
|
||||
1. Name its job and state owner. Decide whether the editor controller, canvas, or reusable view owns the value and mutation.
|
||||
2. Identify the closest reference pattern: Stage inspector, Stage dialog, Prop Editor mode control, Prop canvas selection, or Stickman body-part panel. Record what should carry over and what should not.
|
||||
3. Define the semantic tokens and states before styling. Reuse existing token names where their meaning matches; add new names to the theme defaults and JSON with validation/fallback behavior.
|
||||
4. Build with Godot containers and native controls first. Add a custom control only when it provides repeated behavior or custom canvas drawing.
|
||||
5. Wire typed intent signals to the owning controller. Refresh controls from document state without emitting a second mutation.
|
||||
6. Check the smallest supported window, larger type, long labels, all modes, selected/disabled/focused states, and save/error/cancel paths.
|
||||
7. Capture before/after screenshots or a short manual checklist. Update the user guide after the behavior is accepted.
|
||||
|
||||
This is recommended implementation order, not a claim that every current editor control follows it.
|
||||
|
||||
### Recommended harmonization order
|
||||
|
||||
1. **Agree on token ownership and names.** Define shared semantic roles for background, surface, text, muted text, EDIT accent, focus, primary/destructive action, spacing, radii, borders, and minimum targets. Keep canvas-specific colors separate.
|
||||
2. **Resolve token drift.** Decide whether Prop's #4FA3FF accent and 36 px button/row minima should move toward the Stage reference, or remain intentional editor overrides. Record the choice and add an accessibility rationale for target sizes.
|
||||
3. **Bring Stickman chrome into token ownership.** Move its scene-level colors, font sizes, spacing, and bar sizing into a validated theme source while preserving the panel/preview composition that supports drawing.
|
||||
4. **Improve responsive layout.** Replace brittle fixed offsets where font or window growth can clip controls; establish minimum column widths and wrapping behavior for both editors.
|
||||
5. **Unify states and dialogs.** Verify focus, disabled reasons, save/error status, destructive confirmation, and cancel behavior. Reuse dialog styling only after its host/lifecycle works in the editor.
|
||||
6. **Validate visually at agreed sizes.** Compare the editor screenshots with the Stage reference and adjust intentional differences explicitly.
|
||||
|
||||
This order is a recommendation. It does not select a new shared-theme class or authorize a source migration; choose the theme architecture when implementation scope is defined.
|
||||
|
||||
### Visual QA checklist
|
||||
|
||||
- [ ] Compare Prop and Stickman editor startup screens side by side with the Stage's dark surface and EDIT accent references.
|
||||
- [ ] Check every mode with no selection, with a selected object, during drag, and after release/cancel.
|
||||
- [ ] Check primary action, destructive action, disabled control, hover, keyboard focus, and persistent selected states.
|
||||
- [ ] Check prop autosave recovery, load failure, unsaved New/Close, and successful save feedback.
|
||||
- [ ] Check Stickman Clear confirmation, load failure, snap status, pose guide, and cross-panel selection.
|
||||
- [ ] Resize to the smallest supported viewport and increase font size. Confirm canvas access, scroll access, and nonoverlapping controls.
|
||||
- [ ] Verify visible keyboard order and Return/Space/Escape behavior; confirm focus returns to the invoking control where appropriate.
|
||||
- [ ] Check grid, artwork, selection halo, vertex handles, pose guide, anchor markers, and preview ghosts against light and dark artwork.
|
||||
- [ ] Check text contrast and state distinction with a contrast measurement and color-vision simulation; the current specs do not provide measured results.
|
||||
- [ ] Repeat the checks with emoji rendering unavailable or replaced; action meaning must remain clear from text.
|
||||
|
||||
## 8. Recommended additional coverage
|
||||
|
||||
To make this guide a stronger long-term design reference, add these pieces as the editor UI evolves:
|
||||
|
||||
1. **Annotated screenshots at canonical window sizes.** Include each editor's initial screen, every mode, selection/reshape states, Attach preview, and one modal. Mark which visuals are current and which are target.
|
||||
2. **A shared token inventory.** Decide semantic names and authoritative ownership for surfaces, accents, focus, destructive actions, canvas grid, selection halo, typography, spacing, and minimum target sizes. Reconcile SandboxTheme, PropTheme, and Stickman Editor scene-level values in a planned migration.
|
||||
3. **Responsive breakpoints and minimum workspace sizes.** Record minimum usable canvas/inspector widths, when controls wrap, how the Attach preview stacks, and how Stickman Editor's three columns adapt.
|
||||
4. **Accessibility and contrast acceptance checks.** Specify contrast targets, keyboard tab order, focus visibility, touch target checks, color-vision-safe cues, and high-font-size behavior. The current references provide useful states and target sizes but do not establish a measured accessibility audit.
|
||||
5. **Motion and sound rules.** Define when transitions, selection flashes, save feedback, and error feedback animate or use sound; keep them brief, informative, and respectful of reduced-motion preferences.
|
||||
6. **Content and localization rules.** Include label length budgets, wrapping/truncation behavior, tooltip standards, pluralization, and translation expansion checks.
|
||||
7. **Editor-specific control patterns.** Document mode bars, drawing canvases, color pickers, anchor lists, selection gizmos, empty states, and destructive file flows as reusable examples with their owning state and signals.
|
||||
8. **Verification matrix.** Link each visual contract to a scene/test/manual check so future changes can distinguish an implemented pattern from a recommendation.
|
||||
|
||||
## 9. Reference files
|
||||
|
||||
- [AGENTS.md](../../AGENTS.md) — repository layout, editor architecture, shared Stage UI, and child-facing dialog guardrails.
|
||||
- [Stage panel UI system](../specs/stage_panel_ui_system.md) — Stage inspector card, transient capture bar, component contracts, fitting, and acceptance criteria.
|
||||
- [Dialog Design Principles](../specs/dialog_design_principles.md) — child-facing modal shell, action hierarchy, and stable resize behavior.
|
||||
- [SandboxTheme implementation](../../scripts/stage/sandbox_theme.gd) and [SandboxTheme config](../../sandbox_theme.json).
|
||||
- [PropTheme implementation](../../scripts/editors/prop/prop_theme.gd) and [PropTheme config](../../prop_theme.json).
|
||||
- [Prop Editor controller](../../scripts/editors/prop/prop_editor.gd), [Prop Editor scene](../../scenes/editors/prop/prop_editor.tscn), and [Prop Editor guide](prop_editor_user_guide.md).
|
||||
- [Prop canvas](../../scripts/editors/prop/prop_canvas.gd) — mode behavior, canvas gestures, selection handles, and context menus.
|
||||
- [Stickman Editor controller](../../scripts/editors/stickman/stickman_editor.gd), [Stickman Editor scene](../../scenes/editors/stickman/stickman_editor.tscn), [BodyPartPanel](../../scripts/editors/stickman/body_part_panel.gd), and [Whole Stickman preview](../../scripts/editors/stickman/whole_stickman_preview.gd).
|
||||
- [Stage dialog style helper](../../scripts/shared/stage_ui/stage_dialog_styler.gd) and [Stage capture bar](../../scripts/editors/stage/stage_use_target_capture_bar.gd).
|
||||
- [Prop Editor UX specification](../specs/phase_9c_prop_editor_ux.md) — current approved Prop Editor mode, Attach preview, marker, and layout behavior.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1260" viewBox="0 0 1800 1260" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Editor visual harmonization: three design directions</title>
|
||||
<desc id="desc">A comparison board showing Familiar Studio, Adaptive Studio, and Focused Maker concepts for the Prop and Stickman Editors.</desc>
|
||||
<defs>
|
||||
<style>
|
||||
.bg { fill: #171b24; }
|
||||
.card { fill: #202633; stroke: #465064; stroke-width: 2; }
|
||||
.card-rec { fill: #202b39; stroke: #22c6ff; stroke-width: 3; }
|
||||
.surface { fill: #252b38; stroke: #596477; stroke-width: 1.5; }
|
||||
.work { fill: #171d28; stroke: #657184; stroke-width: 1.5; }
|
||||
.canvas { fill: #20232b; stroke: #7f8ca0; stroke-width: 1.5; }
|
||||
.cyan { fill: #22c6ff; }
|
||||
.cyan-stroke { stroke: #22c6ff; }
|
||||
.blue { fill: #087dba; }
|
||||
.muted { fill: #aab2c0; }
|
||||
.text { fill: #f5f7fa; font-family: Arial, sans-serif; }
|
||||
.small { font-size: 17px; }
|
||||
.body { font-size: 20px; }
|
||||
.label { font-size: 18px; font-weight: 700; }
|
||||
.h2 { font-size: 30px; font-weight: 700; }
|
||||
.title { font-size: 40px; font-weight: 700; }
|
||||
.chip { fill: #2d3748; stroke: #596477; stroke-width: 1; }
|
||||
.active-chip { fill: #103b4c; stroke: #22c6ff; stroke-width: 2; }
|
||||
.line { stroke: #667286; stroke-width: 1.5; }
|
||||
.accent-line { stroke: #22c6ff; stroke-width: 3; }
|
||||
.selected { fill: none; stroke: #22c6ff; stroke-width: 3; }
|
||||
.outline { fill: none; stroke: #f5f7fa; stroke-width: 2; }
|
||||
.figure { fill: none; stroke: #aab2c0; stroke-width: 4; stroke-linecap: round; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="bg" width="1800" height="1260"/>
|
||||
<text class="text title" x="48" y="62">Two editors, one visual language</text>
|
||||
<text class="text body muted" x="48" y="98">Phase 0a · Three layout directions · Shared tokens and accessible states in every option</text>
|
||||
|
||||
<!-- Concept A -->
|
||||
<g transform="translate(42,132)">
|
||||
<rect class="card" width="556" height="1080" rx="20"/>
|
||||
<text class="text h2" x="26" y="46">A. Familiar Studio</text>
|
||||
<text class="text small muted" x="26" y="76">Keep today’s workspace arrangement</text>
|
||||
<rect class="chip" x="26" y="94" width="176" height="34" rx="17"/>
|
||||
<text class="text label small" x="42" y="117">LOWEST CHANGE</text>
|
||||
|
||||
<text class="text label" x="26" y="166">PROP EDITOR · WIDE</text>
|
||||
<rect class="surface" x="26" y="180" width="504" height="260" rx="12"/>
|
||||
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
|
||||
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Unsaved changes</tspan></text>
|
||||
<rect class="active-chip" x="319" y="197" width="82" height="26" rx="13"/>
|
||||
<text class="text small" x="330" y="216">Arrange</text>
|
||||
<rect class="chip" x="405" y="197" width="68" height="26" rx="13"/>
|
||||
<text class="text small" x="415" y="216">Reshape</text>
|
||||
<rect class="chip" x="477" y="197" width="36" height="26" rx="13"/>
|
||||
<text class="text small" x="484" y="216">…</text>
|
||||
<rect class="canvas" x="36" y="237" width="322" height="192" rx="8"/>
|
||||
<path class="figure" d="M196 278 L196 363 M196 297 L151 337 M196 297 L242 337 M196 363 L163 406 M196 363 L230 406"/>
|
||||
<circle class="selected" cx="196" cy="278" r="16"/>
|
||||
<rect class="surface" x="368" y="237" width="152" height="192" rx="8"/>
|
||||
<text class="text small" x="381" y="263">Inspector</text>
|
||||
<rect class="chip" x="380" y="275" width="128" height="34" rx="7"/>
|
||||
<text class="text small" x="393" y="298">Fill color</text>
|
||||
<rect class="chip" x="380" y="317" width="128" height="34" rx="7"/>
|
||||
<text class="text small" x="393" y="340">Outline</text>
|
||||
<rect class="blue" x="380" y="365" width="128" height="42" rx="9"/>
|
||||
<text class="text small" x="403" y="392">Add Shape</text>
|
||||
|
||||
<text class="text label" x="26" y="484">STICKMAN EDITOR · WIDE</text>
|
||||
<rect class="surface" x="26" y="498" width="504" height="330" rx="12"/>
|
||||
<rect class="surface" x="36" y="508" width="484" height="35" rx="8"/>
|
||||
<text class="text small" x="48" y="532">File Save View <tspan fill="#aab2c0">Saved</tspan></text>
|
||||
<rect class="work" x="36" y="552" width="148" height="264" rx="8"/>
|
||||
<rect class="work" x="194" y="552" width="148" height="264" rx="8"/>
|
||||
<rect class="work" x="352" y="552" width="158" height="264" rx="8"/>
|
||||
<text class="text small" x="48" y="577">Head · Torso</text>
|
||||
<text class="text small" x="206" y="577">Arms · Legs</text>
|
||||
<text class="text small" x="364" y="577">Whole Stickman</text>
|
||||
<g fill="#252b38" stroke="#586477">
|
||||
<rect x="46" y="591" width="58" height="62" rx="6"/><rect x="113" y="591" width="58" height="62" rx="6"/>
|
||||
<rect x="46" y="662" width="58" height="62" rx="6"/><rect x="113" y="662" width="58" height="62" rx="6"/>
|
||||
<rect x="46" y="733" width="58" height="62" rx="6"/><rect x="113" y="733" width="58" height="62" rx="6"/>
|
||||
<rect x="204" y="591" width="58" height="62" rx="6"/><rect x="271" y="591" width="58" height="62" rx="6"/>
|
||||
<rect x="204" y="662" width="58" height="62" rx="6"/><rect x="271" y="662" width="58" height="62" rx="6"/>
|
||||
</g>
|
||||
<rect class="selected" x="45" y="590" width="60" height="64" rx="7"/>
|
||||
<path class="figure" d="M431 603 L431 708 M431 623 L405 651 M431 623 L457 651 M431 708 L411 760 M431 708 L451 760"/>
|
||||
<text class="text small muted" x="26" y="858">Update styling, targets, focus, and dialogs;</text>
|
||||
<text class="text small muted" x="26" y="882">keep all ten drawing panels visible.</text>
|
||||
|
||||
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
|
||||
<text class="text label" x="26" y="955">Tradeoff</text>
|
||||
<text class="text body" x="26" y="986">Least workflow change. The ten small canvases</text>
|
||||
<text class="text body" x="26" y="1014">stay dense; narrow windows may hide the preview.</text>
|
||||
</g>
|
||||
|
||||
<!-- Concept B -->
|
||||
<g transform="translate(622,132)">
|
||||
<rect class="card-rec" width="556" height="1080" rx="20"/>
|
||||
<text class="text h2" x="26" y="46">B. Adaptive Studio</text>
|
||||
<text class="text small muted" x="26" y="76">Keep overview; add a workspace that can grow</text>
|
||||
<rect class="active-chip" x="26" y="94" width="198" height="34" rx="17"/>
|
||||
<text class="text label small" x="42" y="117">RECOMMENDED</text>
|
||||
|
||||
<text class="text label" x="26" y="166">PROP EDITOR · WIDE</text>
|
||||
<rect class="surface" x="26" y="180" width="504" height="220" rx="12"/>
|
||||
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
|
||||
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Saved</tspan></text>
|
||||
<rect class="active-chip" x="312" y="197" width="82" height="26" rx="13"/>
|
||||
<text class="text small" x="323" y="216">Arrange</text>
|
||||
<rect class="chip" x="398" y="197" width="68" height="26" rx="13"/>
|
||||
<text class="text small" x="408" y="216">Reshape</text>
|
||||
<rect class="chip" x="470" y="197" width="42" height="26" rx="13"/>
|
||||
<text class="text small" x="478" y="216">Attach</text>
|
||||
<rect class="canvas" x="36" y="237" width="318" height="152" rx="8"/>
|
||||
<path class="figure" d="M192 265 L192 337 M192 281 L154 312 M192 281 L230 312 M192 337 L164 375 M192 337 L221 375"/>
|
||||
<circle class="selected" cx="192" cy="265" r="14"/>
|
||||
<rect class="surface" x="364" y="237" width="156" height="152" rx="8"/>
|
||||
<text class="text small" x="376" y="263">Scroll settings</text>
|
||||
<rect class="chip" x="376" y="276" width="132" height="30" rx="7"/>
|
||||
<rect class="chip" x="376" y="314" width="132" height="30" rx="7"/>
|
||||
<rect class="blue" x="376" y="352" width="132" height="28" rx="7"/>
|
||||
<text class="text small" x="399" y="372">Add Shape</text>
|
||||
|
||||
<text class="text label" x="26" y="446">STICKMAN · FOCUS ON PART</text>
|
||||
<rect class="surface" x="26" y="460" width="504" height="354" rx="12"/>
|
||||
<rect class="surface" x="36" y="470" width="484" height="36" rx="8"/>
|
||||
<text class="text small" x="48" y="495">File Save <tspan fill="#aab2c0">Unsaved changes</tspan></text>
|
||||
<rect class="chip" x="36" y="514" width="224" height="34" rx="8"/>
|
||||
<text class="text small" x="95" y="537">Overview · 10 parts</text>
|
||||
<rect class="active-chip" x="268" y="514" width="252" height="34" rx="8"/>
|
||||
<text class="text small" x="330" y="537">Focus on Part</text>
|
||||
<rect class="active-chip" x="36" y="556" width="484" height="38" rx="8"/>
|
||||
<text class="text label" x="51" y="581">Body part: Head ▾</text>
|
||||
<rect class="canvas" x="36" y="602" width="294" height="170" rx="8"/>
|
||||
<text class="text small muted" x="50" y="625">Head drawing canvas</text>
|
||||
<circle class="outline" cx="181" cy="688" r="43"/>
|
||||
<circle class="selected" cx="181" cy="688" r="48"/>
|
||||
<rect class="surface" x="340" y="602" width="180" height="170" rx="8"/>
|
||||
<text class="text small" x="354" y="627">Whole Stickman</text>
|
||||
<path class="figure" d="M430 643 L430 707 M430 656 L410 674 M430 656 L450 674 M430 707 L414 750 M430 707 L446 750"/>
|
||||
<rect class="surface" x="36" y="780" width="484" height="26" rx="8"/>
|
||||
<text class="text small" x="48" y="800">Add Shape Arrange Reshape Color + Future tools</text>
|
||||
|
||||
<text class="text small muted" x="26" y="848">Overview keeps all ten panels available; Focus makes</text>
|
||||
<text class="text small muted" x="26" y="874">room for new tools and is useful at any window size.</text>
|
||||
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
|
||||
<text class="text label" x="26" y="955">Tradeoff</text>
|
||||
<text class="text body" x="26" y="986">More workspace and state work; switching views</text>
|
||||
<text class="text body" x="26" y="1014">must preserve selection and drawing context.</text>
|
||||
</g>
|
||||
|
||||
<!-- Concept C -->
|
||||
<g transform="translate(1202,132)">
|
||||
<rect class="card" width="556" height="1080" rx="20"/>
|
||||
<text class="text h2" x="26" y="46">C. Focused Maker</text>
|
||||
<text class="text small muted" x="26" y="76">Make one task and canvas the center of both editors</text>
|
||||
<rect class="chip" x="26" y="94" width="205" height="34" rx="17"/>
|
||||
<text class="text label small" x="42" y="117">LARGEST REDESIGN</text>
|
||||
|
||||
<text class="text label" x="26" y="166">ONE SHARED WORKSPACE PATTERN</text>
|
||||
<rect class="surface" x="26" y="180" width="504" height="552" rx="12"/>
|
||||
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
|
||||
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Saved</tspan></text>
|
||||
<rect class="surface" x="36" y="238" width="484" height="50" rx="8"/>
|
||||
<text class="text label" x="52" y="270">Choose task or body part ▾</text>
|
||||
<rect class="canvas" x="36" y="298" width="318" height="338" rx="8"/>
|
||||
<text class="text small muted" x="51" y="324">Large active canvas</text>
|
||||
<path class="figure" d="M195 382 L195 500 M195 405 L151 444 M195 405 L239 444 M195 500 L160 570 M195 500 L230 570"/>
|
||||
<circle class="selected" cx="195" cy="382" r="26"/>
|
||||
<rect class="surface" x="364" y="298" width="156" height="338" rx="8"/>
|
||||
<text class="text small" x="377" y="325">For this task</text>
|
||||
<rect class="chip" x="376" y="340" width="132" height="39" rx="7"/>
|
||||
<text class="text small" x="389" y="366">Color</text>
|
||||
<rect class="chip" x="376" y="388" width="132" height="39" rx="7"/>
|
||||
<text class="text small" x="389" y="414">Shape</text>
|
||||
<rect class="blue" x="376" y="440" width="132" height="42" rx="8"/>
|
||||
<text class="text small" x="392" y="467">Add Shape</text>
|
||||
<rect class="chip" x="376" y="492" width="132" height="39" rx="7"/>
|
||||
<text class="text small" x="389" y="518">Erase Shape</text>
|
||||
<rect class="active-chip" x="36" y="646" width="484" height="72" rx="9"/>
|
||||
<text class="text label" x="53" y="675">Preview drawer</text>
|
||||
<text class="text small" x="53" y="702">Whole Stickman · Attach preview · Open</text>
|
||||
|
||||
<text class="text small muted" x="26" y="770">The same task-first frame in Prop and Stickman.</text>
|
||||
<text class="text small muted" x="26" y="796">The preview opens only when needed.</text>
|
||||
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
|
||||
<text class="text label" x="26" y="955">Tradeoff</text>
|
||||
<text class="text body" x="26" y="986">Strongest focus and consistency, but most users</text>
|
||||
<text class="text body" x="26" y="1014">must relearn navigation; all ten parts are no</text>
|
||||
<text class="text body" x="26" y="1042">longer visible at once.</text>
|
||||
</g>
|
||||
|
||||
<text class="text small muted" x="48" y="1240">For discussion only · Palette and labels follow the Editor Visual Style Guide; this is not final UI styling.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,224 @@
|
||||
# Editor visual harmonization — architecture and UI specification
|
||||
|
||||
> **Status:** Proposed for final user sign-off (2026-10-23). **Branch:** `feature/editor-visual-harmonization`. **Scope:** Prop Editor and Stickman Editor presentation, responsive workspaces, control feedback, dialogs, and save status. This specification does not change `.prop` or `.stk` formats or Stage inspector behavior.
|
||||
|
||||
## 1. Outcome and approved direction
|
||||
|
||||
The two editors should read as parts of one Stickman Studio product while keeping their distinct authoring tasks. The canvas remains the main work surface. The Prop Editor retains its Arrange, Reshape, and Attach modes, split canvas, Attach preview, and scrolling inspector. The Stickman Editor gains **Overview**, with all ten body-part panels available, and **Focus on Part**, with one larger live panel, a body-part picker, and the Whole Stickman preview. Focus can be chosen at any window size. It is the automatic default only when the minimum widths needed by Overview do not fit. Once a child chooses a view, that choice remains in effect for the session through window resizing.
|
||||
|
||||
The [Editor Visual Style Guide](../guides/editor_visual_style_guide.md) supplies the visual direction, and [Dialog Design Principles](dialog_design_principles.md) supplies modal behavior. [Stage panel UI system](stage_panel_ui_system.md) remains scoped to in-stage selected-object cards. Neither `StageInspectorPanel` nor its choice/toggle controls are transplanted into the persistent editor workspaces.
|
||||
|
||||
The implementation proceeds in staged slices under this one contract. Each slice should be reviewable without changing the saved document schema. Final handoff waits for the behavior and rendered visual checks in §10.
|
||||
|
||||
## 2. Current architecture and constraints
|
||||
|
||||
| Area | Current fact | Consequence |
|
||||
| --- | --- | --- |
|
||||
| Prop theme | `PropTheme` reads `prop_theme.json` with defaults, clamping, and typed accessors. `PropEditor` builds a local `Theme`; it also hardcodes some stylebox colors and gives mode buttons `FOCUS_NONE`. | Share chrome roles through one editor token source and restore a distinct keyboard focus state. Keep prop material, primitive, and canvas values under `PropTheme`. |
|
||||
| Prop layout | `prop_editor.tscn` has fixed top-row offsets, outer `HSplitContainer`, scrolling inspector, and an inner Attach split. | Replace brittle top-row positioning with container sizing; keep both splitters and the inspector scroll. |
|
||||
| Stickman state | `StickmanEditor` instantiates ten `BodyPartPanel` scenes, stores them by part name, and reads their live shapes for preview and save. Each panel also owns shape selection, zoom, pan, and popup state. | A view switch must move the same nodes. Calling `set_shape_data()` would reset selection, zoom, and pan. |
|
||||
| Stickman layout | The scene has two five-panel `VBoxContainer` columns and one `WholeStickmanPreview` in a fixed three-column `HBoxContainer`. Each body panel has a 230 × 160 minimum. | Overview needs scroll access; Focus needs a separate host and width-based initial choice. |
|
||||
| Stickman document feedback | Save and load serialize directly; no current path or dirty status is tracked. `WholeStickmanPreview.part_moved` reports translation, while rotation and scale have no commit signal. | Dirty tracking and unsaved-work flows need an explicit controller boundary covering every authored transform. |
|
||||
| Attach drawing order | Anchor `z_index_offset` is an integer clamped to −5…5. `PropAttachPreview` assigns it to the preview prop's `z_index`; held runtime props apply it relative to the rig's index. | Keep the signed value and preview result. Zero means normal ordering, not a universal “front” preset. |
|
||||
|
||||
## 3. Theme ownership and visual contract
|
||||
|
||||
### 3.1 One shared editor chrome source
|
||||
|
||||
Add `scripts/shared/editor_ui/editor_visual_theme.gd` as `class_name EditorVisualTheme extends RefCounted` and `res://editor_visual_theme.json`. It owns **shared editor chrome**: background and raised surfaces, primary and muted text, EDIT accent, keyboard focus, primary/destructive actions, borders, typography, spacing, radii, and minimum control targets. Its loader follows the validated `PropTheme`/`SandboxTheme` pattern: immutable defaults, deep merge of the committed JSON, type validation, clamping, a warning once per invalid key, and a safe default when a file or value is missing. Do not read `SandboxTheme` at runtime to style either editor; its values are a product reference and its popup/inspector semantics belong to the Stage.
|
||||
|
||||
Both editor controllers hold an `EditorVisualTheme` instance and build/apply their own local Godot `Theme` or per-control style overrides. They never mutate `ThemeDB`, the project-wide GUI theme, or a cached shared `Theme` for one selected control. `PropEditor` retains `PropTheme` for material tints, primitive defaults, outline widths, and **prop canvas** colors. Stickman canvas grid, silhouette guide, and selection colors live in a separate `stickman_canvas` namespace with typed accessors; artwork colors are never overridden by chrome styling. Existing `PropTheme.ui` getters may forward during the staged migration, but the final chrome value has one authoritative source in `EditorVisualTheme`. Remove duplicate configuration values when callers have migrated.
|
||||
|
||||
Suggested typed contract (precise names may change in implementation while preserving ownership):
|
||||
|
||||
```gdscript
|
||||
class_name EditorVisualTheme
|
||||
extends RefCounted
|
||||
|
||||
const DEFAULT_PATH: String = "res://editor_visual_theme.json"
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool: ...
|
||||
func chrome_color(role: StringName, editor: StringName = &"") -> Color: ...
|
||||
func chrome_px(role: StringName, editor: StringName = &"") -> int: ...
|
||||
func stickman_canvas_color(role: StringName) -> Color: ...
|
||||
func control_theme(editor: StringName) -> Theme: ... # return an editor-local instance
|
||||
```
|
||||
|
||||
The JSON payload is organized by semantic chrome and drawing purpose, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"chrome": {
|
||||
"colors": {"background": "#171B24F2", "surface": "#252B38FA", "focus": "#8BD9FF"},
|
||||
"targets": {"button_min_height": 44, "mode_button_min_height": 56}
|
||||
},
|
||||
"editors": {"prop": {}, "stickman": {}},
|
||||
"stickman_canvas": {"grid_color": "#FFFFFF26", "selection_core": "#FFFFFF", "selection_halo": "#000000D9"}
|
||||
}
|
||||
```
|
||||
|
||||
The complete committed config and script defaults must contain all roles in §3.2, not only these example keys. Resolution order is script defaults, committed shared JSON, then an explicit editor override for the requested editor. Known roles use named accessors or validated role IDs, never arbitrary dotted strings at call sites. An optional `editors.prop` / `editors.stickman` override is allowed only where density or composition has a documented need; shared meanings stay the same. Document any legacy `PropTheme.ui` accessor forwarding and removal in the implementation slice.
|
||||
|
||||
### 3.2 Baseline tokens and states
|
||||
|
||||
| Role | Starting value or rule | Use |
|
||||
| --- | --- | --- |
|
||||
| Main background / raised surface | Stage references `#171B24F2` / `#252B38FA` | Editor chrome; keep drawing canvases visually distinct. |
|
||||
| Primary / muted text | `#F5F7FA` / `#AAB2C0` | Titles, labels, helper copy, disabled reasons. |
|
||||
| EDIT accent | `#22C6FF` | Active editor mode, part/view choice, and selection accent. |
|
||||
| Keyboard focus | `#8BD9FF`, at least a distinct 2 px outline | Visible over normal, hover, and pressed styles; native Tab/Space/Enter remain usable. |
|
||||
| Primary / destructive action | `#087DBA` / `#E66B75` | One forward dialog action and explicit erase/discard actions. Destructive controls retain words and a cancel path. |
|
||||
| Targets | At least 44 × 44 logical pixels for child-facing interactive controls; mode buttons may stay 56 px high | Increase Prop's current 36 px button/row minima; enlarge click areas without enlarging canvas handles visually. |
|
||||
| Density | Body text about 18–20 px, section headers about 20–24 px; compact Overview part title may be about 16–18 px | Confirm fit at minimum supported viewport and larger type. Focus may use a larger part title. |
|
||||
| Canvas selection | Bright core plus dark backing; handle size and hit area are screen-space | Preserve Prop's existing two-tone halo. Extend the same contrast principle to Stickman panel and preview gizmos. |
|
||||
|
||||
The final theme must distinguish neutral, hover, pressed/selected, keyboard focus, disabled, dragging/preview, error, saved, and dirty states. Focus and selection may coexist; focus must not paint over or hide the active mode, as the current Prop button style does. Icons or emoji may add cues but each action remains understandable from text alone. The grid, pose guide, authored shape colors, and material colors remain canvas/domain tokens.
|
||||
|
||||
## 4. Stickman adaptive workspace
|
||||
|
||||
### 4.1 Node hierarchy and state owners
|
||||
|
||||
```text
|
||||
StickmanEditor : Control (document, save/load, dirty, active part, view choice)
|
||||
└─ ChromeRoot : VBoxContainer
|
||||
├─ MenuBar / name and view controls
|
||||
├─ WorkspaceRow : HSplitContainer
|
||||
│ ├─ BodyPartWorkspace : Control
|
||||
│ │ ├─ OverviewScroll : ScrollContainer
|
||||
│ │ │ └─ OverviewColumns : HBoxContainer
|
||||
│ │ │ ├─ LeftColumn : VBoxContainer (five original panels)
|
||||
│ │ │ └─ CenterColumn : VBoxContainer (five original panels)
|
||||
│ │ └─ FocusHost : VBoxContainer
|
||||
│ │ ├─ PartPicker : native choices, scroll/wrap as needed
|
||||
│ │ └─ FocusPanelHost : Control (one original BodyPartPanel)
|
||||
│ └─ WholeStickmanPreview : PanelContainer (script: WholeStickmanPreview; permanent sibling)
|
||||
└─ StatusBar
|
||||
```
|
||||
|
||||
There is exactly one `BodyPartPanel` instance per part and one `WholeStickmanPreview` instance. The preview remains a permanent sibling of the body-part workspace in `WorkspaceRow`; it is not duplicated or serialized for a view switch. The controller records each panel's original Overview parent and index. Entering Focus reparents the active panel into `FocusPanelHost`; leaving Focus restores it at the original index. Changing focused part restores the outgoing panel before moving the incoming panel. The controller's existing panel dictionary and signal connections remain stable.
|
||||
|
||||
`StickmanEditor` owns `enum WorkspaceView { OVERVIEW, FOCUS }`, `_active_part_name: String`, `_view_choice_was_manual: bool`, and the pending view/part request. `BodyPartPanel` owns its shapes, selected shape, zoom, and pan; the preview owns its assembly transform and zoom/pan. The part picker changes `_active_part_name` without calling `BodyPartPanel.select()`, which currently selects the topmost shape and would overwrite the child's shape selection. Clicking a panel updates the active part through the existing selection signal. The active part gets a text label and visible border cue in Overview and Focus.
|
||||
|
||||
Suggested typed additions:
|
||||
|
||||
```gdscript
|
||||
# BodyPartPanel
|
||||
signal presentation_safe
|
||||
func can_switch_presentation() -> bool: ... # false during drag, pan, or popup
|
||||
func add_shape(kind: StringName, at_world: Vector2) -> bool: ...
|
||||
func open_selected_color_picker() -> void: ...
|
||||
|
||||
# StickmanEditor
|
||||
func request_workspace_view(view: WorkspaceView, user_chosen: bool = true) -> void: ...
|
||||
func request_active_part(part_name: String) -> void: ...
|
||||
func _apply_pending_presentation() -> void: ...
|
||||
```
|
||||
|
||||
The picker and view buttons emit typed intent (`view_requested(view: int)`, `part_requested(part_name: String)` if extracted as a small reusable control). The controller validates the part ID against `BODY_PART_NAMES`. No signal bus or saved UI resource is needed for these synchronous presentation decisions.
|
||||
|
||||
Keep one visible action row tied to the active part in either view. **Add Shape** opens the existing Line/Rectangle/Circle choices and places the new shape at the center of that panel's visible drawing area; **Color** opens the selected shape's existing picker and is disabled with a clear reason until a shape is selected. `BodyPartPanel` exposes small typed methods for those actions and remains the owner of shape mutation. The right-click menu stays available for point editing, ordering, copy/paste, mirroring, and erase; use readable wording and a short on-canvas hint so children can discover those secondary tools. This shared row avoids repeating full 44 px toolbars inside all ten Overview panels and shrinking their drawing areas.
|
||||
|
||||
### 4.2 Responsive rules and safe switching
|
||||
|
||||
The automatic initial view is calculated after the first layout pass from available workspace width and the **Overview** content minimum: both panel columns at their useful minimum widths, container gaps, and the preview's useful minimum width. Use the same stable reference widths when the window resizes; a hidden/expanded view must not alter the threshold. If Overview fits, the default is Overview; otherwise Focus. Automatic reevaluation applies only until a child chooses either view. A manual choice remains for the session, including across later resizing and document load/clear. Do not put it in `.stk` or `user://settings.json` in this phase.
|
||||
|
||||
Overview keeps all ten panels reachable, with vertical and, when needed, horizontal scrolling rather than shrinking drawing areas below their usable minimums. A manual Overview choice remains available at a narrow width. Focus is available at any width. Wide Focus shows a persistent, resizable Whole Stickman preview in the split beside the enlarged selected panel. Narrow Focus starts with the preview collapsed so the drawing panel keeps its minimum width; a visible **Whole Stickman** button opens and closes it. If both pane minima cannot fit at once, opening the preview gives it the available workspace and offers a visible **Back to Part** action; no control or panel may be clipped. Keep the preview node alive so its zoom, pan, guide, selection, and part transforms survive collapse and return.
|
||||
|
||||
Do not reparent a panel during vertex/shape drag, pan, context menu, or color popup. Store the requested view/part and apply it when the panel reports a safe point after the gesture or popup closes. This avoids a partial geometry commit and avoids discarding a color choice. A view switch must not call `set_shape_data()`, `clear_shape()`, `reset_view()`, or `select()`; those methods change selection or view context. In particular, `set_shape_data()` currently resets zoom/pan/selection. A presentation change changes no document data, emits no shape-changed signal, and does not mark the document dirty.
|
||||
|
||||
### 4.3 Data and event flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
View[Overview / Focus request] --> Guard{Panel gesture or popup active?}
|
||||
Guard -->|yes| Pending[Keep request pending]
|
||||
Guard -->|no| Move[Reparent one live panel]
|
||||
Pending -->|presentation_safe| Move
|
||||
Move --> Refresh[Update picker, active cue, preview visibility]
|
||||
Refresh --> Status[Document dirty state unchanged]
|
||||
Edit[Draw or transform a part] --> Owner[Live BodyPartPanel / WholeStickmanPreview]
|
||||
Owner --> Controller[StickmanEditor observes commit]
|
||||
Controller --> Assembly[Refresh assembled preview]
|
||||
Controller --> Dirty[Mark Unsaved changes]
|
||||
```
|
||||
|
||||
For example, when the child focuses the left lower arm, its original panel moves from `LeftColumn` to `FocusPanelHost`. Its current `shapes`, selected shape index, `_zoom`, and `_pan_offset` remain in that node. Editing still triggers the controller's existing shape-changed handler; the preview reads from the same ten-node dictionary. Returning to Overview restores that panel to its original slot with the same drawing context.
|
||||
|
||||
## 5. Prop Editor workspace and controls
|
||||
|
||||
Keep the existing `PropCanvas`, scrolling inspector, outer canvas/inspector `HSplitContainer`, and inner Attach preview split. Convert fixed `TopRow`/`TopBar` offsets into container-driven chrome so Grid/Snap, the name field, and mode controls remain visible at larger fonts and narrow sizes. Tool rows may wrap through `FlowContainer`; keep action order stable. Maintain a useful canvas minimum and allow the inspector to scroll rather than clip. Preserve the clicked control's position when a mode reveals a section or Attach preview.
|
||||
|
||||
Restore keyboard focus on Arrange, Reshape, and Attach mode buttons. Give the local Button theme a focus outline that composes with the pressed/selected appearance; keep selected state persistent and text stable. Programmatic refresh uses `set_pressed_no_signal()` as today. Disabled controls explain nonobvious reasons near the control or via a clear tooltip. Arrange-only and Attach-only controls retain their existing mode gating, document mutation, and preview-only behavior.
|
||||
|
||||
Use visible labels and 44 px targets for the primary shape/anchor operations currently hidden in context menus where layout permits. Context menus remain useful shortcuts; the common child path for adding a shape, reshaping a point, and choosing a body part should have an obvious on-screen entry. Preserve Prop's existing two-tone halo and keep its screen-space handles and hit areas distinct. Anchor and preview markers need equivalent contrast against light and dark props. Canvas artwork and material tint values remain unchanged by chrome token migration.
|
||||
|
||||
## 6. Copy, dialogs, and save feedback
|
||||
|
||||
### 6.1 Child-facing wording
|
||||
|
||||
| Current wording | Target copy / rule | Behavioral reason |
|
||||
| --- | --- | --- |
|
||||
| Prop `Friction: 0.xx` | **Surface grip: 0.xx**, with optional helper “Higher values make sliding harder.” | Keep the exact `PhysicsMaterial.friction` value and its surface-contact meaning; do not imply hand gripping or change physics presets. |
|
||||
| Attach `Z Offset` | **Held prop layer** with nearby “Negative: behind · 0: normal order · Positive: farther in front.” Keep the −5…5 integer control and show its visual effect in the preview. | `z_index_offset` is a signed render-layer offset in both preview and held runtime behavior. Zero may draw in front in the preview due tree order, so do not relabel zero as a fixed front/behind category. |
|
||||
| Destructive commands | Prefer **Erase Shape**, **Start Over**, and explicit **Discard Changes** in dialogs. For Start Over, state “Start over with a blank stickman?” and keep a separate visible **Cancel** action. | Say which authored work is affected; pair with a visible Cancel path. |
|
||||
| Technical axis labels | Keep X/Y when they are editable coordinates; use plain helper text where the operation is unclear. | Avoid silently changing geometry or transform semantics while updating words. |
|
||||
|
||||
The Prop Editor's existing dirty title/status and autosave/recovery flow remain authoritative. Improve status wording to clear **Saved** / **Unsaved changes** states; a failed save/load keeps the current document and shows an actionable reason. Preview-only choices, Show Stickman Preview, marker visibility, and mode/view changes do not mark dirty.
|
||||
|
||||
The Stickman Editor adds controller-owned `_current_path: String` and `_dirty: bool`. Name changes, shape edits, shape order, color edits, and committed Whole Stickman position/rotation/scale changes mark dirty; grid/snap, guide, zoom/pan, selection, part picker, and Overview/Focus do not. Add one transform-commit signal or equivalent controller-observable path to `WholeStickmanPreview` that covers translation, rotation, and scale, because its current `part_moved` signal covers only translation. Only a successful save clears dirty; a failed save preserves it. Successful load/clear initializes the expected clean state, and save/load continue to use the existing `.stk` schema and `ContentPaths.canonicalize()` behavior.
|
||||
|
||||
Before Stickman **Load**, **Clear**, or window close replaces unsaved work, show the child-facing **Save / Discard Changes / Cancel** choice. Save with no usable path opens the existing Save dialog and resumes the pending action only after successful save. Cancel or a failed save leaves the document and active view untouched. Clear continues to name that it removes the current stickman. Keep Prop's current New/Close/recovery transactions intact while restyling them. Both editors' dialogs follow the dialog guide: rounded shell, readable type, blue primary action, distinct danger action, 44 px targets, visible labeled dismissal, safe viewport fit, and stable anchor when contents change. Adapt `StageDialogStyler` visual rules; its Stage overlay host is not part of either editor.
|
||||
|
||||
## 7. Static contracts and document boundaries
|
||||
|
||||
| Target | Contract | Owner / boundary |
|
||||
| --- | --- | --- |
|
||||
| `EditorVisualTheme : RefCounted` | `load_from(path: String) -> bool`; typed semantic color/size accessors; local `Theme` construction or copies | Validated shared chrome only; no Stage runtime dependency. |
|
||||
| `PropTheme : RefCounted` | Existing material, primitive, outline, and canvas accessors; temporary UI forwards only while callers migrate | Prop-specific presentation and authoring defaults; never physics material mass/friction/bounce values. |
|
||||
| `PropEditor : Control` | Existing mode, file, autosave, inspector signals and handlers; consume shared chrome tokens | Prop document mutation, Save/Load, dirty, mode control, local Theme. |
|
||||
| `PropCanvas : Control` | Existing typed mode and shape/anchor signals; selection drawing uses canvas tokens | Authoring input and visual feedback. |
|
||||
| `StickmanEditor : Control` | Add `class_name StickmanEditor`; `WorkspaceView`, `request_workspace_view`, `request_active_part`; `_dirty`/pending document action | One live panel dictionary, responsive presentation, `.stk` save/load, dirty state. |
|
||||
| `BodyPartPanel : PanelContainer` | Existing `shape_changed(Array)` and `shape_selected()`; `can_switch_presentation() -> bool`, `presentation_safe`, `add_shape(kind: StringName, at_world: Vector2) -> bool`, `open_selected_color_picker() -> void` | One part's shapes, selection, zoom/pan, gesture/popup safety, and shape operations. |
|
||||
| `WholeStickmanPreview : PanelContainer` | Existing `part_moved(String, Vector2)`; add typed `part_transform_committed(part_name: String, position: Vector2, rotation_deg: float, scale: Vector2)` or equivalent | One assembled preview and document transform commit boundary. |
|
||||
|
||||
No new global bus, serialized `Resource`, duplicate canvas instance, or `.prop`/`.stk` version bump is needed. UI preferences remain session state unless a later feature specifically approves persistence. Saving gathers from the ten live panels and the one live preview, independent of which view is visible.
|
||||
|
||||
## 8. Staged implementation slices
|
||||
|
||||
1. **Shared foundation.** Add the shared editor chrome theme/config and typed accessors. Set semantic roles, focus/selection/disabled states, minimum targets, and scoped theme application. Keep Prop-specific canvas/material tokens. Confirm both config and defaults match.
|
||||
2. **Prop Editor pass.** Migrate Prop chrome to shared roles, fix mode-button keyboard focus, raise touch targets, make top controls and inspector fit, refine labels/help, and style existing dialogs. Preserve its file and autosave behavior.
|
||||
3. **Stickman workspace pass.** Apply the theme to Stickman chrome, introduce Overview/Focus and the body-part picker, move live panel instances safely, add scrolling and narrow preview collapse, preserve drawing context, and expose active-part feedback.
|
||||
4. **Shared state and recovery pass.** Align canvas contrast and action discovery, add complete Stickman dirty/unsaved transactions, and finish dialog and status consistency. Resolve remaining measured layout and contrast issues from rendered review.
|
||||
|
||||
Keep one spec so theme roles, view state, and save behavior are reviewed together. Each slice should leave its editor usable and should report which acceptance outcomes below it satisfies. User-guide changes follow the spec-pipeline documentation phase after the user verifies the feature in the editor.
|
||||
|
||||
## 9. Architectural trade-offs and risks
|
||||
|
||||
| Decision | Benefit | Cost / mitigation |
|
||||
| --- | --- | --- |
|
||||
| Shared semantic editor source with local editor Themes | Consistent chrome and focus states without coupling to Stage internals | Migrate Prop's old `ui` values deliberately; prevent two active authorities for one chrome role. |
|
||||
| Reparent one existing BodyPartPanel into Focus | Preserves shapes, selection, zoom/pan, signals, and current save architecture | Node movement during a gesture/popup is unsafe; defer until `presentation_safe`. Keep original parent/index map. |
|
||||
| Permanent WholeStickmanPreview sibling | One source of assembly transform and preview view state | Narrow layout needs collapse/return controls and a minimum-width fallback. |
|
||||
| Session-only manual view choice | A child's chosen workspace does not jump on resize | Automatic layout helps only until the first manual choice; narrow Overview requires scroll access. |
|
||||
| One umbrella specification, staged slices | Prevents token, mode, and state contracts from drifting across separate documents | Each slice needs explicit acceptance accounting and user review. |
|
||||
| Stickman dirty state added with transform commit | Protects edits during Load/Clear/Close | All authored mutation paths must be audited; translation-only signal is insufficient. |
|
||||
|
||||
Known dependencies: the untracked style guide and design-direction board are source references in this checkout; unrelated dirty Stage work must remain untouched. Theme and layout work can be implemented independently of that Stage work. Prop's in-Stage authoring mode also instantiates `PropEditor`, so chrome changes must preserve its host and hide/disable behavior. Long labels, emoji fallback, larger fonts, and small viewports may expose new minimum-size pressure; resolve by wrapping/scrolling and measured fitting, not by silently shrinking controls.
|
||||
|
||||
## 10. Observable acceptance and verification routes
|
||||
|
||||
| User path | Observable outcome | Verification route |
|
||||
| --- | --- | --- |
|
||||
| Open each editor at a wide window; Tab through menu, primary controls, mode/view buttons, inspector, and dialogs; activate with Space/Enter. | All focusable controls show a distinct focus ring, active mode/view remains visible under focus, and controls meet the agreed target size. | Rendered GUI capture plus real keyboard navigation at ordinary and larger font settings. |
|
||||
| Open Stickman wide, select a part, use Add Shape and Color, draw in a second part, choose Focus on Part, change the focused part, then return to Overview. | The visible actions affect only the active part; Overview has all ten panels; Focus works at wide width; each part retains shapes, selected shape, zoom, and pan; preview updates from edits; no view switch marks dirty. | Interact through production controls; compare panel and preview state before/after switches and inspect rendered captures. |
|
||||
| Open Stickman where Overview's measured minimum does not fit; choose Overview manually, resize wider and narrower, then choose Focus. | Focus is the initial automatic view; Overview remains reachable through scrolling; manual choice remains through resize; Focus preview collapses with visible Whole Stickman access and returns without resetting preview view state. | Rendered narrow/wide GUI session with actual resize and control input. Record viewport dimensions and pane sizes. |
|
||||
| Begin a panel drag or open its color popup, request another view/part, then finish or dismiss that interaction. | The switch waits until the interaction is safe; geometry/color is neither partially committed nor lost; selected shape and pan/zoom remain. | Real input path through the panel and popup; inspect shapes and resulting selection after release/dismissal. |
|
||||
| In Prop, switch Arrange/Reshape/Attach, resize, edit a shape and anchor, and open its preview. | Mode state, inspector sections, top Grid/Snap, canvas, and Attach preview remain reachable without clipped labels or jumping controls; existing document and preview-only dirty rules hold. | Rendered GUI captures of all modes at wide/narrow sizes; check actual edits and status. |
|
||||
| In Prop Attach, set Held prop layer to −1, 0, and +1 and view the preview; save and reopen. | Negative places the held prop behind the rig, zero follows normal ordering, positive places it farther in front; numeric −5…5 values round-trip unchanged. | Preview capture for each value plus a small repeatable save/reopen scenario using an anchor and visible overlapping prop. Confirm runtime held ordering in a Stage scenario where a rig holds the same prop. |
|
||||
| In Prop, choose a material preset and read Surface grip, then switch presets. | The displayed value equals `PhysicsMaterial.friction`; wording describes sliding resistance without changing physics values. | Inspector readback against preset values and rendered text check. |
|
||||
| Edit a Stickman shape, move/rotate/scale a preview part, then Save; edit again and choose Load, Clear, or close. | Every authored mutation sets Unsaved changes; successful Save clears it; Save/Discard Changes/Cancel protects the current document and resumes only the chosen action. | Small repeatable `.stk` scenario: draw and transform two parts, save, reload, compare shape/transform data, then exercise each unsaved branch. Measure status and file content before/after. |
|
||||
| Open save/load/recovery/clear/error dialogs; enlarge type or shorten the viewport. | Primary/destructive actions, visible Cancel/Back/Close, message, and focused control remain accessible; content scrolls or fits without abrupt recentering after a choice. | Rendered dialog states and real pointer/keyboard interactions; capture normal, error, and destructive branches. |
|
||||
| Select artwork with light and dark fills in each editor, drag handles, and use the pose guide/Attach markers. | Selection, active handle, guide, and markers remain legible over both fills and at working zoom; grid stays quieter than the artwork. | Rendered pixel inspection at named scenes/states, with a contrast/color-vision check for text and UI state. |
|
||||
|
||||
Headless parsing or a successful scene launch alone does not establish these visual or interaction outcomes. During implementation, run targeted Godot import/parse and relevant existing regressions, then use a render-capable editor session and real controls for the visual paths. Record scene/state, viewport size, capture location, observed result, and any outcome that could not be verified. Stop for user verification after agent evidence is complete; update user guides only after the user confirms the feature works.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"chrome": {
|
||||
"colors": {
|
||||
"background": "#171B24F2",
|
||||
"surface": "#252B38FA",
|
||||
"surface_hover": "#30394A",
|
||||
"text": "#F5F7FA",
|
||||
"text_muted": "#AAB2C0",
|
||||
"edit": "#22C6FF",
|
||||
"preview": "#22C6FF66",
|
||||
"focus": "#8BD9FF",
|
||||
"action_primary": "#087DBA",
|
||||
"destructive": "#E66B75",
|
||||
"border": "#FFFFFF33",
|
||||
"disabled": "#77808F",
|
||||
"saved": "#8FD6A4",
|
||||
"dirty": "#FFD166",
|
||||
"error": "#FF8A8A"
|
||||
},
|
||||
"fonts": {
|
||||
"body": 18,
|
||||
"helper": 16,
|
||||
"section": 22,
|
||||
"title": 26,
|
||||
"menu": 20,
|
||||
"status": 16,
|
||||
"mode": 24,
|
||||
"prop_name": 20,
|
||||
"context_menu": 20
|
||||
},
|
||||
"spacing": { "small": 8, "medium": 12, "large": 16 },
|
||||
"radii": { "control": 8, "panel": 10 },
|
||||
"borders": { "normal": 1, "focus": 2 },
|
||||
"targets": { "button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56 }
|
||||
},
|
||||
"editors": { "prop": {}, "stickman": {} },
|
||||
"stickman_canvas": {
|
||||
"grid_color": "#FFFFFF26",
|
||||
"selection_core": "#FFFFFF",
|
||||
"selection_halo": "#000000D9"
|
||||
}
|
||||
}
|
||||
@@ -20,29 +20,5 @@
|
||||
"grid_size": 15,
|
||||
"background_color": "#1F1F1F",
|
||||
"grid_color": "#FFFFFF26"
|
||||
},
|
||||
"ui": {
|
||||
"fonts": {
|
||||
"body": 18,
|
||||
"header": 24,
|
||||
"menu": 22,
|
||||
"menu_bar": 22,
|
||||
"mode": 26,
|
||||
"status": 18,
|
||||
"prop_name": 20,
|
||||
"context_menu": 22
|
||||
},
|
||||
"hit_targets": {
|
||||
"button_min_height": 36,
|
||||
"row_min_height": 36,
|
||||
"mode_button_min_height": 56
|
||||
},
|
||||
"colors": {
|
||||
"text": "#F2F2F2",
|
||||
"text_muted": "#9A9A9A",
|
||||
"border": "#FFFFFF33",
|
||||
"accent": "#4FA3FF",
|
||||
"inspector_background": "#262C39"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,31 @@
|
||||
"danger_hover_surface": "#3B252C",
|
||||
"danger_pressed_surface": "#4A252D"
|
||||
},
|
||||
"ground_finish_chooser": {
|
||||
"card_width": 880,
|
||||
"max_height_ratio": 0.86,
|
||||
"screen_margin": 24,
|
||||
"choice_height": 96,
|
||||
"choice_preview_height": 48,
|
||||
"choice_font_size": 18,
|
||||
"choice_min_width": 104,
|
||||
"choice_corner_radius": 12,
|
||||
"compact_choice_height": 50,
|
||||
"compact_choice_preview_height": 22,
|
||||
"compact_choice_font_size": 16,
|
||||
"compact_grid_gap": 6,
|
||||
"compact_layout_max_width": 600,
|
||||
"compact_layout_max_height": 460,
|
||||
"compact_height_ratio": 0.89,
|
||||
"preview_height": 128,
|
||||
"compact_preview_height": 88,
|
||||
"preview_min_width": 224,
|
||||
"preview_corner_radius": 12,
|
||||
"preview_surface": "#151C28",
|
||||
"preview_border": "#39465B",
|
||||
"grid_gap": 8,
|
||||
"wide_layout_min_width": 760
|
||||
},
|
||||
"asset_selector": {
|
||||
"backdrop_alpha": 0.55,
|
||||
"cell_size": [190, 220],
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/editors/prop/prop_editor.gd" id="1_editor_script"]
|
||||
[ext_resource type="Script" path="res://scripts/editors/prop/prop_canvas.gd" id="2_canvas_script"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_canvas"]
|
||||
bg_color = Color(0.121569, 0.121569, 0.121569, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3, 0.3, 0.3, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[node name="PropEditor" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
@@ -24,91 +12,89 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_editor_script")
|
||||
|
||||
[node name="TopRow" type="HBoxContainer" parent="."]
|
||||
[node name="ChromeRoot" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 44.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="MenuBar" type="MenuBar" parent="TopRow"]
|
||||
[node name="TopRow" type="FlowContainer" parent="ChromeRoot"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
theme_override_constants/h_separation = 8
|
||||
theme_override_constants/v_separation = 4
|
||||
|
||||
[node name="MenuBar" type="MenuBar" parent="ChromeRoot/TopRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TopRightControls" type="FlowContainer" parent="ChromeRoot/TopRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/h_separation = 8
|
||||
theme_override_constants/v_separation = 4
|
||||
|
||||
[node name="TopRightControls" type="HBoxContainer" parent="TopRow"]
|
||||
unique_name_in_owner = true
|
||||
[node name="TopBar" type="HBoxContainer" parent="ChromeRoot"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
|
||||
[node name="TopBar" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_right = 1.0
|
||||
offset_top = 44.0
|
||||
offset_bottom = 88.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="NameLabel" type="Label" parent="TopBar"]
|
||||
[node name="NameLabel" type="Label" parent="ChromeRoot/TopBar"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
|
||||
text = "Prop Name:"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="PropNameEdit" type="LineEdit" parent="TopBar"]
|
||||
[node name="PropNameEdit" type="LineEdit" parent="ChromeRoot/TopBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Unnamed Prop"
|
||||
|
||||
[node name="MainLayout" type="HSplitContainer" parent="."]
|
||||
[node name="MainLayout" type="HSplitContainer" parent="ChromeRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 92.0
|
||||
offset_bottom = -28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="CanvasPanel" type="PanelContainer" parent="MainLayout"]
|
||||
[node name="CanvasPanel" type="PanelContainer" parent="ChromeRoot/MainLayout"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
size_flags_stretch_ratio = 2.0
|
||||
custom_minimum_size = Vector2(260, 0)
|
||||
theme_override_styles/panel = SubResource("StyleBox_canvas")
|
||||
|
||||
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
|
||||
[node name="CanvasArea" type="VBoxContainer" parent="ChromeRoot/MainLayout/CanvasPanel"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="ModeBar" type="HBoxContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
[node name="ModeBar" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/h_separation = 8
|
||||
theme_override_constants/v_separation = 4
|
||||
|
||||
[node name="SizeTools" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SizeTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="AttachTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
[node name="AttachTools" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="CanvasWorkspace" type="HSplitContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
|
||||
[node name="CanvasWorkspace" type="HSplitContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
split_offset = 520
|
||||
|
||||
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
|
||||
[node name="PropCanvas" type="Control" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
@@ -117,18 +103,18 @@ mouse_filter = 0
|
||||
clip_contents = true
|
||||
script = ExtResource("2_canvas_script")
|
||||
|
||||
[node name="InspectorColumn" type="PanelContainer" parent="MainLayout"]
|
||||
[node name="InspectorColumn" type="PanelContainer" parent="ChromeRoot/MainLayout"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
custom_minimum_size = Vector2(300, 0)
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="InspectorScroll" type="ScrollContainer" parent="MainLayout/InspectorColumn"]
|
||||
[node name="InspectorScroll" type="ScrollContainer" parent="ChromeRoot/MainLayout/InspectorColumn"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn/InspectorScroll"]
|
||||
[node name="InspectorList" type="VBoxContainer" parent="ChromeRoot/MainLayout/InspectorColumn/InspectorScroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
@@ -154,18 +140,12 @@ filters = PackedStringArray("*.prop ; Prop Files")
|
||||
unique_name_in_owner = true
|
||||
title = "Error"
|
||||
|
||||
[node name="StatusBar" type="HBoxContainer" parent="."]
|
||||
[node name="StatusBar" type="HBoxContainer" parent="ChromeRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchor_left = 0.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 28)
|
||||
|
||||
[node name="SaveStatus" type="Label" parent="StatusBar"]
|
||||
[node name="SaveStatus" type="Label" parent="ChromeRoot/StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
@@ -173,7 +153,7 @@ text = ""
|
||||
horizontal_alignment = 0
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CursorCoords" type="Label" parent="StatusBar"]
|
||||
[node name="CursorCoords" type="Label" parent="ChromeRoot/StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = ""
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/editors/stickman/body_part_panel.gd" id="1_panel_script"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_panel"]
|
||||
bg_color = Color(0.15, 0.15, 0.15, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3, 0.3, 0.3, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_drawing"]
|
||||
bg_color = Color(0.12, 0.12, 0.12, 1)
|
||||
border_width_left = 1
|
||||
@@ -27,7 +15,6 @@ custom_minimum_size = Vector2(230, 160)
|
||||
offset_right = 230.0
|
||||
offset_bottom = 160.0
|
||||
size_flags_vertical = 3
|
||||
theme_override_styles/panel = SubResource("StyleBox_panel")
|
||||
script = ExtResource("1_panel_script")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
@@ -42,8 +29,6 @@ unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Body Part"
|
||||
horizontal_alignment = 1
|
||||
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
|
||||
[node name="DrawingArea" type="Control" parent="VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
@@ -70,6 +55,7 @@ layout_mode = 2
|
||||
custom_minimum_size = Vector2(300, 300)
|
||||
|
||||
[node name="ButtonRow" type="HBoxContainer" parent="ColorPickerPopup/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
alignment = 2
|
||||
|
||||
@@ -81,4 +67,4 @@ text = "Cancel"
|
||||
[node name="OKButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "OK"
|
||||
text = "Use Color"
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://6wqo0sp4eij6"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://6wqo0sp4eij6"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://dlacmxk257ro" path="res://scripts/editors/stickman/stickman_editor.gd" id="1_editor_script"]
|
||||
[ext_resource type="Script" uid="uid://codv3g6qw1tef" path="res://scripts/editors/stickman/whole_stickman_preview.gd" id="2_preview_script"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_preview"]
|
||||
bg_color = Color(0.12, 0.12, 0.12, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3, 0.3, 0.3, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[node name="StickmanEditor" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
@@ -24,85 +12,195 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_editor_script")
|
||||
|
||||
[node name="MenuBar" type="MenuBar" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 32.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="TopBar" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_right = 1.0
|
||||
offset_top = 32.0
|
||||
offset_bottom = 64.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="NameLabel" type="Label" parent="TopBar"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
|
||||
text = "Stickman Name:"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="StickmanNameEdit" type="LineEdit" parent="TopBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Unnamed Stickman"
|
||||
|
||||
[node name="MainLayout" type="HBoxContainer" parent="."]
|
||||
[node name="ChromeRoot" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 68.0
|
||||
offset_bottom = -28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="LeftColumn" type="VBoxContainer" parent="MainLayout"]
|
||||
[node name="MenuBar" type="MenuBar" parent="ChromeRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
|
||||
[node name="TopBar" type="HBoxContainer" parent="ChromeRoot"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="NameLabel" type="Label" parent="ChromeRoot/TopBar"]
|
||||
layout_mode = 2
|
||||
text = "Stickman Name:"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="StickmanNameEdit" type="LineEdit" parent="ChromeRoot/TopBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
custom_minimum_size = Vector2(180, 44)
|
||||
placeholder_text = "Unnamed Stickman"
|
||||
|
||||
[node name="WorkspaceToolbar" type="FlowContainer" parent="ChromeRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/h_separation = 8
|
||||
theme_override_constants/v_separation = 4
|
||||
|
||||
[node name="OverviewButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Overview"
|
||||
toggle_mode = true
|
||||
|
||||
[node name="FocusButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Focus on Part"
|
||||
toggle_mode = true
|
||||
|
||||
[node name="PartPicker" type="OptionButton" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
||||
[node name="AddShapeButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Add Shape"
|
||||
|
||||
[node name="ColorButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Color"
|
||||
disabled = true
|
||||
|
||||
[node name="WholeStickmanButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Whole Stickman"
|
||||
visible = false
|
||||
|
||||
[node name="BackToPartButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Back to Part"
|
||||
visible = false
|
||||
|
||||
[node name="WorkspaceRow" type="HSplitContainer" parent="ChromeRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="BodyPartWorkspace" type="Control" parent="ChromeRoot/WorkspaceRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
custom_minimum_size = Vector2(350, 260)
|
||||
|
||||
[node name="OverviewScroll" type="ScrollContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
horizontal_scroll_mode = 1
|
||||
vertical_scroll_mode = 1
|
||||
|
||||
[node name="OverviewColumns" type="HBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="LeftColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="CenterColumn" type="VBoxContainer" parent="MainLayout"]
|
||||
[node name="CenterColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="WholeStickmanPreview" type="PanelContainer" parent="MainLayout"]
|
||||
[node name="FocusHost" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
visible = false
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="FocusPartLabel" type="Label" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Head"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="FocusPanelHost" type="Control" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
clip_contents = true
|
||||
|
||||
[node name="WholeStickmanPreview" type="PanelContainer" parent="ChromeRoot/WorkspaceRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
size_flags_stretch_ratio = 2.0
|
||||
theme_override_styles/panel = SubResource("StyleBox_preview")
|
||||
custom_minimum_size = Vector2(320, 260)
|
||||
script = ExtResource("2_preview_script")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MainLayout/WholeStickmanPreview"]
|
||||
[node name="PreviewColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="PreviewTitle" type="Label" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
|
||||
[node name="PreviewTitle" type="Label" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview/PreviewColumn"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
text = "Whole Stickman"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="PreviewArea" type="Control" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
|
||||
[node name="PreviewArea" type="Control" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview/PreviewColumn"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 1
|
||||
clip_contents = true
|
||||
|
||||
[node name="StatusBar" type="HBoxContainer" parent="ChromeRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 32)
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="CursorCoords" type="Label" parent="ChromeRoot/StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = ""
|
||||
horizontal_alignment = 0
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="SaveStatus" type="Label" parent="ChromeRoot/StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "New stickman"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="SnapStatus" type="Label" parent="ChromeRoot/StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "SNAP: OFF"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="SaveDialog" type="FileDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Save Stickman"
|
||||
@@ -112,7 +210,7 @@ filters = PackedStringArray("*.stk ; Stickman Files")
|
||||
|
||||
[node name="LoadDialog" type="FileDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Open a File"
|
||||
title = "Open a Stickman"
|
||||
size = Vector2i(600, 450)
|
||||
ok_button_text = "Open"
|
||||
file_mode = 0
|
||||
@@ -121,19 +219,24 @@ filters = PackedStringArray("*.stk ; Stickman Files")
|
||||
|
||||
[node name="ClearConfirmDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Clear Stickman"
|
||||
ok_button_text = "Clear"
|
||||
dialog_text = "Are you sure you want to clear the current stickman?
|
||||
This action cannot be undone."
|
||||
title = "Start Over"
|
||||
ok_button_text = "Start Over"
|
||||
dialog_text = "Start over with a blank stickman?"
|
||||
|
||||
[node name="UnsavedChangesDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Unsaved Changes"
|
||||
ok_button_text = "Save"
|
||||
dialog_text = "Save changes before continuing?"
|
||||
|
||||
[node name="ErrorDialog" type="AcceptDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Error"
|
||||
title = "Could Not Complete"
|
||||
|
||||
[node name="GridConfigDialog" type="ConfirmationDialog" parent="."]
|
||||
unique_name_in_owner = true
|
||||
title = "Configure Grid"
|
||||
ok_button_text = "OK"
|
||||
ok_button_text = "Done"
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="GridConfigDialog"]
|
||||
layout_mode = 2
|
||||
@@ -145,34 +248,9 @@ text = "Grid Size (pixels):"
|
||||
[node name="GridSizeSpinBox" type="SpinBox" parent="GridConfigDialog/VBoxContainer"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
min_value = 1.0
|
||||
max_value = 100.0
|
||||
value = 15.0
|
||||
step = 1.0
|
||||
rounded = true
|
||||
|
||||
[node name="StatusBar" type="HBoxContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchor_left = 0.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -28.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
|
||||
[node name="CursorCoords" type="Label" parent="StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = ""
|
||||
horizontal_alignment = 0
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="SnapStatus" type="Label" parent="StatusBar"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "SNAP: OFF"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
@@ -121,6 +121,7 @@ var _pan_offset: Vector2 = Vector2.ZERO
|
||||
var _grid_size: int = 15
|
||||
var _snap_enabled: bool = false
|
||||
var _has_clipboard: bool = false
|
||||
var _create_point_armed: bool = false
|
||||
var _context_menu: PopupMenu
|
||||
var _edit_mode: EditMode = EditMode.TRANSFORM
|
||||
var _anchor_overlay: RefCounted
|
||||
@@ -214,6 +215,8 @@ func set_edit_mode(mode: int) -> void:
|
||||
if _stage_size_preview_active:
|
||||
cancel_stage_size_preview()
|
||||
_edit_mode = m
|
||||
if _edit_mode != EditMode.POINTS:
|
||||
_create_point_armed = false
|
||||
if _edit_mode != EditMode.TRANSFORM:
|
||||
set_whole_prop_resize_enabled(false)
|
||||
_drag_mode = DragMode.NONE
|
||||
@@ -225,6 +228,21 @@ func set_edit_mode(mode: int) -> void:
|
||||
func get_edit_mode() -> int:
|
||||
return _edit_mode
|
||||
|
||||
|
||||
func arm_create_point() -> void:
|
||||
set_edit_mode(EditMode.POINTS)
|
||||
_create_point_armed = true
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func cancel_create_point() -> void:
|
||||
_create_point_armed = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func is_create_point_armed() -> bool:
|
||||
return _create_point_armed
|
||||
|
||||
func set_anchor_data(data: Variant) -> void:
|
||||
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
|
||||
_anchor_overlay.set_anchor_data(data)
|
||||
@@ -1254,6 +1272,9 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
|
||||
return
|
||||
if mb.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mb.pressed:
|
||||
if _create_point_armed and _edit_mode == EditMode.POINTS:
|
||||
_create_point_from_toolbar(world_pos)
|
||||
else:
|
||||
_handle_left_press(world_pos)
|
||||
else:
|
||||
_handle_left_release()
|
||||
@@ -1464,6 +1485,21 @@ func _handle_left_press(world_pos: Vector2) -> void:
|
||||
shape_selected.emit(-1)
|
||||
|
||||
|
||||
func _create_point_from_toolbar(world_pos: Vector2) -> void:
|
||||
var shape_idx: int = hit_test_segment(world_pos)
|
||||
if shape_idx < 0:
|
||||
return
|
||||
_selected_shape_idx = shape_idx
|
||||
shape_selected.emit(shape_idx)
|
||||
var new_vertex_index: int = create_point_on_edge(world_pos)
|
||||
if new_vertex_index < 0:
|
||||
return
|
||||
_create_point_armed = false
|
||||
_drag_mode = DragMode.NONE
|
||||
_emit_changed()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _handle_drag_motion(mm: InputEventMouseMotion, world_pos: Vector2, ctrl_pressed: bool) -> void:
|
||||
match _drag_mode:
|
||||
DragMode.VERTEX:
|
||||
|
||||
@@ -12,6 +12,7 @@ extends Control
|
||||
|
||||
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
|
||||
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
|
||||
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
|
||||
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
||||
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
|
||||
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
|
||||
@@ -92,10 +93,10 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@onready var _menu_bar: MenuBar = %MenuBar
|
||||
@onready var _top_right: HBoxContainer = %TopRightControls
|
||||
@onready var _top_right: FlowContainer = %TopRightControls
|
||||
@onready var _prop_name_edit: LineEdit = %PropNameEdit
|
||||
@onready var _canvas: PropCanvas = %PropCanvas
|
||||
@onready var _mode_bar: HBoxContainer = %ModeBar
|
||||
@onready var _mode_bar: FlowContainer = %ModeBar
|
||||
@onready var _size_tools: FlowContainer = %SizeTools
|
||||
@onready var _attach_tools: FlowContainer = %AttachTools
|
||||
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
|
||||
@@ -114,6 +115,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _theme: PropTheme
|
||||
var _editor_visual_theme: EditorVisualTheme
|
||||
var _material_preset: int = PropBlockScript.MaterialPreset.NONE
|
||||
var _current_path: String = ""
|
||||
var _dirty: bool = false
|
||||
@@ -139,6 +141,8 @@ var _radius_row: Control
|
||||
var _reshaped_note: Label
|
||||
var _vertex_label: Label
|
||||
var _delete_button: Button
|
||||
var _create_point_button: Button
|
||||
var _create_point_hint: Label
|
||||
var _shape_section: VBoxContainer
|
||||
var _primitive_option: OptionButton
|
||||
var _params_box: VBoxContainer
|
||||
@@ -212,6 +216,8 @@ var _recent_props: Array[String] = []
|
||||
func _ready() -> void:
|
||||
if not _stage_authoring:
|
||||
get_tree().auto_accept_quit = false
|
||||
_editor_visual_theme = EditorVisualThemeScript.new()
|
||||
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
|
||||
_theme = PropThemeScript.new()
|
||||
_theme.load_from(PropThemeScript.DEFAULT_PATH)
|
||||
_thumb_cache = ThumbnailCacheScript.new()
|
||||
@@ -260,7 +266,7 @@ func _ready() -> void:
|
||||
_cursor_coords.add_theme_font_size_override("font_size", _theme.font_status())
|
||||
_save_status.add_theme_font_size_override("font_size", _theme.font_status())
|
||||
|
||||
_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
_mode_bar.alignment = FlowContainer.ALIGNMENT_CENTER
|
||||
|
||||
_refresh_inspector_from_selection()
|
||||
_sync_mode_buttons(_canvas.get_edit_mode())
|
||||
@@ -311,7 +317,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
return
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
|
||||
if not (focus_owner is LineEdit or focus_owner is TextEdit):
|
||||
if _canvas.is_whole_prop_resize_dragging():
|
||||
if _canvas.is_create_point_armed():
|
||||
_canvas.cancel_create_point()
|
||||
_update_create_point_feedback()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif _canvas.is_whole_prop_resize_dragging():
|
||||
_canvas.cancel_whole_prop_resize()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif _canvas.is_whole_prop_resize_enabled():
|
||||
@@ -337,8 +347,11 @@ func _update_save_status() -> void:
|
||||
var name: String = _prop_name_edit.text if _prop_name_edit != null else ""
|
||||
if name.is_empty():
|
||||
name = "Unnamed Prop"
|
||||
var suffix: String = " • Modified" if _dirty else ""
|
||||
_save_status.text = "%s — %s%s" % [name, PRESET_LABELS[_material_preset], suffix]
|
||||
var has_saved_path: bool = not _current_path.is_empty()
|
||||
var state: String = "Unsaved changes" if _dirty else ("Saved" if has_saved_path else "Not saved yet")
|
||||
_save_status.text = "%s — %s — %s" % [name, PRESET_LABELS[_material_preset], state]
|
||||
var status_color: Color = _editor_visual_theme.chrome_color(&"dirty", &"prop") if _dirty else (_editor_visual_theme.chrome_color(&"saved", &"prop") if has_saved_path else _editor_visual_theme.chrome_color(&"text_muted", &"prop"))
|
||||
_save_status.add_theme_color_override("font_color", status_color)
|
||||
_update_window_title()
|
||||
|
||||
|
||||
@@ -372,10 +385,7 @@ func _make_mode_button(mode: int) -> Button:
|
||||
button.button_group = _mode_group
|
||||
button.custom_minimum_size.y = float(_theme.mode_button_min_height())
|
||||
button.add_theme_font_size_override("font_size", _theme.font_mode())
|
||||
# No keyboard focus: the focus stylebox otherwise draws over the pressed
|
||||
# stylebox, hiding the selected (accent-bordered) state until the cursor
|
||||
# moves away (Round 4 fix).
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.focus_mode = Control.FOCUS_ALL
|
||||
_mode_bar.add_child(button)
|
||||
return button
|
||||
|
||||
@@ -399,6 +409,8 @@ func _on_canvas_mode_changed(mode: int) -> void:
|
||||
return
|
||||
if mode != PropCanvasScript.EditMode.TRANSFORM:
|
||||
_set_whole_prop_resize_enabled(false)
|
||||
if mode != PropCanvasScript.EditMode.POINTS and _canvas.is_create_point_armed():
|
||||
_canvas.cancel_create_point()
|
||||
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
|
||||
if entering_attach and _show_preview_check != null:
|
||||
_show_preview_check.set_pressed_no_signal(true)
|
||||
@@ -430,6 +442,11 @@ func _sync_mode_controls(mode: int) -> void:
|
||||
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _delete_button != null:
|
||||
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _create_point_button != null:
|
||||
_create_point_button.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
|
||||
if _create_point_hint != null:
|
||||
_create_point_hint.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
|
||||
_update_create_point_feedback()
|
||||
if _shape_section != null:
|
||||
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
|
||||
if _material_section != null:
|
||||
@@ -461,73 +478,15 @@ func _build_attach_tools() -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_control_theme() -> void:
|
||||
_ui_theme = Theme.new()
|
||||
var bg: Color = _theme.canvas_background_color()
|
||||
var panel := StyleBoxFlat.new()
|
||||
panel.bg_color = Color(0.12, 0.12, 0.12, 1.0)
|
||||
panel.set_corner_radius_all(6)
|
||||
panel.content_margin_left = 8
|
||||
panel.content_margin_right = 8
|
||||
panel.content_margin_top = 6
|
||||
panel.content_margin_bottom = 6
|
||||
panel.border_color = Color(1.0, 1.0, 1.0, 0.12)
|
||||
panel.set_border_width_all(1)
|
||||
var panel_dark := StyleBoxFlat.new()
|
||||
panel_dark.bg_color = bg.darkened(0.25)
|
||||
panel_dark.set_corner_radius_all(6)
|
||||
panel_dark.content_margin_left = 8
|
||||
panel_dark.content_margin_right = 8
|
||||
panel_dark.content_margin_top = 6
|
||||
panel_dark.content_margin_bottom = 6
|
||||
panel_dark.border_color = Color(1.0, 1.0, 1.0, 0.12)
|
||||
panel_dark.set_border_width_all(1)
|
||||
var panel_pressed := StyleBoxFlat.new()
|
||||
panel_pressed.bg_color = bg.darkened(0.25)
|
||||
panel_pressed.set_corner_radius_all(6)
|
||||
panel_pressed.content_margin_left = 8
|
||||
panel_pressed.content_margin_right = 8
|
||||
panel_pressed.content_margin_top = 6
|
||||
panel_pressed.content_margin_bottom = 6
|
||||
panel_pressed.border_color = _theme.accent_color()
|
||||
panel_pressed.set_border_width_all(2)
|
||||
_ui_theme.set_stylebox("normal", "Button", panel)
|
||||
_ui_theme.set_stylebox("hover", "Button", panel_dark)
|
||||
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
|
||||
_ui_theme.set_stylebox("focus", "Button", panel)
|
||||
var inspector_surface := StyleBoxFlat.new()
|
||||
inspector_surface.bg_color = _theme.inspector_background_color()
|
||||
inspector_surface.border_color = _theme.border_color()
|
||||
inspector_surface.set_border_width_all(1)
|
||||
inspector_surface.content_margin_left = 8
|
||||
inspector_surface.content_margin_right = 8
|
||||
_ui_theme = _editor_visual_theme.control_theme(&"prop")
|
||||
theme = _ui_theme
|
||||
var inspector_surface: StyleBoxFlat = _ui_theme.get_stylebox("panel", "PanelContainer").duplicate() as StyleBoxFlat
|
||||
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
|
||||
var splitter := StyleBoxFlat.new()
|
||||
splitter.bg_color = _theme.border_color().lightened(0.35)
|
||||
splitter.bg_color = _editor_visual_theme.chrome_color(&"border", &"prop").lightened(0.35)
|
||||
splitter.content_margin_left = 3
|
||||
splitter.content_margin_right = 3
|
||||
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
|
||||
_ui_theme.set_stylebox("normal", "OptionButton", panel)
|
||||
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
|
||||
_ui_theme.set_stylebox("pressed", "OptionButton", panel_dark)
|
||||
_ui_theme.set_stylebox("normal", "SpinBox", panel_dark)
|
||||
_ui_theme.set_stylebox("normal", "LineEdit", panel_dark)
|
||||
_ui_theme.set_stylebox("normal", "ColorPickerButton", panel)
|
||||
_ui_theme.set_stylebox("hover", "ColorPickerButton", panel_dark)
|
||||
_ui_theme.set_stylebox("normal", "PopupMenu", panel_dark)
|
||||
_ui_theme.set_stylebox("panel", "MenuBar", panel_dark)
|
||||
_ui_theme.set_stylebox("normal", "CheckBox", panel)
|
||||
_ui_theme.set_stylebox("panel", "PanelContainer", panel_dark)
|
||||
_ui_theme.set_color("font_color", "Label", _theme.text_color())
|
||||
_ui_theme.set_color("font_color", "Button", _theme.text_color())
|
||||
_ui_theme.set_color("font_hover_color", "Button", Color.WHITE)
|
||||
_ui_theme.set_color("font_pressed_color", "Button", _theme.accent_color())
|
||||
_ui_theme.set_font_size("font_size", "Label", _theme.font_body())
|
||||
_ui_theme.set_font_size("font_size", "Button", _theme.font_body())
|
||||
_ui_theme.set_font_size("font_size", "OptionButton", _theme.font_body())
|
||||
_ui_theme.set_font_size("font_size", "SpinBox", _theme.font_body())
|
||||
_ui_theme.set_font_size("font_size", "LineEdit", _theme.font_body())
|
||||
_ui_theme.set_font_size("font_size", "PopupMenu", _theme.font_menu())
|
||||
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
|
||||
_menu_bar.theme = _ui_theme
|
||||
_top_right.theme = _ui_theme
|
||||
_prop_name_edit.theme = _ui_theme
|
||||
@@ -654,11 +613,18 @@ func _setup_dialogs() -> void:
|
||||
_load_dialog.current_dir = content_dir
|
||||
_save_dialog.file_selected.connect(_on_save_file_selected)
|
||||
_load_dialog.file_selected.connect(_on_load_file_selected)
|
||||
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"prop")
|
||||
_build_exit_dialog()
|
||||
_rename_anchor_dialog = ConfirmationDialog.new()
|
||||
_rename_anchor_dialog.title = "Rename Anchor"
|
||||
_rename_anchor_dialog.ok_button_text = "Rename"
|
||||
_rename_anchor_dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
_rename_anchor_edit = LineEdit.new()
|
||||
_rename_anchor_edit.placeholder_text = "Anchor name"
|
||||
_rename_anchor_dialog.add_child(_rename_anchor_edit)
|
||||
@@ -708,8 +674,8 @@ func _build_top_right_controls() -> void:
|
||||
_top_right.add_child(_snap_check)
|
||||
func _build_size_tools() -> void:
|
||||
_whole_prop_resize_button = Button.new()
|
||||
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
|
||||
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
|
||||
_whole_prop_resize_button.text = "Move, Turn, or Resize Prop"
|
||||
_whole_prop_resize_button.tooltip_text = "Move, turn, or resize the whole prop, including grip markers."
|
||||
_whole_prop_resize_button.toggle_mode = true
|
||||
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
|
||||
@@ -923,9 +889,12 @@ func _build_exit_dialog() -> void:
|
||||
_exit_dialog.title = "⚠️ Unsaved Changes"
|
||||
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
|
||||
_exit_dialog.ok_button_text = "💾 Save"
|
||||
_exit_dialog.add_button("🗑️ Discard", false, "discard")
|
||||
var discard_button: Button = _exit_dialog.add_button("🗑️ Discard", false, "discard")
|
||||
_exit_dialog.get_cancel_button().text = "❌ Cancel"
|
||||
_exit_dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(_exit_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
|
||||
_editor_visual_theme.style_button_role(_exit_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
add_child(_exit_dialog)
|
||||
_exit_dialog.confirmed.connect(_on_exit_save)
|
||||
_exit_dialog.custom_action.connect(_on_exit_custom_action)
|
||||
@@ -934,9 +903,12 @@ func _build_exit_dialog() -> void:
|
||||
_off_origin_save_dialog.title = "🎯 Center Prop?"
|
||||
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
|
||||
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
|
||||
_off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
|
||||
var save_anyway_button: Button = _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
|
||||
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
|
||||
_off_origin_save_dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(save_anyway_button, &"secondary", &"prop")
|
||||
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
add_child(_off_origin_save_dialog)
|
||||
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
|
||||
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
|
||||
@@ -1178,9 +1150,22 @@ func _build_inspector() -> void:
|
||||
_vertex_label = Label.new()
|
||||
_shape_section.add_child(_vertex_label)
|
||||
|
||||
_create_point_button = Button.new()
|
||||
_create_point_button.text = "📍 Create Point"
|
||||
_create_point_button.tooltip_text = "Add a point to the selected shape's edge."
|
||||
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
|
||||
_create_point_button.pressed.connect(_on_create_point_pressed)
|
||||
_shape_section.add_child(_create_point_button)
|
||||
_create_point_hint = Label.new()
|
||||
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
|
||||
_shape_section.add_child(_create_point_hint)
|
||||
|
||||
_delete_button = Button.new()
|
||||
_delete_button.text = "Erase Shape"
|
||||
_delete_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
|
||||
_delete_button.pressed.connect(_on_delete_shape_pressed)
|
||||
_shape_section.add_child(_delete_button)
|
||||
|
||||
@@ -1208,6 +1193,7 @@ func _build_inspector() -> void:
|
||||
_add_button = Button.new()
|
||||
_add_button.text = "Add a Shape"
|
||||
_add_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
|
||||
_add_button.pressed.connect(_on_add_pressed)
|
||||
_add_section.add_child(_add_button)
|
||||
|
||||
@@ -1219,7 +1205,7 @@ func _build_inspector() -> void:
|
||||
|
||||
|
||||
func _build_stage_authoring_controls() -> void:
|
||||
var top_bar: HBoxContainer = get_node_or_null(NodePath("TopBar")) as HBoxContainer
|
||||
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
|
||||
if top_bar == null:
|
||||
return
|
||||
var name_label: Label = top_bar.get_node_or_null(NodePath("NameLabel")) as Label
|
||||
@@ -1232,6 +1218,7 @@ func _build_stage_authoring_controls() -> void:
|
||||
_stage_place_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_stage_place_button.size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||
_stage_place_button.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(_stage_place_button, &"primary", &"prop")
|
||||
_stage_place_button.pressed.connect(_request_stage_return)
|
||||
top_bar.add_child(_stage_place_button)
|
||||
|
||||
@@ -1312,6 +1299,8 @@ func _build_anchor_inspector() -> void:
|
||||
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
|
||||
for label: String in ["Add Anchor", "Rename", "Delete"]:
|
||||
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
|
||||
var role: StringName = &"primary" if label == "Add Anchor" else (&"destructive" if label == "Delete" else &"secondary")
|
||||
_editor_visual_theme.style_button_role(button, role, &"prop")
|
||||
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
|
||||
elif label == "Rename":
|
||||
_rename_anchor_button = button
|
||||
@@ -1324,11 +1313,17 @@ func _build_anchor_inspector() -> void:
|
||||
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
|
||||
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
|
||||
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
|
||||
_anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed)
|
||||
_anchor_z = _anchor_spin("Held prop layer", -5.0, 5.0, _on_anchor_z_changed)
|
||||
_anchor_z.step = 1.0
|
||||
var anchor_layer_hint := Label.new()
|
||||
anchor_layer_hint.text = "Negative: behind · 0: normal order · Positive: farther in front."
|
||||
anchor_layer_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
anchor_layer_hint.add_theme_font_size_override("font_size", _theme.font_status())
|
||||
anchor_layer_hint.add_theme_color_override("font_color", _theme.text_muted_color())
|
||||
_anchor_section.add_child(anchor_layer_hint)
|
||||
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
|
||||
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
|
||||
var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
|
||||
var button := Button.new(); button.text = str(entry["text"]); button.custom_minimum_size.y = float(_theme.button_min_height()); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
|
||||
_refresh_anchor_inspector()
|
||||
|
||||
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
|
||||
@@ -1341,6 +1336,7 @@ func _anchor_option(label_text: String, entries: PackedStringArray, callback: Ca
|
||||
row.add_child(label)
|
||||
var option := OptionButton.new()
|
||||
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
option.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
for entry: String in entries:
|
||||
option.add_item(entry)
|
||||
option.item_selected.connect(callback)
|
||||
@@ -1360,6 +1356,7 @@ func _anchor_spin(label_text: String, minimum: float, maximum: float, callback:
|
||||
spin.max_value = maximum
|
||||
spin.step = 1.0
|
||||
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spin.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
spin.value_changed.connect(callback)
|
||||
row.add_child(spin)
|
||||
return spin
|
||||
@@ -1467,14 +1464,15 @@ func _apply_control_theme(node: Node) -> void:
|
||||
func _update_physics_labels() -> void:
|
||||
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
|
||||
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset)
|
||||
_friction_label.text = "Friction: %.2f" % mat.friction
|
||||
_friction_label.text = "Surface grip: %.2f" % mat.friction
|
||||
_friction_label.tooltip_text = "Higher values make sliding harder."
|
||||
_bounce_label.text = "Bounce: %.2f" % mat.bounce
|
||||
|
||||
|
||||
func _on_preset_selected(index: int) -> void:
|
||||
_material_preset = clampi(index, 0, PRESET_LABELS.size() - 1)
|
||||
_update_physics_labels()
|
||||
_update_save_status()
|
||||
_mark_dirty()
|
||||
|
||||
|
||||
func _rebuild_primitive_params() -> void:
|
||||
@@ -1604,6 +1602,7 @@ func _refresh_inspector_from_selection() -> void:
|
||||
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
|
||||
_mark_dirty()
|
||||
_refresh_inspector_from_selection()
|
||||
_sync_mode_controls(_canvas.get_edit_mode())
|
||||
_refresh_anchor_inspector()
|
||||
_refresh_center_prop_button()
|
||||
_sync_attach_preview()
|
||||
@@ -1700,6 +1699,25 @@ func _center_prop_on_origin(focus_view: bool = true) -> bool:
|
||||
|
||||
func _on_canvas_shape_selected(_idx: int) -> void:
|
||||
_refresh_inspector_from_selection()
|
||||
_sync_mode_controls(_canvas.get_edit_mode())
|
||||
|
||||
|
||||
func _on_create_point_pressed() -> void:
|
||||
if _canvas.get_selected_index() < 0:
|
||||
return
|
||||
_canvas.arm_create_point()
|
||||
_sync_mode_buttons(_canvas.get_edit_mode())
|
||||
_sync_mode_controls(_canvas.get_edit_mode())
|
||||
_update_create_point_feedback()
|
||||
|
||||
|
||||
func _update_create_point_feedback() -> void:
|
||||
if _create_point_button == null or _create_point_hint == null or _canvas == null:
|
||||
return
|
||||
var armed: bool = _canvas.is_create_point_armed()
|
||||
_create_point_button.text = "Click an Edge to Place Point" if armed else "📍 Create Point"
|
||||
_create_point_button.tooltip_text = "Press Esc to cancel." if armed else "Add a point to the selected shape's edge."
|
||||
_create_point_hint.text = "Click near an edge to place the point. Press Esc to cancel." if armed else "Adds a point where you click on the selected edge."
|
||||
|
||||
|
||||
func _on_canvas_copy_requested(shape: Dictionary) -> void:
|
||||
@@ -1770,8 +1788,11 @@ func _check_autosave_recovery() -> void:
|
||||
dialog.title = "Restore Prop?"
|
||||
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
|
||||
dialog.ok_button_text = "🔄 Restore"
|
||||
dialog.add_button("🗑️ Discard", false, "discard")
|
||||
var discard_button: Button = dialog.add_button("🗑️ Discard", false, "discard")
|
||||
dialog.theme = _ui_theme
|
||||
_editor_visual_theme.style_button_role(dialog.get_ok_button(), &"primary", &"prop")
|
||||
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
|
||||
_editor_visual_theme.style_button_role(dialog.get_cancel_button(), &"secondary", &"prop")
|
||||
add_child(dialog)
|
||||
dialog.confirmed.connect(func() -> void:
|
||||
_apply_json_data(raw, true)
|
||||
|
||||
@@ -10,6 +10,7 @@ extends RefCounted
|
||||
## truth.
|
||||
|
||||
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
||||
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
|
||||
|
||||
const SCHEMA_VERSION: String = "1.0"
|
||||
const DEFAULT_PATH: String = "res://prop_theme.json"
|
||||
@@ -39,19 +40,16 @@ const DEFAULT_CONFIG: Dictionary = {
|
||||
"background_color": "#1F1F1F",
|
||||
"grid_color": "#FFFFFF26",
|
||||
},
|
||||
"ui": {
|
||||
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
|
||||
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
|
||||
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
|
||||
},
|
||||
}
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _warned: Dictionary = {}
|
||||
var _editor_visual_theme: EditorVisualTheme = EditorVisualThemeScript.new()
|
||||
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool:
|
||||
invalidate_caches()
|
||||
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
|
||||
_config = DEFAULT_CONFIG.duplicate(true)
|
||||
if not FileAccess.file_exists(path):
|
||||
_warn_once(path, "PropTheme: '%s' was not found; using defaults." % path)
|
||||
@@ -155,71 +153,71 @@ func canvas_grid_color() -> Color:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kid-friendly UI tokens (Round 2)
|
||||
# Compatibility accessors: editor chrome is owned by EditorVisualTheme.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func font_body() -> int:
|
||||
return integer("ui.fonts.body", 18, 8, 72)
|
||||
return _editor_visual_theme.chrome_px(&"body", &"prop")
|
||||
|
||||
|
||||
func font_header() -> int:
|
||||
return integer("ui.fonts.header", 24, 8, 96)
|
||||
return _editor_visual_theme.chrome_px(&"section", &"prop")
|
||||
|
||||
|
||||
func font_menu() -> int:
|
||||
return integer("ui.fonts.menu", 22, 8, 72)
|
||||
return _editor_visual_theme.chrome_px(&"menu", &"prop")
|
||||
|
||||
|
||||
func font_status() -> int:
|
||||
return integer("ui.fonts.status", 18, 8, 72)
|
||||
return _editor_visual_theme.chrome_px(&"status", &"prop")
|
||||
|
||||
|
||||
func font_mode() -> int:
|
||||
return integer("ui.fonts.mode", 26, 8, 96)
|
||||
return _editor_visual_theme.chrome_px(&"mode", &"prop")
|
||||
|
||||
|
||||
func font_menu_bar() -> int:
|
||||
return integer("ui.fonts.menu_bar", 22, 8, 72)
|
||||
return _editor_visual_theme.chrome_px(&"menu", &"prop")
|
||||
|
||||
|
||||
func font_prop_name() -> int:
|
||||
return integer("ui.fonts.prop_name", 20, 8, 96)
|
||||
return _editor_visual_theme.chrome_px(&"prop_name", &"prop")
|
||||
|
||||
|
||||
func font_context_menu() -> int:
|
||||
return integer("ui.fonts.context_menu", 22, 8, 96)
|
||||
return _editor_visual_theme.chrome_px(&"context_menu", &"prop")
|
||||
|
||||
|
||||
func button_min_height() -> int:
|
||||
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
|
||||
return _editor_visual_theme.chrome_px(&"button_min_height", &"prop")
|
||||
|
||||
|
||||
func row_min_height() -> int:
|
||||
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
|
||||
return _editor_visual_theme.chrome_px(&"row_min_height", &"prop")
|
||||
|
||||
|
||||
func mode_button_min_height() -> int:
|
||||
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
|
||||
return _editor_visual_theme.chrome_px(&"mode_button_min_height", &"prop")
|
||||
|
||||
|
||||
func text_color() -> Color:
|
||||
return color("ui.colors.text", Color("#F2F2F2"))
|
||||
return _editor_visual_theme.chrome_color(&"text", &"prop")
|
||||
|
||||
|
||||
func text_muted_color() -> Color:
|
||||
return color("ui.colors.text_muted", Color("#9A9A9A"))
|
||||
return _editor_visual_theme.chrome_color(&"text_muted", &"prop")
|
||||
|
||||
|
||||
func border_color() -> Color:
|
||||
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
|
||||
return _editor_visual_theme.chrome_color(&"border", &"prop")
|
||||
|
||||
|
||||
func accent_color() -> Color:
|
||||
return color("ui.colors.accent", Color("#4FA3FF"))
|
||||
return _editor_visual_theme.chrome_color(&"edit", &"prop")
|
||||
|
||||
|
||||
func inspector_background_color() -> Color:
|
||||
return color("ui.colors.inspector_background", Color("#262C39"))
|
||||
return _editor_visual_theme.chrome_color(&"surface", &"prop")
|
||||
|
||||
|
||||
func invalidate_caches() -> void:
|
||||
|
||||
@@ -63,6 +63,7 @@ const STAGE_PANEL_CHOICE_GROUP_SCRIPT := preload("res://scripts/shared/stage_ui/
|
||||
const STAGE_SURFACE_COLOR_CONTROLS_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
|
||||
const STAGE_SURFACE_COLOR_DIALOG_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
|
||||
const STAGE_SURFACE_PREVIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
|
||||
const STAGE_GROUND_FINISH_CHOOSER_SCRIPT := preload("res://scripts/shared/stage_ui/stage_ground_finish_chooser.gd")
|
||||
const STAGE_DEPTH_CHOICE_PREVIEW_SCRIPT := preload("res://scripts/shared/stage_ui/stage_depth_choice_preview.gd")
|
||||
const PROP_FILE_SCRIPT := preload("res://scripts/editors/prop/prop_file.gd")
|
||||
const PROP_EDITOR_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
|
||||
@@ -277,7 +278,7 @@ var _plain_color_draft: Color = Color("#F5EFE0")
|
||||
var _new_ground_finish: StringName = &"finish_ground_grass"
|
||||
var _hosted_prop_editor: PropEditor
|
||||
var _hosted_artwork_path: String = ""
|
||||
var _ground_finish_popup: PopupMenu
|
||||
var _ground_finish_chooser: StageGroundFinishChooser
|
||||
var _scenery_edit_mode: bool = false
|
||||
var _scenery_multi_select: bool = false
|
||||
var _scenery_single_dragging: bool = false
|
||||
@@ -1729,8 +1730,12 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||||
return
|
||||
if _scenery_controller != null and _scenery_controller.is_active():
|
||||
var scenery_world_position: Vector2 = _viewport_to_world(mb.position)
|
||||
var return_to_scenery_selector: bool = mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed and _scenery_controller.is_placement_tool()
|
||||
if _scenery_controller.handle_mouse_button(mb, scenery_world_position, _is_mouse_over_ui()):
|
||||
get_viewport().set_input_as_handled()
|
||||
if return_to_scenery_selector:
|
||||
_return_to_scenery_selector()
|
||||
else:
|
||||
_refresh_scenery_capture_bar()
|
||||
return
|
||||
if _scenery_edit_mode:
|
||||
@@ -3281,7 +3286,7 @@ func _build_ui() -> void:
|
||||
_asset_browser.surface_color_confirmed.connect(_record_recent_surface_color)
|
||||
_asset_browser.set_surface_color_history(_recent_surface_colors)
|
||||
_asset_browser.set_background_state(_background_kind, _sky_id, _plain_color)
|
||||
_build_ground_finish_popup(ui)
|
||||
_build_ground_finish_chooser(ui)
|
||||
|
||||
_modal_views.build_file_dialogs(ui)
|
||||
_scene_open_dialog = _modal_views.scene_open_dialog
|
||||
@@ -3895,43 +3900,41 @@ func _update_scenery_depth_cursor_badge() -> void:
|
||||
_scenery_depth_cursor_badge.visible = true
|
||||
|
||||
|
||||
func _build_ground_finish_popup(ui: CanvasLayer) -> void:
|
||||
_ground_finish_popup = PopupMenu.new()
|
||||
_ground_finish_popup.name = "NewGroundLook"
|
||||
_ground_finish_popup.theme = _sandbox_theme.control_theme() if _sandbox_theme != null else null
|
||||
for index: int in GROUND_FINISH_LABELS.size():
|
||||
_ground_finish_popup.add_item(GROUND_FINISH_LABELS[index], index)
|
||||
_ground_finish_popup.set_item_as_checkable(index, true)
|
||||
_ground_finish_popup.id_pressed.connect(_on_ground_finish_selected)
|
||||
ui.add_child(_ground_finish_popup)
|
||||
func _build_ground_finish_chooser(ui: CanvasLayer) -> void:
|
||||
_ground_finish_chooser = STAGE_GROUND_FINISH_CHOOSER_SCRIPT.new() as StageGroundFinishChooser
|
||||
_ground_finish_chooser.name = "NewGroundLook"
|
||||
_ground_finish_chooser.configure(_sandbox_theme as SandboxTheme, _ui_font, GROUND_FINISH_IDS, GROUND_FINISH_LABELS)
|
||||
_ground_finish_chooser.confirmed.connect(_on_ground_finish_draw_requested)
|
||||
_ground_finish_chooser.canceled.connect(_on_ground_finish_chooser_closed)
|
||||
ui.add_child(_ground_finish_chooser)
|
||||
|
||||
|
||||
func _open_ground_finish_popup() -> void:
|
||||
if _ground_finish_popup == null:
|
||||
func _open_ground_finish_chooser() -> void:
|
||||
if _ground_finish_chooser == null:
|
||||
return
|
||||
for index: int in GROUND_FINISH_IDS.size():
|
||||
_ground_finish_popup.set_item_checked(index, GROUND_FINISH_IDS[index] == _new_ground_finish)
|
||||
var origin: Vector2 = get_viewport().get_mouse_position()
|
||||
var ground_button: Button = _palette_buttons.get("ground") as Button
|
||||
if ground_button != null:
|
||||
var button_rect: Rect2 = ground_button.get_global_rect()
|
||||
origin = Vector2(button_rect.position.x, button_rect.end.y)
|
||||
var viewport_size: Vector2 = get_viewport_rect().size
|
||||
origin.x = clampf(origin.x, 8.0, maxf(8.0, viewport_size.x - 288.0))
|
||||
origin.y = clampf(origin.y, 8.0, maxf(8.0, viewport_size.y - 320.0))
|
||||
_ground_finish_popup.popup(Rect2i(Vector2i(origin), Vector2i(280, 312)))
|
||||
_ground_finish_chooser.open_for_finish(_new_ground_finish)
|
||||
|
||||
|
||||
func _on_ground_finish_selected(index: int) -> void:
|
||||
if index < 0 or index >= GROUND_FINISH_IDS.size():
|
||||
func _on_ground_finish_draw_requested() -> void:
|
||||
if _ground_finish_chooser == null:
|
||||
return
|
||||
_new_ground_finish = GROUND_FINISH_IDS[index]
|
||||
var finish_id: StringName = _ground_finish_chooser.selected_finish()
|
||||
var index: int = GROUND_FINISH_IDS.find(finish_id)
|
||||
if index < 0 or index >= GROUND_FINISH_LABELS.size():
|
||||
return
|
||||
_new_ground_finish = finish_id
|
||||
if _terrain_painter != null:
|
||||
_terrain_painter.set_ground_finish(_new_ground_finish)
|
||||
set_placement_mode("ground")
|
||||
_show_toast("New Ground look: %s" % GROUND_FINISH_LABELS[index])
|
||||
|
||||
|
||||
func _on_ground_finish_chooser_closed() -> void:
|
||||
for placement_id: String in _palette_buttons:
|
||||
var palette_button: Button = _palette_buttons[placement_id] as Button
|
||||
palette_button.set_pressed_no_signal(placement_id == _placement_id)
|
||||
|
||||
|
||||
func _build_use_spot_overlay(ui: CanvasLayer) -> void:
|
||||
_use_spot_overlay = STAGE_USE_SPOT_OVERLAY_SCRIPT.new() as StageUseSpotOverlay
|
||||
_use_spot_overlay.name = "UseSpotOverlay"
|
||||
@@ -4938,7 +4941,7 @@ func _on_palette_toggled(pressed: bool, id: String) -> void:
|
||||
_open_selector(id)
|
||||
return
|
||||
if id == "ground":
|
||||
_open_ground_finish_popup()
|
||||
_open_ground_finish_chooser()
|
||||
return
|
||||
set_placement_mode(id)
|
||||
else:
|
||||
@@ -4954,7 +4957,7 @@ func _on_palette_card_activated(payload: Dictionary) -> void:
|
||||
if bool(payload.get("opens_selector", false)):
|
||||
_open_selector(id)
|
||||
elif id == "ground":
|
||||
_open_ground_finish_popup()
|
||||
_open_ground_finish_chooser()
|
||||
else:
|
||||
set_placement_mode(id)
|
||||
|
||||
@@ -5001,12 +5004,13 @@ func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
|
||||
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func _open_selector(kind: String) -> void:
|
||||
func _open_selector(kind: String, preserve_scenery_browse_state: bool = false) -> void:
|
||||
if kind == "scenery":
|
||||
if _scenery_edit_mode:
|
||||
_exit_scenery_edit_mode()
|
||||
if _scenery_controller != null and _scenery_controller.is_active():
|
||||
_scenery_controller.exit_tool()
|
||||
_hide_scenery_capture_bar()
|
||||
_scenery_placed_count = 0
|
||||
elif _scenery_edit_mode:
|
||||
_exit_scenery_edit_mode()
|
||||
@@ -5017,12 +5021,14 @@ func _open_selector(kind: String) -> void:
|
||||
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
|
||||
if kind == "scenery":
|
||||
_asset_browser.set_surface_color_history(_recent_surface_colors)
|
||||
_asset_browser.set_background_state(_background_kind, _sky_id, _plain_color)
|
||||
var selector_state: Dictionary = _asset_browser.open_selector(
|
||||
kind,
|
||||
_spawner.get_selected_stickman_path(),
|
||||
_sky_id,
|
||||
_background_kind,
|
||||
_plain_color
|
||||
_plain_color,
|
||||
preserve_scenery_browse_state
|
||||
)
|
||||
if kind == "stickman":
|
||||
var selected_stickman_path: String = String(selector_state.get("selected_stickman_path", _spawner.get_selected_stickman_path()))
|
||||
@@ -5099,6 +5105,11 @@ func _on_asset_selected(entry: Dictionary) -> void:
|
||||
set_placement_mode(kind)
|
||||
|
||||
|
||||
func _return_to_scenery_selector() -> void:
|
||||
_hide_scenery_capture_bar()
|
||||
_open_selector("scenery", true)
|
||||
|
||||
|
||||
func _load_starter_scene(entry: Dictionary) -> void:
|
||||
var path: String = CONTENT_PATHS.canonicalize(String(entry.get("path", "")))
|
||||
var read_result: Dictionary = _document.read(path) if _document != null else {}
|
||||
@@ -5658,7 +5669,7 @@ func _refresh_scenery_capture_bar() -> void:
|
||||
actions.append(_make_scenery_depth_capture_action(&"depth_floor", &"floor", "Front", selected_depth))
|
||||
var artwork_name: String = String(_scenery_controller.get_active_artwork_name())
|
||||
var prompt: String = "Click to place %s" % artwork_name if _scenery_placed_count == 0 else "Place another · Cancel undoes"
|
||||
actions.append({"id": "done_tool", "label": "Done", "primary": true})
|
||||
actions.append({"id": "done_tool", "label": "Back", "primary": true})
|
||||
actions.append({"id": "cancel_tool", "label": "Cancel"})
|
||||
_use_target_capture_bar.set_scenery_capture_actions(prompt, actions)
|
||||
elif _scenery_controller.get_active_tool() in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"]:
|
||||
@@ -5672,8 +5683,7 @@ func _refresh_scenery_capture_bar() -> void:
|
||||
actions.append({"id": "cancel_tool", "label": "Cancel"})
|
||||
_use_target_capture_bar.set_scenery_capture_actions(erase_prompt, actions)
|
||||
else:
|
||||
actions.append({"id": "choose_scenery", "label": "Choose scenery"})
|
||||
actions.append({"id": "done_tool", "label": "Done", "primary": true})
|
||||
actions.append({"id": "done_tool", "label": "Back", "primary": true})
|
||||
actions.append({"id": "cancel_tool", "label": "Cancel"})
|
||||
if _scenery_controller.get_active_tool() == &"background_ground":
|
||||
var ground_prompt: String = "Draw background ground · No walking"
|
||||
@@ -5737,8 +5747,6 @@ func _hide_scenery_capture_bar() -> void:
|
||||
|
||||
func _on_capture_action_requested(action_id: StringName) -> void:
|
||||
match action_id:
|
||||
&"choose_scenery":
|
||||
_open_selector("scenery")
|
||||
&"depth_far":
|
||||
_scenery_controller.set_depth(&"far")
|
||||
_refresh_scenery_capture_bar()
|
||||
@@ -5749,6 +5757,10 @@ func _on_capture_action_requested(action_id: StringName) -> void:
|
||||
_scenery_controller.set_depth(&"floor")
|
||||
_refresh_scenery_capture_bar()
|
||||
&"done_tool":
|
||||
if _scenery_controller != null and _scenery_controller.is_placement_tool():
|
||||
_return_to_scenery_selector()
|
||||
else:
|
||||
if _scenery_controller != null:
|
||||
_scenery_controller.exit_tool()
|
||||
_hide_scenery_capture_bar()
|
||||
&"cancel_tool":
|
||||
|
||||
@@ -160,8 +160,12 @@ func open_selector(
|
||||
current_stickman_path: String,
|
||||
current_sky_id: StringName = &"",
|
||||
background_kind: StringName = &"sky",
|
||||
plain_color: Color = Color("#F5EFE0")
|
||||
plain_color: Color = Color("#F5EFE0"),
|
||||
preserve_scenery_browse_state: bool = false
|
||||
) -> Dictionary:
|
||||
var scenery_browse_state: Dictionary = {}
|
||||
if preserve_scenery_browse_state and kind == "scenery" and selector != null:
|
||||
scenery_browse_state = selector.capture_browse_state()
|
||||
_current_background_kind = &"plain" if background_kind == &"plain" else &"sky"
|
||||
_current_sky_id = current_sky_id if not current_sky_id.is_empty() else &"sky_neutral"
|
||||
_current_plain_color = Color(plain_color.r, plain_color.g, plain_color.b, 1.0)
|
||||
@@ -177,7 +181,7 @@ func open_selector(
|
||||
selected_stickman_path = String(entries[0].get("path", ""))
|
||||
var auto_selection: Dictionary = entries[0] if kind == "stickman" and entries.size() == 1 else {}
|
||||
if auto_selection.is_empty():
|
||||
open(kind, entries, _current_sky_id)
|
||||
open(kind, entries, _current_sky_id, scenery_browse_state)
|
||||
return {
|
||||
"entries": entries,
|
||||
"selected_stickman_path": selected_stickman_path,
|
||||
@@ -201,14 +205,19 @@ func set_selector_kind(kind: String) -> void:
|
||||
selector_kind = kind
|
||||
|
||||
|
||||
func open(kind: String, entries: Array[Dictionary], current_sky_id: StringName = &"") -> void:
|
||||
func open(
|
||||
kind: String,
|
||||
entries: Array[Dictionary],
|
||||
current_sky_id: StringName = &"",
|
||||
scenery_browse_state: Dictionary = {}
|
||||
) -> void:
|
||||
selector_kind = kind
|
||||
selector_open = true
|
||||
if selector_dim != null:
|
||||
selector_dim.visible = true
|
||||
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
|
||||
if selector != null:
|
||||
selector.open(kind, entries, String(current_sky_id), String(_current_background_kind), _current_plain_color)
|
||||
selector.open(kind, entries, String(current_sky_id), String(_current_background_kind), _current_plain_color, scenery_browse_state)
|
||||
load_or_enqueue_thumbnails(kind, entries)
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ func is_active() -> bool:
|
||||
return not _active_tool.is_empty()
|
||||
|
||||
|
||||
func is_placement_tool() -> bool:
|
||||
return _is_placement_tool()
|
||||
|
||||
|
||||
func is_group_preview_pending() -> bool:
|
||||
return _group_pending
|
||||
|
||||
|
||||
@@ -893,7 +893,7 @@ func _apply_compact_action_labels(compact: bool) -> void:
|
||||
"resize_group": "Size",
|
||||
"keep_group": "Keep",
|
||||
"cancel_group": "Cancel",
|
||||
"done_tool": "Done",
|
||||
"done_tool": "Back",
|
||||
"cancel_tool": "Cancel",
|
||||
"depth_far": "Back",
|
||||
"depth_near": "Middle",
|
||||
|
||||
@@ -20,6 +20,7 @@ signal shape_selected()
|
||||
signal shape_copy_requested(shape_dict: Dictionary)
|
||||
signal shape_paste_requested(world_pos: Vector2)
|
||||
signal color_selected(color: Color)
|
||||
signal presentation_safe
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exported / public properties
|
||||
@@ -66,6 +67,7 @@ const ZOOM_STEP: float = 1.10
|
||||
@onready var context_menu: PopupMenu = %ContextMenu
|
||||
@onready var color_picker_popup: PopupPanel = %ColorPickerPopup
|
||||
@onready var color_picker: ColorPicker = %ColorPicker
|
||||
@onready var color_picker_button_row: HBoxContainer = %ButtonRow
|
||||
@onready var cancel_button: Button = %CancelButton
|
||||
@onready var ok_button: Button = %OKButton
|
||||
|
||||
@@ -89,6 +91,12 @@ var _snap_enabled: bool = false
|
||||
var _is_panning: bool = false
|
||||
var _pan_start: Vector2 = Vector2.ZERO
|
||||
var _restore_color: Color = Color.BLACK
|
||||
var _grid_color: Color = GRID_COLOR
|
||||
var _selection_core_color: Color = Color.WHITE
|
||||
var _selection_halo_color: Color = Color(0.0, 0.0, 0.0, 0.85)
|
||||
var _hint_color: Color = Color(0.67, 0.70, 0.75, 0.8)
|
||||
var _button_target_height: float = 44.0
|
||||
var _editor_visual_theme: EditorVisualTheme
|
||||
|
||||
# Phase 4: tracks which shape was right-clicked for context menu ops.
|
||||
var _context_shape_idx: int = -1
|
||||
@@ -117,6 +125,18 @@ func _ready() -> void:
|
||||
ok_button.pressed.connect(_on_color_picker_ok)
|
||||
if not cancel_button.pressed.is_connected(_on_color_picker_cancel):
|
||||
cancel_button.pressed.connect(_on_color_picker_cancel)
|
||||
if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden):
|
||||
context_menu.popup_hide.connect(_on_presentation_popup_hidden)
|
||||
if not color_picker_popup.popup_hide.is_connected(_on_presentation_popup_hidden):
|
||||
color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden)
|
||||
cancel_button.text = "Cancel"
|
||||
cancel_button.custom_minimum_size.y = _button_target_height
|
||||
ok_button.text = "Use Color"
|
||||
ok_button.custom_minimum_size.y = _button_target_height
|
||||
color_picker_button_row.add_theme_constant_override("separation", 12)
|
||||
if _editor_visual_theme != null:
|
||||
_editor_visual_theme.style_button_role(ok_button, &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(cancel_button, &"secondary", &"stickman")
|
||||
|
||||
_drawing_area_initialized = true
|
||||
|
||||
@@ -262,6 +282,75 @@ func reset_view() -> void:
|
||||
_pan_offset = Vector2.ZERO
|
||||
drawing_area.queue_redraw()
|
||||
|
||||
|
||||
func can_switch_presentation() -> bool:
|
||||
return not _is_panning \
|
||||
and _dragging_vertex < 0 \
|
||||
and not _is_dragging_shape \
|
||||
and not context_menu.visible \
|
||||
and not color_picker_popup.visible
|
||||
|
||||
|
||||
func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo: Color, hint_color: Color = Color(0.67, 0.70, 0.75, 0.8), button_target_height: float = 44.0, editor_visual_theme: EditorVisualTheme = null) -> void:
|
||||
_grid_color = grid_color
|
||||
_selection_core_color = selection_core
|
||||
_selection_halo_color = selection_halo
|
||||
_hint_color = hint_color
|
||||
_button_target_height = button_target_height
|
||||
_editor_visual_theme = editor_visual_theme
|
||||
if drawing_area != null:
|
||||
drawing_area.queue_redraw()
|
||||
|
||||
|
||||
func set_active_presentation(active: bool, surface: Color, border: Color, accent: Color, section_font_size: int = 22) -> void:
|
||||
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
style.bg_color = surface
|
||||
style.border_color = accent if active else border
|
||||
style.set_border_width_all(2 if active else 1)
|
||||
style.set_corner_radius_all(8)
|
||||
style.content_margin_left = 6
|
||||
style.content_margin_right = 6
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_bottom = 4
|
||||
add_theme_stylebox_override("panel", style)
|
||||
title_label.add_theme_color_override("font_color", accent if active else _selection_core_color)
|
||||
title_label.add_theme_font_size_override("font_size", section_font_size)
|
||||
|
||||
|
||||
func add_shape(kind: StringName, at_world: Vector2) -> bool:
|
||||
match kind:
|
||||
&"line": _create_line(at_world)
|
||||
&"rectangle": _create_rectangle(at_world)
|
||||
&"circle": _create_circle(at_world)
|
||||
_:
|
||||
return false
|
||||
drawing_area.queue_redraw()
|
||||
_emit_changed()
|
||||
return true
|
||||
|
||||
|
||||
func selected_shape_index() -> int:
|
||||
return _selected_shape_idx
|
||||
|
||||
|
||||
func has_selected_shape() -> bool:
|
||||
return _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size()
|
||||
|
||||
|
||||
func open_selected_color_picker() -> void:
|
||||
if not has_selected_shape():
|
||||
return
|
||||
_context_shape_idx = _selected_shape_idx
|
||||
_show_color_picker()
|
||||
|
||||
|
||||
func get_view_context() -> Dictionary:
|
||||
return {"zoom": _zoom, "pan_offset": _pan_offset}
|
||||
|
||||
|
||||
func drawing_center_world() -> Vector2:
|
||||
return _screen_to_world(drawing_area.size * 0.5)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5: Clipboard
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -358,11 +447,11 @@ func _on_drawing_area_draw() -> void:
|
||||
drawing_area.draw_string(
|
||||
font,
|
||||
hint_pos,
|
||||
"Right-click to add shape",
|
||||
"Use Add Shape above",
|
||||
HORIZONTAL_ALIGNMENT_CENTER,
|
||||
-1,
|
||||
14,
|
||||
Color(0.5, 0.5, 0.5, 0.6)
|
||||
_hint_color
|
||||
)
|
||||
return
|
||||
|
||||
@@ -386,7 +475,8 @@ func _on_drawing_area_draw() -> void:
|
||||
|
||||
# 3. Selection highlight (only on selected shape)
|
||||
if i == _selected_shape_idx:
|
||||
_draw_polyline(pts, Color.WHITE, closed, 2.5)
|
||||
_draw_polyline(pts, _selection_halo_color, closed, 5.0)
|
||||
_draw_polyline(pts, _selection_core_color, closed, 2.5)
|
||||
|
||||
# 4. Vertex handles (only on selected shape)
|
||||
for j: int in range(count):
|
||||
@@ -396,6 +486,7 @@ func _on_drawing_area_draw() -> void:
|
||||
if sd_vf.size() > j:
|
||||
flag = sd_vf[j]
|
||||
|
||||
drawing_area.draw_circle(pt, (HANDLE_RADIUS + 2.0) / _zoom, _selection_halo_color)
|
||||
var handle_color := color
|
||||
if j == _dragging_vertex:
|
||||
handle_color = Color.YELLOW
|
||||
@@ -423,12 +514,12 @@ func _draw_grid() -> void:
|
||||
|
||||
var x: float = start_x
|
||||
while x <= world_end.x:
|
||||
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
|
||||
drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), _grid_color, line_width)
|
||||
x += gs
|
||||
|
||||
var y: float = start_y
|
||||
while y <= world_end.y:
|
||||
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
|
||||
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), _grid_color, line_width)
|
||||
y += gs
|
||||
|
||||
|
||||
@@ -470,6 +561,7 @@ func _on_drawing_area_gui_input(event: InputEvent) -> void:
|
||||
_dragging_vertex = -1
|
||||
else:
|
||||
_is_panning = false
|
||||
_emit_presentation_safe()
|
||||
return
|
||||
|
||||
# 2. Mouse wheel -> zoom
|
||||
@@ -660,6 +752,7 @@ func _handle_left_release() -> void:
|
||||
_shape_drag_original_points = PackedVector2Array()
|
||||
drawing_area.queue_redraw()
|
||||
_emit_changed()
|
||||
_emit_presentation_safe()
|
||||
return
|
||||
|
||||
if _dragging_vertex >= 0:
|
||||
@@ -667,6 +760,22 @@ func _handle_left_release() -> void:
|
||||
_drag_offset = Vector2.ZERO
|
||||
drawing_area.queue_redraw()
|
||||
_emit_changed()
|
||||
_emit_presentation_safe()
|
||||
return
|
||||
_emit_presentation_safe()
|
||||
|
||||
|
||||
func _on_presentation_popup_hidden() -> void:
|
||||
_emit_presentation_safe()
|
||||
|
||||
|
||||
func _emit_presentation_safe() -> void:
|
||||
call_deferred("_emit_presentation_safe_if_idle")
|
||||
|
||||
|
||||
func _emit_presentation_safe_if_idle() -> void:
|
||||
if can_switch_presentation():
|
||||
presentation_safe.emit()
|
||||
|
||||
|
||||
func _on_context_menu_id_pressed(id: int) -> void:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
class_name StickmanEditor
|
||||
extends Control
|
||||
## StickmanEditor - Main controller for the Stickman Studio editor.
|
||||
##
|
||||
@@ -15,6 +16,10 @@ extends Control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
|
||||
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
|
||||
|
||||
enum WorkspaceView { OVERVIEW, FOCUS }
|
||||
enum PendingDocumentAction { NONE, LOAD, CLEAR, QUIT }
|
||||
|
||||
const BODY_PART_NAMES: PackedStringArray = [
|
||||
"head", "torso",
|
||||
@@ -89,6 +94,10 @@ const SETTINGS_VERSION := "1.0"
|
||||
const DEFAULT_GRID_SIZE := 15
|
||||
const MIN_GRID_SIZE := 1
|
||||
const MAX_GRID_SIZE := 100
|
||||
const OVERVIEW_PANEL_MIN_WIDTH: float = 230.0
|
||||
const OVERVIEW_PREVIEW_MIN_WIDTH: float = 320.0
|
||||
const FOCUS_PANEL_MIN_WIDTH: float = 350.0
|
||||
const WORKSPACE_GAP: float = 8.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-ready node references
|
||||
@@ -97,6 +106,19 @@ const MAX_GRID_SIZE := 100
|
||||
@onready var _stickman_name_edit: LineEdit = %StickmanNameEdit
|
||||
@onready var _menu_bar: MenuBar = %MenuBar
|
||||
@onready var _body_part_panels: Dictionary = {} ## { String : BodyPartPanel }
|
||||
@onready var _body_part_workspace: Control = %BodyPartWorkspace
|
||||
@onready var _overview_scroll: ScrollContainer = %OverviewScroll
|
||||
@onready var _focus_host: VBoxContainer = %FocusHost
|
||||
@onready var _focus_panel_host: Control = %FocusPanelHost
|
||||
@onready var _focus_part_label: Label = %FocusPartLabel
|
||||
@onready var _workspace_row: HSplitContainer = %WorkspaceRow
|
||||
@onready var _overview_button: Button = %OverviewButton
|
||||
@onready var _focus_button: Button = %FocusButton
|
||||
@onready var _part_picker: OptionButton = %PartPicker
|
||||
@onready var _add_shape_button: Button = %AddShapeButton
|
||||
@onready var _color_button: Button = %ColorButton
|
||||
@onready var _whole_stickman_button: Button = %WholeStickmanButton
|
||||
@onready var _back_to_part_button: Button = %BackToPartButton
|
||||
@onready var _whole_preview: WholeStickmanPreview = %WholeStickmanPreview
|
||||
@onready var _save_dialog: FileDialog = %SaveDialog
|
||||
@onready var _load_dialog: FileDialog = %LoadDialog
|
||||
@@ -106,6 +128,24 @@ const MAX_GRID_SIZE := 100
|
||||
@onready var _grid_size_spin_box: SpinBox = %GridSizeSpinBox
|
||||
@onready var _status_cursor_coords: Label = %CursorCoords
|
||||
@onready var _status_snap_status: Label = %SnapStatus
|
||||
@onready var _status_save_status: Label = %SaveStatus
|
||||
@onready var _unsaved_dialog: ConfirmationDialog = %UnsavedChangesDialog
|
||||
|
||||
var _editor_visual_theme: EditorVisualTheme
|
||||
var _body_part_origins: Dictionary = {}
|
||||
var _workspace_view: WorkspaceView = WorkspaceView.OVERVIEW
|
||||
var _view_choice_was_manual: bool = false
|
||||
var _active_part_name: String = "head"
|
||||
var _pending_view: int = -1
|
||||
var _pending_part_name: String = ""
|
||||
var _workspace_controls_initialized: bool = false
|
||||
var _view_button_group: ButtonGroup
|
||||
var _add_shape_menu: PopupMenu
|
||||
var _preview_takes_workspace: bool = false
|
||||
var _current_path: String = ""
|
||||
var _dirty: bool = false
|
||||
var _applying_document: bool = false
|
||||
var _pending_document_action: PendingDocumentAction = PendingDocumentAction.NONE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grid / snap state
|
||||
@@ -131,19 +171,42 @@ var _view_menu: PopupMenu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_editor_visual_theme = EditorVisualThemeScript.new()
|
||||
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
|
||||
theme = _editor_visual_theme.control_theme(&"stickman")
|
||||
_setup_menu_bar()
|
||||
_setup_dialogs()
|
||||
_populate_body_part_panels()
|
||||
_setup_workspace_controls()
|
||||
_whole_preview.part_moved.connect(_on_preview_part_moved)
|
||||
_whole_preview.part_transform_committed.connect(_on_preview_part_transform_committed)
|
||||
_whole_preview.part_order_changed.connect(_on_preview_part_order_changed)
|
||||
_whole_preview.set_body_parts(_collect_all_shape_data())
|
||||
_whole_preview.set_canvas_tokens(
|
||||
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
|
||||
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
|
||||
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
|
||||
_editor_visual_theme.chrome_color(&"preview", &"stickman")
|
||||
)
|
||||
_stickman_name_edit.text_changed.connect(_on_stickman_name_changed)
|
||||
|
||||
_load_settings()
|
||||
_update_snap_status_label()
|
||||
_broadcast_settings()
|
||||
_broadcast_recent_colors()
|
||||
_update_active_part_presentation()
|
||||
_update_document_status()
|
||||
|
||||
if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed):
|
||||
_grid_config_dialog.confirmed.connect(_on_grid_config_confirmed)
|
||||
call_deferred("_initialize_workspace_presentation")
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST:
|
||||
_request_document_action(PendingDocumentAction.QUIT)
|
||||
elif what == NOTIFICATION_RESIZED:
|
||||
call_deferred("_refresh_responsive_workspace")
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
@@ -151,7 +214,7 @@ func _process(_delta: float) -> void:
|
||||
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
|
||||
if panel and panel.is_cursor_over_drawing(mouse_pos):
|
||||
if panel and panel.is_visible_in_tree() and panel.is_cursor_over_drawing(mouse_pos):
|
||||
var world_pos := panel.global_to_world(mouse_pos)
|
||||
_status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)]
|
||||
return
|
||||
@@ -173,7 +236,7 @@ func _setup_menu_bar() -> void:
|
||||
file_menu.add_item("Save", 0)
|
||||
file_menu.add_item("Load", 1)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("Clear", 2)
|
||||
file_menu.add_item("Start Over", 2)
|
||||
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
|
||||
_menu_bar.add_child(file_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
|
||||
@@ -201,12 +264,319 @@ func _setup_menu_bar() -> void:
|
||||
|
||||
|
||||
func _setup_dialogs() -> void:
|
||||
_save_dialog.theme = theme
|
||||
_load_dialog.theme = theme
|
||||
_clear_confirm.theme = theme
|
||||
_unsaved_dialog.theme = theme
|
||||
_error_dialog.theme = theme
|
||||
_grid_config_dialog.theme = theme
|
||||
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR)
|
||||
_save_dialog.current_dir = content_dir
|
||||
_load_dialog.current_dir = content_dir
|
||||
_save_dialog.file_selected.connect(_on_save_file_selected)
|
||||
_load_dialog.file_selected.connect(_on_load_file_selected)
|
||||
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"stickman")
|
||||
_clear_confirm.confirmed.connect(_on_clear_confirmed)
|
||||
_clear_confirm.get_cancel_button().text = "Cancel"
|
||||
_editor_visual_theme.style_button_role(_clear_confirm.get_ok_button(), &"destructive", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_clear_confirm.get_cancel_button(), &"secondary", &"stickman")
|
||||
var discard_button: Button = _unsaved_dialog.add_button("Discard Changes", false, "discard")
|
||||
_unsaved_dialog.get_cancel_button().text = "Cancel"
|
||||
_editor_visual_theme.style_button_role(_unsaved_dialog.get_ok_button(), &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_unsaved_dialog.get_cancel_button(), &"secondary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_grid_config_dialog.get_ok_button(), &"primary", &"stickman")
|
||||
_editor_visual_theme.style_button_role(_grid_config_dialog.get_cancel_button(), &"secondary", &"stickman")
|
||||
_unsaved_dialog.confirmed.connect(_on_unsaved_save_pressed)
|
||||
_unsaved_dialog.custom_action.connect(_on_unsaved_custom_action)
|
||||
_unsaved_dialog.canceled.connect(_cancel_pending_document_action)
|
||||
get_tree().auto_accept_quit = false
|
||||
|
||||
|
||||
func _setup_workspace_controls() -> void:
|
||||
_view_button_group = ButtonGroup.new()
|
||||
_overview_button.button_group = _view_button_group
|
||||
_focus_button.button_group = _view_button_group
|
||||
_overview_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_focus_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"mode_button_min_height", &"stickman"))
|
||||
_part_picker.custom_minimum_size = Vector2(160.0, float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")))
|
||||
_add_shape_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_editor_visual_theme.style_button_role(_add_shape_button, &"primary", &"stickman")
|
||||
_color_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_editor_visual_theme.style_button_role(_color_button, &"secondary", &"stickman")
|
||||
_whole_stickman_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_back_to_part_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
|
||||
_focus_part_label.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"section", &"stickman"))
|
||||
_status_cursor_coords.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
|
||||
_status_save_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
|
||||
_status_snap_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
_part_picker.add_item(BODY_PART_DISPLAY[part_name])
|
||||
_overview_button.pressed.connect(func() -> void: request_workspace_view(WorkspaceView.OVERVIEW))
|
||||
_focus_button.pressed.connect(func() -> void: request_workspace_view(WorkspaceView.FOCUS))
|
||||
_part_picker.item_selected.connect(_on_part_picker_selected)
|
||||
_add_shape_button.pressed.connect(_on_add_shape_button_pressed)
|
||||
_color_button.pressed.connect(_on_color_button_pressed)
|
||||
_whole_stickman_button.pressed.connect(_on_whole_stickman_button_pressed)
|
||||
_back_to_part_button.pressed.connect(_on_back_to_part_button_pressed)
|
||||
_add_shape_menu = PopupMenu.new()
|
||||
_add_shape_menu.add_item("Line", 0)
|
||||
_add_shape_menu.add_item("Rectangle", 1)
|
||||
_add_shape_menu.add_item("Circle", 2)
|
||||
_add_shape_menu.id_pressed.connect(_on_add_shape_menu_id_pressed)
|
||||
_add_shape_menu.theme = theme
|
||||
add_child(_add_shape_menu)
|
||||
_workspace_controls_initialized = true
|
||||
_refresh_workspace_controls()
|
||||
|
||||
|
||||
func _initialize_workspace_presentation() -> void:
|
||||
if _workspace_row.size.x <= 0.0:
|
||||
await get_tree().process_frame
|
||||
if not _view_choice_was_manual:
|
||||
var initial_view: WorkspaceView = WorkspaceView.OVERVIEW if _overview_width_fits() else WorkspaceView.FOCUS
|
||||
request_workspace_view(initial_view, false)
|
||||
_refresh_responsive_workspace()
|
||||
|
||||
|
||||
func request_workspace_view(view: WorkspaceView, user_chosen: bool = true) -> void:
|
||||
if user_chosen:
|
||||
_view_choice_was_manual = true
|
||||
var panel: BodyPartPanel = _active_panel()
|
||||
if panel != null and not panel.can_switch_presentation():
|
||||
_pending_view = int(view)
|
||||
return
|
||||
_pending_view = -1
|
||||
_apply_workspace_view(view)
|
||||
|
||||
|
||||
func request_active_part(part_name: String) -> void:
|
||||
if not BODY_PART_NAMES.has(part_name):
|
||||
return
|
||||
var panel: BodyPartPanel = _active_panel()
|
||||
if panel != null and not panel.can_switch_presentation():
|
||||
_pending_part_name = part_name
|
||||
return
|
||||
_pending_part_name = ""
|
||||
_set_active_part(part_name)
|
||||
|
||||
|
||||
func get_workspace_view() -> WorkspaceView:
|
||||
return _workspace_view
|
||||
|
||||
|
||||
func get_active_part_name() -> String:
|
||||
return _active_part_name
|
||||
|
||||
|
||||
func has_unsaved_changes() -> bool:
|
||||
return _dirty
|
||||
|
||||
|
||||
func current_path() -> String:
|
||||
return _current_path
|
||||
|
||||
|
||||
func _apply_pending_presentation() -> void:
|
||||
var panel: BodyPartPanel = _active_panel()
|
||||
if panel != null and not panel.can_switch_presentation():
|
||||
return
|
||||
if not _pending_part_name.is_empty():
|
||||
var requested_part: String = _pending_part_name
|
||||
_pending_part_name = ""
|
||||
_set_active_part(requested_part)
|
||||
if _pending_view >= 0:
|
||||
var requested_view: WorkspaceView = _pending_view
|
||||
_pending_view = -1
|
||||
_apply_workspace_view(requested_view)
|
||||
|
||||
|
||||
func _apply_workspace_view(view: WorkspaceView) -> void:
|
||||
if view == _workspace_view:
|
||||
_refresh_workspace_controls()
|
||||
_refresh_responsive_workspace()
|
||||
return
|
||||
if _workspace_view == WorkspaceView.FOCUS:
|
||||
_restore_panel_to_overview(_active_part_name)
|
||||
_workspace_view = view
|
||||
_preview_takes_workspace = false
|
||||
if _workspace_view == WorkspaceView.FOCUS:
|
||||
_move_panel_to_focus(_active_part_name)
|
||||
_overview_scroll.visible = _workspace_view == WorkspaceView.OVERVIEW
|
||||
_focus_host.visible = _workspace_view == WorkspaceView.FOCUS
|
||||
_refresh_workspace_controls()
|
||||
_update_active_part_presentation()
|
||||
_refresh_responsive_workspace()
|
||||
|
||||
|
||||
func _set_active_part(part_name: String) -> void:
|
||||
if not BODY_PART_NAMES.has(part_name) or part_name == _active_part_name:
|
||||
_refresh_workspace_controls()
|
||||
return
|
||||
if _workspace_view == WorkspaceView.FOCUS:
|
||||
_restore_panel_to_overview(_active_part_name)
|
||||
_active_part_name = part_name
|
||||
if _workspace_view == WorkspaceView.FOCUS:
|
||||
_move_panel_to_focus(_active_part_name)
|
||||
_refresh_workspace_controls()
|
||||
_update_active_part_presentation()
|
||||
|
||||
|
||||
func _active_panel() -> BodyPartPanel:
|
||||
return _body_part_panels.get(_active_part_name) as BodyPartPanel
|
||||
|
||||
|
||||
func _move_panel_to_focus(part_name: String) -> void:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
|
||||
if panel == null or panel.get_parent() == _focus_panel_host:
|
||||
return
|
||||
var current_focus_panel: Control = _focus_panel_host.get_child(0) as Control if _focus_panel_host.get_child_count() > 0 else null
|
||||
if current_focus_panel != null:
|
||||
var current_part: String = String(current_focus_panel.get_meta("body_part_name", ""))
|
||||
if not current_part.is_empty():
|
||||
_restore_panel_to_overview(current_part)
|
||||
var old_parent: Node = panel.get_parent()
|
||||
if old_parent == null:
|
||||
return
|
||||
old_parent.remove_child(panel)
|
||||
_focus_panel_host.add_child(panel)
|
||||
panel.set_meta("body_part_name", part_name)
|
||||
panel.layout_mode = 1
|
||||
panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
panel.custom_minimum_size = Vector2(FOCUS_PANEL_MIN_WIDTH, 300.0)
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
|
||||
|
||||
func _restore_panel_to_overview(part_name: String) -> void:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
|
||||
var origin: Dictionary = _body_part_origins.get(part_name, {})
|
||||
if panel == null or origin.is_empty():
|
||||
return
|
||||
var origin_parent: Node = origin.get("parent") as Node
|
||||
if origin_parent == null or panel.get_parent() == origin_parent:
|
||||
return
|
||||
var old_parent: Node = panel.get_parent()
|
||||
if old_parent != null:
|
||||
old_parent.remove_child(panel)
|
||||
panel.layout_mode = 2
|
||||
panel.custom_minimum_size = origin.get("minimum_size", Vector2(230.0, 160.0))
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
origin_parent.add_child(panel)
|
||||
origin_parent.move_child(panel, int(origin.get("index", origin_parent.get_child_count() - 1)))
|
||||
panel.remove_meta("body_part_name")
|
||||
|
||||
|
||||
func _overview_width_fits() -> bool:
|
||||
var required_width: float = OVERVIEW_PANEL_MIN_WIDTH * 2.0 + OVERVIEW_PREVIEW_MIN_WIDTH + WORKSPACE_GAP * 2.0
|
||||
return _available_workspace_width() >= required_width
|
||||
|
||||
|
||||
func _available_workspace_width() -> float:
|
||||
var viewport_width: float = get_viewport_rect().size.x
|
||||
return minf(size.x, viewport_width) if viewport_width > 0.0 else _workspace_row.size.x
|
||||
|
||||
|
||||
func _refresh_responsive_workspace() -> void:
|
||||
if not is_node_ready():
|
||||
return
|
||||
if not _view_choice_was_manual and _workspace_controls_initialized:
|
||||
var responsive_view: WorkspaceView = WorkspaceView.OVERVIEW if _overview_width_fits() else WorkspaceView.FOCUS
|
||||
if responsive_view != _workspace_view:
|
||||
request_workspace_view(responsive_view, false)
|
||||
var narrow_preview: bool = _available_workspace_width() < FOCUS_PANEL_MIN_WIDTH + OVERVIEW_PREVIEW_MIN_WIDTH + WORKSPACE_GAP
|
||||
if _workspace_view == WorkspaceView.OVERVIEW:
|
||||
_preview_takes_workspace = false
|
||||
_body_part_workspace.visible = true
|
||||
_whole_preview.visible = not narrow_preview
|
||||
_whole_stickman_button.visible = false
|
||||
_back_to_part_button.visible = false
|
||||
return
|
||||
if narrow_preview and _preview_takes_workspace:
|
||||
_body_part_workspace.visible = false
|
||||
_whole_preview.visible = true
|
||||
_whole_stickman_button.visible = false
|
||||
_back_to_part_button.visible = true
|
||||
elif narrow_preview:
|
||||
_body_part_workspace.visible = true
|
||||
_whole_preview.visible = false
|
||||
_whole_stickman_button.visible = true
|
||||
_back_to_part_button.visible = false
|
||||
else:
|
||||
_preview_takes_workspace = false
|
||||
_body_part_workspace.visible = true
|
||||
_whole_preview.visible = true
|
||||
_whole_stickman_button.visible = false
|
||||
_back_to_part_button.visible = false
|
||||
|
||||
|
||||
func _update_active_part_presentation() -> void:
|
||||
if _editor_visual_theme == null:
|
||||
return
|
||||
var surface: Color = _editor_visual_theme.chrome_color(&"surface", &"stickman")
|
||||
var border: Color = _editor_visual_theme.chrome_color(&"border", &"stickman")
|
||||
var accent: Color = _editor_visual_theme.chrome_color(&"edit", &"stickman")
|
||||
var section_size: int = _editor_visual_theme.chrome_px(&"section", &"stickman")
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
|
||||
if panel != null:
|
||||
panel.set_active_presentation(part_name == _active_part_name, surface, border, accent, section_size)
|
||||
if is_instance_valid(_focus_part_label):
|
||||
_focus_part_label.text = BODY_PART_DISPLAY.get(_active_part_name, _active_part_name)
|
||||
|
||||
|
||||
func _refresh_workspace_controls() -> void:
|
||||
if not _workspace_controls_initialized:
|
||||
return
|
||||
_overview_button.set_pressed_no_signal(_workspace_view == WorkspaceView.OVERVIEW)
|
||||
_focus_button.set_pressed_no_signal(_workspace_view == WorkspaceView.FOCUS)
|
||||
var part_index: int = BODY_PART_NAMES.find(_active_part_name)
|
||||
if part_index >= 0:
|
||||
_part_picker.select(part_index)
|
||||
var active: BodyPartPanel = _active_panel()
|
||||
var selected: bool = active != null and active.has_selected_shape()
|
||||
_color_button.disabled = not selected
|
||||
_color_button.tooltip_text = "Select a shape to choose its color." if not selected else "Choose this shape's color."
|
||||
_update_active_part_presentation()
|
||||
|
||||
|
||||
func _on_part_picker_selected(index: int) -> void:
|
||||
if index >= 0 and index < BODY_PART_NAMES.size():
|
||||
request_active_part(BODY_PART_NAMES[index])
|
||||
|
||||
|
||||
func _on_add_shape_button_pressed() -> void:
|
||||
var rect: Rect2 = Rect2(_add_shape_button.global_position + Vector2(0, _add_shape_button.size.y), Vector2(_add_shape_button.size.x, 1.0))
|
||||
_add_shape_menu.popup(rect)
|
||||
|
||||
|
||||
func _on_add_shape_menu_id_pressed(id: int) -> void:
|
||||
var kind: StringName = [&"line", &"rectangle", &"circle"][clampi(id, 0, 2)]
|
||||
var panel: BodyPartPanel = _active_panel()
|
||||
if panel != null:
|
||||
panel.add_shape(kind, panel.drawing_center_world())
|
||||
_refresh_workspace_controls()
|
||||
|
||||
|
||||
func _on_color_button_pressed() -> void:
|
||||
var panel: BodyPartPanel = _active_panel()
|
||||
if panel != null:
|
||||
panel.open_selected_color_picker()
|
||||
|
||||
|
||||
func _on_whole_stickman_button_pressed() -> void:
|
||||
_preview_takes_workspace = true
|
||||
_refresh_responsive_workspace()
|
||||
|
||||
|
||||
func _on_back_to_part_button_pressed() -> void:
|
||||
_preview_takes_workspace = false
|
||||
_refresh_responsive_workspace()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body-part panel population
|
||||
@@ -230,7 +600,17 @@ func _populate_body_part_panels() -> void:
|
||||
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
|
||||
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
|
||||
panel.color_selected.connect(_on_color_selected.bind(part_name))
|
||||
panel.presentation_safe.connect(_apply_pending_presentation)
|
||||
panel.set_canvas_tokens(
|
||||
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
|
||||
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
|
||||
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
|
||||
_editor_visual_theme.chrome_color(&"text_muted", &"stickman"),
|
||||
float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")),
|
||||
_editor_visual_theme
|
||||
)
|
||||
_body_part_panels[part_name] = panel
|
||||
_body_part_origins[part_name] = {"parent": left_col, "index": left_col.get_child_count(), "minimum_size": panel.custom_minimum_size}
|
||||
left_col.add_child(panel)
|
||||
|
||||
for part_name: String in center_names:
|
||||
@@ -242,7 +622,17 @@ func _populate_body_part_panels() -> void:
|
||||
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
|
||||
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
|
||||
panel.color_selected.connect(_on_color_selected.bind(part_name))
|
||||
panel.presentation_safe.connect(_apply_pending_presentation)
|
||||
panel.set_canvas_tokens(
|
||||
_editor_visual_theme.stickman_canvas_color(&"grid_color"),
|
||||
_editor_visual_theme.stickman_canvas_color(&"selection_core"),
|
||||
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
|
||||
_editor_visual_theme.chrome_color(&"text_muted", &"stickman"),
|
||||
float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")),
|
||||
_editor_visual_theme
|
||||
)
|
||||
_body_part_panels[part_name] = panel
|
||||
_body_part_origins[part_name] = {"parent": center_col, "index": center_col.get_child_count(), "minimum_size": panel.custom_minimum_size}
|
||||
center_col.add_child(panel)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -252,11 +642,121 @@ func _populate_body_part_panels() -> void:
|
||||
func _on_file_menu_id_pressed(id: int) -> void:
|
||||
match id:
|
||||
0: # Save
|
||||
_save_dialog.popup_centered_ratio(0.6)
|
||||
_save_current_document()
|
||||
1: # Load
|
||||
_request_document_action(PendingDocumentAction.LOAD)
|
||||
2: # Start Over
|
||||
_request_document_action(PendingDocumentAction.CLEAR)
|
||||
|
||||
|
||||
func _save_current_document() -> void:
|
||||
if _current_path.is_empty():
|
||||
_save_dialog.popup_centered_ratio(0.6)
|
||||
return
|
||||
save_to_path(_current_path)
|
||||
|
||||
|
||||
func save_to_path(path: String) -> bool:
|
||||
var save_path: String = ContentPathsScript.canonicalize(path)
|
||||
if save_path.get_extension().to_lower() != "stk":
|
||||
save_path = save_path.get_basename() + ".stk"
|
||||
var file: FileAccess = FileAccess.open(save_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
_show_error("Could not save the stickman. Check that the file location is available.")
|
||||
return false
|
||||
file.store_string(JSON.stringify(_build_json_data(), "\t", false))
|
||||
var write_error: Error = file.get_error()
|
||||
file.close()
|
||||
if write_error != OK:
|
||||
_show_error("Could not finish saving the stickman. Check the file location and try again.")
|
||||
return false
|
||||
_current_path = save_path
|
||||
_dirty = false
|
||||
_update_document_status()
|
||||
return true
|
||||
|
||||
|
||||
func _request_document_action(action: PendingDocumentAction) -> void:
|
||||
if _dirty:
|
||||
_pending_document_action = action
|
||||
match action:
|
||||
PendingDocumentAction.LOAD:
|
||||
_unsaved_dialog.dialog_text = "Save changes before loading another stickman?"
|
||||
PendingDocumentAction.CLEAR:
|
||||
_unsaved_dialog.dialog_text = "Save changes before starting over with a blank stickman?"
|
||||
PendingDocumentAction.QUIT:
|
||||
_unsaved_dialog.dialog_text = "Save changes before closing Stickman Studio?"
|
||||
_unsaved_dialog.popup_centered()
|
||||
return
|
||||
_continue_document_action(action)
|
||||
|
||||
|
||||
func _continue_document_action(action: PendingDocumentAction) -> void:
|
||||
match action:
|
||||
PendingDocumentAction.LOAD:
|
||||
_load_dialog.popup_centered_ratio(0.6)
|
||||
2: # Clear
|
||||
PendingDocumentAction.CLEAR:
|
||||
_clear_confirm.popup_centered()
|
||||
PendingDocumentAction.QUIT:
|
||||
get_tree().quit()
|
||||
_pending_document_action = PendingDocumentAction.NONE
|
||||
|
||||
|
||||
func _on_unsaved_save_pressed() -> void:
|
||||
if _pending_document_action == PendingDocumentAction.NONE:
|
||||
return
|
||||
if _current_path.is_empty():
|
||||
_save_dialog.popup_centered_ratio(0.6)
|
||||
return
|
||||
if save_to_path(_current_path):
|
||||
_resume_pending_document_action()
|
||||
else:
|
||||
_cancel_pending_document_action()
|
||||
|
||||
|
||||
func _on_unsaved_custom_action(action: StringName) -> void:
|
||||
if action != &"discard":
|
||||
return
|
||||
_resume_pending_document_action()
|
||||
|
||||
|
||||
func _resume_pending_document_action() -> void:
|
||||
var action: PendingDocumentAction = _pending_document_action
|
||||
_pending_document_action = PendingDocumentAction.NONE
|
||||
if action == PendingDocumentAction.CLEAR:
|
||||
_clear_all()
|
||||
return
|
||||
_continue_document_action(action)
|
||||
|
||||
|
||||
func _cancel_pending_document_action() -> void:
|
||||
_pending_document_action = PendingDocumentAction.NONE
|
||||
|
||||
|
||||
func _on_stickman_name_changed(_value: String) -> void:
|
||||
if not _applying_document:
|
||||
_mark_dirty()
|
||||
|
||||
|
||||
func _mark_dirty() -> void:
|
||||
if _applying_document:
|
||||
return
|
||||
_dirty = true
|
||||
_update_document_status()
|
||||
|
||||
|
||||
func _update_document_status() -> void:
|
||||
if _status_save_status == null:
|
||||
return
|
||||
if _dirty:
|
||||
_status_save_status.text = "Unsaved changes"
|
||||
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"dirty", &"stickman"))
|
||||
elif _current_path.is_empty():
|
||||
_status_save_status.text = "Not saved yet"
|
||||
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"text_muted", &"stickman"))
|
||||
else:
|
||||
_status_save_status.text = "Saved"
|
||||
_status_save_status.add_theme_color_override("font_color", _editor_visual_theme.chrome_color(&"saved", &"stickman"))
|
||||
|
||||
|
||||
func _on_edit_menu_id_pressed(id: int) -> void:
|
||||
@@ -320,19 +820,12 @@ func _on_grid_config_confirmed() -> void:
|
||||
|
||||
func _on_save_file_selected(path: String) -> void:
|
||||
path = ContentPathsScript.canonicalize(path)
|
||||
if not path.ends_with(".stk"):
|
||||
path += ".stk"
|
||||
|
||||
var data := _build_json_data()
|
||||
var json_text := JSON.stringify(data, "\t", false)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
_show_error("Failed to save file: " + str(FileAccess.get_open_error()))
|
||||
if path.get_extension().to_lower() != "stk":
|
||||
path = path.get_basename() + ".stk"
|
||||
if not save_to_path(path):
|
||||
_cancel_pending_document_action()
|
||||
return
|
||||
|
||||
file.store_string(json_text)
|
||||
file.close()
|
||||
_resume_pending_document_action()
|
||||
|
||||
|
||||
func _on_load_file_selected(path: String) -> void:
|
||||
@@ -358,6 +851,9 @@ func _on_load_file_selected(path: String) -> void:
|
||||
if not err.is_empty():
|
||||
_show_error(err)
|
||||
return
|
||||
_current_path = path
|
||||
_dirty = false
|
||||
_update_document_status()
|
||||
|
||||
|
||||
func _on_clear_confirmed() -> void:
|
||||
@@ -379,19 +875,31 @@ func _on_body_part_shape_changed(_new_data: Array, part_name: String) -> void:
|
||||
_whole_preview.set_part_rotation(part_name, 0.0)
|
||||
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
|
||||
_broadcast_clipboard_state()
|
||||
_mark_dirty()
|
||||
_refresh_workspace_controls()
|
||||
|
||||
|
||||
func _on_body_part_shape_selected(part_name: String) -> void:
|
||||
request_active_part(part_name)
|
||||
for other_name: String in BODY_PART_NAMES:
|
||||
if other_name != part_name:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(other_name) as BodyPartPanel
|
||||
if panel:
|
||||
panel.deselect()
|
||||
_refresh_workspace_controls()
|
||||
|
||||
|
||||
func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _on_preview_part_transform_committed(_part_name: String, _position: Vector2, _rotation_deg: float, _scale: Vector2) -> void:
|
||||
_mark_dirty()
|
||||
|
||||
|
||||
func _on_preview_part_order_changed(_part_name: String, _new_order: Array[String]) -> void:
|
||||
_mark_dirty()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data collection / JSON helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -478,13 +986,13 @@ func _apply_json_data(json: Variant) -> String:
|
||||
if version not in SUPPORTED_VERSIONS:
|
||||
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', '1.4', or '1.5')" % version
|
||||
|
||||
_stickman_name_edit.text = dict.get("stickman_name", "")
|
||||
|
||||
var body_parts: Variant = dict.get("body_parts")
|
||||
if not body_parts is Dictionary:
|
||||
return "Invalid file format: 'body_parts' must be a dictionary."
|
||||
|
||||
var body_dict: Dictionary = body_parts as Dictionary
|
||||
_applying_document = true
|
||||
_stickman_name_edit.text = str(dict.get("stickman_name", ""))
|
||||
|
||||
_clear_all_panels()
|
||||
|
||||
@@ -546,6 +1054,9 @@ func _apply_json_data(json: Variant) -> String:
|
||||
_whole_preview.reset_view()
|
||||
_whole_preview.set_all_part_positions(positions)
|
||||
_whole_preview.set_body_parts(_collect_all_shape_data())
|
||||
_applying_document = false
|
||||
_dirty = false
|
||||
_update_document_status()
|
||||
|
||||
return ""
|
||||
|
||||
@@ -561,12 +1072,18 @@ func _clear_all_panels() -> void:
|
||||
|
||||
|
||||
func _clear_all() -> void:
|
||||
_applying_document = true
|
||||
_clear_all_panels()
|
||||
_whole_preview.clear_all()
|
||||
_whole_preview.set_all_part_positions(DEFAULT_POSITIONS.duplicate())
|
||||
_stickman_name_edit.text = ""
|
||||
_shape_clipboard.clear()
|
||||
_broadcast_clipboard_state()
|
||||
_applying_document = false
|
||||
_current_path = ""
|
||||
_dirty = false
|
||||
_update_document_status()
|
||||
_refresh_workspace_controls()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5: Shape clipboard
|
||||
@@ -611,6 +1128,7 @@ func _on_color_selected(color: Color, _part_name: String) -> void:
|
||||
_recent_colors.pop_back()
|
||||
_save_settings()
|
||||
_broadcast_recent_colors()
|
||||
_mark_dirty()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: Settings persistence
|
||||
|
||||
@@ -18,6 +18,8 @@ class_name WholeStickmanPreview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
signal part_moved(part_name: String, new_position: Vector2)
|
||||
signal part_transform_committed(part_name: String, position: Vector2, rotation_deg: float, scale: Vector2)
|
||||
signal part_order_changed(part_name: String, new_order: Array[String])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -147,6 +149,14 @@ var _selected_gizmo_bounds: Rect2 = Rect2()
|
||||
|
||||
# Phase 7: pose silhouette guide (default ON per user decision)
|
||||
var _show_guide: bool = true
|
||||
var _grid_color: Color = GRID_COLOR
|
||||
var _selection_core_color: Color = Color.WHITE
|
||||
var _selection_halo_color: Color = Color(0.0, 0.0, 0.0, 0.85)
|
||||
var _preview_color: Color = Color(0.13, 0.78, 1.0, 0.4)
|
||||
var _interaction_start_part: String = ""
|
||||
var _interaction_start_position: Vector2 = Vector2.ZERO
|
||||
var _interaction_start_rotation: float = 0.0
|
||||
var _interaction_start_scale: Vector2 = Vector2.ONE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -271,6 +281,14 @@ func set_show_guide(enabled: bool) -> void:
|
||||
preview_area.queue_redraw()
|
||||
|
||||
|
||||
func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo: Color, preview_color: Color = Color(0.13, 0.78, 1.0, 0.4)) -> void:
|
||||
_grid_color = grid_color
|
||||
_selection_core_color = selection_core
|
||||
_selection_halo_color = selection_halo
|
||||
_preview_color = preview_color
|
||||
preview_area.queue_redraw()
|
||||
|
||||
|
||||
## Returns the preview-space position of a guide joint (a master-space
|
||||
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
|
||||
## unknown joint names.
|
||||
@@ -399,25 +417,35 @@ func _on_preview_draw() -> void:
|
||||
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
|
||||
var bounds: Variant = _part_bounds.get(_dragging_part)
|
||||
if bounds is Rect2:
|
||||
preview_area.draw_rect(bounds as Rect2, Color(1.0, 0.8, 0.0, 0.35), false, 1.5)
|
||||
preview_area.draw_rect(bounds as Rect2, _preview_color, false, 1.5 / _zoom)
|
||||
|
||||
# Draw selection gizmos on top of all parts
|
||||
if _selected_gizmo_bounds.has_area():
|
||||
var b: Rect2 = _selected_gizmo_bounds
|
||||
preview_area.draw_rect(b, Color.WHITE, false, 1.5 / _zoom)
|
||||
preview_area.draw_rect(b, _selection_halo_color, false, 4.0 / _zoom)
|
||||
preview_area.draw_rect(b, _selection_core_color, false, 1.5 / _zoom)
|
||||
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
|
||||
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE)
|
||||
preview_area.draw_circle(rot_center, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, _selection_halo_color)
|
||||
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, _selection_core_color)
|
||||
var cross_half := SCALE_CROSS_SIZE / _zoom
|
||||
for i: int in range(4):
|
||||
var corner := _get_corner_position(b, i)
|
||||
preview_area.draw_line(
|
||||
corner + Vector2(-cross_half, 0),
|
||||
corner + Vector2(cross_half, 0),
|
||||
Color.WHITE, 1.5 / _zoom)
|
||||
_selection_halo_color, 4.0 / _zoom)
|
||||
preview_area.draw_line(
|
||||
corner + Vector2(0, -cross_half),
|
||||
corner + Vector2(0, cross_half),
|
||||
Color.WHITE, 1.5 / _zoom)
|
||||
_selection_halo_color, 4.0 / _zoom)
|
||||
preview_area.draw_line(
|
||||
corner + Vector2(-cross_half, 0),
|
||||
corner + Vector2(cross_half, 0),
|
||||
_selection_core_color, 1.5 / _zoom)
|
||||
preview_area.draw_line(
|
||||
corner + Vector2(0, -cross_half),
|
||||
corner + Vector2(0, cross_half),
|
||||
_selection_core_color, 1.5 / _zoom)
|
||||
|
||||
|
||||
func _draw_grid() -> void:
|
||||
@@ -436,12 +464,12 @@ func _draw_grid() -> void:
|
||||
|
||||
var x: float = start_x
|
||||
while x <= world_end.x:
|
||||
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
|
||||
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), _grid_color, line_width)
|
||||
x += gs
|
||||
|
||||
var y: float = start_y
|
||||
while y <= world_end.y:
|
||||
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
|
||||
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), _grid_color, line_width)
|
||||
y += gs
|
||||
|
||||
|
||||
@@ -688,6 +716,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
|
||||
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
|
||||
if world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom:
|
||||
_interaction = Interaction.ROTATE
|
||||
_capture_transform_start(_selected_part)
|
||||
_rotate_start_rotation = _part_rotations[_selected_part]
|
||||
_rotate_total_delta = 0.0
|
||||
var bounds_center := bounds.position + bounds.size * 0.5
|
||||
@@ -700,6 +729,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
|
||||
var corner := _get_corner_position(bounds, i)
|
||||
if world_pos.distance_to(corner) <= SCALE_CROSS_HIT_RADIUS / _zoom:
|
||||
_interaction = Interaction.SCALE
|
||||
_capture_transform_start(_selected_part)
|
||||
_scale_corner = i
|
||||
_scale_anchor = _get_opposite_corner(bounds, i)
|
||||
_scale_original_size = _compute_original_bounds_size(_selected_part)
|
||||
@@ -717,6 +747,7 @@ func _try_start_interaction(world_pos: Vector2) -> void:
|
||||
if rect.grow(8.0).has_point(world_pos):
|
||||
_selected_part = part_name
|
||||
_interaction = Interaction.TRANSLATE
|
||||
_capture_transform_start(_selected_part)
|
||||
_dragging_part = part_name
|
||||
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
|
||||
_drag_offset = world_pos - pos
|
||||
@@ -731,13 +762,40 @@ func _try_start_interaction(world_pos: Vector2) -> void:
|
||||
|
||||
func _end_interaction() -> void:
|
||||
if _interaction != Interaction.NONE:
|
||||
var changed_part: String = _interaction_start_part
|
||||
var changed: bool = changed_part != "" and (
|
||||
_part_positions.get(changed_part, Vector2.ZERO) != _interaction_start_position
|
||||
or not is_equal_approx(float(_part_rotations.get(changed_part, 0.0)), _interaction_start_rotation)
|
||||
or Vector2(_part_scales.get(changed_part, Vector2.ONE)) != _interaction_start_scale
|
||||
)
|
||||
if _interaction == Interaction.TRANSLATE and not _dragging_part.is_empty():
|
||||
_dragging_part = ""
|
||||
_drag_offset = Vector2.ZERO
|
||||
_interaction = Interaction.NONE
|
||||
if changed:
|
||||
_emit_transform_committed(changed_part)
|
||||
_interaction_start_part = ""
|
||||
preview_area.queue_redraw()
|
||||
|
||||
|
||||
func _capture_transform_start(part_name: String) -> void:
|
||||
_interaction_start_part = part_name
|
||||
_interaction_start_position = _part_positions.get(part_name, Vector2.ZERO)
|
||||
_interaction_start_rotation = float(_part_rotations.get(part_name, 0.0))
|
||||
_interaction_start_scale = _part_scales.get(part_name, Vector2.ONE)
|
||||
|
||||
|
||||
func _emit_transform_committed(part_name: String) -> void:
|
||||
if part_name.is_empty():
|
||||
return
|
||||
part_transform_committed.emit(
|
||||
part_name,
|
||||
_part_positions.get(part_name, Vector2.ZERO),
|
||||
float(_part_rotations.get(part_name, 0.0)),
|
||||
_part_scales.get(part_name, Vector2.ONE)
|
||||
)
|
||||
|
||||
|
||||
func _handle_translate_drag(mm: InputEventMouseMotion) -> void:
|
||||
if _dragging_part.is_empty():
|
||||
return
|
||||
@@ -996,6 +1054,7 @@ func _send_back_part() -> void:
|
||||
_part_order.remove_at(idx)
|
||||
_part_order.insert(idx - 1, _selected_part)
|
||||
preview_area.queue_redraw()
|
||||
part_order_changed.emit(_selected_part, _part_order.duplicate())
|
||||
|
||||
|
||||
func _bring_forward_part() -> void:
|
||||
@@ -1007,6 +1066,7 @@ func _bring_forward_part() -> void:
|
||||
_part_order.remove_at(idx)
|
||||
_part_order.insert(idx + 1, _selected_part)
|
||||
preview_area.queue_redraw()
|
||||
part_order_changed.emit(_selected_part, _part_order.duplicate())
|
||||
|
||||
|
||||
func _mirror_part_x() -> void:
|
||||
@@ -1016,6 +1076,7 @@ func _mirror_part_x() -> void:
|
||||
_part_scales[_selected_part] = Vector2(-scl.x, scl.y)
|
||||
_rebuild_bounds()
|
||||
preview_area.queue_redraw()
|
||||
_emit_transform_committed(_selected_part)
|
||||
|
||||
|
||||
func _mirror_part_y() -> void:
|
||||
@@ -1025,3 +1086,4 @@ func _mirror_part_y() -> void:
|
||||
_part_scales[_selected_part] = Vector2(scl.x, -scl.y)
|
||||
_rebuild_bounds()
|
||||
preview_area.queue_redraw()
|
||||
_emit_transform_committed(_selected_part)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
class_name EditorVisualTheme
|
||||
extends RefCounted
|
||||
## Validated semantic chrome tokens shared by the standalone editors.
|
||||
## Canvas artwork and editor-specific drawing values stay with their owners.
|
||||
|
||||
const SCHEMA_VERSION: String = "1.0"
|
||||
const DEFAULT_PATH: String = "res://editor_visual_theme.json"
|
||||
|
||||
const DEFAULT_CONFIG: Dictionary = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"chrome": {
|
||||
"colors": {
|
||||
"background": "#171B24F2",
|
||||
"surface": "#252B38FA",
|
||||
"surface_hover": "#30394A",
|
||||
"text": "#F5F7FA",
|
||||
"text_muted": "#AAB2C0",
|
||||
"edit": "#22C6FF",
|
||||
"preview": "#22C6FF66",
|
||||
"focus": "#8BD9FF",
|
||||
"action_primary": "#087DBA",
|
||||
"destructive": "#E66B75",
|
||||
"border": "#FFFFFF33",
|
||||
"disabled": "#77808F",
|
||||
"saved": "#8FD6A4",
|
||||
"dirty": "#FFD166",
|
||||
"error": "#FF8A8A",
|
||||
},
|
||||
"fonts": {
|
||||
"body": 18,
|
||||
"helper": 16,
|
||||
"section": 22,
|
||||
"title": 26,
|
||||
"menu": 20,
|
||||
"status": 16,
|
||||
"mode": 24,
|
||||
"prop_name": 20,
|
||||
"context_menu": 20,
|
||||
},
|
||||
"spacing": {"small": 8, "medium": 12, "large": 16},
|
||||
"radii": {"control": 8, "panel": 10},
|
||||
"borders": {"normal": 1, "focus": 2},
|
||||
"targets": {"button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56},
|
||||
},
|
||||
"editors": {"prop": {}, "stickman": {}},
|
||||
"stickman_canvas": {
|
||||
"grid_color": "#FFFFFF26",
|
||||
"selection_core": "#FFFFFF",
|
||||
"selection_halo": "#000000D9",
|
||||
},
|
||||
}
|
||||
|
||||
const COLOR_ROLE_SECTIONS: Dictionary = {
|
||||
"background": "colors",
|
||||
"surface": "colors",
|
||||
"surface_hover": "colors",
|
||||
"text": "colors",
|
||||
"text_muted": "colors",
|
||||
"edit": "colors",
|
||||
"preview": "colors",
|
||||
"focus": "colors",
|
||||
"action_primary": "colors",
|
||||
"destructive": "colors",
|
||||
"border": "colors",
|
||||
"disabled": "colors",
|
||||
"saved": "colors",
|
||||
"dirty": "colors",
|
||||
"error": "colors",
|
||||
}
|
||||
const SIZE_ROLE_SECTIONS: Dictionary = {
|
||||
"body": "fonts",
|
||||
"helper": "fonts",
|
||||
"section": "fonts",
|
||||
"title": "fonts",
|
||||
"menu": "fonts",
|
||||
"status": "fonts",
|
||||
"mode": "fonts",
|
||||
"prop_name": "fonts",
|
||||
"context_menu": "fonts",
|
||||
"small": "spacing",
|
||||
"medium": "spacing",
|
||||
"large": "spacing",
|
||||
"control": "radii",
|
||||
"panel": "radii",
|
||||
"normal_border": "borders",
|
||||
"focus_border": "borders",
|
||||
"button_min_height": "targets",
|
||||
"row_min_height": "targets",
|
||||
"mode_button_min_height": "targets",
|
||||
}
|
||||
const CONFIG_SIZE_KEYS: Dictionary = {
|
||||
"normal_border": "normal",
|
||||
"focus_border": "focus",
|
||||
}
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _warned: Dictionary = {}
|
||||
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool:
|
||||
_warned.clear()
|
||||
_config = DEFAULT_CONFIG.duplicate(true)
|
||||
if not FileAccess.file_exists(path):
|
||||
_warn_once(path, "EditorVisualTheme: '%s' was not found; using defaults." % path)
|
||||
return false
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
_warn_once(path, "EditorVisualTheme: '%s' could not be read; using defaults." % path)
|
||||
return false
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
_warn_once(path, "EditorVisualTheme: '%s' is malformed; using defaults." % path)
|
||||
return false
|
||||
_config = _deep_merge(_config, parsed as Dictionary)
|
||||
return true
|
||||
|
||||
|
||||
func raw_config() -> Dictionary:
|
||||
return _config.duplicate(true)
|
||||
|
||||
|
||||
func chrome_color(role: StringName, editor: StringName = &"") -> Color:
|
||||
var role_key: String = String(role)
|
||||
if not COLOR_ROLE_SECTIONS.has(role_key):
|
||||
_warn_once("color:" + role_key, "EditorVisualTheme: unknown color role '%s'." % role_key)
|
||||
return Color.MAGENTA
|
||||
var section: String = COLOR_ROLE_SECTIONS[role_key]
|
||||
var raw: Variant = _resolved_value(section, role_key, editor)
|
||||
var fallback_config: Dictionary = DEFAULT_CONFIG["chrome"][section]
|
||||
var fallback: Color = Color.from_string(String(fallback_config[role_key]), Color.WHITE)
|
||||
if not raw is String:
|
||||
_warn_once("color:" + role_key, "EditorVisualTheme: invalid value for color role '%s'." % role_key)
|
||||
return fallback
|
||||
var hex: String = String(raw)
|
||||
if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9):
|
||||
_warn_once("color:" + role_key, "EditorVisualTheme: invalid color at '%s'." % role_key)
|
||||
return fallback
|
||||
return Color.from_string(hex, fallback)
|
||||
|
||||
|
||||
func chrome_px(role: StringName, editor: StringName = &"") -> int:
|
||||
var role_key: String = String(role)
|
||||
if not SIZE_ROLE_SECTIONS.has(role_key):
|
||||
_warn_once("size:" + role_key, "EditorVisualTheme: unknown size role '%s'." % role_key)
|
||||
return 0
|
||||
var section: String = SIZE_ROLE_SECTIONS[role_key]
|
||||
var config_key: String = String(CONFIG_SIZE_KEYS.get(role_key, role_key))
|
||||
var raw: Variant = _resolved_value(section, config_key, editor)
|
||||
var fallback: int = int(DEFAULT_CONFIG["chrome"][section][config_key])
|
||||
if not raw is int and not raw is float:
|
||||
_warn_once("size:" + role_key, "EditorVisualTheme: invalid value for size role '%s'." % role_key)
|
||||
return fallback
|
||||
return clampi(int(round(float(raw))), 0, 256)
|
||||
|
||||
|
||||
func stickman_canvas_color(role: StringName) -> Color:
|
||||
var role_key: String = String(role)
|
||||
var fallback_text: String = String(DEFAULT_CONFIG["stickman_canvas"].get(role_key, "#FFFFFF"))
|
||||
var fallback: Color = Color.from_string(fallback_text, Color.WHITE)
|
||||
var raw: Variant = _get_nested(_config, ["stickman_canvas", role_key], fallback_text)
|
||||
if not raw is String:
|
||||
_warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key)
|
||||
return fallback
|
||||
var hex: String = String(raw)
|
||||
if not hex.begins_with("#") or (hex.length() != 7 and hex.length() != 9):
|
||||
_warn_once("stickman_canvas:" + role_key, "EditorVisualTheme: invalid Stickman canvas color '%s'." % role_key)
|
||||
return fallback
|
||||
return Color.from_string(hex, fallback)
|
||||
|
||||
|
||||
func control_theme(editor: StringName) -> Theme:
|
||||
var theme: Theme = Theme.new()
|
||||
var surface: Color = chrome_color(&"surface", editor)
|
||||
var background: Color = chrome_color(&"background", editor)
|
||||
var hover: Color = chrome_color(&"surface_hover", editor)
|
||||
var text: Color = chrome_color(&"text", editor)
|
||||
var muted: Color = chrome_color(&"text_muted", editor)
|
||||
var edit: Color = chrome_color(&"edit", editor)
|
||||
var focus: Color = chrome_color(&"focus", editor)
|
||||
var border: Color = chrome_color(&"border", editor)
|
||||
var radius: int = chrome_px(&"control", editor)
|
||||
var normal_width: int = chrome_px(&"normal_border", editor)
|
||||
var focus_width: int = maxi(2, chrome_px(&"focus_border", editor))
|
||||
var normal_box: StyleBoxFlat = _make_box(surface, border, radius, normal_width)
|
||||
var hover_box: StyleBoxFlat = _make_box(hover, edit, radius, normal_width)
|
||||
var pressed_box: StyleBoxFlat = _make_box(surface.darkened(0.12), edit, radius, focus_width)
|
||||
var focus_box: StyleBoxFlat = _make_box(Color(0, 0, 0, 0), focus, radius, focus_width)
|
||||
var disabled_box: StyleBoxFlat = _make_box(background.darkened(0.1), border, radius, normal_width)
|
||||
for type_name: String in ["Button", "OptionButton", "ColorPickerButton", "CheckBox"]:
|
||||
theme.set_stylebox("normal", type_name, normal_box.duplicate())
|
||||
theme.set_stylebox("hover", type_name, hover_box.duplicate())
|
||||
theme.set_stylebox("pressed", type_name, pressed_box.duplicate())
|
||||
theme.set_stylebox("focus", type_name, focus_box.duplicate())
|
||||
theme.set_stylebox("disabled", type_name, disabled_box.duplicate())
|
||||
theme.set_color("font_color", type_name, text)
|
||||
theme.set_color("font_hover_color", type_name, text)
|
||||
theme.set_color("font_pressed_color", type_name, edit)
|
||||
theme.set_color("font_disabled_color", type_name, muted)
|
||||
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
|
||||
var input_box: StyleBoxFlat = _make_box(background, border, radius, normal_width)
|
||||
var input_focus: StyleBoxFlat = _make_box(background, focus, radius, focus_width)
|
||||
for type_name: String in ["LineEdit", "SpinBox", "TextEdit"]:
|
||||
theme.set_stylebox("normal", type_name, input_box.duplicate())
|
||||
theme.set_stylebox("focus", type_name, input_focus.duplicate())
|
||||
theme.set_color("font_color", type_name, text)
|
||||
theme.set_color("font_placeholder_color", type_name, muted)
|
||||
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
|
||||
var panel_box: StyleBoxFlat = _make_box(surface, border, chrome_px(&"panel", editor), normal_width)
|
||||
theme.set_stylebox("panel", "PanelContainer", panel_box)
|
||||
theme.set_stylebox("normal", "PopupMenu", panel_box.duplicate())
|
||||
theme.set_stylebox("panel", "MenuBar", panel_box.duplicate())
|
||||
theme.set_font_size("font_size", "Label", chrome_px(&"body", editor))
|
||||
theme.set_color("font_color", "Label", text)
|
||||
theme.set_color("font_hover_color", "Label", text)
|
||||
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu", editor))
|
||||
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu", editor))
|
||||
theme.set_color("font_color", "MenuBar", text)
|
||||
return theme
|
||||
|
||||
|
||||
func style_button_role(button: Button, role: StringName, editor: StringName) -> void:
|
||||
button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, float(chrome_px(&"button_min_height", editor)))
|
||||
if role != &"primary" and role != &"destructive":
|
||||
return
|
||||
var fill_role: StringName = &"action_primary" if role == &"primary" else &"destructive"
|
||||
var fill: Color = chrome_color(fill_role, editor)
|
||||
var focus: Color = chrome_color(&"focus", editor)
|
||||
var radius: int = chrome_px(&"control", editor)
|
||||
var border_width: int = chrome_px(&"normal_border", editor)
|
||||
var focus_width: int = maxi(2, chrome_px(&"focus_border", editor))
|
||||
button.add_theme_stylebox_override("normal", _make_box(fill, fill.lightened(0.12), radius, border_width))
|
||||
button.add_theme_stylebox_override("hover", _make_box(fill.lightened(0.12), focus, radius, border_width))
|
||||
button.add_theme_stylebox_override("pressed", _make_box(fill.darkened(0.12), focus, radius, focus_width))
|
||||
button.add_theme_stylebox_override("focus", _make_box(Color(0.0, 0.0, 0.0, 0.0), focus, radius, focus_width))
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_pressed_color", Color.WHITE)
|
||||
|
||||
|
||||
func _resolved_value(section: String, key: String, editor: StringName) -> Variant:
|
||||
if not editor.is_empty():
|
||||
var editor_value: Variant = _get_nested(_config, ["editors", String(editor), section, key], null)
|
||||
if editor_value != null:
|
||||
return editor_value
|
||||
return _get_nested(_config, ["chrome", section, key], null)
|
||||
|
||||
|
||||
func _get_nested(source: Dictionary, keys: Array[String], fallback: Variant) -> Variant:
|
||||
var cursor: Variant = source
|
||||
for key: String in keys:
|
||||
if not cursor is Dictionary or not (cursor as Dictionary).has(key):
|
||||
return fallback
|
||||
cursor = (cursor as Dictionary)[key]
|
||||
return cursor
|
||||
|
||||
|
||||
func _make_box(fill: Color, outline: Color, radius: int, width: int) -> StyleBoxFlat:
|
||||
var box: StyleBoxFlat = StyleBoxFlat.new()
|
||||
box.bg_color = fill
|
||||
box.border_color = outline
|
||||
box.set_border_width_all(width)
|
||||
box.set_corner_radius_all(radius)
|
||||
box.content_margin_left = 8
|
||||
box.content_margin_right = 8
|
||||
box.content_margin_top = 6
|
||||
box.content_margin_bottom = 6
|
||||
return box
|
||||
|
||||
|
||||
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
||||
var merged: Dictionary = base.duplicate(true)
|
||||
for key: Variant in overrides:
|
||||
if merged.get(key) is Dictionary and overrides[key] is Dictionary:
|
||||
merged[key] = _deep_merge(merged[key] as Dictionary, overrides[key] as Dictionary)
|
||||
else:
|
||||
merged[key] = overrides[key]
|
||||
return merged
|
||||
|
||||
|
||||
func _warn_once(key: String, message: String) -> void:
|
||||
if not _warned.has(key):
|
||||
_warned[key] = true
|
||||
push_warning(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2mnpqu565na
|
||||
@@ -12,6 +12,9 @@ const DETAIL_META := &"stage_dialog_detail"
|
||||
const ACTIONS_META := &"stage_dialog_actions"
|
||||
const EXTRAS_META := &"stage_dialog_extra_controls"
|
||||
const CARD_META := &"stage_dialog_card"
|
||||
const CARD_WIDTH_META := &"stage_dialog_card_width"
|
||||
const MAX_HEIGHT_RATIO_META := &"stage_dialog_max_height_ratio"
|
||||
const SCREEN_MARGIN_META := &"stage_dialog_screen_margin"
|
||||
const TITLE_META := &"stage_dialog_title"
|
||||
const ICON_META := &"stage_dialog_icon"
|
||||
const ICON_LABEL_META := &"stage_dialog_icon_label"
|
||||
@@ -54,21 +57,13 @@ static func popup(dialog: Window) -> void:
|
||||
dialog.hide()
|
||||
dialog.set_meta(OPEN_META, true)
|
||||
overlay.show()
|
||||
var host_viewport: Viewport = overlay.get_viewport()
|
||||
if host_viewport != null:
|
||||
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size)
|
||||
var card: PanelContainer = dialog.get_meta(CARD_META) as PanelContainer
|
||||
if card != null:
|
||||
var card_width: int = active_theme.integer("popups.confirmation.card_width", 560, 320, 720)
|
||||
card.custom_minimum_size.x = minf(float(card_width), maxf(280.0, float(viewport_size.x - 36)))
|
||||
_fit_body_scroll(dialog, viewport_size, active_theme)
|
||||
refit(dialog)
|
||||
var tree: SceneTree = dialog.get_tree()
|
||||
if tree != null:
|
||||
var fit_after_final_layout := func() -> void:
|
||||
if not is_instance_valid(dialog) or not dialog.is_inside_tree() or not is_open(dialog):
|
||||
return
|
||||
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size) if host_viewport != null else Vector2i.ZERO
|
||||
_fit_body_scroll(dialog, viewport_size, active_theme)
|
||||
refit(dialog)
|
||||
var fit_after_layout := func() -> void:
|
||||
if is_instance_valid(tree):
|
||||
tree.process_frame.connect(fit_after_final_layout, Object.CONNECT_ONE_SHOT)
|
||||
@@ -91,6 +86,27 @@ static func is_open(dialog: Window) -> bool:
|
||||
return dialog.visible
|
||||
|
||||
|
||||
static func refit(dialog: Window) -> void:
|
||||
if dialog == null or not is_instance_valid(dialog) or not dialog.has_meta(OVERLAY_META):
|
||||
return
|
||||
var overlay: Control = dialog.get_meta(OVERLAY_META) as Control
|
||||
if overlay == null or not is_instance_valid(overlay):
|
||||
return
|
||||
var host_viewport: Viewport = overlay.get_viewport()
|
||||
if host_viewport == null:
|
||||
return
|
||||
var active_theme: SandboxTheme = _theme_for(dialog)
|
||||
var viewport_size: Vector2i = Vector2i(host_viewport.get_visible_rect().size)
|
||||
var card: PanelContainer = dialog.get_meta(CARD_META) as PanelContainer
|
||||
if card != null:
|
||||
var screen_margin: int = int(dialog.get_meta(SCREEN_MARGIN_META, 18))
|
||||
var default_width: int = active_theme.integer("popups.confirmation.card_width", 560, 320, 720)
|
||||
var card_width: int = int(dialog.get_meta(CARD_WIDTH_META, default_width))
|
||||
var available_width: float = maxf(0.0, float(viewport_size.x - screen_margin * 2))
|
||||
card.custom_minimum_size.x = minf(float(card_width), available_width)
|
||||
_fit_body_scroll(dialog, viewport_size, active_theme)
|
||||
|
||||
|
||||
static func is_styled(dialog: Window) -> bool:
|
||||
return dialog != null and is_instance_valid(dialog) and dialog.has_meta(OVERLAY_META)
|
||||
|
||||
@@ -498,7 +514,10 @@ static func _fit_body_scroll(dialog: Window, viewport_size: Vector2i, stage_them
|
||||
var body_width: float = maxf(0.0, card_width - card_padding * 2.0)
|
||||
var preferred_body_height: float = body.get_combined_minimum_size().y
|
||||
var fixed_height: float = header.get_combined_minimum_size().y + actions.get_combined_minimum_size().y + card_padding * 2.0 + content_gap * 2.0
|
||||
var max_body_height: float = maxf(48.0, float(viewport_size.y) - 36.0 - fixed_height)
|
||||
var screen_margin: int = int(dialog.get_meta(SCREEN_MARGIN_META, 18))
|
||||
var max_height_ratio: float = clampf(float(dialog.get_meta(MAX_HEIGHT_RATIO_META, 1.0)), 0.35, 1.0)
|
||||
var available_height: float = maxf(0.0, minf(float(viewport_size.y - screen_margin * 2), float(viewport_size.y) * max_height_ratio))
|
||||
var max_body_height: float = maxf(48.0, available_height - fixed_height)
|
||||
if preferred_body_height > max_body_height:
|
||||
var vertical_bar: VScrollBar = body_scroll.get_v_scroll_bar()
|
||||
if vertical_bar != null:
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
class_name StageGroundFinishChooser
|
||||
extends ConfirmationDialog
|
||||
## Modal single-choice gallery for choosing the look of newly painted Ground.
|
||||
|
||||
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||
const GROUND_PREVIEW := preload("res://scripts/shared/stage_ui/stage_ground_finish_preview.gd")
|
||||
const DEFAULT_THEME := preload("res://scripts/stage/sandbox_theme.gd")
|
||||
|
||||
const DEFAULT_PAINT_TINT: Color = Color(0.25, 0.55, 0.25, 1.0)
|
||||
|
||||
var _stage_theme: SandboxTheme
|
||||
var _ui_font: Font
|
||||
var _finish_ids: Array[StringName] = []
|
||||
var _finish_labels: Array[String] = []
|
||||
var _selected_finish: StringName = &""
|
||||
var _button_group: ButtonGroup
|
||||
var _choice_buttons: Dictionary = {}
|
||||
var _choice_previews: Dictionary = {}
|
||||
var _choice_labels: Dictionary = {}
|
||||
var _content_root: VBoxContainer
|
||||
var _layout_row: HBoxContainer
|
||||
var _choice_grid: GridContainer
|
||||
var _preview_panel: PanelContainer
|
||||
var _selected_preview: StageGroundFinishPreview
|
||||
var _choice_height: int = 96
|
||||
var _choice_preview_height: int = 48
|
||||
var _choice_font_size: int = 18
|
||||
var _preview_height: int = 128
|
||||
var _compact_preview_height: int = 88
|
||||
var _preview_min_width: int = 224
|
||||
var _choice_min_width: int = 104
|
||||
var _grid_gap: int = 8
|
||||
var _compact_choice_height: int = 54
|
||||
var _compact_choice_preview_height: int = 22
|
||||
var _compact_choice_font_size: int = 16
|
||||
var _compact_grid_gap: int = 6
|
||||
var _compact_layout_max_width: int = 600
|
||||
var _compact_layout_max_height: int = 460
|
||||
var _wide_layout_min_width: int = 760
|
||||
var _screen_margin: int = 24
|
||||
var _is_wide_layout: bool = true
|
||||
var _is_compact_layout: bool = false
|
||||
|
||||
|
||||
func configure(
|
||||
stage_theme: SandboxTheme,
|
||||
ui_font: Font,
|
||||
finish_ids: Array[StringName],
|
||||
finish_labels: Array[String]
|
||||
) -> void:
|
||||
_stage_theme = stage_theme if stage_theme != null else DEFAULT_THEME.new() as SandboxTheme
|
||||
_ui_font = ui_font
|
||||
_finish_ids.assign(finish_ids)
|
||||
_finish_labels.assign(finish_labels)
|
||||
if not _finish_ids.is_empty():
|
||||
_selected_finish = _finish_ids[0]
|
||||
_configure_theme_values()
|
||||
|
||||
|
||||
func open_for_finish(finish_id: StringName) -> void:
|
||||
_select_finish(finish_id)
|
||||
_apply_responsive_layout()
|
||||
DIALOG_STYLER.popup(self)
|
||||
call_deferred("_refit_dialog")
|
||||
call_deferred("_focus_selected_choice")
|
||||
|
||||
|
||||
func selected_finish() -> StringName:
|
||||
return _selected_finish
|
||||
|
||||
|
||||
func choice_buttons() -> Dictionary:
|
||||
return _choice_buttons.duplicate()
|
||||
|
||||
|
||||
func selected_preview_finish() -> StringName:
|
||||
return &"" if _selected_preview == null else _selected_preview.finish_id
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if _stage_theme == null:
|
||||
_stage_theme = DEFAULT_THEME.new() as SandboxTheme
|
||||
_configure_theme_values()
|
||||
title = "Choose Ground"
|
||||
dialog_text = "Stickmen walk here"
|
||||
cancel_button_text = "Close"
|
||||
ok_button_text = "Draw Grass Ground"
|
||||
theme = _stage_theme.control_theme()
|
||||
set_meta(DIALOG_STYLER.CARD_WIDTH_META, _theme_int("card_width", 880, 420, 1200))
|
||||
set_meta(DIALOG_STYLER.MAX_HEIGHT_RATIO_META, _theme_number("max_height_ratio", 0.86, 0.55, 0.96))
|
||||
set_meta(DIALOG_STYLER.SCREEN_MARGIN_META, _screen_margin)
|
||||
_build_content()
|
||||
DIALOG_STYLER.apply(self, _stage_theme, _ui_font, get_ok_button())
|
||||
var icon_badge: Control = get_meta(DIALOG_STYLER.ICON_BADGE_META, null) as Control
|
||||
if icon_badge != null:
|
||||
icon_badge.hide()
|
||||
_apply_responsive_layout()
|
||||
var viewport: Viewport = _stage_viewport()
|
||||
if viewport != null:
|
||||
viewport.size_changed.connect(_on_viewport_size_changed)
|
||||
|
||||
|
||||
func _configure_theme_values() -> void:
|
||||
_choice_font_size = _theme_int("choice_font_size", 18, 14, 26)
|
||||
_choice_height = _theme_int("choice_height", 96, 72, 132)
|
||||
_choice_preview_height = _theme_int("choice_preview_height", 48, 32, 72)
|
||||
_compact_choice_height = _theme_int("compact_choice_height", 50, 48, 72)
|
||||
_compact_choice_preview_height = _theme_int("compact_choice_preview_height", 22, 16, 36)
|
||||
_compact_choice_font_size = _theme_int("compact_choice_font_size", 16, 14, 20)
|
||||
_compact_grid_gap = _theme_int("compact_grid_gap", 6, 4, 12)
|
||||
_compact_layout_max_width = _theme_int("compact_layout_max_width", 600, 440, 760)
|
||||
_compact_layout_max_height = _theme_int("compact_layout_max_height", 460, 360, 640)
|
||||
_preview_height = _theme_int("preview_height", 128, 88, 220)
|
||||
_compact_preview_height = _theme_int("compact_preview_height", 88, 64, 140)
|
||||
_preview_min_width = _theme_int("preview_min_width", 224, 160, 360)
|
||||
_choice_min_width = _theme_int("choice_min_width", 104, 88, 160)
|
||||
_grid_gap = _theme_int("grid_gap", 8, 4, 24)
|
||||
_wide_layout_min_width = _theme_int("wide_layout_min_width", 760, 600, 1100)
|
||||
_screen_margin = _theme_int("screen_margin", 24, 12, 64)
|
||||
|
||||
|
||||
func _build_content() -> void:
|
||||
_button_group = ButtonGroup.new()
|
||||
_button_group.allow_unpress = false
|
||||
_content_root = VBoxContainer.new()
|
||||
_content_root.name = "GroundFinishGallery"
|
||||
_content_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_content_root.add_theme_constant_override("separation", _grid_gap)
|
||||
_layout_row = HBoxContainer.new()
|
||||
_layout_row.name = "GalleryAndPreview"
|
||||
_layout_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_layout_row.add_theme_constant_override("separation", 16)
|
||||
_choice_grid = GridContainer.new()
|
||||
_choice_grid.name = "GroundFinishChoices"
|
||||
_choice_grid.columns = 3
|
||||
_choice_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_choice_grid.add_theme_constant_override("h_separation", _grid_gap)
|
||||
_choice_grid.add_theme_constant_override("v_separation", _grid_gap)
|
||||
_build_choice_buttons()
|
||||
_preview_panel = _build_selected_preview_panel()
|
||||
_layout_row.add_child(_choice_grid)
|
||||
_layout_row.add_child(_preview_panel)
|
||||
_content_root.add_child(_layout_row)
|
||||
add_child(_content_root)
|
||||
|
||||
|
||||
func _build_choice_buttons() -> void:
|
||||
for index: int in mini(_finish_ids.size(), _finish_labels.size()):
|
||||
var finish_id: StringName = _finish_ids[index]
|
||||
var finish_label: String = _finish_labels[index]
|
||||
var button := Button.new()
|
||||
button.name = "GroundFinish_%s" % String(finish_id).trim_prefix("finish_ground_")
|
||||
button.toggle_mode = true
|
||||
button.button_group = _button_group
|
||||
button.text = ""
|
||||
button.custom_minimum_size = Vector2(float(_choice_min_width), float(_choice_height))
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.focus_mode = Control.FOCUS_ALL
|
||||
_apply_choice_style(button)
|
||||
var contents := VBoxContainer.new()
|
||||
contents.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
contents.add_theme_constant_override("separation", 4)
|
||||
contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var sample := GROUND_PREVIEW.new() as StageGroundFinishPreview
|
||||
sample.name = "FinishSample"
|
||||
sample.custom_minimum_size.y = float(_choice_preview_height)
|
||||
sample.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
sample.configure(finish_id, DEFAULT_PAINT_TINT)
|
||||
contents.add_child(sample)
|
||||
var label := Label.new()
|
||||
label.name = "FinishLabel"
|
||||
label.text = finish_label
|
||||
label.custom_minimum_size.y = 20.0
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_size_override("font_size", _choice_font_size)
|
||||
label.add_theme_color_override("font_color", _theme_color("stage_panels.type.text_color", Color("#F5F7FA")))
|
||||
if _ui_font != null:
|
||||
label.add_theme_font_override("font", _ui_font)
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
contents.add_child(label)
|
||||
button.add_child(contents)
|
||||
button.pressed.connect(_on_choice_pressed.bind(finish_id))
|
||||
_choice_grid.add_child(button)
|
||||
_choice_buttons[finish_id] = button
|
||||
_choice_previews[finish_id] = sample
|
||||
_choice_labels[finish_id] = label
|
||||
|
||||
|
||||
func _build_selected_preview_panel() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.name = "SelectedGroundPreview"
|
||||
panel.custom_minimum_size = Vector2(float(_preview_min_width), 0.0)
|
||||
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
panel.add_theme_stylebox_override("panel", _preview_panel_style())
|
||||
var stack := VBoxContainer.new()
|
||||
stack.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
stack.add_theme_constant_override("separation", 10)
|
||||
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var heading := Label.new()
|
||||
heading.name = "GroundPreviewHeading"
|
||||
heading.text = "New Ground will look like this"
|
||||
heading.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
heading.add_theme_font_size_override("font_size", 18)
|
||||
heading.add_theme_color_override("font_color", _theme_color("global.text_color", Color("#F5F7FA")))
|
||||
if _ui_font != null:
|
||||
heading.add_theme_font_override("font", _ui_font)
|
||||
stack.add_child(heading)
|
||||
_selected_preview = GROUND_PREVIEW.new() as StageGroundFinishPreview
|
||||
_selected_preview.name = "SelectedGroundFinishSample"
|
||||
_selected_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_selected_preview.custom_minimum_size.y = float(_preview_height)
|
||||
_selected_preview.configure(_selected_finish if not _selected_finish.is_empty() else &"finish_ground_grass", DEFAULT_PAINT_TINT)
|
||||
stack.add_child(_selected_preview)
|
||||
panel.add_child(stack)
|
||||
return panel
|
||||
|
||||
|
||||
func _select_finish(finish_id: StringName) -> void:
|
||||
if not _finish_ids.has(finish_id):
|
||||
if _finish_ids.is_empty():
|
||||
return
|
||||
finish_id = _finish_ids[0]
|
||||
_selected_finish = finish_id
|
||||
for key: Variant in _choice_buttons:
|
||||
var button: Button = _choice_buttons[key] as Button
|
||||
button.set_pressed_no_signal(StringName(key) == finish_id)
|
||||
if _selected_preview != null:
|
||||
_selected_preview.configure(finish_id, DEFAULT_PAINT_TINT)
|
||||
_update_primary_button_text()
|
||||
|
||||
|
||||
func _update_primary_button_text() -> void:
|
||||
var label: String = "Ground"
|
||||
var index: int = _finish_ids.find(_selected_finish)
|
||||
if index >= 0 and index < _finish_labels.size():
|
||||
label = _finish_labels[index]
|
||||
var button: Button = get_ok_button()
|
||||
if button == null:
|
||||
return
|
||||
button.text = "Draw %s Ground" % label
|
||||
var visual_button: Button = DIALOG_STYLER.visual_button(button)
|
||||
if visual_button != null and visual_button != button:
|
||||
visual_button.text = button.text
|
||||
|
||||
|
||||
func _on_choice_pressed(finish_id: StringName) -> void:
|
||||
_select_finish(finish_id)
|
||||
|
||||
|
||||
func _on_viewport_size_changed() -> void:
|
||||
call_deferred("_apply_responsive_layout")
|
||||
call_deferred("_refit_dialog")
|
||||
|
||||
|
||||
func _apply_responsive_layout() -> void:
|
||||
if _content_root == null or _choice_grid == null or _preview_panel == null or _layout_row == null:
|
||||
return
|
||||
var viewport: Viewport = _stage_viewport()
|
||||
if viewport == null:
|
||||
return
|
||||
var viewport_size: Vector2 = viewport.get_visible_rect().size
|
||||
var viewport_width: float = viewport_size.x
|
||||
var card_width: float = minf(
|
||||
float(_theme_int("card_width", 880, 420, 1200)),
|
||||
maxf(0.0, viewport_width - float(_screen_margin * 2))
|
||||
)
|
||||
var body_width: float = maxf(0.0, card_width - 64.0)
|
||||
var wide: bool = viewport_width >= float(_wide_layout_min_width) and body_width >= float(_preview_min_width + _choice_min_width * 3 + _grid_gap * 4)
|
||||
var compact: bool = viewport_width < float(_compact_layout_max_width) or viewport_size.y < float(_compact_layout_max_height)
|
||||
var columns: int = 3 if body_width >= float(_choice_min_width * 3 + _grid_gap * 2) else 2
|
||||
_choice_grid.columns = columns
|
||||
_is_wide_layout = wide
|
||||
_is_compact_layout = compact
|
||||
var card: Control = get_meta(DIALOG_STYLER.CARD_META) as Control
|
||||
if card != null:
|
||||
set_meta(DIALOG_STYLER.MAX_HEIGHT_RATIO_META, _theme_number("compact_height_ratio", 0.89, 0.70, 0.89) if compact else _theme_number("max_height_ratio", 0.86, 0.55, 0.86))
|
||||
_apply_choice_density(compact)
|
||||
if wide:
|
||||
if _choice_grid.get_parent() != _layout_row:
|
||||
if _choice_grid.get_parent() == _content_root:
|
||||
_content_root.remove_child(_choice_grid)
|
||||
if _preview_panel.get_parent() == _content_root:
|
||||
_content_root.remove_child(_preview_panel)
|
||||
_layout_row.add_child(_choice_grid)
|
||||
_layout_row.add_child(_preview_panel)
|
||||
_content_root.add_child(_layout_row)
|
||||
_preview_panel.visible = true
|
||||
_preview_panel.custom_minimum_size.x = float(_preview_min_width)
|
||||
_selected_preview.custom_minimum_size.y = float(_preview_height)
|
||||
else:
|
||||
if _choice_grid.get_parent() != _content_root:
|
||||
if _choice_grid.get_parent() == _layout_row:
|
||||
_layout_row.remove_child(_choice_grid)
|
||||
if _preview_panel.get_parent() == _layout_row:
|
||||
_layout_row.remove_child(_preview_panel)
|
||||
if _layout_row.get_parent() == _content_root:
|
||||
_content_root.remove_child(_layout_row)
|
||||
_content_root.add_child(_preview_panel)
|
||||
_content_root.add_child(_choice_grid)
|
||||
_preview_panel.visible = not compact
|
||||
_preview_panel.custom_minimum_size.x = 0.0
|
||||
_selected_preview.custom_minimum_size.y = float(_compact_preview_height if compact else _preview_height)
|
||||
_preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_choice_grid.add_theme_constant_override("v_separation", _compact_grid_gap if compact else _grid_gap)
|
||||
_content_root.queue_sort()
|
||||
|
||||
|
||||
func _refit_dialog() -> void:
|
||||
if DIALOG_STYLER.is_open(self):
|
||||
DIALOG_STYLER.refit(self)
|
||||
|
||||
|
||||
func _stage_viewport() -> Viewport:
|
||||
var overlay: Control = get_meta(DIALOG_STYLER.OVERLAY_META, null) as Control
|
||||
if overlay != null and is_instance_valid(overlay):
|
||||
return overlay.get_viewport()
|
||||
return get_viewport()
|
||||
|
||||
|
||||
func _apply_choice_density(compact: bool) -> void:
|
||||
var target_height: float = float(_compact_choice_height if compact else _choice_height)
|
||||
var target_preview_height: float = float(_compact_choice_preview_height if compact else _choice_preview_height)
|
||||
var target_font_size: int = _compact_choice_font_size if compact else _choice_font_size
|
||||
for finish_id: StringName in _choice_buttons:
|
||||
var button: Button = _choice_buttons[finish_id] as Button
|
||||
button.custom_minimum_size.y = target_height
|
||||
_apply_choice_style(button)
|
||||
var sample: StageGroundFinishPreview = _choice_previews[finish_id] as StageGroundFinishPreview
|
||||
if sample != null:
|
||||
sample.custom_minimum_size.y = target_preview_height
|
||||
var label: Label = _choice_labels[finish_id] as Label
|
||||
if label != null:
|
||||
label.add_theme_font_size_override("font_size", target_font_size)
|
||||
label.custom_minimum_size.y = 18.0 if compact else 20.0
|
||||
|
||||
|
||||
func _focus_selected_choice() -> void:
|
||||
if not is_inside_tree() or not DIALOG_STYLER.is_open(self):
|
||||
return
|
||||
var button: Button = _choice_buttons.get(_selected_finish) as Button
|
||||
if button != null and button.visible:
|
||||
button.grab_focus()
|
||||
|
||||
|
||||
func _apply_choice_style(button: Button) -> void:
|
||||
var neutral: Color = _theme_color("stage_panels.choice.neutral_surface", Color("#171B24"))
|
||||
var hover: Color = _theme_color("stage_panels.choice.hover_surface", Color("#303A4B"))
|
||||
var selected: Color = _theme_color("stage_panels.choice.selected_surface", Color("#12394A"))
|
||||
var border: Color = _theme_color("stage_panels.choice.border_color", Color("#FFFFFF33"))
|
||||
var accent: Color = _theme_color("mode_colors.edit", Color("#22C6FF"))
|
||||
button.add_theme_stylebox_override("normal", _choice_box(neutral, border, 2))
|
||||
button.add_theme_stylebox_override("hover", _choice_box(hover, accent, 2))
|
||||
button.add_theme_stylebox_override("pressed", _choice_box(selected, accent, 3))
|
||||
button.add_theme_stylebox_override("hover_pressed", _choice_box(selected.lightened(0.06), accent, 3))
|
||||
button.add_theme_stylebox_override("focus", _choice_box(Color.TRANSPARENT, Color("#F5F7FA"), 3))
|
||||
|
||||
|
||||
func _choice_box(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = background
|
||||
style.border_color = border
|
||||
style.set_border_width_all(border_width)
|
||||
style.set_corner_radius_all(_theme_int("choice_corner_radius", 12, 8, 24))
|
||||
style.content_margin_left = 8.0
|
||||
style.content_margin_right = 8.0
|
||||
var vertical_padding: float = 2.0 if _is_compact_layout else 6.0
|
||||
style.content_margin_top = vertical_padding
|
||||
style.content_margin_bottom = vertical_padding
|
||||
return style
|
||||
|
||||
|
||||
func _preview_panel_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = _theme_color("preview_surface", Color("#151C28"))
|
||||
style.border_color = _theme_color("preview_border", Color("#39465B"))
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(_theme_int("preview_corner_radius", 12, 8, 24))
|
||||
style.content_margin_left = 12.0
|
||||
style.content_margin_right = 12.0
|
||||
style.content_margin_top = 12.0
|
||||
style.content_margin_bottom = 12.0
|
||||
return style
|
||||
|
||||
|
||||
func _theme_int(key: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||
return fallback if _stage_theme == null else _stage_theme.integer("popups.ground_finish_chooser." + key, fallback, minimum, maximum)
|
||||
|
||||
|
||||
func _theme_number(key: String, fallback: float, minimum: float, maximum: float) -> float:
|
||||
return fallback if _stage_theme == null else _stage_theme.number("popups.ground_finish_chooser." + key, fallback, minimum, maximum)
|
||||
|
||||
|
||||
func _theme_color(key: String, fallback: Color) -> Color:
|
||||
return fallback if _stage_theme == null else _stage_theme.color("popups.ground_finish_chooser." + key, fallback)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfo24vsra2cye
|
||||
@@ -0,0 +1,30 @@
|
||||
class_name StageGroundFinishPreview
|
||||
extends Control
|
||||
## Render-only Ground finish sample using the same motifs as Stage terrain.
|
||||
|
||||
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||
|
||||
const DEFAULT_PAINT_TINT: Color = Color(0.25, 0.55, 0.25, 1.0)
|
||||
|
||||
var finish_id: StringName = &"finish_ground_grass"
|
||||
var paint_tint: Color = DEFAULT_PAINT_TINT
|
||||
|
||||
|
||||
func configure(p_finish_id: StringName, p_paint_tint: Color = DEFAULT_PAINT_TINT) -> void:
|
||||
finish_id = p_finish_id
|
||||
paint_tint = p_paint_tint
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var preview_top: float = 2.0
|
||||
var motif_scale := Vector2.ONE
|
||||
if finish_id == &"finish_ground_grass":
|
||||
preview_top = minf(12.0, maxf(2.0, size.y * 0.48))
|
||||
motif_scale.y = maxf(1.0, 12.0 / maxf(1.0, preview_top - 1.0))
|
||||
var rect := Rect2(Vector2(2.0, preview_top), Vector2(size.x - 4.0, size.y - preview_top - 2.0))
|
||||
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
|
||||
return
|
||||
SURFACE_RENDERER.draw_ground(self, rect, finish_id, paint_tint, Vector2.ZERO, motif_scale)
|
||||
draw_rect(rect, Color("#111821"), false, 2.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgh4be2rdultd
|
||||
@@ -150,8 +150,13 @@ func open(
|
||||
entries: Array[Dictionary],
|
||||
current_sky_id: String = "",
|
||||
current_background_kind: String = "sky",
|
||||
plain_color: Color = Color("#F5EFE0")
|
||||
plain_color: Color = Color("#F5EFE0"),
|
||||
browse_state: Dictionary = {}
|
||||
) -> void:
|
||||
var restore_scenery_browse_state: bool = p_kind == "scenery" and not browse_state.is_empty()
|
||||
var restored_gallery_tab: String = String(browse_state.get("gallery_tab", "backgrounds")) if restore_scenery_browse_state else "backgrounds"
|
||||
var restored_filter_id: String = String(browse_state.get("tag_id", "all")) if restore_scenery_browse_state else "all"
|
||||
var restored_scroll: int = int(browse_state.get("scroll_vertical", 0)) if restore_scenery_browse_state else 0
|
||||
_cancel_plain_picker_preview(false)
|
||||
kind = p_kind
|
||||
_setup_kind = &""
|
||||
@@ -164,28 +169,62 @@ func open(
|
||||
_current_sky_id = current_sky_id if kind == "scenery" else ""
|
||||
_current_background_kind = &"plain" if current_background_kind == "plain" else &"sky"
|
||||
_current_plain_color = _opaque_color(plain_color)
|
||||
_title_label.text = "Choose Your Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
|
||||
_title_label.text = "Choose a Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
|
||||
_backgrounds_back_button.visible = false
|
||||
_apply_chooser_visual_style()
|
||||
_tabs.visible = kind == "scenery"
|
||||
_filter_chips.visible = false
|
||||
_make_artwork_button.visible = false
|
||||
_edit_scenery_button.visible = false
|
||||
_browse_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
|
||||
_refresh_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
|
||||
_empty_label.text = "No scenery found. Choose Make artwork to draw a new piece." if kind == "scenery" else ("No props found. Browse for a .prop file." if kind == "prop" else ("No starter scenes are available." if kind == "starters" else "No stickmen found."))
|
||||
_gallery_tab = "backgrounds"
|
||||
_tag_id = "all"
|
||||
_backgrounds_tab.set_pressed_no_signal(true)
|
||||
_artwork_tab.set_pressed_no_signal(false)
|
||||
_browse_button.visible = false
|
||||
_refresh_button.visible = false
|
||||
_empty_label.text = "No scenery found. Choose Make artwork to draw a new piece." if kind == "scenery" else ("No props in the library yet." if kind == "prop" else ("No starter scenes are available." if kind == "starters" else "No stickmen in the library yet."))
|
||||
_gallery_tab = restored_gallery_tab
|
||||
_tag_id = restored_filter_id
|
||||
_backgrounds_tab.set_pressed_no_signal(_gallery_tab == "backgrounds")
|
||||
_artwork_tab.set_pressed_no_signal(_gallery_tab == "artwork")
|
||||
set_entries(entries)
|
||||
if kind == "scenery":
|
||||
_set_gallery_tab("backgrounds")
|
||||
_set_gallery_tab(_gallery_tab)
|
||||
popup_centered(size)
|
||||
call_deferred("_settle_popup_after_open")
|
||||
if restore_scenery_browse_state:
|
||||
call_deferred("_restore_browse_scroll_after_open", restored_scroll, restored_gallery_tab)
|
||||
|
||||
|
||||
func capture_browse_state() -> Dictionary:
|
||||
if kind != "scenery":
|
||||
return {}
|
||||
return {
|
||||
"gallery_tab": _gallery_tab,
|
||||
"tag_id": _tag_id,
|
||||
"scroll_vertical": _grid_scroll.scroll_vertical if _grid_scroll != null else 0,
|
||||
}
|
||||
|
||||
|
||||
func _restore_browse_scroll_after_open(scroll_vertical: int, gallery_tab: String) -> void:
|
||||
await get_tree().process_frame
|
||||
if not is_inside_tree() or not visible or kind != "scenery" or _gallery_tab != gallery_tab:
|
||||
return
|
||||
_grid_scroll.scroll_vertical = maxi(0, scroll_vertical)
|
||||
|
||||
|
||||
func _settle_popup_after_open() -> void:
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
if is_inside_tree() and visible:
|
||||
# PopupPanel can resize after the first native layout pass. Reapply the
|
||||
# content-sized width once child minimum sizes and DPI scaling have settled.
|
||||
var size_before_settle: Vector2i = size
|
||||
_update_popup_layout(true)
|
||||
if size != size_before_settle:
|
||||
popup_centered(size)
|
||||
|
||||
|
||||
func set_entries(entries: Array[Dictionary]) -> void:
|
||||
_entries = entries
|
||||
if _grid_scroll != null:
|
||||
_grid_scroll.scroll_vertical = 0
|
||||
if kind == "scenery":
|
||||
_rebuild_artwork_filter_chips()
|
||||
_thumbnails.clear()
|
||||
@@ -881,8 +920,8 @@ func _set_gallery_tab(tab: String) -> void:
|
||||
_filter_chips.visible = kind == "scenery" and tab == "artwork"
|
||||
_make_artwork_button.visible = kind == "scenery" and tab == "artwork"
|
||||
_edit_scenery_button.visible = kind == "scenery"
|
||||
_browse_button.visible = kind == "stickman" or kind == "prop"
|
||||
_refresh_button.visible = kind == "stickman" or kind == "prop"
|
||||
_browse_button.visible = false
|
||||
_refresh_button.visible = false
|
||||
_empty_label.text = "No artwork here yet." if tab == "artwork" else "Choose a Sky look or wall finish."
|
||||
_page = 0
|
||||
_update_popup_layout(true)
|
||||
@@ -953,6 +992,7 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
|
||||
var in_subview: bool = in_setup or in_color_picker or in_plain_choices
|
||||
var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_subview
|
||||
var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_subview
|
||||
var asset_scrolling_gallery: bool = _uses_scrolling_asset_gallery() and not in_subview
|
||||
var filtered: Array[Dictionary] = _filtered_entries()
|
||||
_tabs.visible = kind == "scenery" and not in_subview
|
||||
_filter_chips.visible = scenery_artwork_gallery
|
||||
@@ -972,17 +1012,17 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
|
||||
_empty_label.visible = filtered.is_empty() and not in_subview
|
||||
_grid.visible = not filtered.is_empty() and not in_subview
|
||||
_grid_scroll.visible = not filtered.is_empty() or in_subview
|
||||
_footer.alignment = BoxContainer.ALIGNMENT_BEGIN if scenery_artwork_gallery or in_subview else BoxContainer.ALIGNMENT_CENTER
|
||||
_footer.alignment = BoxContainer.ALIGNMENT_BEGIN if scenery_artwork_gallery or asset_scrolling_gallery or in_subview else BoxContainer.ALIGNMENT_CENTER
|
||||
_footer.add_theme_constant_override(
|
||||
"separation",
|
||||
_theme_integer("popups.asset_selector.footer_gap", 16, 8, 32) if in_subview or kind == "scenery" else 8
|
||||
)
|
||||
var available: Vector2 = _usable_screen_size()
|
||||
var width_ratio: float = 0.98 if in_subview else (0.96 if kind == "starters" else 0.94)
|
||||
var width_ratio: float = 0.98 if in_subview or asset_scrolling_gallery else (0.96 if kind == "starters" else 0.94)
|
||||
var max_width: float = maxf(1.0, minf(available.x * width_ratio, available.x - POPUP_SAFE_SCREEN_MARGIN))
|
||||
var height_ratio: float = _theme_number("popups.asset_selector.starter_viewport_height_ratio", 0.96, 0.7, 0.98) if kind == "starters" else 0.86
|
||||
var max_height: float = maxf(1.0, minf(available.y * height_ratio, available.y - POPUP_SAFE_SCREEN_MARGIN * 2.0))
|
||||
var preferred_width: float = CELL_MIN_SIZE.x * (2 if kind == "starters" else COLUMNS) + GRID_H_SEPARATION * ((2 if kind == "starters" else COLUMNS) - 1) + POPUP_SIDE_PADDING
|
||||
var preferred_width: float = _cell_min_size.x * (2 if kind == "starters" else COLUMNS) + GRID_H_SEPARATION * ((2 if kind == "starters" else COLUMNS) - 1) + POPUP_SIDE_PADDING
|
||||
var preferred_height: float = max_height
|
||||
if kind == "starters":
|
||||
preferred_width = _theme_number("popups.asset_selector.starter_dialog_width", 1160.0, 440.0, 2400.0)
|
||||
@@ -993,14 +1033,14 @@ func _update_popup_layout(keep_visible_position: bool = false) -> void:
|
||||
var popup_width: float = minf(preferred_width, max_width)
|
||||
_position_footer(popup_width)
|
||||
var grid_width: float = maxf(1.0, popup_width - POPUP_SIDE_PADDING)
|
||||
var minimum_card_width: float = CELL_MIN_SIZE.x
|
||||
var minimum_card_width: float = _cell_min_size.x
|
||||
if kind == "starters":
|
||||
minimum_card_width = _theme_number("popups.asset_selector.starter_card_min_width", 340.0, 220.0, 800.0)
|
||||
_active_columns = clampi(floori((grid_width + GRID_H_SEPARATION) / (minimum_card_width + GRID_H_SEPARATION)), 1, 2 if kind == "starters" else COLUMNS)
|
||||
_page_capacity = maxi(1, filtered.size()) if scenery_background_gallery or scenery_artwork_gallery or kind == "starters" else _active_columns * ROWS
|
||||
_page_capacity = maxi(1, filtered.size()) if scenery_background_gallery or scenery_artwork_gallery or asset_scrolling_gallery or kind == "starters" else _active_columns * ROWS
|
||||
_grid.columns = _active_columns
|
||||
var available_card_width: float = (grid_width - GRID_H_SEPARATION * (_active_columns - 1)) / float(_active_columns)
|
||||
var card_width: float = available_card_width if kind == "starters" else minf(CELL_MIN_SIZE.x, available_card_width)
|
||||
var card_width: float = available_card_width if kind == "starters" else minf(_cell_min_size.x, available_card_width)
|
||||
var card_height_limit: float = 248.0 if kind != "starters" else INF
|
||||
var card_height: float
|
||||
if kind == "starters":
|
||||
@@ -1128,6 +1168,7 @@ func _rebuild() -> void:
|
||||
var in_subview: bool = in_setup or in_color_picker or in_plain_choices
|
||||
var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_subview
|
||||
var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_subview
|
||||
var asset_scrolling_gallery: bool = _uses_scrolling_asset_gallery() and not in_subview
|
||||
_position_footer(float(size.x))
|
||||
_sync_stage_preview_dimming()
|
||||
_close_button.visible = not in_setup and not in_color_picker
|
||||
@@ -1144,8 +1185,8 @@ func _rebuild() -> void:
|
||||
_color_use_button.visible = in_color_picker
|
||||
_edit_scenery_button.visible = kind == "scenery" and not in_subview
|
||||
_make_artwork_button.visible = scenery_artwork_gallery
|
||||
_browse_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker
|
||||
_refresh_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker
|
||||
_browse_button.visible = false
|
||||
_refresh_button.visible = false
|
||||
_footer_spacer.visible = scenery_artwork_gallery
|
||||
_empty_label.visible = filtered.is_empty() and not in_subview
|
||||
_grid.visible = not filtered.is_empty() and not in_subview
|
||||
@@ -1255,6 +1296,10 @@ func _is_scenery_artwork_gallery() -> bool:
|
||||
return kind == "scenery" and _gallery_tab == "artwork"
|
||||
|
||||
|
||||
func _uses_scrolling_asset_gallery() -> bool:
|
||||
return kind == "prop" or kind == "stickman"
|
||||
|
||||
|
||||
func _count_scene_background_entries(entries: Array[Dictionary]) -> int:
|
||||
var count: int = 0
|
||||
for entry: Dictionary in entries:
|
||||
@@ -1324,7 +1369,7 @@ func _build_cell(entry: Dictionary, requested_size: Vector2 = Vector2.ZERO) -> C
|
||||
card_payload["status"] = "Current background"
|
||||
card_payload["status_color"] = _theme_color("mode_colors.edit", Color("#22C6FF"))
|
||||
cell.configure(card_payload, _thumbnails.get(key, null), null)
|
||||
if kind == "scenery" or kind == "starters":
|
||||
if kind == "scenery" or kind == "starters" or _uses_scrolling_asset_gallery():
|
||||
var card_button: Button = cell as Button
|
||||
var card_border_width: int = _theme_integer("popups.asset_selector.button_border_width", 2, 1, 5)
|
||||
var card_corner_radius: int = _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32)
|
||||
@@ -1351,7 +1396,6 @@ func _build_cell(entry: Dictionary, requested_size: Vector2 = Vector2.ZERO) -> C
|
||||
card_hover_border_width,
|
||||
card_corner_radius
|
||||
))
|
||||
if kind == "starters":
|
||||
card_button.add_theme_stylebox_override("hover_pressed", _make_rounded_style(
|
||||
card_selected_surface.lightened(0.05),
|
||||
_theme_color("mode_colors.edit", Color("#22C6FF")),
|
||||
@@ -1447,7 +1491,7 @@ func _apply_chooser_visual_style() -> void:
|
||||
frame_style.set_corner_radius_all(_theme_integer("popups.asset_selector.surface_corner_radius", 14, 4, 32))
|
||||
preview_frame.add_theme_stylebox_override("panel", frame_style)
|
||||
if _title_label != null:
|
||||
var title_size: int = _theme_integer("popups.asset_selector.setup_title_size", 30, 22, 48) if kind == "scenery" else (_theme_integer("popups.asset_selector.starter_title_size", 30, 22, 48) if kind == "starters" else 24)
|
||||
var title_size: int = _theme_integer("popups.asset_selector.starter_title_size", 30, 22, 48) if kind == "starters" else _theme_integer("popups.asset_selector.setup_title_size", 30, 22, 48)
|
||||
_title_label.add_theme_font_size_override("font_size", title_size)
|
||||
_title_label.add_theme_color_override("font_color", text_color)
|
||||
_apply_emphasis_font(_title_label)
|
||||
|
||||
@@ -125,6 +125,36 @@ func _apply_phase19_defaults() -> void:
|
||||
if not asset_selector.has(key):
|
||||
asset_selector[key] = starter_defaults[key]
|
||||
popups["asset_selector"] = asset_selector
|
||||
var ground_finish_defaults: Dictionary = {
|
||||
"card_width": 880,
|
||||
"max_height_ratio": 0.86,
|
||||
"screen_margin": 24,
|
||||
"choice_height": 96,
|
||||
"choice_preview_height": 48,
|
||||
"choice_font_size": 18,
|
||||
"choice_min_width": 104,
|
||||
"choice_corner_radius": 12,
|
||||
"compact_choice_height": 50,
|
||||
"compact_choice_preview_height": 22,
|
||||
"compact_choice_font_size": 16,
|
||||
"compact_grid_gap": 6,
|
||||
"compact_layout_max_width": 600,
|
||||
"compact_layout_max_height": 460,
|
||||
"compact_height_ratio": 0.89,
|
||||
"preview_height": 128,
|
||||
"compact_preview_height": 88,
|
||||
"preview_min_width": 224,
|
||||
"preview_corner_radius": 12,
|
||||
"preview_surface": "#151C28",
|
||||
"preview_border": "#39465B",
|
||||
"grid_gap": 8,
|
||||
"wide_layout_min_width": 760,
|
||||
}
|
||||
var ground_finish: Dictionary = popups.get("ground_finish_chooser", {}).duplicate(true)
|
||||
for key: String in ground_finish_defaults:
|
||||
if not ground_finish.has(key):
|
||||
ground_finish[key] = ground_finish_defaults[key]
|
||||
popups["ground_finish_chooser"] = ground_finish
|
||||
_config["popups"] = popups
|
||||
var overlays: Dictionary = _config.get("overlays", {}).duplicate(true)
|
||||
var gizmos: Dictionary = overlays.get("gizmos", {}).duplicate(true)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
extends SceneTree
|
||||
|
||||
const STICKMAN_SCENE := preload("res://scenes/main.tscn")
|
||||
const PROP_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("\n=== EDITOR VISUAL HARMONIZATION TEST ===")
|
||||
root.size = Vector2i(1280, 800)
|
||||
await _test_stickman_workspace_and_transactions()
|
||||
await _test_prop_reshape_and_chrome()
|
||||
print("----------------------------------------")
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d assertions)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func check(label: String, condition: bool) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: %s" % label)
|
||||
|
||||
|
||||
func _test_stickman_workspace_and_transactions() -> void:
|
||||
var main_scene: Control = STICKMAN_SCENE.instantiate() as Control
|
||||
root.add_child(main_scene)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var editor: Control = main_scene.find_child("StickmanEditor", true, false) as Control
|
||||
var panels: Dictionary = editor.get("_body_part_panels") as Dictionary
|
||||
var preview: Control = editor.find_child("WholeStickmanPreview", true, false) as Control
|
||||
var menu_bar: Control = editor.get_node("ChromeRoot/MenuBar") as Control
|
||||
var top_bar: Control = editor.get_node("ChromeRoot/TopBar") as Control
|
||||
var workspace_toolbar: Control = editor.get_node("ChromeRoot/WorkspaceToolbar") as Control
|
||||
var workspace_row: Control = editor.get_node("ChromeRoot/WorkspaceRow") as Control
|
||||
var status_bar: Control = editor.get_node("ChromeRoot/StatusBar") as Control
|
||||
var stickman_save_status: Label = editor.get_node("ChromeRoot/StatusBar/SaveStatus") as Label
|
||||
check("Stickman keeps ten live BodyPartPanel instances", panels.size() == 10)
|
||||
check("one permanent Whole Stickman preview exists", preview != null)
|
||||
check("wide layout adapts to Overview", int(editor.call("get_workspace_view")) == 0)
|
||||
check("Stickman chrome rows and workspace do not overlap", top_bar.position.y >= menu_bar.position.y + menu_bar.size.y - 1.0 and workspace_toolbar.position.y >= top_bar.position.y + top_bar.size.y - 1.0 and workspace_row.position.y >= workspace_toolbar.position.y + workspace_toolbar.size.y - 1.0 and status_bar.position.y >= workspace_row.position.y + workspace_row.size.y - 1.0)
|
||||
check("Overview body panels begin below the chrome", (panels.get("head") as Control).get_global_rect().position.y >= workspace_row.get_global_rect().position.y - 1.0)
|
||||
check("clean new Stickman shows explicit unsaved status", stickman_save_status.text == "Not saved yet")
|
||||
|
||||
var head: Control = panels.get("head") as Control
|
||||
var head_id: int = head.get_instance_id()
|
||||
check("head part supports authored shapes", bool(head.call("add_shape", &"rectangle", head.call("drawing_center_world"))))
|
||||
check("shape creation marks the document dirty", bool(editor.call("has_unsaved_changes")))
|
||||
check("Stickman dirty transition shows explicit status", stickman_save_status.text == "Unsaved changes")
|
||||
check("new shape is selected", int(head.call("selected_shape_index")) == 0)
|
||||
|
||||
var zoom: InputEventMouseButton = InputEventMouseButton.new()
|
||||
zoom.button_index = MOUSE_BUTTON_WHEEL_UP
|
||||
zoom.pressed = true
|
||||
zoom.position = Vector2(100.0, 80.0)
|
||||
head.call("_on_drawing_area_gui_input", zoom)
|
||||
var pan_down: InputEventMouseButton = InputEventMouseButton.new()
|
||||
pan_down.button_index = MOUSE_BUTTON_MIDDLE
|
||||
pan_down.pressed = true
|
||||
pan_down.position = Vector2(100.0, 80.0)
|
||||
head.call("_on_drawing_area_gui_input", pan_down)
|
||||
var pan_motion: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||
pan_motion.position = Vector2(112.0, 91.0)
|
||||
head.call("_on_drawing_area_gui_input", pan_motion)
|
||||
var pan_up: InputEventMouseButton = InputEventMouseButton.new()
|
||||
pan_up.button_index = MOUSE_BUTTON_MIDDLE
|
||||
pan_up.pressed = false
|
||||
pan_up.position = Vector2(112.0, 91.0)
|
||||
head.call("_on_drawing_area_gui_input", pan_up)
|
||||
var saved_view: Dictionary = head.call("get_view_context") as Dictionary
|
||||
var saved_pan: Vector2 = saved_view.get("pan_offset", Vector2.ZERO) as Vector2
|
||||
check("panel zoom and pan are active before moving it", float(saved_view.get("zoom", 1.0)) > 1.0 and saved_pan.length() > 0.0)
|
||||
|
||||
var stickman_path: String = "user://editor_visual_harmonization_test.stk"
|
||||
check("Stickman saves through its document API", bool(editor.call("save_to_path", stickman_path)))
|
||||
check("successful save clears dirty state", not bool(editor.call("has_unsaved_changes")))
|
||||
check("saved Stickman shows explicit saved status", stickman_save_status.text == "Saved")
|
||||
var stickman_file: FileAccess = FileAccess.open(stickman_path, FileAccess.READ)
|
||||
var stickman_json: Variant = JSON.parse_string(stickman_file.get_as_text())
|
||||
stickman_file.close()
|
||||
var saved_parts: Dictionary = (stickman_json as Dictionary).get("body_parts", {}) as Dictionary
|
||||
var saved_head: Dictionary = saved_parts.get("head", {}) as Dictionary
|
||||
check("save preserves the existing 1.5 format", str((stickman_json as Dictionary).get("version", "")) == "1.5")
|
||||
check("save includes authored head shapes", (saved_head.get("shapes", []) as Array).size() == 1)
|
||||
check("save still writes guide offset metadata", saved_head.has("guide_offset"))
|
||||
|
||||
var before_switch_view: Dictionary = head.call("get_view_context") as Dictionary
|
||||
editor.call("request_workspace_view", 1, true)
|
||||
check("Focus view keeps the same panel instance", head.get_instance_id() == head_id and int(editor.call("get_workspace_view")) == 1)
|
||||
check("Focus view preserves panel zoom and pan", _view_contexts_equal(before_switch_view, head.call("get_view_context") as Dictionary))
|
||||
check("view choice does not dirty authored content", not bool(editor.call("has_unsaved_changes")))
|
||||
root.size = Vector2i(520, 800)
|
||||
await process_frame
|
||||
editor.call("_refresh_responsive_workspace")
|
||||
check("manual Focus choice survives a narrow resize", int(editor.call("get_workspace_view")) == 1)
|
||||
var whole_stickman_button: Button = editor.get_node("ChromeRoot/WorkspaceToolbar/WholeStickmanButton") as Button
|
||||
var back_to_part_button: Button = editor.get_node("ChromeRoot/WorkspaceToolbar/BackToPartButton") as Button
|
||||
var body_part_workspace: Control = editor.get_node("ChromeRoot/WorkspaceRow/BodyPartWorkspace") as Control
|
||||
check("narrow Focus keeps part controls and Whole Stickman access visible", body_part_workspace.visible and whole_stickman_button.visible and not preview.visible)
|
||||
whole_stickman_button.emit_signal("pressed")
|
||||
await process_frame
|
||||
check("narrow Whole Stickman control opens the permanent preview", not body_part_workspace.visible and preview.visible and back_to_part_button.visible and int(editor.call("get_workspace_view")) == 1)
|
||||
back_to_part_button.emit_signal("pressed")
|
||||
await process_frame
|
||||
check("Back to Part restores the same live panel at narrow width", body_part_workspace.visible and not preview.visible and whole_stickman_button.visible and head.get_instance_id() == head_id)
|
||||
root.size = Vector2i(1280, 800)
|
||||
await process_frame
|
||||
editor.call("_refresh_responsive_workspace")
|
||||
|
||||
editor.call("request_active_part", "torso")
|
||||
check("part picker switches the active focus panel", str(editor.call("get_active_part_name")) == "torso")
|
||||
editor.call("request_active_part", "head")
|
||||
check("switching back restores the original head instance", panels.get("head") == head and head.get_instance_id() == head_id)
|
||||
check("switching parts preserves head geometry and selection", int(head.call("selected_shape_index")) == 0 and (head.get("shapes") as Array).size() == 1)
|
||||
editor.call("request_workspace_view", 0, true)
|
||||
check("Overview restores the same authored panel", int(editor.call("get_workspace_view")) == 0 and head.get_instance_id() == head_id)
|
||||
|
||||
head.call("open_selected_color_picker")
|
||||
var picker: PopupPanel = head.get("color_picker_popup") as PopupPanel
|
||||
check("open color popup blocks presentation switching", picker.visible and not bool(head.call("can_switch_presentation")))
|
||||
editor.call("request_active_part", "torso")
|
||||
editor.call("request_workspace_view", 1, true)
|
||||
check("deferred view switch waits while popup is open", int(editor.call("get_workspace_view")) == 0 and str(editor.call("get_active_part_name")) == "head")
|
||||
picker.hide()
|
||||
await process_frame
|
||||
await process_frame
|
||||
check("deferred part and view switch resumes after popup closes", int(editor.call("get_workspace_view")) == 1 and str(editor.call("get_active_part_name")) == "torso")
|
||||
check("popup and workspace changes remain clean", not bool(editor.call("has_unsaved_changes")))
|
||||
editor.call("request_active_part", "head")
|
||||
var preview_area: Control = preview.get("preview_area") as Control
|
||||
await process_frame
|
||||
var raw_points: PackedVector2Array = preview.call("_collect_all_raw_points", "head") as PackedVector2Array
|
||||
var part_center: Vector2 = preview.call("_compute_center", raw_points) as Vector2
|
||||
var initial_part_position: Vector2 = preview.call("get_part_position", "head") as Vector2
|
||||
var mouse_down: InputEventMouseButton = InputEventMouseButton.new()
|
||||
mouse_down.button_index = MOUSE_BUTTON_LEFT
|
||||
mouse_down.pressed = true
|
||||
mouse_down.position = part_center
|
||||
preview.call("_on_preview_gui_input", mouse_down)
|
||||
var drag: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||
drag.position = part_center + Vector2(16.0, 12.0)
|
||||
preview.call("_on_preview_gui_input", drag)
|
||||
var mouse_up: InputEventMouseButton = InputEventMouseButton.new()
|
||||
mouse_up.button_index = MOUSE_BUTTON_LEFT
|
||||
mouse_up.pressed = false
|
||||
mouse_up.position = drag.position
|
||||
preview.call("_on_preview_gui_input", mouse_up)
|
||||
check("preview drag commits an authored part transform", not (preview.call("get_part_position", "head") as Vector2).is_equal_approx(initial_part_position))
|
||||
check("committed preview transform marks document dirty", bool(editor.call("has_unsaved_changes")))
|
||||
check("preview input uses the live preview area", preview_area != null and preview_area.size.x > 0.0)
|
||||
check("save after transform keeps the existing format", bool(editor.call("save_to_path", stickman_path)) and not bool(editor.call("has_unsaved_changes")))
|
||||
preview.call("_mirror_part_x")
|
||||
check("mirror transform marks document dirty", bool(editor.call("has_unsaved_changes")) and (preview.call("get_part_scale", "head") as Vector2).x < 0.0)
|
||||
check("mirror transform can be saved", bool(editor.call("save_to_path", stickman_path)) and not bool(editor.call("has_unsaved_changes")))
|
||||
preview.call("_bring_forward_part")
|
||||
check("part order edit marks document dirty", bool(editor.call("has_unsaved_changes")))
|
||||
|
||||
check("second shape can be authored", bool(head.call("add_shape", &"line", head.call("drawing_center_world"))))
|
||||
editor.call("_on_file_menu_id_pressed", 2)
|
||||
var unsaved_dialog: ConfirmationDialog = editor.get("_unsaved_dialog") as ConfirmationDialog
|
||||
check("Start Over shows a save/discard/cancel transaction", unsaved_dialog.visible)
|
||||
unsaved_dialog.get_cancel_button().emit_signal("pressed")
|
||||
await process_frame
|
||||
check("Cancel leaves dirty content in place", bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).size() == 2)
|
||||
editor.call("_on_file_menu_id_pressed", 2)
|
||||
unsaved_dialog.emit_signal("custom_action", "discard")
|
||||
await process_frame
|
||||
check("Discard clears authored parts and dirty state", not bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).is_empty())
|
||||
check("clean new Stickman returns to explicit unsaved status", stickman_save_status.text == "Not saved yet")
|
||||
check("Start Over keeps the selected workspace view", int(editor.call("get_workspace_view")) == 1)
|
||||
DirAccess.remove_absolute(stickman_path)
|
||||
main_scene.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _test_prop_reshape_and_chrome() -> void:
|
||||
var editor: Control = PROP_SCENE.instantiate() as Control
|
||||
root.add_child(editor)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var canvas: Control = editor.get("_canvas") as Control
|
||||
var save_status: Label = editor.get("_save_status") as Label
|
||||
check("Prop chrome uses wrapping top and mode rows", editor.get("_top_right") is FlowContainer and editor.get("_mode_bar") is FlowContainer)
|
||||
check("Prop action target meets 44 px minimum", (editor.get("_create_point_button") as Button).custom_minimum_size.y >= 44.0)
|
||||
check("new Prop has an explicit clean save status", str(save_status.text).ends_with("Not saved yet"))
|
||||
check("whole-prop action uses approved discoverable wording", str((editor.get("_whole_prop_resize_button") as Button).text) == "Move, Turn, or Resize Prop")
|
||||
check("material inspector uses child-facing grip language", str((editor.get("_friction_label") as Label).text).begins_with("Surface grip:"))
|
||||
check("anchor layer control uses the approved label", (editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) is Label and str(((editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) as Label).text) == "Held prop layer:")
|
||||
|
||||
canvas.call("add_primitive", "box", Vector2(150.0, 150.0))
|
||||
var shapes: Array = canvas.call("get_shape_data") as Array
|
||||
check("prop primitive is selected for editing", int(canvas.call("get_selected_index")) == 0 and shapes.size() == 1)
|
||||
check("Prop edit shows explicit unsaved status", str(save_status.text).ends_with("Unsaved changes"))
|
||||
var prop_path: String = "user://editor_visual_harmonization_test.prop"
|
||||
var save_result: Variant = await editor.call("_save_to", prop_path, false)
|
||||
check("clean Prop save shows explicit saved status", bool(save_result) and str(save_status.text).ends_with("Saved"))
|
||||
editor.call("_on_points_mode_pressed")
|
||||
var create_button: Button = editor.get("_create_point_button") as Button
|
||||
check("Reshape exposes the Create Point action", create_button.visible and create_button.text == "📍 Create Point")
|
||||
create_button.emit_signal("pressed")
|
||||
check("Create Point arms with a visible Esc hint", bool(canvas.call("is_create_point_armed")) and str((editor.get("_create_point_hint") as Label).text).contains("Press Esc"))
|
||||
var escape: InputEventKey = InputEventKey.new()
|
||||
escape.keycode = KEY_ESCAPE
|
||||
escape.pressed = true
|
||||
editor.call("_unhandled_key_input", escape)
|
||||
check("Esc cancels the armed point action", not bool(canvas.call("is_create_point_armed")))
|
||||
create_button.emit_signal("pressed")
|
||||
var internal_shapes: Array = canvas.get("shapes") as Array
|
||||
var first_shape: Dictionary = internal_shapes[0] as Dictionary
|
||||
var original_points: PackedVector2Array = first_shape.get("points", PackedVector2Array()) as PackedVector2Array
|
||||
var edge_midpoint: Vector2 = (original_points[0] + original_points[1]) * 0.5
|
||||
var canvas_zoom: float = float(canvas.get("_zoom"))
|
||||
var canvas_pan: Vector2 = canvas.get("_pan_offset") as Vector2
|
||||
var edge_screen_pos: Vector2 = edge_midpoint * canvas_zoom + canvas_pan
|
||||
check("toolbar click target resolves to the visible shape edge", int(canvas.call("hit_test_segment", edge_midpoint)) == 0)
|
||||
var edge_click: InputEventMouseButton = InputEventMouseButton.new()
|
||||
edge_click.button_index = MOUSE_BUTTON_LEFT
|
||||
edge_click.pressed = true
|
||||
edge_click.position = edge_screen_pos
|
||||
canvas.call("_on_canvas_gui_input", edge_click)
|
||||
shapes = canvas.call("get_shape_data") as Array
|
||||
var reshaped: Dictionary = shapes[0] as Dictionary
|
||||
var vertices: PackedVector2Array = reshaped.get("points", PackedVector2Array()) as PackedVector2Array
|
||||
check("armed edge click inserts a point", vertices.size() == 5)
|
||||
check("inserted point records reshaped state", bool(reshaped.get("reshaped", false)))
|
||||
check("inserted point marks Prop document dirty", bool(editor.get("_dirty")))
|
||||
check("Create Point updates Prop status to unsaved", str(save_status.text).ends_with("Unsaved changes"))
|
||||
check("one click disarms Create Point", not bool(canvas.call("is_create_point_armed")))
|
||||
if FileAccess.file_exists(prop_path):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(prop_path))
|
||||
editor.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _view_contexts_equal(left: Dictionary, right: Dictionary) -> bool:
|
||||
var left_pan: Vector2 = left.get("pan_offset", Vector2.ZERO) as Vector2
|
||||
var right_pan: Vector2 = right.get("pan_offset", Vector2.ZERO) as Vector2
|
||||
return is_equal_approx(float(left.get("zoom", 1.0)), float(right.get("zoom", 1.0))) and left_pan.is_equal_approx(right_pan)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bulw0ym7dnnaj
|
||||
@@ -0,0 +1,72 @@
|
||||
extends SceneTree
|
||||
|
||||
const EDITOR_VISUAL_THEME := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var defaults: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
|
||||
check("committed shared theme loads", defaults.load_from(EDITOR_VISUAL_THEME.DEFAULT_PATH))
|
||||
check("default body font is 18", defaults.chrome_px(&"body", &"prop") == 18)
|
||||
check("default touch target is 44 px", defaults.chrome_px(&"button_min_height", &"stickman") == 44)
|
||||
check("preview role preserves alpha", is_equal_approx(defaults.chrome_color(&"preview").a, 0.4))
|
||||
|
||||
var custom_path: String = "user://editor_visual_theme_test.json"
|
||||
var custom_file: FileAccess = FileAccess.open(custom_path, FileAccess.WRITE)
|
||||
custom_file.store_string('{"chrome":{"colors":{"edit":"#22C6FF","focus":"#FF00AA"},"targets":{"button_min_height":48}},"editors":{"prop":{"colors":{"edit":"#FF8800"}}}}')
|
||||
custom_file.close()
|
||||
var custom: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
|
||||
check("custom shared theme loads", custom.load_from(custom_path))
|
||||
check("prop semantic override wins", custom.chrome_color(&"edit", &"prop").is_equal_approx(Color("#FF8800")))
|
||||
check("stickman uses common semantic default", custom.chrome_color(&"edit", &"stickman").is_equal_approx(Color("#22C6FF")))
|
||||
check("common touch target override applies", custom.chrome_px(&"button_min_height", &"prop") == 48)
|
||||
check("local focus token overrides default", custom.chrome_color(&"focus").is_equal_approx(Color("#FF00AA")))
|
||||
DirAccess.remove_absolute(custom_path)
|
||||
|
||||
var invalid_path: String = "user://editor_visual_theme_test_invalid.json"
|
||||
var invalid_file: FileAccess = FileAccess.open(invalid_path, FileAccess.WRITE)
|
||||
invalid_file.store_string('{"chrome":{"colors":{"edit":"electric"},"targets":{"button_min_height":"large"}}}')
|
||||
invalid_file.close()
|
||||
var invalid: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
|
||||
invalid.load_from(invalid_path)
|
||||
check("invalid color falls back to semantic default", invalid.chrome_color(&"edit").is_equal_approx(Color("#22C6FF")))
|
||||
check("invalid size falls back to semantic default", invalid.chrome_px(&"button_min_height") == 44)
|
||||
DirAccess.remove_absolute(invalid_path)
|
||||
|
||||
var prop_theme: Theme = defaults.control_theme(&"prop")
|
||||
var stickman_theme: Theme = defaults.control_theme(&"stickman")
|
||||
var prop_focus: StyleBoxFlat = prop_theme.get_stylebox("focus", "Button") as StyleBoxFlat
|
||||
var stickman_focus: StyleBoxFlat = stickman_theme.get_stylebox("focus", "Button") as StyleBoxFlat
|
||||
check("each editor receives a separate local Theme", prop_theme.get_instance_id() != stickman_theme.get_instance_id())
|
||||
check("each editor receives separate focus styles", prop_focus.get_instance_id() != stickman_focus.get_instance_id())
|
||||
check("focus outline uses semantic focus token", prop_focus.border_color.is_equal_approx(defaults.chrome_color(&"focus")))
|
||||
|
||||
var action: Button = Button.new()
|
||||
defaults.style_button_role(action, &"primary", &"prop")
|
||||
var action_box: StyleBoxFlat = action.get_theme_stylebox("normal") as StyleBoxFlat
|
||||
check("primary action uses semantic action color", action_box.bg_color.is_equal_approx(defaults.chrome_color(&"action_primary", &"prop")))
|
||||
check("primary action reaches the minimum target size", action.custom_minimum_size.y >= 44.0)
|
||||
action.free()
|
||||
|
||||
print("----------------------------------------")
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d assertions)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func check(label: String, condition: bool) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmve03xjcfcg8
|
||||
@@ -0,0 +1,235 @@
|
||||
extends SceneTree
|
||||
|
||||
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
|
||||
const DIALOG_STYLER: Script = preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
|
||||
|
||||
var _checks: int = 0
|
||||
var _failures: int = 0
|
||||
var _stage: SandboxStage
|
||||
var _chooser: StageGroundFinishChooser
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
|
||||
root.add_child(_stage)
|
||||
await _wait_frames(3)
|
||||
_open_through_palette_signal()
|
||||
await _wait_frames(3)
|
||||
_test_initial_gallery()
|
||||
await _save_capture("phase19_visual_ground_finish_chooser_1280x720.png")
|
||||
await _test_narrow_viewport_fit()
|
||||
await _save_capture("phase19_visual_ground_finish_chooser_470x417.png")
|
||||
_test_close_keeps_placement_unchanged()
|
||||
root.size = Vector2i(1280, 720)
|
||||
await _wait_frames(2)
|
||||
_open_through_palette_signal()
|
||||
await process_frame
|
||||
DIALOG_STYLER.dismiss_on_escape(_chooser)
|
||||
_test_escape_keeps_placement_unchanged()
|
||||
_open_through_palette_signal()
|
||||
await _wait_frames(2)
|
||||
_test_finish_selection_is_a_draft()
|
||||
await _save_capture("phase19_visual_ground_finish_chooser_dirt_selected.png")
|
||||
_test_draw_commits_selected_finish()
|
||||
if DisplayServer.get_name() != "headless":
|
||||
Input.warp_mouse(Vector2i(900, 420))
|
||||
await _wait_frames(3)
|
||||
_test_ground_ghost_is_armed()
|
||||
await _save_capture("phase19_visual_ground_finish_placement.png")
|
||||
await _test_ground_click_commits_selected_finish()
|
||||
_stage.queue_free()
|
||||
await _wait_frames(3)
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d checks)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func _open_through_palette_signal() -> void:
|
||||
var buttons: Dictionary = _stage.get("_palette_buttons") as Dictionary
|
||||
var ground_button: AssetCard = buttons.get("ground") as AssetCard
|
||||
_expect(ground_button != null, "Ground palette card exists")
|
||||
if ground_button == null:
|
||||
return
|
||||
ground_button.set_pressed_no_signal(true)
|
||||
ground_button.activated.emit(ground_button.payload())
|
||||
_chooser = _stage.get("_ground_finish_chooser") as StageGroundFinishChooser
|
||||
|
||||
|
||||
func _test_initial_gallery() -> void:
|
||||
_expect(_chooser != null, "Ground finish chooser is built")
|
||||
if _chooser == null:
|
||||
return
|
||||
_expect(DIALOG_STYLER.is_open(_chooser), "Ground card opens the modal chooser through the palette signal")
|
||||
_expect(_chooser.title == "Choose Ground", "Chooser title matches the approved gallery")
|
||||
_expect(_chooser.dialog_text == "Stickmen walk here", "Chooser explains the Ground role")
|
||||
_expect(_chooser.cancel_button_text == "Close", "Footer dismissal action is Close")
|
||||
_expect(_chooser.get_ok_button().text == "Draw Grass Ground", "Primary action names the selected finish")
|
||||
var buttons: Dictionary = _chooser.choice_buttons()
|
||||
_expect(buttons.size() == 7, "Gallery contains seven exclusive Ground finish choices")
|
||||
var grass_button: Button = buttons.get(&"finish_ground_grass") as Button
|
||||
_expect(grass_button != null and grass_button.button_pressed, "Current Grass finish is visibly selected")
|
||||
_expect(_chooser.selected_preview_finish() == &"finish_ground_grass", "Large sample previews the selected finish")
|
||||
var grid: GridContainer = _chooser.get("_choice_grid") as GridContainer
|
||||
var wide_row: HBoxContainer = _chooser.get("_layout_row") as HBoxContainer
|
||||
var preview_panel: Control = _chooser.get("_preview_panel") as Control
|
||||
_expect(bool(_chooser.get("_is_wide_layout")) and grid != null and grid.columns == 3, "1280×720 opens the side-by-side three-column gallery")
|
||||
_expect(grid != null and grid.get_parent() == wide_row and preview_panel != null and preview_panel.visible, "Wide gallery keeps choices beside the selected preview")
|
||||
var card: Control = _chooser.get_meta(DIALOG_STYLER.CARD_META) as Control
|
||||
var viewport_size: Vector2 = root.get_visible_rect().size
|
||||
var card_rect: Rect2 = card.get_global_rect() if card != null else Rect2()
|
||||
_expect(card != null and card_rect.size.x < viewport_size.x * 0.9, "Roomy chooser card leaves visible horizontal Stage margins")
|
||||
_expect(card != null and card_rect.size.y < viewport_size.y * 0.82, "Roomy chooser card stays comfortably below the Stage height")
|
||||
_expect(card != null and card_rect.position.x >= 20.0 and card_rect.end.x <= viewport_size.x - 20.0, "Roomy chooser remains inside the viewport width")
|
||||
_expect(card != null and card_rect.position.y >= 20.0 and card_rect.end.y <= viewport_size.y - 20.0, "Roomy chooser remains inside the viewport height")
|
||||
|
||||
|
||||
func _test_narrow_viewport_fit() -> void:
|
||||
if _chooser == null:
|
||||
return
|
||||
root.size = Vector2i(470, 417)
|
||||
await _wait_frames(2)
|
||||
_chooser.call("_apply_responsive_layout")
|
||||
DIALOG_STYLER.refit(_chooser)
|
||||
await _wait_frames(2)
|
||||
var card: Control = _chooser.get_meta(DIALOG_STYLER.CARD_META) as Control
|
||||
var viewport_size: Vector2 = root.get_visible_rect().size
|
||||
var card_rect: Rect2 = card.get_global_rect() if card != null else Rect2()
|
||||
var grid: GridContainer = _chooser.get("_choice_grid") as GridContainer
|
||||
var preview_panel: Control = _chooser.get("_preview_panel") as Control
|
||||
_expect(card != null and card_rect.position.x >= 12.0 and card_rect.end.x <= viewport_size.x - 12.0, "Narrow chooser clamps within horizontal safe margins")
|
||||
_expect(card != null and card_rect.position.y >= 12.0 and card_rect.end.y <= viewport_size.y - 12.0, "Narrow chooser clamps within vertical safe margins")
|
||||
_expect(card != null and card_rect.size.x < viewport_size.x and card_rect.size.y < viewport_size.y, "Narrow chooser remains a card instead of a full-scene sheet")
|
||||
_expect(bool(_chooser.get("_is_compact_layout")) and grid != null and grid.columns == 3, "470×417 uses the compact three-column gallery")
|
||||
_expect(preview_panel != null and not preview_panel.visible, "Compact gallery hides the redundant large preview")
|
||||
var body_scroll: ScrollContainer = _chooser.get_meta(DIALOG_STYLER.BODY_SCROLL_META) as ScrollContainer
|
||||
var body_rect: Rect2 = body_scroll.get_global_rect() if body_scroll != null else Rect2()
|
||||
var all_choices_visible: bool = grid != null and grid.get_child_count() == 7 and body_scroll != null
|
||||
if all_choices_visible:
|
||||
for child: Node in grid.get_children():
|
||||
var choice: Control = child as Control
|
||||
var choice_rect: Rect2 = choice.get_global_rect()
|
||||
if not choice.visible or choice_rect.position.x < body_rect.position.x - 1.0 or choice_rect.end.x > body_rect.end.x + 1.0 or choice_rect.position.y < body_rect.position.y - 1.0 or choice_rect.end.y > body_rect.end.y + 1.0:
|
||||
all_choices_visible = false
|
||||
break
|
||||
_expect(all_choices_visible, "Compact gallery exposes all seven finish cards without scrolling")
|
||||
var actions: HBoxContainer = _chooser.get_meta(DIALOG_STYLER.ACTIONS_META) as HBoxContainer
|
||||
var close_button: Button = DIALOG_STYLER.visual_button(_chooser.get_cancel_button())
|
||||
var draw_button: Button = DIALOG_STYLER.visual_button(_chooser.get_ok_button())
|
||||
_expect(actions != null and close_button != null and draw_button != null, "Narrow chooser keeps both footer actions in its fixed footer")
|
||||
_expect(close_button != null and close_button.get_global_rect().intersects(card_rect), "Narrow chooser keeps Close reachable inside the card")
|
||||
_expect(draw_button != null and draw_button.get_global_rect().intersects(card_rect), "Narrow chooser keeps Draw Ground reachable inside the card")
|
||||
|
||||
|
||||
func _test_close_keeps_placement_unchanged() -> void:
|
||||
if _chooser == null:
|
||||
return
|
||||
var close_button: Button = DIALOG_STYLER.visual_button(_chooser.get_cancel_button())
|
||||
if close_button != null:
|
||||
close_button.pressed.emit()
|
||||
var placement_id: String = String(_stage.get("_placement_id"))
|
||||
var new_finish: StringName = StringName(_stage.get("_new_ground_finish"))
|
||||
var ground_button: Button = (_stage.get("_palette_buttons") as Dictionary).get("ground") as Button
|
||||
_expect(not DIALOG_STYLER.is_open(_chooser), "Close dismisses the chooser")
|
||||
_expect(placement_id.is_empty(), "Close does not arm Ground placement")
|
||||
_expect(new_finish == &"finish_ground_grass", "Close discards the unconfirmed finish choice")
|
||||
_expect(ground_button != null and not ground_button.button_pressed, "Close restores the Ground card to its inactive state")
|
||||
|
||||
|
||||
func _test_escape_keeps_placement_unchanged() -> void:
|
||||
if _chooser == null:
|
||||
return
|
||||
var placement_id: String = String(_stage.get("_placement_id"))
|
||||
var ground_button: Button = (_stage.get("_palette_buttons") as Dictionary).get("ground") as Button
|
||||
_expect(not DIALOG_STYLER.is_open(_chooser), "Escape dismisses the chooser")
|
||||
_expect(placement_id.is_empty(), "Escape does not arm Ground placement")
|
||||
_expect(ground_button != null and not ground_button.button_pressed, "Escape restores the Ground card to its inactive state")
|
||||
|
||||
|
||||
func _test_finish_selection_is_a_draft() -> void:
|
||||
if _chooser == null:
|
||||
return
|
||||
var dirt_button: Button = _chooser.choice_buttons().get(&"finish_ground_dirt") as Button
|
||||
if dirt_button != null:
|
||||
dirt_button.set_pressed_no_signal(true)
|
||||
dirt_button.pressed.emit()
|
||||
_expect(_chooser.selected_finish() == &"finish_ground_dirt", "Choosing Dirt updates the draft finish")
|
||||
_expect(_chooser.selected_preview_finish() == &"finish_ground_dirt", "Choosing Dirt updates the large preview")
|
||||
_expect(_chooser.get_ok_button().text == "Draw Dirt Ground", "Choosing Dirt updates the primary action")
|
||||
_expect(StringName(_stage.get("_new_ground_finish")) == &"finish_ground_grass", "Draft selection does not mutate the active Ground finish")
|
||||
_expect(String(_stage.get("_placement_id")).is_empty(), "Draft selection does not start placement")
|
||||
|
||||
|
||||
func _test_draw_commits_selected_finish() -> void:
|
||||
if _chooser == null:
|
||||
return
|
||||
var draw_button: Button = DIALOG_STYLER.visual_button(_chooser.get_ok_button())
|
||||
if draw_button != null:
|
||||
draw_button.pressed.emit()
|
||||
var painter: StageTerrainPainter = _stage.get("_terrain_painter") as StageTerrainPainter
|
||||
_expect(not DIALOG_STYLER.is_open(_chooser), "Draw Ground closes the chooser")
|
||||
_expect(StringName(_stage.get("_new_ground_finish")) == &"finish_ground_dirt", "Draw Ground commits the selected finish")
|
||||
_expect(String(_stage.get("_placement_id")) == "ground", "Draw Ground enters the existing Ground placement tool")
|
||||
_expect(painter != null and StringName(painter.get("_ground_finish")) == &"finish_ground_dirt", "Ground painter receives the selected finish")
|
||||
|
||||
|
||||
func _test_ground_ghost_is_armed() -> void:
|
||||
var ghost: Node2D = _stage.get("_ghost") as Node2D
|
||||
_expect(ghost != null and is_instance_valid(ghost), "Draw Ground shows the cursor-following Ground ghost")
|
||||
|
||||
|
||||
func _test_ground_click_commits_selected_finish() -> void:
|
||||
var stage_position := Vector2(900.0, 420.0)
|
||||
var press := InputEventMouseButton.new()
|
||||
press.position = stage_position
|
||||
press.global_position = stage_position
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
_stage._unhandled_input(press)
|
||||
var release := InputEventMouseButton.new()
|
||||
release.position = stage_position
|
||||
release.global_position = stage_position
|
||||
release.button_index = MOUSE_BUTTON_LEFT
|
||||
release.pressed = false
|
||||
_stage._unhandled_input(release)
|
||||
await _wait_frames(3)
|
||||
var placed_dirt_ground: bool = false
|
||||
var world: Node2D = _stage.get_node("World") as Node2D
|
||||
if world != null:
|
||||
for child: Node in world.get_children():
|
||||
if child is TerrainBlock and (child as TerrainBlock).spawn_id == "ground" and (child as TerrainBlock).finish_id == &"finish_ground_dirt":
|
||||
placed_dirt_ground = true
|
||||
break
|
||||
_expect(placed_dirt_ground, "Production Ground input places the selected Dirt finish")
|
||||
|
||||
|
||||
func _wait_frames(count: int) -> void:
|
||||
for _frame_index: int in count:
|
||||
await process_frame
|
||||
|
||||
|
||||
func _save_capture(file_name: String) -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
await RenderingServer.frame_post_draw
|
||||
var capture: Image = root.get_texture().get_image()
|
||||
var path: String = ProjectSettings.globalize_path("res://tests/" + file_name)
|
||||
var error: Error = capture.save_png(path)
|
||||
_expect(error == OK, "render capture saved for visual inspection: %s" % path)
|
||||
print("CAPTURE: %s size=%s result=%s" % [path, str(capture.get_size()), str(error)])
|
||||
|
||||
|
||||
func _expect(condition: bool, description: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: %s" % description)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: %s" % description)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1fx16sotrdpp
|
||||
@@ -20,8 +20,9 @@ func _check(label: String, value: bool) -> void:
|
||||
push_error("FAIL: %s" % label)
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1200, 800)
|
||||
var theme := THEME.new()
|
||||
_check("default inspector background is #262C39", theme.inspector_background_color() == Color("#262C39"))
|
||||
_check("default inspector background uses shared surface token", theme.inspector_background_color().is_equal_approx(Color("#252B38FA")))
|
||||
var preview := PREVIEW_SCENE.instantiate() as Control
|
||||
root.add_child(preview)
|
||||
await process_frame
|
||||
@@ -39,7 +40,6 @@ func _run() -> void:
|
||||
_check("front pose stops gait", int(preview.get("_motion")) == 0 and bool(preview.get("_walk_paused")))
|
||||
preview.queue_free()
|
||||
var editor := CANVAS_SCENE.instantiate() as Control
|
||||
editor.size = Vector2(1200.0, 800.0)
|
||||
root.add_child(editor)
|
||||
await process_frame
|
||||
var canvas := editor.get("_canvas") as PropCanvas
|
||||
@@ -95,12 +95,12 @@ func _run() -> void:
|
||||
var eye_data: Dictionary = PROP_FILE.load("res://content/props/eye.prop")
|
||||
_check("eye prop fixture is available", not eye_data.is_empty())
|
||||
editor.call("_apply_json_data", eye_data)
|
||||
editor.size = Vector2(1200.0, 600.0)
|
||||
root.size = Vector2i(1200, 600)
|
||||
canvas.set_edit_mode(PropCanvas.EditMode.ATTACH)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var top_bar := editor.get_node("TopBar") as Control
|
||||
var status_bar := editor.get_node("StatusBar") as Control
|
||||
var top_bar := editor.get_node("ChromeRoot/TopBar") as Control
|
||||
var status_bar := editor.get_node("ChromeRoot/StatusBar") as Control
|
||||
var holding_preview := editor.get("_attach_preview") as Control
|
||||
_check("Attach layout stays between name and status bars", main_split.get_global_rect().position.y >= top_bar.get_global_rect().end.y - 1.0 and holding_preview.get_global_rect().end.y <= status_bar.get_global_rect().position.y + 1.0)
|
||||
var preview_container := holding_preview.get("_viewport_container") as SubViewportContainer
|
||||
|
||||
@@ -146,25 +146,24 @@ func _test_ui_tokens() -> void:
|
||||
var theme: PropTheme = PROP_THEME.new()
|
||||
theme.load_from(PROP_THEME.DEFAULT_PATH)
|
||||
check("font_body 18", theme.font_body() == 18)
|
||||
check("font_header 24", theme.font_header() == 24)
|
||||
check("font_menu 22", theme.font_menu() == 22)
|
||||
check("font_menu_bar 22", theme.font_menu_bar() == 22)
|
||||
check("font_mode 26", theme.font_mode() == 26)
|
||||
check("font_status 18", theme.font_status() == 18)
|
||||
check("font_header 22", theme.font_header() == 22)
|
||||
check("font_menu 20", theme.font_menu() == 20)
|
||||
check("font_menu_bar 20", theme.font_menu_bar() == 20)
|
||||
check("font_mode 24", theme.font_mode() == 24)
|
||||
check("font_status 16", theme.font_status() == 16)
|
||||
check("font_prop_name 20", theme.font_prop_name() == 20)
|
||||
check("font_context_menu 22", theme.font_context_menu() == 22)
|
||||
check("button_min_height 36", theme.button_min_height() == 36)
|
||||
check("row_min_height 36", theme.row_min_height() == 36)
|
||||
check("font_context_menu 20", theme.font_context_menu() == 20)
|
||||
check("button_min_height 44", theme.button_min_height() == 44)
|
||||
check("row_min_height 44", theme.row_min_height() == 44)
|
||||
check("mode_button_min_height 56", theme.mode_button_min_height() == 56)
|
||||
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F2F2F2", Color.WHITE)))
|
||||
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#9A9A9A", Color.WHITE)))
|
||||
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F5F7FA", Color.WHITE)))
|
||||
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#AAB2C0", Color.WHITE)))
|
||||
check("border token", theme.border_color().is_equal_approx(Color.from_string("#FFFFFF33", Color.WHITE)))
|
||||
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#4FA3FF", Color.WHITE)))
|
||||
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#22C6FF", Color.WHITE)))
|
||||
var path := "user://prop_theme_test_ui.json"
|
||||
_write_user_theme('{"version":"1.0","ui":{"fonts":{"body":24},"colors":{"accent":"#FF8800"}}}', path)
|
||||
var custom: PropTheme = PROP_THEME.new()
|
||||
custom.load_from(path)
|
||||
check("ui font override", custom.font_body() == 24)
|
||||
check("unoverridden ui token falls back", custom.font_header() == 24)
|
||||
check("ui accent override", custom.accent_color().is_equal_approx(Color.from_string("#FF8800", Color.WHITE)))
|
||||
check("legacy PropTheme ui font does not override shared tokens", custom.font_body() == 18)
|
||||
check("legacy PropTheme ui accent does not override shared tokens", custom.accent_color().is_equal_approx(Color.from_string("#22C6FF", Color.WHITE)))
|
||||
DirAccess.remove_absolute(path)
|
||||
|
||||
Reference in New Issue
Block a user