Add reusable UI components for Phase 7 UI Polish
- Introduced `EmojiQuickGrid`, `FontPresetPicker`, and `PresetSizeToggle` classes for emoji selection, font presets, and size toggles respectively. - Implemented configuration methods for each component to handle dynamic content and theming. - Created tests for the new components to ensure functionality and integration with the existing UI. - Added a comprehensive architecture specification for Phase 7 UI Polish detailing scope, implementation, and validation strategies. - Updated existing theme handling to support new UI elements and ensure consistent styling across the application.
This commit is contained in:
@@ -667,65 +667,18 @@ While a click-awaiting director step is active — a pending **Walk To** target,
|
|||||||
|
|
||||||
#### 21.3 `res://sandbox_theme.json` (styling defaults)
|
#### 21.3 `res://sandbox_theme.json` (styling defaults)
|
||||||
|
|
||||||
A single committed, hand-editable JSON config drives sandbox font/size/color/grid defaults. It loads in `_ready()` before `_build_ui()`; a missing or malformed file logs one `push_warning` and uses all built-in constants (never crashes); unknown extra keys are ignored; a referenced font that does not exist falls back to `ThemeDB.fallback_font` with a warning. The **live** grid size/snap/grid-visibility remain persisted in `user://sandbox_settings.json` (runtime source of truth); the theme supplies the **initial default** grid size on first run. Schema:
|
The Sandbox theme is schema `2.0` and now covers the code-built chrome,
|
||||||
|
palette, selectors, dialogs, overlays, reusable widget data, fonts, colors,
|
||||||
|
and grid defaults. See the complete current key reference, examples, legacy
|
||||||
|
compatibility notes, and troubleshooting guide in
|
||||||
|
[docs/SANDBOX_THEME.md](docs/SANDBOX_THEME.md).
|
||||||
|
|
||||||
```json
|
The short version: edit `res://sandbox_theme.json`, restart the Sandbox scene
|
||||||
{
|
to reload it, and remember that the live grid size/snap/grid-visibility remain
|
||||||
"version": "1.0",
|
in `user://sandbox_settings.json`. Missing or malformed theme data falls back
|
||||||
"fonts": {
|
to built-in defaults; missing fonts fall back to `ThemeDB.fallback_font` with a
|
||||||
"ui_font": "",
|
warning. Speech font/size/emoji lists are reusable-widget data reserved for a
|
||||||
"emoji_font": "",
|
future speech-customization UI and do not yet alter saved speech actions.
|
||||||
"ui_font_bold": "",
|
|
||||||
"ui_font_italic": "",
|
|
||||||
"action_popup_font_size": 24,
|
|
||||||
"action_popup_emoji_size": 22,
|
|
||||||
"assignment_badge_font_size": 20,
|
|
||||||
"assignment_badge_radius": 9,
|
|
||||||
"rule_label_font_size": 16,
|
|
||||||
"status_pill_font_size": 16,
|
|
||||||
"tooltip_font_size": 18,
|
|
||||||
"queue_panel_font_size": 18,
|
|
||||||
"rule_panel_font_size": 18,
|
|
||||||
"action_editor_font_size": 18,
|
|
||||||
"rule_editor_font_size": 18,
|
|
||||||
"panel_row_font_size": 16,
|
|
||||||
"panel_title_font_size": 18,
|
|
||||||
"panel_title_bold": true,
|
|
||||||
"rule_label_bold": false,
|
|
||||||
"badge_bold": true,
|
|
||||||
"action_popup": { "size": 24, "bold": false, "italic": false }
|
|
||||||
},
|
|
||||||
"grid": {
|
|
||||||
"snap_size": 15.0
|
|
||||||
},
|
|
||||||
"mode_colors": {
|
|
||||||
"edit_accent": "#22c6ff",
|
|
||||||
"direct_accent": "#ffb300",
|
|
||||||
"play_accent": "#33dd77",
|
|
||||||
"guide_line": "#22c6ff"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | Default | Consumed by |
|
|
||||||
|---|---|---|
|
|
||||||
| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. |
|
|
||||||
| `fonts.ui_font_bold` / `fonts.ui_font_italic` | `""` (fallback to `ui_font`) | **Phase 3c** style-variant font paths for bold/italic; empty → `ui_font`. Bold/italic are realised via a dedicated `FontVariation` (e.g. `variation_embolden`, OpenType slant) when no separate file is configured. |
|
|
||||||
| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. **Phase 3c:** `action_popup_emoji_size` is now **consumed** — applied as the popup menu's emoji-glyph font size when a popup font is configured (`_apply_popup_theme()`). |
|
|
||||||
| `fonts.action_popup` (optional object) | `{ "size": 24, "bold": false, "italic": false }` | **Phase 3c** per-widget object form for the popups; when present, overrides the flat `action_popup_font_size` / bold keys for that widget. |
|
|
||||||
| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. |
|
|
||||||
| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. **Phase 3c** `fonts.rule_label_bold` (default `false`) adds the bold flag; `fonts.badge_bold` (default `true`) bolds the action/trigger badges. |
|
|
||||||
| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. |
|
|
||||||
| `fonts.queue_panel_font_size` / `rule_panel_font_size` | 18 / 18 | **Phase 3c** font sizes for the Queue / Rule panel bodies; fall back to `action_popup_font_size`. |
|
|
||||||
| `fonts.action_editor_font_size` / `rule_editor_font_size` | 18 / 18 | **Phase 3c** font sizes for the Action / Rule editors; fall back to `action_popup_font_size`. |
|
|
||||||
| `fonts.panel_row_font_size` / `panel_title_font_size` | 16 / 18 | **Phase 3c** per-row summary/number label and panel title label sizes; `fonts.panel_title_bold` (default `true`) bolds panel titles. |
|
|
||||||
| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). |
|
|
||||||
| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. |
|
|
||||||
| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). |
|
|
||||||
|
|
||||||
`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present.
|
|
||||||
|
|
||||||
**Phase 3c theming (font styles/sizes):** the Phase 3c widgets now accept an `apply_font(ui_font, emoji_font, sizes)` call — `QueuePanel`, `RulePanel`, `ActionEditor`, and `RuleEditor` (see §23) each expose it (mirroring `AssetSelector.apply_font`), and `SandboxStage._build_ui()` invokes it **after** `add_child(...)`. Per-widget sizes come from the `*_panel_font_size` / `*_editor_font_size` / `panel_row_font_size` / `panel_title_font_size` keys; bold/italic style flags (`panel_title_bold`, `rule_label_bold`, `badge_bold`) and a widget's optional `{size, bold, italic}` object form are applied via the `ui_font_bold` / `ui_font_italic` `FontVariation`-derived font. The `PopupMenu`s (rig/waypoint context) keep `_apply_popup_theme()`, extended to honour `action_popup_emoji_size` and the `action_popup` object form. Authoritative schema: `docs/phase_3c_editor_spec.md` §14.
|
|
||||||
|
|
||||||
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
|
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# Sandbox theme reference
|
||||||
|
|
||||||
|
`res://sandbox_theme.json` is the hand-editable presentation configuration for
|
||||||
|
the Sandbox Stage (`res://scenes/sandbox_stage.tscn`). It is loaded by
|
||||||
|
`SandboxStage` during `_ready()`, before the code-built UI is created. The
|
||||||
|
loader is `res://scripts/sandbox_theme.gd` (`class_name SandboxTheme`).
|
||||||
|
|
||||||
|
The committed file uses schema `"2.0"`. The theme controls presentation
|
||||||
|
defaults only; it does not save a scene, placement, or action data.
|
||||||
|
|
||||||
|
## Load, merge, and validation rules
|
||||||
|
|
||||||
|
The load path is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
res://sandbox_theme.json
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SandboxTheme.DEFAULT_CONFIG
|
||||||
|
│ deep-merge file values
|
||||||
|
▼
|
||||||
|
SandboxStage._build_ui() and visual overlays
|
||||||
|
```
|
||||||
|
|
||||||
|
- Missing, unreadable, malformed, or non-object JSON uses the built-in default
|
||||||
|
configuration and emits one warning. The stage continues to run.
|
||||||
|
- Missing nested keys inherit the built-in value. Unknown keys are retained in
|
||||||
|
the raw dictionary but have no effect until a consumer is added.
|
||||||
|
- `stylebox(component)` resolves styles in this order:
|
||||||
|
`global` → `<section>.style_defaults` → the component object. For example,
|
||||||
|
`palette` is resolved from `global`, then `palette.style_defaults`, then
|
||||||
|
`palette`.
|
||||||
|
- Colors must be strings in `#RRGGBB` or `#RRGGBBAA` form. Invalid colors use
|
||||||
|
the requested fallback and emit a warning.
|
||||||
|
- Numeric accessors accept finite integers/floats and clamp them to the range
|
||||||
|
requested by the consumer. Invalid or non-finite numbers use that consumer's
|
||||||
|
fallback. Size arrays are accepted when they contain two numeric values;
|
||||||
|
individual controls apply their own minimum sizes.
|
||||||
|
- Font paths are `res://` resource paths. A missing resource, or a resource
|
||||||
|
that is not a `Font`, uses `ThemeDB.fallback_font` and emits a warning.
|
||||||
|
- The theme is loaded once per Sandbox Stage instance. Restart the scene (F6)
|
||||||
|
to see edits; there is no live file watcher or reload button.
|
||||||
|
|
||||||
|
The live grid size, snap toggle, and grid visibility are stored in
|
||||||
|
`user://sandbox_settings.json`. `grid.snap_size` supplies the initial grid-size
|
||||||
|
default; it does not overwrite a value already saved in user settings.
|
||||||
|
|
||||||
|
## Schema overview
|
||||||
|
|
||||||
|
Every current top-level block is listed below. The values shown are the
|
||||||
|
defaults in the committed `sandbox_theme.json` (not necessarily the fallback
|
||||||
|
used by an older caller).
|
||||||
|
|
||||||
|
### Current consumer status
|
||||||
|
|
||||||
|
The schema intentionally includes forward-looking values for the reusable
|
||||||
|
widget and speech work. The current Sandbox runtime applies these groups:
|
||||||
|
|
||||||
|
- `global` style values used by resolved styleboxes and the selector control
|
||||||
|
theme (background/border/radius/padding); `global.text_color` and
|
||||||
|
`global.muted_text_color` are available presentation values but are not
|
||||||
|
applied to every existing label automatically.
|
||||||
|
- `fonts.ui_font`, `emoji_font`, bold/italic variants, the nested `sizes`
|
||||||
|
entries for action popups, tooltips, status pills, queue/rule/action editors,
|
||||||
|
panel rows/titles, `file_menu`, `file_menu_popup`, and `mode_switcher`, plus
|
||||||
|
the related `flags`.
|
||||||
|
- `mode_colors.edit`, `direct`, `play`, and `guide`; `grid.snap_size` as the
|
||||||
|
first-run grid default; dialog sizes/fonts; `chrome.mode_switcher` width;
|
||||||
|
palette width/card size/drag threshold/style; selector cell and thumbnail
|
||||||
|
sizes; and all `overlays` values.
|
||||||
|
|
||||||
|
These declared values currently remain reserved or require a legacy adapter:
|
||||||
|
|
||||||
|
- `chrome.top_bar.height`, `status_bar.height`, `mode_frame`, `mode_badge`,
|
||||||
|
and `tooltip` describe the intended chrome contract, but the corresponding
|
||||||
|
layout/style constants are still authored in `SandboxStage`.
|
||||||
|
- `popups.action_menu.font_size` and `popups.asset_selector.backdrop_alpha`
|
||||||
|
are reserved component overrides. The active action-menu size comes from
|
||||||
|
`fonts.sizes.action_popup`; selector `cell_size` and `thumbnail_size` are
|
||||||
|
active.
|
||||||
|
- `palette.icon_size` and `palette.selected_border_width` are reserved for
|
||||||
|
the palette card renderer; the current card has its own glyph/border values.
|
||||||
|
- `fonts.sizes.assignment_badge`, `rule_label`, `asset_label`, and
|
||||||
|
`asset_badge` are part of the resolved schema, but the current direct call
|
||||||
|
into `StageDirectorVisuals` still reads the legacy flat badge/label names.
|
||||||
|
Use the legacy names documented below when changing those director visuals.
|
||||||
|
- `fonts.presets` and every `widgets` list are exposed through reusable
|
||||||
|
controls, but the current Sandbox Speak action does not instantiate those
|
||||||
|
controls yet. They are future speech-customization data.
|
||||||
|
|
||||||
|
### `version`
|
||||||
|
|
||||||
|
| Key | Format | Current value | Notes |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `version` | string | `"2.0"` | Use `"2.0"` when using the nested schema below. |
|
||||||
|
|
||||||
|
### `global`
|
||||||
|
|
||||||
|
Global style values are inherited by `SandboxTheme.stylebox()` and the default
|
||||||
|
control theme.
|
||||||
|
|
||||||
|
| Key | Format | Default | Purpose |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `font_preset` | string | `"ui"` | Named base-font selection for future/general UI use. |
|
||||||
|
| `font_size` | integer | `18` | Default `Control` font size. |
|
||||||
|
| `text_color`, `muted_text_color` | color string | `#F5F7FA`, `#AAB2C0` | Primary and secondary text colors. |
|
||||||
|
| `bg_color`, `surface_color` | color string | `#171B24F2`, `#252B38FA` | Global and raised-surface backgrounds. |
|
||||||
|
| `border_color` | color string | `#FFFFFF33` | Default border color. |
|
||||||
|
| `border_width` | integer | `1` | Default border width. |
|
||||||
|
| `corner_radius` | integer | `10` | Default corner radius. |
|
||||||
|
| `padding`, `spacing` | integer | `8`, `8` | Default style padding and layout spacing. |
|
||||||
|
|
||||||
|
### `fonts`
|
||||||
|
|
||||||
|
Font paths may be empty (`""`) to use the engine fallback. `presets` is an
|
||||||
|
object of arbitrary preset IDs; each entry has a display `label` and a `path`.
|
||||||
|
The committed file provides `ui`, `comic`, `pixel`, and `scary` entries.
|
||||||
|
|
||||||
|
| Key | Format | Default | Consumer |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `ui_font`, `emoji_font` | `res://` path or empty | `""` | Base UI and emoji-capable stage/director controls. |
|
||||||
|
| `ui_font_bold`, `ui_font_italic` | `res://` path or empty | `""` | Optional style variants; bold can be generated from the base font when no dedicated path is supplied. |
|
||||||
|
| `presets` | object `{id:{label,path}}` | see JSON | `font_presets()` and future font selectors. |
|
||||||
|
| `sizes.action_popup` | integer | `24` | Director/action popup text. |
|
||||||
|
| `sizes.action_popup_emoji` | integer | `22` | Effective popup size when a popup font is active (Godot popup menus have one font size). |
|
||||||
|
| `sizes.assignment_badge` | integer | `20` | Reserved resolved-theme value; current director visuals use legacy `fonts.assignment_badge_font_size`. |
|
||||||
|
| `sizes.rule_label` | integer | `18` | Reserved resolved-theme value; current director visuals use legacy `fonts.rule_label_font_size`. |
|
||||||
|
| `sizes.status_pill` | integer | `18` | Mode badge/pill. |
|
||||||
|
| `sizes.tooltip` | integer | `18` | Cursor-attached targeting tooltip. |
|
||||||
|
| `sizes.file_menu` | integer | `22` | `File` button in the top bar. |
|
||||||
|
| `sizes.file_menu_popup` | integer | `26` | File menu and Open Recent popup rows. |
|
||||||
|
| `sizes.mode_switcher` | integer | `24` | Edit/Direct/Play buttons. |
|
||||||
|
| `sizes.queue_panel`, `sizes.rule_panel` | integer | `22`, `22` | Queue and rule editor panels. |
|
||||||
|
| `sizes.action_editor`, `sizes.rule_editor` | integer | `22`, `22` | Action and rule editor controls. |
|
||||||
|
| `sizes.panel_row`, `sizes.panel_title` | integer | `20`, `24` | Editor row text and panel titles. |
|
||||||
|
| `sizes.asset_label`, `sizes.asset_badge` | integer | `18`, `14` | Reserved asset-card text sizing. |
|
||||||
|
| `flags.panel_title_bold` | boolean | `true` | Bold queue/rule panel titles. |
|
||||||
|
| `flags.rule_label_bold` | boolean | `false` | Bold director rule labels. |
|
||||||
|
| `flags.badge_bold` | boolean | `true` | Bold director badges. |
|
||||||
|
| `flags.action_popup_bold`, `flags.action_popup_italic` | boolean | `false`, `false` | Popup style flags. |
|
||||||
|
|
||||||
|
### `mode_colors`
|
||||||
|
|
||||||
|
Each value is a color string. These accents are used by the mode badge/frame,
|
||||||
|
active mode controls, targeting feedback, and related stage chrome.
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `edit` | `#22C6FF` | Edit-mode accent. |
|
||||||
|
| `direct` | `#FFB300` | Direct-mode accent. |
|
||||||
|
| `play` | `#33DD77` | Play-mode accent. |
|
||||||
|
| `guide` | `#22C6FF` | Terrain painting guide line. |
|
||||||
|
|
||||||
|
### `grid`
|
||||||
|
|
||||||
|
| Key | Format | Default | Purpose |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `snap_size` | finite number | `15.0` | Initial grid size, clamped to the stage's supported range. |
|
||||||
|
|
||||||
|
### `chrome`
|
||||||
|
|
||||||
|
`style_defaults` is the optional common style object for chrome components. Its
|
||||||
|
style keys follow the `global` style formats (`bg_color`, `border_color`,
|
||||||
|
`border_width`, `corner_radius`, `padding`).
|
||||||
|
|
||||||
|
| Key | Format | Default | Purpose |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `style_defaults` | style object | `{ "bg_color":"#171B24F2", "corner_radius":10 }` | Shared chrome style base. |
|
||||||
|
| `top_bar.height` | number | `64` | Top navigation bar height. |
|
||||||
|
| `status_bar.height` | number | `38` | Bottom status bar height. |
|
||||||
|
| `mode_switcher.segment_min_width` | number | `124` | Minimum width of each Edit/Direct/Play segment. |
|
||||||
|
| `mode_frame.border_width` | integer | `3` | Colored stage frame width. |
|
||||||
|
| `mode_badge.corner_radius` | integer | `12` | Mode badge corner radius. |
|
||||||
|
| `mode_badge.padding` | number | `10` | Mode badge padding. |
|
||||||
|
| `tooltip.bg_color` | color | `#101010E6` | Targeting tooltip background. |
|
||||||
|
| `tooltip.border_width` | integer | `1` | Targeting tooltip border. |
|
||||||
|
| `dialogs.queue_size` | `[width,height]` | `[680,520]` | Queue editor window minimum size. |
|
||||||
|
| `dialogs.rule_size` | `[width,height]` | `[720,560]` | Rule editor window minimum size. |
|
||||||
|
| `dialogs.action_size` | `[width,height]` | `[560,390]` | Action editor window minimum size. |
|
||||||
|
| `dialogs.confirm_size` | `[width,height]` | `[620,300]` | Rename, unsaved, missing-assets, and other confirmation/error windows. |
|
||||||
|
| `dialogs.font_size` | integer | `22` | Dialog controls/buttons. |
|
||||||
|
| `dialogs.message_font_size` | integer | `26` | Centered AcceptDialog message text. |
|
||||||
|
|
||||||
|
### `popups`
|
||||||
|
|
||||||
|
`style_defaults` accepts the normal style keys and is inherited by popup
|
||||||
|
components.
|
||||||
|
|
||||||
|
| Key | Format | Default | Purpose |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `style_defaults` | style object | bg `#202633FA`, border `#FFFFFF33`, radius `12`, padding `12` | Common popup surface. |
|
||||||
|
| `action_menu.font_size` | integer | `24` | Reserved action-menu component override; use `fonts.sizes.action_popup` for the active menu. |
|
||||||
|
| `dialog` | object | `{}` | Reserved per-dialog popup overrides. |
|
||||||
|
| `asset_selector.backdrop_alpha` | finite number | `0.55` | Reserved selector backdrop opacity. |
|
||||||
|
| `asset_selector.cell_size` | `[width,height]` | `[190,220]` | Prop/stickman selector cell size. |
|
||||||
|
| `asset_selector.thumbnail_size` | `[width,height]` | `[156,156]` | Prop/stickman selector thumbnail size. |
|
||||||
|
|
||||||
|
### `palette`
|
||||||
|
|
||||||
|
`style_defaults` uses normal style keys and styles the stage placement palette.
|
||||||
|
|
||||||
|
| Key | Format | Default | Purpose |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `style_defaults` | style object | bg `#252B38FA`, radius `10`, padding `6` | Palette surface/card base. |
|
||||||
|
| `dock_width` | number | `116` | Palette width in Edit mode and stage-frame inset. |
|
||||||
|
| `card_size` | `[width,height]` | `[96,94]` | Placement-card minimum size; the card keeps room for its label. |
|
||||||
|
| `icon_size` | number | `30` | Reserved palette icon/glyph size. |
|
||||||
|
| `drag_threshold` | number | `8` | Pointer distance before a card becomes a drag. |
|
||||||
|
| `selected_border_width` | integer | `3` | Reserved selected palette card border. |
|
||||||
|
|
||||||
|
### `overlays`
|
||||||
|
|
||||||
|
`style_defaults` controls dashed overlay geometry in world pixels; colors are
|
||||||
|
RGBA strings.
|
||||||
|
|
||||||
|
| Key | Format | Default | Purpose |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `style_defaults.line_width` | number | `2` | Dashed trajectory/guide width. |
|
||||||
|
| `style_defaults.dash_length` | number | `8` | Dash length. |
|
||||||
|
| `style_defaults.dash_gap` | number | `5` | Gap length. |
|
||||||
|
| `terrain_guide.color` | color | `#22C6FF` | Terrain paint guide. |
|
||||||
|
| `valid.color` | color | `#4DE66680` | Valid director target/placement feedback. |
|
||||||
|
| `same_type.color` | color | `#FFFFFF26` | Same-type placement feedback. |
|
||||||
|
| `invalid.color` | color | `#FF4D4D80` | Invalid target/placement feedback. |
|
||||||
|
|
||||||
|
### `widgets`
|
||||||
|
|
||||||
|
These lists feed reusable controls (`EmojiQuickGrid`, `FontPresetPicker`, and
|
||||||
|
`PresetSizeToggle`). They are data sources, not speech-action persistence.
|
||||||
|
|
||||||
|
| Key | Format | Default | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `swatches` | array of color strings | white, black, cyan, amber, green, red | Color selector entries. |
|
||||||
|
| `size_presets` | array of `{id,label,value}` objects | Small/Medium/HUGE (`20/28/40`) | Size selector entries; `value` is an integer-like size. |
|
||||||
|
| `emoji` | array of strings | eight emoji entries | Emoji quick-grid entries. |
|
||||||
|
|
||||||
|
## Practical edits
|
||||||
|
|
||||||
|
Make the top navigation easier to read:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fonts": {
|
||||||
|
"sizes": {
|
||||||
|
"file_menu": 26,
|
||||||
|
"file_menu_popup": 30,
|
||||||
|
"mode_switcher": 28
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the mode frame and make the stage colors more distinct:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode_colors": {
|
||||||
|
"edit": "#4DD9FF",
|
||||||
|
"direct": "#FF8A3D",
|
||||||
|
"play": "#65E68A",
|
||||||
|
"guide": "#4DD9FF"
|
||||||
|
},
|
||||||
|
"chrome": { "mode_frame": { "border_width": 4 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Give selectors larger cards and thumbnails:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"popups": {
|
||||||
|
"asset_selector": {
|
||||||
|
"cell_size": [220, 260],
|
||||||
|
"thumbnail_size": [190, 190]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Make kid-facing editors and confirmations larger:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chrome": {
|
||||||
|
"dialogs": {
|
||||||
|
"queue_size": [760, 600],
|
||||||
|
"rule_size": [800, 640],
|
||||||
|
"font_size": 26,
|
||||||
|
"message_font_size": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"sizes": {
|
||||||
|
"queue_panel": 24,
|
||||||
|
"rule_panel": 24,
|
||||||
|
"action_editor": 24,
|
||||||
|
"rule_editor": 24,
|
||||||
|
"panel_row": 22,
|
||||||
|
"panel_title": 28
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legacy v1 files
|
||||||
|
|
||||||
|
Older Phase 4b/3c theme files used flat keys such as
|
||||||
|
`fonts.action_popup_font_size`, `fonts.queue_panel_font_size`,
|
||||||
|
`fonts.panel_title_font_size`, and mode keys such as
|
||||||
|
`mode_colors.edit_accent` and `mode_colors.guide_line`. They remain accepted.
|
||||||
|
The loader aliases the legacy mode names and selected flat font-size names into
|
||||||
|
the nested structure, while `SandboxStage` retains the old precedence for a
|
||||||
|
file that does not declare version `"2.0"`.
|
||||||
|
|
||||||
|
The complete legacy names still read by the current runtime are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fonts.action_popup_font_size fonts.action_popup_emoji_size
|
||||||
|
fonts.assignment_badge_font_size fonts.assignment_badge_radius
|
||||||
|
fonts.rule_label_font_size fonts.status_pill_font_size
|
||||||
|
fonts.tooltip_font_size fonts.queue_panel_font_size
|
||||||
|
fonts.rule_panel_font_size fonts.action_editor_font_size
|
||||||
|
fonts.rule_editor_font_size fonts.panel_row_font_size
|
||||||
|
fonts.panel_title_font_size fonts.panel_title_bold
|
||||||
|
fonts.rule_label_bold fonts.badge_bold
|
||||||
|
fonts.action_popup = { size, bold, italic }
|
||||||
|
|
||||||
|
mode_colors.edit_accent mode_colors.direct_accent
|
||||||
|
mode_colors.play_accent mode_colors.guide_line
|
||||||
|
```
|
||||||
|
|
||||||
|
For a migration, copy the values into the nested keys in this document and set
|
||||||
|
`"version": "2.0"`. Keep only one spelling of each setting so the result is
|
||||||
|
easy to maintain.
|
||||||
|
|
||||||
|
## Fonts, emoji, and troubleshooting
|
||||||
|
|
||||||
|
- Use project-relative paths such as `res://fonts/kids_font.ttf`; do not use a
|
||||||
|
machine-specific absolute path.
|
||||||
|
- If text falls back to the Godot default, check the path, import status, and
|
||||||
|
that the resource is actually a `Font`/font file. Restart the Sandbox after
|
||||||
|
correcting it.
|
||||||
|
- Emoji availability depends on the selected font and platform glyph support.
|
||||||
|
A missing `emoji_font` is non-fatal; the normal UI fallback remains usable.
|
||||||
|
- If a color or number is ignored, check JSON commas/quotes, use a supported
|
||||||
|
color form, and keep numbers finite. The warning identifies the affected key.
|
||||||
|
- If a selector or dialog looks cramped, increase its size array and its
|
||||||
|
related font size together; restart F6 to reload the file.
|
||||||
|
|
||||||
|
## Speech customization status
|
||||||
|
|
||||||
|
`fonts.presets` (`ui`, `comic`, `pixel`, `scary`), `widgets.size_presets`,
|
||||||
|
`widgets.swatches`, and `widgets.emoji` are the reusable data contract for a
|
||||||
|
future speech-customization UI. They are intentionally available now so a
|
||||||
|
future Speak dialog can consume the same theme.
|
||||||
|
|
||||||
|
The current Sandbox speech action still stores and renders text through the
|
||||||
|
existing `StickmanSpeechBubble`; these theme lists do not yet change saved
|
||||||
|
speech actions, bubble shape, bubble colors, or bubble font at runtime. Do not
|
||||||
|
assume that changing a preset changes an existing speech action. Treat
|
||||||
|
speech-specific fields under `popups.dialog` or future bubble sections as
|
||||||
|
reserved until that feature is implemented.
|
||||||
+9
-33
@@ -1,35 +1,11 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0",
|
"version": "2.0",
|
||||||
"fonts": {
|
"global": { "font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8 },
|
||||||
"ui_font": "",
|
"fonts": { "ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": { "ui": {"label":"Standard","path":""}, "comic":{"label":"Comic","path":""}, "pixel":{"label":"Pixel","path":""}, "scary":{"label":"Scary","path":""} }, "sizes": { "action_popup":24, "action_popup_emoji":22, "assignment_badge":20, "rule_label":18, "status_pill":18, "tooltip":18, "file_menu":22, "file_menu_popup":26, "mode_switcher":24, "queue_panel":22, "rule_panel":22, "action_editor":22, "rule_editor":22, "panel_row":20, "panel_title":24, "asset_label":18, "asset_badge":14 }, "flags": { "panel_title_bold":true, "rule_label_bold":false, "badge_bold":true, "action_popup_bold":false, "action_popup_italic":false } },
|
||||||
"emoji_font": "",
|
"mode_colors": { "edit":"#22C6FF", "direct":"#FFB300", "play":"#33DD77", "guide":"#22C6FF" }, "grid": { "snap_size":15.0 },
|
||||||
"action_popup_font_size": 24,
|
"chrome": { "style_defaults": {"bg_color":"#171B24F2","corner_radius":10}, "top_bar":{"height":64}, "status_bar":{"height":38}, "mode_switcher":{"segment_min_width":124}, "mode_frame":{"border_width":3}, "mode_badge":{"corner_radius":12,"padding":10}, "tooltip":{"bg_color":"#101010E6","border_width":1}, "dialogs":{"queue_size":[680,520],"rule_size":[720,560],"action_size":[560,390],"confirm_size":[620,300],"font_size":22,"message_font_size":26} },
|
||||||
"action_popup_emoji_size": 22,
|
"popups": { "style_defaults":{"bg_color":"#202633FA","border_color":"#FFFFFF33","corner_radius":12,"padding":12}, "action_menu":{"font_size":24}, "dialog":{}, "asset_selector":{"backdrop_alpha":0.55,"cell_size":[190,220],"thumbnail_size":[156,156]} },
|
||||||
"assignment_badge_font_size": 20,
|
"palette": { "style_defaults":{"bg_color":"#252B38FA","corner_radius":10,"padding":6}, "dock_width":116, "card_size":[96,94], "icon_size":30, "drag_threshold":8, "selected_border_width":3 },
|
||||||
"assignment_badge_radius": 9,
|
"overlays": { "style_defaults":{"line_width":2,"dash_length":8,"dash_gap":5}, "terrain_guide":{"color":"#22C6FF"}, "valid":{"color":"#4DE66680"}, "same_type":{"color":"#FFFFFF26"}, "invalid":{"color":"#FF4D4D80"} },
|
||||||
"rule_label_font_size": 16,
|
"widgets": { "swatches":["#FFFFFF","#111111","#22C6FF","#FFB300","#33DD77","#FF4D4D"], "size_presets":[{"id":"small","label":"Small","value":20},{"id":"medium","label":"Medium","value":28},{"id":"huge","label":"HUGE","value":40}], "emoji":["😀","😂","😍","😮","😢","😡","👍","❤️"] }
|
||||||
"status_pill_font_size": 16,
|
|
||||||
"tooltip_font_size": 18,
|
|
||||||
"ui_font_bold": "",
|
|
||||||
"ui_font_italic": "",
|
|
||||||
"queue_panel_font_size": 18,
|
|
||||||
"rule_panel_font_size": 18,
|
|
||||||
"action_editor_font_size": 18,
|
|
||||||
"rule_editor_font_size": 18,
|
|
||||||
"panel_row_font_size": 16,
|
|
||||||
"panel_title_font_size": 18,
|
|
||||||
"panel_title_bold": true,
|
|
||||||
"rule_label_bold": false,
|
|
||||||
"badge_bold": true,
|
|
||||||
"action_popup": { "size": 24, "bold": false, "italic": false }
|
|
||||||
},
|
|
||||||
"grid": {
|
|
||||||
"snap_size": 15.0
|
|
||||||
},
|
|
||||||
"mode_colors": {
|
|
||||||
"edit_accent": "#22c6ff",
|
|
||||||
"direct_accent": "#ffb300",
|
|
||||||
"play_accent": "#33dd77",
|
|
||||||
"guide_line": "#22c6ff"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
|||||||
_base_font_size = int(sizes.get("action_editor", 18))
|
_base_font_size = int(sizes.get("action_editor", 18))
|
||||||
_apply_font_recursive(self, _base_font_size)
|
_apply_font_recursive(self, _base_font_size)
|
||||||
|
|
||||||
|
func apply_theme(sandbox_theme: Variant) -> void:
|
||||||
|
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||||
|
|
||||||
|
|
||||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
for child: Node in node.get_children():
|
for child: Node in node.get_children():
|
||||||
|
|||||||
+28
-41
@@ -10,8 +10,9 @@ const COLUMNS := 4
|
|||||||
const ROWS := 3
|
const ROWS := 3
|
||||||
const PAGE_SIZE := COLUMNS * ROWS
|
const PAGE_SIZE := COLUMNS * ROWS
|
||||||
|
|
||||||
const CELL_MIN_SIZE := Vector2(140.0, 168.0)
|
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
|
||||||
const THUMB_SIZE := Vector2(120.0, 120.0)
|
const THUMB_SIZE := Vector2(156.0, 156.0)
|
||||||
|
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
|
||||||
|
|
||||||
var kind: String = ""
|
var kind: String = ""
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ var _cell_texrects: Dictionary = {}
|
|||||||
var _placeholder_tex: ImageTexture = null
|
var _placeholder_tex: ImageTexture = null
|
||||||
var _ui_font: Font = null
|
var _ui_font: Font = null
|
||||||
var _emoji_font: Font = null
|
var _emoji_font: Font = null
|
||||||
|
var _sandbox_theme: Variant = null
|
||||||
|
|
||||||
@onready var _title_label: Label = %TitleLabel
|
@onready var _title_label: Label = %TitleLabel
|
||||||
@onready var _grid: GridContainer = %GridContainer
|
@onready var _grid: GridContainer = %GridContainer
|
||||||
@@ -87,6 +89,21 @@ func apply_font(ui_font: Font, emoji_font: Font) -> void:
|
|||||||
for c: Control in controls:
|
for c: Control in controls:
|
||||||
_apply_font_to(c)
|
_apply_font_to(c)
|
||||||
|
|
||||||
|
func apply_theme(theme: Variant) -> void:
|
||||||
|
_sandbox_theme = theme
|
||||||
|
if theme != null:
|
||||||
|
self.theme = theme.control_theme()
|
||||||
|
add_theme_stylebox_override("panel", theme.stylebox("popups.asset_selector"))
|
||||||
|
var cell_size: Variant = theme.value("popups.asset_selector.cell_size", CELL_MIN_SIZE)
|
||||||
|
if cell_size is Array and (cell_size as Array).size() == 2:
|
||||||
|
_cell_min_size = Vector2(float(cell_size[0]), float(cell_size[1]))
|
||||||
|
var thumbnail_size: Variant = theme.value("popups.asset_selector.thumbnail_size", THUMB_SIZE)
|
||||||
|
if thumbnail_size is Array and (thumbnail_size as Array).size() == 2:
|
||||||
|
_thumbnail_size = Vector2(float(thumbnail_size[0]), float(thumbnail_size[1]))
|
||||||
|
|
||||||
|
var _cell_min_size: Vector2 = CELL_MIN_SIZE
|
||||||
|
var _thumbnail_size: Vector2 = THUMB_SIZE
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if visible and event is InputEventKey:
|
if visible and event is InputEventKey:
|
||||||
@@ -152,46 +169,16 @@ func _rebuild() -> void:
|
|||||||
|
|
||||||
func _build_cell(entry: Dictionary) -> Control:
|
func _build_cell(entry: Dictionary) -> Control:
|
||||||
var key := _entry_key(entry)
|
var key := _entry_key(entry)
|
||||||
var cell := Button.new()
|
var cell: Variant = ASSET_CARD.new()
|
||||||
cell.custom_minimum_size = CELL_MIN_SIZE
|
# Selector cells inherit the popup theme. Avoid a per-card state override so
|
||||||
cell.toggle_mode = false
|
# opening a selector never implies that one asset is pre-selected.
|
||||||
|
cell.configure({"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": "", "category": String(entry.get("material_label", "")), "thumbnail_size": _thumbnail_size}, _thumbnails.get(key, null), null)
|
||||||
|
cell.custom_minimum_size = _cell_min_size
|
||||||
cell.focus_mode = Control.FOCUS_NONE
|
cell.focus_mode = Control.FOCUS_NONE
|
||||||
cell.pressed.connect(_on_cell_pressed.bind(entry))
|
cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry))
|
||||||
|
|
||||||
var vbox := VBoxContainer.new()
|
|
||||||
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
||||||
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
vbox.add_theme_constant_override("separation", 2)
|
|
||||||
cell.add_child(vbox)
|
|
||||||
|
|
||||||
var tex_rect := TextureRect.new()
|
|
||||||
tex_rect.custom_minimum_size = THUMB_SIZE
|
|
||||||
tex_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
|
||||||
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
||||||
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
||||||
tex_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
var tex: Texture2D = _thumbnails.get(key, null)
|
var tex: Texture2D = _thumbnails.get(key, null)
|
||||||
tex_rect.texture = tex if tex != null else _get_placeholder()
|
cell.set_thumbnail(tex if tex != null else _get_placeholder())
|
||||||
vbox.add_child(tex_rect)
|
_cell_texrects[key] = cell.thumbnail_rect()
|
||||||
_cell_texrects[key] = tex_rect
|
|
||||||
|
|
||||||
var name_label := Label.new()
|
|
||||||
name_label.text = String(entry.get("name", ""))
|
|
||||||
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
||||||
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
name_label.add_theme_font_size_override("font_size", 14)
|
|
||||||
_apply_font_to(name_label)
|
|
||||||
vbox.add_child(name_label)
|
|
||||||
|
|
||||||
if kind == "prop":
|
|
||||||
var badge := Label.new()
|
|
||||||
badge.text = String(entry.get("material_label", ""))
|
|
||||||
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
||||||
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
badge.add_theme_font_size_override("font_size", 12)
|
|
||||||
badge.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
|
|
||||||
_apply_font_to(badge)
|
|
||||||
vbox.add_child(badge)
|
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
||||||
@@ -211,7 +198,7 @@ func _apply_font_to(c: Control) -> void:
|
|||||||
|
|
||||||
func _get_placeholder() -> ImageTexture:
|
func _get_placeholder() -> ImageTexture:
|
||||||
if _placeholder_tex == null:
|
if _placeholder_tex == null:
|
||||||
var img := Image.create(THUMB_SIZE.x, THUMB_SIZE.y, false, Image.FORMAT_RGBA8)
|
var img := Image.create(int(_thumbnail_size.x), int(_thumbnail_size.y), false, Image.FORMAT_RGBA8)
|
||||||
img.fill(Color(0.16, 0.16, 0.16, 1.0))
|
img.fill(Color(0.16, 0.16, 0.16, 1.0))
|
||||||
_placeholder_tex = ImageTexture.create_from_image(img)
|
_placeholder_tex = ImageTexture.create_from_image(img)
|
||||||
return _placeholder_tex
|
return _placeholder_tex
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
|||||||
if _empty_label != null:
|
if _empty_label != null:
|
||||||
_apply_font_to(_empty_label, _row_font_size)
|
_apply_font_to(_empty_label, _row_font_size)
|
||||||
|
|
||||||
|
func apply_theme(sandbox_theme: Variant) -> void:
|
||||||
|
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||||
|
|
||||||
|
|
||||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
for child: Node in node.get_children():
|
for child: Node in node.get_children():
|
||||||
|
|||||||
@@ -211,6 +211,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
|||||||
_base_font_size = int(sizes.get("rule_editor", 18))
|
_base_font_size = int(sizes.get("rule_editor", 18))
|
||||||
_apply_font_recursive(self, _base_font_size)
|
_apply_font_recursive(self, _base_font_size)
|
||||||
|
|
||||||
|
func apply_theme(sandbox_theme: Variant) -> void:
|
||||||
|
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||||
|
|
||||||
|
|
||||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
for child: Node in node.get_children():
|
for child: Node in node.get_children():
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
|||||||
if _empty_label != null:
|
if _empty_label != null:
|
||||||
_apply_font_to(_empty_label, _row_font_size)
|
_apply_font_to(_empty_label, _row_font_size)
|
||||||
|
|
||||||
|
func apply_theme(sandbox_theme: Variant) -> void:
|
||||||
|
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||||
|
|
||||||
|
|
||||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||||
for child: Node in node.get_children():
|
for child: Node in node.get_children():
|
||||||
|
|||||||
+321
-61
@@ -24,6 +24,8 @@ const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
|
|||||||
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
|
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
|
||||||
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
|
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
|
||||||
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
|
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
|
||||||
|
const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd")
|
||||||
|
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
|
||||||
const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd")
|
const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd")
|
||||||
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
||||||
|
|
||||||
@@ -69,6 +71,8 @@ const ZOOM_STEP: float = 1.10
|
|||||||
const MIN_BOX_AREA: float = 16.0
|
const MIN_BOX_AREA: float = 16.0
|
||||||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||||
const SELECTOR_DIM_ALPHA := 0.5
|
const SELECTOR_DIM_ALPHA := 0.5
|
||||||
|
const TOP_BAR_HEIGHT: float = 64.0
|
||||||
|
const STATUS_BAR_HEIGHT: float = 38.0
|
||||||
|
|
||||||
const SETTINGS_PATH := "user://sandbox_settings.json"
|
const SETTINGS_PATH := "user://sandbox_settings.json"
|
||||||
const THEME_PATH := "res://sandbox_theme.json"
|
const THEME_PATH := "res://sandbox_theme.json"
|
||||||
@@ -165,9 +169,13 @@ var _selector_dim: ColorRect = null
|
|||||||
var _browse_dialog: FileDialog = null
|
var _browse_dialog: FileDialog = null
|
||||||
var _thumbnail_queue: Array[Dictionary] = []
|
var _thumbnail_queue: Array[Dictionary] = []
|
||||||
var _thumbnail_busy: bool = false
|
var _thumbnail_busy: bool = false
|
||||||
|
var _thumbnail_generation: int = 0
|
||||||
|
|
||||||
var _mode_buttons: Dictionary = {} # int (StageMode) -> Button
|
var _mode_buttons: Dictionary = {} # int (StageMode) -> Button
|
||||||
var _palette_buttons: Dictionary = {}
|
var _palette_buttons: Dictionary = {}
|
||||||
|
var _palette_dock: PanelContainer = null
|
||||||
|
var _palette_drag_payload: Dictionary = {}
|
||||||
|
var _palette_drag_previous_id: String = ""
|
||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _status_cursor_coords: Label
|
var _status_cursor_coords: Label
|
||||||
var _direct_hint_label: Label
|
var _direct_hint_label: Label
|
||||||
@@ -264,6 +272,7 @@ var _nav_dirty: bool = true
|
|||||||
|
|
||||||
## Parsed sandbox_theme.json (or {} if missing/malformed).
|
## Parsed sandbox_theme.json (or {} if missing/malformed).
|
||||||
var _theme: Dictionary = {}
|
var _theme: Dictionary = {}
|
||||||
|
var _sandbox_theme = null
|
||||||
var _theme_grid_default: float = DEFAULT_GRID_SIZE
|
var _theme_grid_default: float = DEFAULT_GRID_SIZE
|
||||||
var _accent_edit: Color = Color("#22c6ff")
|
var _accent_edit: Color = Color("#22c6ff")
|
||||||
var _accent_direct: Color = Color("#ffb300")
|
var _accent_direct: Color = Color("#ffb300")
|
||||||
@@ -419,6 +428,7 @@ func _ready() -> void:
|
|||||||
_apply_theme_to_visuals()
|
_apply_theme_to_visuals()
|
||||||
_rebuild_grid_cells()
|
_rebuild_grid_cells()
|
||||||
_apply_grid_settings()
|
_apply_grid_settings()
|
||||||
|
_update_stage_chrome_layout()
|
||||||
_apply_mode_frame()
|
_apply_mode_frame()
|
||||||
_sync_mode_buttons()
|
_sync_mode_buttons()
|
||||||
_refresh_mode_badge()
|
_refresh_mode_badge()
|
||||||
@@ -461,6 +471,18 @@ func _physics_process(_delta: float) -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
|
if not _palette_drag_payload.is_empty():
|
||||||
|
if event is InputEventMouseMotion:
|
||||||
|
_update_palette_drag((event as InputEventMouseMotion).position)
|
||||||
|
return
|
||||||
|
if event is InputEventMouseButton:
|
||||||
|
var drag_button := event as InputEventMouseButton
|
||||||
|
if drag_button.button_index == MOUSE_BUTTON_LEFT and not drag_button.pressed:
|
||||||
|
_finish_palette_drag(drag_button.position)
|
||||||
|
return
|
||||||
|
if drag_button.button_index == MOUSE_BUTTON_RIGHT and drag_button.pressed:
|
||||||
|
_cancel_palette_drag()
|
||||||
|
return
|
||||||
if event is InputEventMagnifyGesture:
|
if event is InputEventMagnifyGesture:
|
||||||
# Touchpad pinch zoom.
|
# Touchpad pinch zoom.
|
||||||
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
|
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
|
||||||
@@ -486,7 +508,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||||||
if current_mode == StageMode.EDIT:
|
if current_mode == StageMode.EDIT:
|
||||||
delete_selected()
|
delete_selected()
|
||||||
KEY_ESCAPE:
|
KEY_ESCAPE:
|
||||||
if _capture_kind != CaptureKind.NONE:
|
if not _palette_drag_payload.is_empty():
|
||||||
|
_cancel_palette_drag()
|
||||||
|
elif _capture_kind != CaptureKind.NONE:
|
||||||
_cancel_capture()
|
_cancel_capture()
|
||||||
elif _rule_step != RuleStep.IDLE:
|
elif _rule_step != RuleStep.IDLE:
|
||||||
_cancel_rule_build()
|
_cancel_rule_build()
|
||||||
@@ -527,7 +551,17 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
|||||||
return
|
return
|
||||||
if mb.button_index == MOUSE_BUTTON_RIGHT:
|
if mb.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
if mb.pressed:
|
if mb.pressed:
|
||||||
_handle_right_click()
|
if _capture_kind != CaptureKind.NONE:
|
||||||
|
_cancel_capture()
|
||||||
|
elif _rule_step != RuleStep.IDLE:
|
||||||
|
_cancel_rule_build()
|
||||||
|
elif _pending_walk_target:
|
||||||
|
_pending_walk_target = false
|
||||||
|
_context_rig = null
|
||||||
|
_apply_cursor()
|
||||||
|
_refresh_status()
|
||||||
|
else:
|
||||||
|
_handle_right_click()
|
||||||
return
|
return
|
||||||
if mb.button_index != MOUSE_BUTTON_LEFT:
|
if mb.button_index != MOUSE_BUTTON_LEFT:
|
||||||
return
|
return
|
||||||
@@ -614,6 +648,8 @@ func set_mode(mode: StageMode) -> void:
|
|||||||
if not _save_scene_to_path(AUTOSAVE_PATH, true):
|
if not _save_scene_to_path(AUTOSAVE_PATH, true):
|
||||||
push_warning("SandboxStage: autosave before Play failed.")
|
push_warning("SandboxStage: autosave before Play failed.")
|
||||||
current_mode = mode
|
current_mode = mode
|
||||||
|
if not _palette_drag_payload.is_empty():
|
||||||
|
_cancel_palette_drag()
|
||||||
match mode:
|
match mode:
|
||||||
StageMode.EDIT:
|
StageMode.EDIT:
|
||||||
_enter_edit_mode()
|
_enter_edit_mode()
|
||||||
@@ -927,6 +963,8 @@ func _block_aabb(cell: Vector2i) -> Rect2:
|
|||||||
|
|
||||||
|
|
||||||
func _begin_terrain_drag(world_pos: Vector2) -> void:
|
func _begin_terrain_drag(world_pos: Vector2) -> void:
|
||||||
|
if _snap_enabled:
|
||||||
|
world_pos = _snap_to_grid(world_pos)
|
||||||
_free_ghost()
|
_free_ghost()
|
||||||
_drag_painted.clear()
|
_drag_painted.clear()
|
||||||
_terrain_dragging = true
|
_terrain_dragging = true
|
||||||
@@ -1458,7 +1496,7 @@ func _check_autosave_recovery() -> void:
|
|||||||
dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?"
|
dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?"
|
||||||
dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free())
|
dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free())
|
||||||
dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free())
|
dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free())
|
||||||
get_node(NodePath("UI")).add_child(dialog); dialog.popup_centered()
|
get_node(NodePath("UI")).add_child(dialog); _apply_dialog_theme(dialog); dialog.popup_centered()
|
||||||
|
|
||||||
|
|
||||||
func _handle_cli_startup(args: Array) -> void:
|
func _handle_cli_startup(args: Array) -> void:
|
||||||
@@ -1517,7 +1555,7 @@ func _refresh_status() -> void:
|
|||||||
sel_text = "%d objects" % sel.size()
|
sel_text = "%d objects" % sel.size()
|
||||||
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
|
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
|
||||||
if _pending_walk_target:
|
if _pending_walk_target:
|
||||||
text += " | Click stage for walk target (Esc to cancel)"
|
text += " | Click stage for walk target (Esc or RMB to cancel)"
|
||||||
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
|
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
|
||||||
text += " | " + _rule_hint
|
text += " | " + _rule_hint
|
||||||
if not _toast_text.is_empty():
|
if not _toast_text.is_empty():
|
||||||
@@ -1554,9 +1592,10 @@ func _sync_mode_buttons() -> void:
|
|||||||
func _apply_mode_frame() -> void:
|
func _apply_mode_frame() -> void:
|
||||||
if _mode_frame == null:
|
if _mode_frame == null:
|
||||||
return
|
return
|
||||||
_mode_frame.visible = current_mode == StageMode.DIRECT
|
_update_stage_chrome_layout()
|
||||||
|
_mode_frame.visible = true
|
||||||
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
|
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
|
||||||
sb.border_color = _accent_for_mode(current_mode)
|
if sb != null: sb.border_color = _accent_for_mode(current_mode)
|
||||||
|
|
||||||
|
|
||||||
func _refresh_mode_badge() -> void:
|
func _refresh_mode_badge() -> void:
|
||||||
@@ -1643,15 +1682,96 @@ func _apply_ui_font(control: Control) -> void:
|
|||||||
control.add_theme_font_override("font", _ui_font)
|
control.add_theme_font_override("font", _ui_font)
|
||||||
|
|
||||||
|
|
||||||
|
func _theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
|
||||||
|
if _sandbox_theme == null:
|
||||||
|
return fallback
|
||||||
|
var raw: Variant = _sandbox_theme.value("chrome.dialogs." + key, [])
|
||||||
|
if raw is Array and (raw as Array).size() == 2:
|
||||||
|
return Vector2i(maxi(320, int(raw[0])), maxi(180, int(raw[1])))
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
## Dialogs are separate native/embedded windows, so they do not reliably
|
||||||
|
## inherit the CanvasLayer's visual defaults. Apply only local overrides.
|
||||||
|
func _apply_dialog_theme(dialog: Window) -> void:
|
||||||
|
if dialog == null:
|
||||||
|
return
|
||||||
|
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
|
||||||
|
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
|
||||||
|
dialog.add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null:
|
||||||
|
dialog.add_theme_font_override("font", _ui_font)
|
||||||
|
if dialog is AcceptDialog:
|
||||||
|
var message: Label = (dialog as AcceptDialog).get_label()
|
||||||
|
if message != null:
|
||||||
|
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
message.add_theme_font_size_override("font_size", message_size)
|
||||||
|
if _ui_font != null: message.add_theme_font_override("font", _ui_font)
|
||||||
|
var ok: Button = (dialog as AcceptDialog).get_ok_button()
|
||||||
|
if ok != null:
|
||||||
|
ok.add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null: ok.add_theme_font_override("font", _ui_font)
|
||||||
|
if dialog is ConfirmationDialog:
|
||||||
|
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
|
||||||
|
if cancel != null:
|
||||||
|
cancel.add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null: cancel.add_theme_font_override("font", _ui_font)
|
||||||
|
_apply_dialog_font_to_children(dialog, size)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_dialog_font_to_children(node: Node, size: int) -> void:
|
||||||
|
for child: Node in node.get_children():
|
||||||
|
if child is Control:
|
||||||
|
(child as Control).add_theme_font_size_override("font_size", size)
|
||||||
|
if _ui_font != null: (child as Control).add_theme_font_override("font", _ui_font)
|
||||||
|
_apply_dialog_font_to_children(child, size)
|
||||||
|
|
||||||
|
|
||||||
func _update_cursor_coords() -> void:
|
func _update_cursor_coords() -> void:
|
||||||
if _status_cursor_coords == null:
|
if _status_cursor_coords == null:
|
||||||
return
|
return
|
||||||
if _camera == null or not is_instance_valid(_camera):
|
if _camera == null or not is_instance_valid(_camera):
|
||||||
return
|
return
|
||||||
|
var viewport_pos: Vector2 = get_viewport().get_mouse_position()
|
||||||
|
if not _is_stage_frame_position(viewport_pos):
|
||||||
|
_status_cursor_coords.text = "X: — Y: —"
|
||||||
|
return
|
||||||
var world_pos := _camera.get_global_mouse_position()
|
var world_pos := _camera.get_global_mouse_position()
|
||||||
_status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]
|
_status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]
|
||||||
|
|
||||||
|
|
||||||
|
func _is_stage_frame_position(viewport_pos: Vector2) -> bool:
|
||||||
|
if _mode_frame == null:
|
||||||
|
return false
|
||||||
|
var viewport_size: Vector2 = get_window().size
|
||||||
|
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||||||
|
viewport_size = Vector2(1280.0, 720.0)
|
||||||
|
var left: float = _mode_frame.offset_left
|
||||||
|
var top: float = _mode_frame.offset_top
|
||||||
|
var right: float = maxf(left + 1.0, viewport_size.x)
|
||||||
|
var bottom: float = maxf(top + 1.0, viewport_size.y + _mode_frame.offset_bottom)
|
||||||
|
return is_point_in_stage_frame(viewport_pos, Rect2(Vector2(left, top), Vector2(right - left, bottom - top)))
|
||||||
|
|
||||||
|
|
||||||
|
static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool:
|
||||||
|
return frame_rect.abs().has_point(viewport_pos)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_stage_chrome_layout() -> void:
|
||||||
|
if _mode_frame == null:
|
||||||
|
return
|
||||||
|
var palette_width: float = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
|
||||||
|
var left_inset: float = palette_width if current_mode == StageMode.EDIT else 0.0
|
||||||
|
_mode_frame.offset_left = left_inset
|
||||||
|
_mode_frame.offset_top = TOP_BAR_HEIGHT
|
||||||
|
_mode_frame.offset_right = 0.0
|
||||||
|
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
|
||||||
|
if _mode_badge != null:
|
||||||
|
_mode_badge.offset_left = left_inset + 12.0
|
||||||
|
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
|
||||||
|
|
||||||
|
|
||||||
func _sync_awaiting_cursor() -> void:
|
func _sync_awaiting_cursor() -> void:
|
||||||
if _is_awaiting_click() != _last_awaiting_click:
|
if _is_awaiting_click() != _last_awaiting_click:
|
||||||
_apply_cursor()
|
_apply_cursor()
|
||||||
@@ -1667,6 +1787,9 @@ func _build_ui() -> void:
|
|||||||
_mode_frame.name = "ModeFrame"
|
_mode_frame.name = "ModeFrame"
|
||||||
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_mode_frame.offset_left = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
|
||||||
|
_mode_frame.offset_top = TOP_BAR_HEIGHT
|
||||||
|
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
|
||||||
var frame_style := StyleBoxFlat.new()
|
var frame_style := StyleBoxFlat.new()
|
||||||
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
||||||
frame_style.border_color = _accent_direct
|
frame_style.border_color = _accent_direct
|
||||||
@@ -1676,22 +1799,43 @@ func _build_ui() -> void:
|
|||||||
|
|
||||||
var top_bar := PanelContainer.new()
|
var top_bar := PanelContainer.new()
|
||||||
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||||
top_bar.offset_bottom = 40.0
|
top_bar.offset_bottom = TOP_BAR_HEIGHT
|
||||||
ui.add_child(top_bar)
|
ui.add_child(top_bar)
|
||||||
|
|
||||||
var hbox := HBoxContainer.new()
|
var chrome := Control.new()
|
||||||
hbox.name = "Content"
|
chrome.name = "Content"
|
||||||
hbox.add_theme_constant_override("separation", 8)
|
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
top_bar.add_child(hbox)
|
chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
top_bar.add_child(chrome)
|
||||||
|
var left_group := HBoxContainer.new()
|
||||||
|
left_group.name = "LeftControls"
|
||||||
|
left_group.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
|
left_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||||
|
left_group.offset_left = 8.0
|
||||||
|
left_group.offset_top = 8.0
|
||||||
|
chrome.add_child(left_group)
|
||||||
|
var right_group := HBoxContainer.new()
|
||||||
|
right_group.name = "RightControls"
|
||||||
|
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
|
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
right_group.offset_right = -8.0
|
||||||
|
right_group.offset_top = 8.0
|
||||||
|
right_group.add_theme_constant_override("separation", 8)
|
||||||
|
chrome.add_child(right_group)
|
||||||
|
|
||||||
_file_menu = MenuButton.new()
|
_file_menu = MenuButton.new()
|
||||||
_file_menu.name = "FileMenu"
|
_file_menu.name = "FileMenu"
|
||||||
_file_menu.text = "File"
|
_file_menu.text = "File"
|
||||||
|
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
|
||||||
|
_file_menu.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.file_menu", 22, 12, 48) if _sandbox_theme != null else 22)
|
||||||
var file_popup := _file_menu.get_popup()
|
var file_popup := _file_menu.get_popup()
|
||||||
|
var file_popup_font_size: int = _sandbox_theme.integer("fonts.sizes.file_menu_popup", 26, 12, 64) if _sandbox_theme != null else 26
|
||||||
|
file_popup.add_theme_font_size_override("font_size", file_popup_font_size)
|
||||||
file_popup.add_item("New", 0)
|
file_popup.add_item("New", 0)
|
||||||
file_popup.add_item("Open…", 1)
|
file_popup.add_item("Open…", 1)
|
||||||
var recent_popup := PopupMenu.new()
|
var recent_popup := PopupMenu.new()
|
||||||
recent_popup.name = "OpenRecent"
|
recent_popup.name = "OpenRecent"
|
||||||
|
recent_popup.add_theme_font_size_override("font_size", file_popup_font_size)
|
||||||
recent_popup.id_pressed.connect(_on_recent_scene_selected)
|
recent_popup.id_pressed.connect(_on_recent_scene_selected)
|
||||||
file_popup.add_child(recent_popup)
|
file_popup.add_child(recent_popup)
|
||||||
file_popup.add_submenu_item("Open Recent", "OpenRecent")
|
file_popup.add_submenu_item("Open Recent", "OpenRecent")
|
||||||
@@ -1700,9 +1844,22 @@ func _build_ui() -> void:
|
|||||||
file_popup.add_item("Rename Scene…", 4)
|
file_popup.add_item("Rename Scene…", 4)
|
||||||
file_popup.id_pressed.connect(_on_file_menu_id_pressed)
|
file_popup.id_pressed.connect(_on_file_menu_id_pressed)
|
||||||
file_popup.about_to_popup.connect(_update_recent_menu)
|
file_popup.about_to_popup.connect(_update_recent_menu)
|
||||||
hbox.add_child(_file_menu)
|
left_group.add_child(_file_menu)
|
||||||
|
|
||||||
# 3-segment mode switcher.
|
# This centered group is independent of left/right controls, preserving its
|
||||||
|
# window-center position at every supported stage width.
|
||||||
|
var mode_center := CenterContainer.new()
|
||||||
|
mode_center.name = "ModeCenter"
|
||||||
|
# This full-width layout wrapper must not shield File or utility controls.
|
||||||
|
mode_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||||
|
mode_center.offset_bottom = TOP_BAR_HEIGHT
|
||||||
|
chrome.add_child(mode_center)
|
||||||
|
var mode_switcher := HBoxContainer.new()
|
||||||
|
mode_switcher.name = "ModeSwitcher"
|
||||||
|
mode_switcher.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
|
mode_switcher.add_theme_constant_override("separation", 4)
|
||||||
|
mode_center.add_child(mode_switcher)
|
||||||
var mode_defs: Array = [
|
var mode_defs: Array = [
|
||||||
[StageMode.EDIT, "✏️ Edit"],
|
[StageMode.EDIT, "✏️ Edit"],
|
||||||
[StageMode.DIRECT, "🎬 Direct"],
|
[StageMode.DIRECT, "🎬 Direct"],
|
||||||
@@ -1713,39 +1870,32 @@ func _build_ui() -> void:
|
|||||||
var btn := Button.new()
|
var btn := Button.new()
|
||||||
btn.text = String(def[1])
|
btn.text = String(def[1])
|
||||||
btn.toggle_mode = true
|
btn.toggle_mode = true
|
||||||
|
btn.custom_minimum_size = Vector2(_sandbox_theme.number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0) if _sandbox_theme != null else 124.0, 48.0)
|
||||||
|
btn.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
|
||||||
btn.toggled.connect(_on_mode_segment_toggled.bind(mid))
|
btn.toggled.connect(_on_mode_segment_toggled.bind(mid))
|
||||||
hbox.add_child(btn)
|
mode_switcher.add_child(btn)
|
||||||
_mode_buttons[int(mid)] = btn
|
_mode_buttons[int(mid)] = btn
|
||||||
|
|
||||||
for id: String in _spawner.get_spawnable_ids():
|
|
||||||
var btn := Button.new()
|
|
||||||
btn.text = _spawner.get_label(id)
|
|
||||||
btn.toggle_mode = true
|
|
||||||
btn.toggled.connect(_on_palette_toggled.bind(id))
|
|
||||||
hbox.add_child(btn)
|
|
||||||
_palette_buttons[id] = btn
|
|
||||||
|
|
||||||
_direct_hint_label = Label.new()
|
_direct_hint_label = Label.new()
|
||||||
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
|
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
|
||||||
_direct_hint_label.visible = false
|
_direct_hint_label.visible = false
|
||||||
_apply_ui_font(_direct_hint_label)
|
_apply_ui_font(_direct_hint_label)
|
||||||
hbox.add_child(_direct_hint_label)
|
right_group.add_child(_direct_hint_label)
|
||||||
|
|
||||||
_grid_check = CheckBox.new()
|
_grid_check = CheckBox.new()
|
||||||
_grid_check.text = "Grid"
|
_grid_check.text = "Grid"
|
||||||
_grid_check.button_pressed = _show_grid
|
_grid_check.button_pressed = _show_grid
|
||||||
_grid_check.toggled.connect(_on_grid_toggled)
|
_grid_check.toggled.connect(_on_grid_toggled)
|
||||||
hbox.add_child(_grid_check)
|
right_group.add_child(_grid_check)
|
||||||
|
|
||||||
_snap_check = CheckBox.new()
|
_snap_check = CheckBox.new()
|
||||||
_snap_check.text = "Snap"
|
_snap_check.text = "Snap"
|
||||||
_snap_check.button_pressed = _snap_enabled
|
_snap_check.button_pressed = _snap_enabled
|
||||||
_snap_check.toggled.connect(_on_snap_toggled)
|
_snap_check.toggled.connect(_on_snap_toggled)
|
||||||
hbox.add_child(_snap_check)
|
right_group.add_child(_snap_check)
|
||||||
|
|
||||||
_grid_size_label = Label.new()
|
_grid_size_label = Label.new()
|
||||||
_grid_size_label.text = "Size"
|
_grid_size_label.text = "Size"
|
||||||
hbox.add_child(_grid_size_label)
|
right_group.add_child(_grid_size_label)
|
||||||
|
|
||||||
_grid_size_spin = SpinBox.new()
|
_grid_size_spin = SpinBox.new()
|
||||||
_grid_size_spin.min_value = MIN_GRID_SIZE
|
_grid_size_spin.min_value = MIN_GRID_SIZE
|
||||||
@@ -1754,12 +1904,32 @@ func _build_ui() -> void:
|
|||||||
_grid_size_spin.value = _grid_size
|
_grid_size_spin.value = _grid_size
|
||||||
_grid_size_spin.rounded = true
|
_grid_size_spin.rounded = true
|
||||||
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
|
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
|
||||||
hbox.add_child(_grid_size_spin)
|
right_group.add_child(_grid_size_spin)
|
||||||
|
|
||||||
|
# Graphical asset palette lives below chrome so stage tools keep their row.
|
||||||
|
_palette_dock = PanelContainer.new()
|
||||||
|
_palette_dock.name = "PaletteDock"
|
||||||
|
_palette_dock.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
|
||||||
|
_palette_dock.offset_top = TOP_BAR_HEIGHT
|
||||||
|
_palette_dock.offset_bottom = -STATUS_BAR_HEIGHT
|
||||||
|
_palette_dock.offset_right = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
|
||||||
|
if _sandbox_theme != null: _palette_dock.add_theme_stylebox_override("panel", _sandbox_theme.stylebox("palette"))
|
||||||
|
var scroll := ScrollContainer.new(); scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED; _palette_dock.add_child(scroll)
|
||||||
|
var cards := VBoxContainer.new(); cards.name = "PaletteCards"; cards.size_flags_horizontal = Control.SIZE_EXPAND_FILL; scroll.add_child(cards)
|
||||||
|
for entry: Dictionary in _spawner.get_palette_entries():
|
||||||
|
var card: Variant = ASSET_CARD.new()
|
||||||
|
card.configure(entry, null, _sandbox_theme)
|
||||||
|
card.toggle_mode = true
|
||||||
|
card.activated.connect(_on_palette_card_activated)
|
||||||
|
card.drag_started.connect(_begin_palette_drag)
|
||||||
|
cards.add_child(card)
|
||||||
|
_palette_buttons[String(entry["placement_id"])] = card
|
||||||
|
ui.add_child(_palette_dock)
|
||||||
|
|
||||||
# Bottom status bar (left = status, right = cursor world coords).
|
# Bottom status bar (left = status, right = cursor world coords).
|
||||||
var bottom_bar := PanelContainer.new()
|
var bottom_bar := PanelContainer.new()
|
||||||
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
|
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
bottom_bar.offset_top = -28.0
|
bottom_bar.offset_top = -STATUS_BAR_HEIGHT
|
||||||
ui.add_child(bottom_bar)
|
ui.add_child(bottom_bar)
|
||||||
|
|
||||||
var status_hbox := HBoxContainer.new()
|
var status_hbox := HBoxContainer.new()
|
||||||
@@ -1780,8 +1950,8 @@ func _build_ui() -> void:
|
|||||||
_mode_badge.name = "ModeBadge"
|
_mode_badge.name = "ModeBadge"
|
||||||
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||||
_mode_badge.offset_top = 48.0
|
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
|
||||||
_mode_badge.offset_left = 8.0
|
_mode_badge.offset_left = (_sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0) + 12.0
|
||||||
var badge_style := StyleBoxFlat.new()
|
var badge_style := StyleBoxFlat.new()
|
||||||
badge_style.bg_color = _accent_edit
|
badge_style.bg_color = _accent_edit
|
||||||
badge_style.set_corner_radius_all(12)
|
badge_style.set_corner_radius_all(12)
|
||||||
@@ -1872,6 +2042,7 @@ func _build_ui() -> void:
|
|||||||
speak_box.add_child(_speak_stop_current_check)
|
speak_box.add_child(_speak_stop_current_check)
|
||||||
_speak_dialog.register_text_enter(_speak_edit)
|
_speak_dialog.register_text_enter(_speak_edit)
|
||||||
ui.add_child(_speak_dialog)
|
ui.add_child(_speak_dialog)
|
||||||
|
_apply_dialog_theme(_speak_dialog)
|
||||||
|
|
||||||
_wait_dialog = AcceptDialog.new()
|
_wait_dialog = AcceptDialog.new()
|
||||||
_wait_dialog.title = "Wait"
|
_wait_dialog.title = "Wait"
|
||||||
@@ -1883,6 +2054,7 @@ func _build_ui() -> void:
|
|||||||
_wait_spin.value = 1.0
|
_wait_spin.value = 1.0
|
||||||
_wait_dialog.add_child(_wait_spin)
|
_wait_dialog.add_child(_wait_spin)
|
||||||
ui.add_child(_wait_dialog)
|
ui.add_child(_wait_dialog)
|
||||||
|
_apply_dialog_theme(_wait_dialog)
|
||||||
|
|
||||||
_apply_popup_theme(_action_popup)
|
_apply_popup_theme(_action_popup)
|
||||||
_apply_popup_theme(_trigger_popup)
|
_apply_popup_theme(_trigger_popup)
|
||||||
@@ -1906,6 +2078,7 @@ func _build_ui() -> void:
|
|||||||
ui.add_child(_selector)
|
ui.add_child(_selector)
|
||||||
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
|
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
|
||||||
_selector.apply_font(_ui_font, _emoji_font)
|
_selector.apply_font(_ui_font, _emoji_font)
|
||||||
|
_selector.apply_theme(_sandbox_theme)
|
||||||
|
|
||||||
_browse_dialog = FileDialog.new()
|
_browse_dialog = FileDialog.new()
|
||||||
_browse_dialog.title = "Open .stk"
|
_browse_dialog.title = "Open .stk"
|
||||||
@@ -1935,7 +2108,7 @@ func _build_ui() -> void:
|
|||||||
_rename_scene_dialog.name = "RenameSceneDialog"
|
_rename_scene_dialog.name = "RenameSceneDialog"
|
||||||
_rename_scene_dialog.title = "Rename Scene"
|
_rename_scene_dialog.title = "Rename Scene"
|
||||||
_rename_scene_dialog.cancel_button_text = "Cancel"
|
_rename_scene_dialog.cancel_button_text = "Cancel"
|
||||||
_rename_scene_dialog.min_size = Vector2i(540, 0)
|
_rename_scene_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
_scene_name_edit = LineEdit.new()
|
_scene_name_edit = LineEdit.new()
|
||||||
_scene_name_edit.name = "SceneNameEdit"
|
_scene_name_edit.name = "SceneNameEdit"
|
||||||
_scene_name_edit.placeholder_text = "Scene name"
|
_scene_name_edit.placeholder_text = "Scene name"
|
||||||
@@ -1945,24 +2118,29 @@ func _build_ui() -> void:
|
|||||||
var scene_title := _scene_name_edit.text.strip_edges()
|
var scene_title := _scene_name_edit.text.strip_edges()
|
||||||
if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty())
|
if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty())
|
||||||
ui.add_child(_rename_scene_dialog)
|
ui.add_child(_rename_scene_dialog)
|
||||||
|
_apply_dialog_theme(_rename_scene_dialog)
|
||||||
|
|
||||||
_missing_assets_dialog = AcceptDialog.new()
|
_missing_assets_dialog = AcceptDialog.new()
|
||||||
_missing_assets_dialog.name = "MissingAssetsDialog"
|
_missing_assets_dialog.name = "MissingAssetsDialog"
|
||||||
_missing_assets_dialog.title = "Missing Scene Assets"
|
_missing_assets_dialog.title = "Missing Scene Assets"
|
||||||
_missing_assets_dialog.min_size = Vector2i(560, 0)
|
_missing_assets_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
ui.add_child(_missing_assets_dialog)
|
ui.add_child(_missing_assets_dialog)
|
||||||
|
_apply_dialog_theme(_missing_assets_dialog)
|
||||||
_cli_error_dialog = AcceptDialog.new()
|
_cli_error_dialog = AcceptDialog.new()
|
||||||
_cli_error_dialog.name = "CliErrorDialog"
|
_cli_error_dialog.name = "CliErrorDialog"
|
||||||
_cli_error_dialog.title = "Could Not Load Scene"
|
_cli_error_dialog.title = "Could Not Load Scene"
|
||||||
_cli_error_dialog.min_size = Vector2i(560, 0)
|
_cli_error_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
ui.add_child(_cli_error_dialog)
|
ui.add_child(_cli_error_dialog)
|
||||||
|
_apply_dialog_theme(_cli_error_dialog)
|
||||||
_unsaved_changes_dialog = ConfirmationDialog.new()
|
_unsaved_changes_dialog = ConfirmationDialog.new()
|
||||||
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Save changes before continuing?"; _unsaved_changes_dialog.ok_button_text = "Save"
|
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "⚠️ Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"; _unsaved_changes_dialog.ok_button_text = "💾 Save"
|
||||||
_unsaved_changes_dialog.add_button("Discard", false, "discard")
|
_unsaved_changes_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
|
_unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
|
||||||
_unsaved_changes_dialog.confirmed.connect(_on_unsaved_save)
|
_unsaved_changes_dialog.confirmed.connect(_on_unsaved_save)
|
||||||
_unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard())
|
_unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard())
|
||||||
_unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
|
_unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
|
||||||
ui.add_child(_unsaved_changes_dialog)
|
ui.add_child(_unsaved_changes_dialog)
|
||||||
|
_apply_dialog_theme(_unsaved_changes_dialog)
|
||||||
|
|
||||||
# ---- Phase 3c editor tools ----
|
# ---- Phase 3c editor tools ----
|
||||||
|
|
||||||
@@ -1980,8 +2158,10 @@ func _build_ui() -> void:
|
|||||||
_apply_popup_theme(_waypoint_context)
|
_apply_popup_theme(_waypoint_context)
|
||||||
|
|
||||||
_confirm_dialog = ConfirmationDialog.new()
|
_confirm_dialog = ConfirmationDialog.new()
|
||||||
|
_confirm_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||||
_confirm_dialog.confirmed.connect(_on_confirm_confirmed)
|
_confirm_dialog.confirmed.connect(_on_confirm_confirmed)
|
||||||
ui.add_child(_confirm_dialog)
|
ui.add_child(_confirm_dialog)
|
||||||
|
_apply_dialog_theme(_confirm_dialog)
|
||||||
|
|
||||||
_queue_panel = QUEUE_PANEL.instantiate() as QueuePanel
|
_queue_panel = QUEUE_PANEL.instantiate() as QueuePanel
|
||||||
_queue_panel.edit_requested.connect(_on_queue_panel_edit_requested)
|
_queue_panel.edit_requested.connect(_on_queue_panel_edit_requested)
|
||||||
@@ -1990,6 +2170,7 @@ func _build_ui() -> void:
|
|||||||
_queue_panel.clear_requested.connect(_on_queue_panel_clear_requested)
|
_queue_panel.clear_requested.connect(_on_queue_panel_clear_requested)
|
||||||
ui.add_child(_queue_panel)
|
ui.add_child(_queue_panel)
|
||||||
_queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
|
_queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||||||
|
_queue_panel.min_size = _theme_dialog_size("queue_size", Vector2i(680, 520))
|
||||||
|
|
||||||
_rule_panel = RULE_PANEL.instantiate() as RulePanel
|
_rule_panel = RULE_PANEL.instantiate() as RulePanel
|
||||||
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
|
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
|
||||||
@@ -1999,6 +2180,7 @@ func _build_ui() -> void:
|
|||||||
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
|
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
|
||||||
ui.add_child(_rule_panel)
|
ui.add_child(_rule_panel)
|
||||||
_rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
|
_rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||||||
|
_rule_panel.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
|
||||||
|
|
||||||
_action_editor = ACTION_EDITOR.instantiate() as ActionEditor
|
_action_editor = ACTION_EDITOR.instantiate() as ActionEditor
|
||||||
_action_editor.committed.connect(_on_action_editor_committed)
|
_action_editor.committed.connect(_on_action_editor_committed)
|
||||||
@@ -2006,6 +2188,7 @@ func _build_ui() -> void:
|
|||||||
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
||||||
ui.add_child(_action_editor)
|
ui.add_child(_action_editor)
|
||||||
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||||||
|
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
|
||||||
|
|
||||||
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor
|
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor
|
||||||
_rule_editor.committed.connect(_on_rule_editor_committed)
|
_rule_editor.committed.connect(_on_rule_editor_committed)
|
||||||
@@ -2015,6 +2198,7 @@ func _build_ui() -> void:
|
|||||||
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
|
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
|
||||||
ui.add_child(_rule_editor)
|
ui.add_child(_rule_editor)
|
||||||
_rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
_rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||||||
|
_rule_editor.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
|
||||||
|
|
||||||
|
|
||||||
func _build_gizmo_layer() -> void:
|
func _build_gizmo_layer() -> void:
|
||||||
@@ -2155,6 +2339,57 @@ func _on_palette_toggled(pressed: bool, id: String) -> void:
|
|||||||
set_placement_mode("")
|
set_placement_mode("")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_palette_card_activated(payload: Dictionary) -> void:
|
||||||
|
var id: String = String(payload.get("placement_id", ""))
|
||||||
|
if id.is_empty(): return
|
||||||
|
if bool(payload.get("opens_selector", false)):
|
||||||
|
_open_selector(id)
|
||||||
|
else:
|
||||||
|
set_placement_mode(id)
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_palette_drag(payload: Dictionary) -> void:
|
||||||
|
if current_mode != StageMode.EDIT or _selector_open: return
|
||||||
|
var id: String = String(payload.get("placement_id", ""))
|
||||||
|
if id.is_empty(): return
|
||||||
|
_palette_drag_payload = payload.duplicate(true)
|
||||||
|
_palette_drag_previous_id = _placement_id
|
||||||
|
_placement_id = id
|
||||||
|
_spawn_ghost()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_palette_drag(viewport_pos: Vector2) -> void:
|
||||||
|
if _ghost != null and is_instance_valid(_ghost):
|
||||||
|
_ghost.global_position = _viewport_to_world(viewport_pos) + _spawner.get_spawn_offset(_placement_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _finish_palette_drag(viewport_pos: Vector2) -> void:
|
||||||
|
var id: String = _placement_id
|
||||||
|
if _is_stage_drop_position(viewport_pos):
|
||||||
|
_place_at(_viewport_to_world(viewport_pos))
|
||||||
|
_palette_drag_payload.clear()
|
||||||
|
_placement_id = _palette_drag_previous_id
|
||||||
|
_palette_drag_previous_id = ""
|
||||||
|
if _placement_id.is_empty(): _free_ghost()
|
||||||
|
else: _spawn_ghost()
|
||||||
|
for pid: String in _palette_buttons: (_palette_buttons[pid] as Button).set_pressed_no_signal(pid == _placement_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _cancel_palette_drag() -> void:
|
||||||
|
_palette_drag_payload.clear()
|
||||||
|
_placement_id = _palette_drag_previous_id
|
||||||
|
_palette_drag_previous_id = ""
|
||||||
|
if _placement_id.is_empty(): _free_ghost()
|
||||||
|
else: _spawn_ghost()
|
||||||
|
|
||||||
|
|
||||||
|
func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
|
||||||
|
var rect: Rect2 = get_viewport().get_visible_rect()
|
||||||
|
if not rect.has_point(viewport_pos): return false
|
||||||
|
var hovered: Control = get_viewport().gui_get_hovered_control()
|
||||||
|
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
|
||||||
func _open_selector(kind: String) -> void:
|
func _open_selector(kind: String) -> void:
|
||||||
for pid: String in _palette_buttons:
|
for pid: String in _palette_buttons:
|
||||||
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
|
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
|
||||||
@@ -2189,6 +2424,7 @@ func _on_asset_selected(entry: Dictionary) -> void:
|
|||||||
func _close_selector() -> void:
|
func _close_selector() -> void:
|
||||||
_selector_open = false
|
_selector_open = false
|
||||||
_selector_kind = ""
|
_selector_kind = ""
|
||||||
|
_thumbnail_generation += 1
|
||||||
_thumbnail_queue.clear()
|
_thumbnail_queue.clear()
|
||||||
if _selector_dim != null:
|
if _selector_dim != null:
|
||||||
_selector_dim.visible = false
|
_selector_dim.visible = false
|
||||||
@@ -2226,26 +2462,28 @@ func _on_refresh_requested() -> void:
|
|||||||
|
|
||||||
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
|
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
|
||||||
_thumbnail_queue.clear()
|
_thumbnail_queue.clear()
|
||||||
|
_thumbnail_generation += 1
|
||||||
|
var generation: int = _thumbnail_generation
|
||||||
for entry: Dictionary in entries:
|
for entry: Dictionary in entries:
|
||||||
if kind == "stickman":
|
if kind == "stickman":
|
||||||
var path := String(entry.get("path", ""))
|
var path := String(entry.get("path", ""))
|
||||||
var key := _thumbnail_cache.stickman_key(path)
|
var key := _thumbnail_cache.stickman_key(path)
|
||||||
var png := _thumbnail_cache.stickman_png(key)
|
var png := _thumbnail_cache.stickman_png(key)
|
||||||
if FileAccess.file_exists(png):
|
var tex := _thumbnail_cache.get_texture(png)
|
||||||
var tex := _thumbnail_cache.load_png(png)
|
if tex != null:
|
||||||
if tex != null and _selector != null:
|
if tex != null and _selector != null:
|
||||||
_selector.set_thumbnail(entry, tex)
|
_selector.set_thumbnail(entry, tex)
|
||||||
else:
|
else:
|
||||||
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key })
|
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
|
||||||
else:
|
else:
|
||||||
var id := String(entry.get("id", ""))
|
var id := String(entry.get("id", ""))
|
||||||
var png := _thumbnail_cache.prop_png(id)
|
var png := _thumbnail_cache.prop_png(id)
|
||||||
if FileAccess.file_exists(png):
|
var tex := _thumbnail_cache.get_texture(png)
|
||||||
var tex := _thumbnail_cache.load_png(png)
|
if tex != null:
|
||||||
if tex != null and _selector != null:
|
if tex != null and _selector != null:
|
||||||
_selector.set_thumbnail(entry, tex)
|
_selector.set_thumbnail(entry, tex)
|
||||||
else:
|
else:
|
||||||
_thumbnail_queue.append({ "kind": kind, "entry": entry })
|
_thumbnail_queue.append({ "kind": kind, "entry": entry, "cache_path": png, "generation": generation })
|
||||||
|
|
||||||
|
|
||||||
func _drain_thumbnail_queue() -> void:
|
func _drain_thumbnail_queue() -> void:
|
||||||
@@ -2261,15 +2499,15 @@ func _drain_thumbnail_queue() -> void:
|
|||||||
var data: Dictionary = entry["data"]
|
var data: Dictionary = entry["data"]
|
||||||
tex = await _stickman_thumb.render(data)
|
tex = await _stickman_thumb.render(data)
|
||||||
if tex != null:
|
if tex != null:
|
||||||
_thumbnail_cache.save_png(tex, _thumbnail_cache.stickman_png(key))
|
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
|
||||||
else:
|
else:
|
||||||
var payload: Dictionary = entry["payload"]
|
var payload: Dictionary = entry["payload"]
|
||||||
var preset: int = int(entry["material_preset"])
|
var preset: int = int(entry["material_preset"])
|
||||||
tex = await _prop_thumb.render(payload, preset)
|
tex = await _prop_thumb.render(payload, preset)
|
||||||
if tex != null:
|
if tex != null:
|
||||||
_thumbnail_cache.save_png(tex, _thumbnail_cache.prop_png(String(entry["id"])))
|
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.prop_png(String(entry["id"])))), tex)
|
||||||
_thumbnail_busy = false
|
_thumbnail_busy = false
|
||||||
if _selector != null and _selector_open and tex != null:
|
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
|
||||||
_selector.set_thumbnail(entry, tex)
|
_selector.set_thumbnail(entry, tex)
|
||||||
|
|
||||||
|
|
||||||
@@ -2727,11 +2965,11 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
|
|||||||
_rule_step = RuleStep.TRIGGER_TARGET
|
_rule_step = RuleStep.TRIGGER_TARGET
|
||||||
match id:
|
match id:
|
||||||
TRIG_ARRIVED:
|
TRIG_ARRIVED:
|
||||||
_rule_hint = "Click the waypoint to trigger on (Esc to cancel)"
|
_rule_hint = "Click the waypoint to trigger on (Esc or RMB to cancel)"
|
||||||
TRIG_ENTERED_AREA:
|
TRIG_ENTERED_AREA:
|
||||||
_rule_hint = "Click the trigger area to watch (Esc to cancel)"
|
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
|
||||||
TRIG_COLLIDED:
|
TRIG_COLLIDED:
|
||||||
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
|
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
else:
|
else:
|
||||||
_rule_step = RuleStep.SELECT_ACTION
|
_rule_step = RuleStep.SELECT_ACTION
|
||||||
@@ -2747,7 +2985,7 @@ func _on_rule_action_popup_id_pressed(id: int) -> void:
|
|||||||
actions.append(action)
|
actions.append(action)
|
||||||
_rule_builder["actions"] = actions
|
_rule_builder["actions"] = actions
|
||||||
_rule_step = RuleStep.ACTION_TARGET
|
_rule_step = RuleStep.ACTION_TARGET
|
||||||
_rule_hint = "Click the stickman who will %s (Esc to cancel)" % _action_verb(id)
|
_rule_hint = "Click the stickman who will %s (Esc or RMB to cancel)" % _action_verb(id)
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
|
|
||||||
|
|
||||||
@@ -2830,7 +3068,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
|||||||
_rule_step = RuleStep.SELECT_ACTION
|
_rule_step = RuleStep.SELECT_ACTION
|
||||||
_open_rule_action_popup()
|
_open_rule_action_popup()
|
||||||
else:
|
else:
|
||||||
_rule_hint = "Click a waypoint dot (Esc to cancel)"
|
_rule_hint = "Click a waypoint dot (Esc or RMB to cancel)"
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
"entered_area":
|
"entered_area":
|
||||||
var hit := _selection.hit_test(world_pos)
|
var hit := _selection.hit_test(world_pos)
|
||||||
@@ -2840,7 +3078,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
|||||||
_rule_step = RuleStep.SELECT_ACTION
|
_rule_step = RuleStep.SELECT_ACTION
|
||||||
_open_rule_action_popup()
|
_open_rule_action_popup()
|
||||||
else:
|
else:
|
||||||
_rule_hint = "Click a trigger area (Esc to cancel)"
|
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
"collided":
|
"collided":
|
||||||
var hit2 := _selection.hit_test(world_pos)
|
var hit2 := _selection.hit_test(world_pos)
|
||||||
@@ -2850,7 +3088,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
|||||||
_rule_step = RuleStep.SELECT_ACTION
|
_rule_step = RuleStep.SELECT_ACTION
|
||||||
_open_rule_action_popup()
|
_open_rule_action_popup()
|
||||||
else:
|
else:
|
||||||
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
|
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
|
|
||||||
|
|
||||||
@@ -2869,7 +3107,7 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
|
|||||||
match String(action.get("type", "")):
|
match String(action.get("type", "")):
|
||||||
"walk_to":
|
"walk_to":
|
||||||
_rule_step = RuleStep.ACTION_POSITION
|
_rule_step = RuleStep.ACTION_POSITION
|
||||||
_rule_hint = "Click where %s should walk (Esc to cancel)" % String(hit.name)
|
_rule_hint = "Click where %s should walk (Esc or RMB to cancel)" % String(hit.name)
|
||||||
_refresh_status()
|
_refresh_status()
|
||||||
"speak":
|
"speak":
|
||||||
_rule_step = RuleStep.PARAMS
|
_rule_step = RuleStep.PARAMS
|
||||||
@@ -3400,7 +3638,7 @@ func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
|
|||||||
var queue := rig.get_queue()
|
var queue := rig.get_queue()
|
||||||
var pos: Vector2 = queue[index].get("target", rig.global_position - STICKMAN_RIG.FOOT_OFFSET)
|
var pos: Vector2 = queue[index].get("target", rig.global_position - STICKMAN_RIG.FOOT_OFFSET)
|
||||||
_director_visuals.set_edit_waypoint(pos)
|
_director_visuals.set_edit_waypoint(pos)
|
||||||
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
|
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc or RMB to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
|
||||||
|
|
||||||
|
|
||||||
func _on_walk_edit_captured(value: Vector2) -> void:
|
func _on_walk_edit_captured(value: Vector2) -> void:
|
||||||
@@ -3667,11 +3905,11 @@ func _on_rule_editor_trigger_target_requested(type: String) -> void:
|
|||||||
_rule_editor.hide()
|
_rule_editor.hide()
|
||||||
match TRIGGER_REGISTRY.target_type(type):
|
match TRIGGER_REGISTRY.target_type(type):
|
||||||
"waypoint":
|
"waypoint":
|
||||||
_begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
|
_begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc or RMB to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
|
||||||
"area":
|
"area":
|
||||||
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
|
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc or RMB to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
|
||||||
"prop":
|
"prop":
|
||||||
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel)
|
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc or RMB to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel)
|
||||||
|
|
||||||
|
|
||||||
func _on_rule_editor_action_add_requested() -> void:
|
func _on_rule_editor_action_add_requested() -> void:
|
||||||
@@ -3763,7 +4001,7 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
|
|||||||
_show_toast("Action inserted")
|
_show_toast("Action inserted")
|
||||||
"rule_add":
|
"rule_add":
|
||||||
_pending_rule_action_flat = flat
|
_pending_rule_action_flat = flat
|
||||||
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
|
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
|
||||||
return
|
return
|
||||||
"rule_edit":
|
"rule_edit":
|
||||||
if _rule_editor != null:
|
if _rule_editor != null:
|
||||||
@@ -3785,7 +4023,7 @@ func _on_action_editor_cancelled() -> void:
|
|||||||
|
|
||||||
func _on_action_editor_target_requested() -> void:
|
func _on_action_editor_target_requested() -> void:
|
||||||
_action_editor.hide()
|
_action_editor.hide()
|
||||||
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||||||
|
|
||||||
|
|
||||||
func _cb_editor_walk_target(value: Vector2) -> void:
|
func _cb_editor_walk_target(value: Vector2) -> void:
|
||||||
@@ -3865,6 +4103,8 @@ func _apply_grid_settings() -> void:
|
|||||||
func _set_edit_controls_visible(visible: bool) -> void:
|
func _set_edit_controls_visible(visible: bool) -> void:
|
||||||
for btn: Button in _palette_buttons.values():
|
for btn: Button in _palette_buttons.values():
|
||||||
btn.visible = visible
|
btn.visible = visible
|
||||||
|
if _palette_dock != null:
|
||||||
|
_palette_dock.visible = visible
|
||||||
if _grid_check != null:
|
if _grid_check != null:
|
||||||
_grid_check.visible = visible
|
_grid_check.visible = visible
|
||||||
if _snap_check != null:
|
if _snap_check != null:
|
||||||
@@ -3899,6 +4139,8 @@ func _world_children_selectable() -> Array[Node2D]:
|
|||||||
## Loads the theme JSON. `source_path` is overridable for tests (missing /
|
## Loads the theme JSON. `source_path` is overridable for tests (missing /
|
||||||
## malformed fallback); defaults to the committed res:// theme.
|
## malformed fallback); defaults to the committed res:// theme.
|
||||||
func _load_theme(source_path: String = THEME_PATH) -> void:
|
func _load_theme(source_path: String = THEME_PATH) -> void:
|
||||||
|
_sandbox_theme = SANDBOX_THEME.new()
|
||||||
|
_sandbox_theme.load_from(source_path)
|
||||||
_theme = {}
|
_theme = {}
|
||||||
_build_font_sizes()
|
_build_font_sizes()
|
||||||
if not FileAccess.file_exists(source_path):
|
if not FileAccess.file_exists(source_path):
|
||||||
@@ -3912,7 +4154,7 @@ func _load_theme(source_path: String = THEME_PATH) -> void:
|
|||||||
if not json is Dictionary:
|
if not json is Dictionary:
|
||||||
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
|
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
|
||||||
return
|
return
|
||||||
_theme = json as Dictionary
|
_theme = _sandbox_theme.raw_config()
|
||||||
var fonts: Dictionary = _theme.get("fonts", {})
|
var fonts: Dictionary = _theme.get("fonts", {})
|
||||||
_action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
|
_action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
|
||||||
_action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22))
|
_action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22))
|
||||||
@@ -3951,6 +4193,24 @@ func _load_theme(source_path: String = THEME_PATH) -> void:
|
|||||||
_accent_direct = _parse_color(String(colors.get("direct_accent", "")), _accent_direct)
|
_accent_direct = _parse_color(String(colors.get("direct_accent", "")), _accent_direct)
|
||||||
_accent_play = _parse_color(String(colors.get("play_accent", "")), _accent_play)
|
_accent_play = _parse_color(String(colors.get("play_accent", "")), _accent_play)
|
||||||
_guide_line_color = _parse_color(String(colors.get("guide_line", "")), _guide_line_color)
|
_guide_line_color = _parse_color(String(colors.get("guide_line", "")), _guide_line_color)
|
||||||
|
# Schema v2 wins only when the source itself opts into v2. Legacy ad-hoc
|
||||||
|
# theme files retain their Phase 3 flat-key precedence for compatibility.
|
||||||
|
if String((json as Dictionary).get("version", "")) == SANDBOX_THEME.SCHEMA_VERSION:
|
||||||
|
_action_popup_font_size = _sandbox_theme.integer("fonts.sizes.action_popup", _action_popup_font_size, 8, 96)
|
||||||
|
_action_popup_emoji_size = _sandbox_theme.integer("fonts.sizes.action_popup_emoji", _action_popup_emoji_size, 8, 96)
|
||||||
|
_tooltip_font_size = _sandbox_theme.integer("fonts.sizes.tooltip", _tooltip_font_size, 8, 96)
|
||||||
|
_status_pill_font_size = _sandbox_theme.integer("fonts.sizes.status_pill", _status_pill_font_size, 8, 96)
|
||||||
|
_queue_panel_font_size = _sandbox_theme.integer("fonts.sizes.queue_panel", _queue_panel_font_size, 8, 96)
|
||||||
|
_rule_panel_font_size = _sandbox_theme.integer("fonts.sizes.rule_panel", _rule_panel_font_size, 8, 96)
|
||||||
|
_action_editor_font_size = _sandbox_theme.integer("fonts.sizes.action_editor", _action_editor_font_size, 8, 96)
|
||||||
|
_rule_editor_font_size = _sandbox_theme.integer("fonts.sizes.rule_editor", _rule_editor_font_size, 8, 96)
|
||||||
|
_panel_row_font_size = _sandbox_theme.integer("fonts.sizes.panel_row", _panel_row_font_size, 8, 96)
|
||||||
|
_panel_title_font_size = _sandbox_theme.integer("fonts.sizes.panel_title", _panel_title_font_size, 8, 96)
|
||||||
|
_accent_edit = _sandbox_theme.color("mode_colors.edit", _accent_edit)
|
||||||
|
_accent_direct = _sandbox_theme.color("mode_colors.direct", _accent_direct)
|
||||||
|
_accent_play = _sandbox_theme.color("mode_colors.play", _accent_play)
|
||||||
|
_guide_line_color = _sandbox_theme.color("mode_colors.guide", _guide_line_color)
|
||||||
|
_theme_grid_default = _sandbox_theme.number("grid.snap_size", _theme_grid_default, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||||||
_build_font_sizes()
|
_build_font_sizes()
|
||||||
|
|
||||||
|
|
||||||
@@ -4018,7 +4278,7 @@ func _apply_theme_to_visuals() -> void:
|
|||||||
_director_visuals.bold_font = _ui_font_bold
|
_director_visuals.bold_font = _ui_font_bold
|
||||||
_director_visuals.italic_font = _ui_font_italic
|
_director_visuals.italic_font = _ui_font_italic
|
||||||
if _placement_overlay != null:
|
if _placement_overlay != null:
|
||||||
_placement_overlay.guide_line_color = _guide_line_color
|
_placement_overlay.apply_style(_sandbox_theme.value("overlays", {}) if _sandbox_theme != null else {})
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Settings persistence
|
# Settings persistence
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
class_name SandboxTheme
|
||||||
|
extends RefCounted
|
||||||
|
## Validated, cached presentation data for the Sandbox Stage.
|
||||||
|
|
||||||
|
const SCHEMA_VERSION: String = "2.0"
|
||||||
|
const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Dictionary = {
|
||||||
|
"version": SCHEMA_VERSION,
|
||||||
|
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
|
||||||
|
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
|
||||||
|
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
|
||||||
|
"grid": {"snap_size": 15.0},
|
||||||
|
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
|
||||||
|
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
|
||||||
|
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
|
||||||
|
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
|
||||||
|
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||||
|
var _font_cache: Dictionary = {}
|
||||||
|
var _style_cache: Dictionary = {}
|
||||||
|
var _control_theme: Theme = null
|
||||||
|
var _warned: Dictionary = {}
|
||||||
|
|
||||||
|
func load_from(path: String = DEFAULT_PATH) -> bool:
|
||||||
|
invalidate_caches()
|
||||||
|
_config = DEFAULT_CONFIG.duplicate(true)
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
|
||||||
|
return false
|
||||||
|
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
_warn_once(path, "SandboxTheme: '%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, "SandboxTheme: '%s' is malformed; using defaults." % path)
|
||||||
|
return false
|
||||||
|
_apply_legacy_aliases(parsed as Dictionary)
|
||||||
|
_config = _deep_merge(_config, parsed as Dictionary)
|
||||||
|
return true
|
||||||
|
|
||||||
|
func raw_config() -> Dictionary:
|
||||||
|
return _config.duplicate(true)
|
||||||
|
|
||||||
|
func value(path: String, fallback: Variant = null) -> Variant:
|
||||||
|
var cursor: Variant = _config
|
||||||
|
for piece: String in path.split("."):
|
||||||
|
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
|
||||||
|
return fallback
|
||||||
|
cursor = (cursor as Dictionary)[piece]
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
func color(path: String, fallback: Color) -> Color:
|
||||||
|
var raw: Variant = value(path, null)
|
||||||
|
if not raw is String:
|
||||||
|
return fallback
|
||||||
|
var text: String = String(raw)
|
||||||
|
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
|
||||||
|
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
|
||||||
|
return fallback
|
||||||
|
return Color.from_string(text, fallback)
|
||||||
|
|
||||||
|
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
|
||||||
|
var raw: Variant = value(path, fallback)
|
||||||
|
if not raw is int and not raw is float:
|
||||||
|
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
|
||||||
|
return fallback
|
||||||
|
var n: float = float(raw)
|
||||||
|
if not is_finite(n): return fallback
|
||||||
|
return clampf(n, minimum, maximum)
|
||||||
|
|
||||||
|
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
||||||
|
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
|
||||||
|
|
||||||
|
func font(component_path: String = "", preset_id: String = "ui") -> Font:
|
||||||
|
var path: String = ""
|
||||||
|
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
|
||||||
|
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
|
||||||
|
if path.is_empty(): path = String(value("fonts.ui_font", ""))
|
||||||
|
if path.is_empty(): return ThemeDB.fallback_font
|
||||||
|
if _font_cache.has(path): return _font_cache[path]
|
||||||
|
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
|
||||||
|
if resource is Font:
|
||||||
|
_font_cache[path] = resource
|
||||||
|
return resource as Font
|
||||||
|
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
|
||||||
|
return ThemeDB.fallback_font
|
||||||
|
|
||||||
|
func emoji_font() -> Font:
|
||||||
|
var path: String = String(value("fonts.emoji_font", ""))
|
||||||
|
if path.is_empty(): return font()
|
||||||
|
return font("", "emoji") if ResourceLoader.exists(path) else font()
|
||||||
|
|
||||||
|
func style(component_path: String) -> Dictionary:
|
||||||
|
var result: Dictionary = {}
|
||||||
|
var section: String = component_path.get_slice(".", 0)
|
||||||
|
result = _deep_merge(result, value("global", {}))
|
||||||
|
result = _deep_merge(result, value(section + ".style_defaults", {}))
|
||||||
|
result = _deep_merge(result, value(component_path, {}))
|
||||||
|
return result
|
||||||
|
|
||||||
|
func stylebox(component_path: String) -> StyleBoxFlat:
|
||||||
|
var cfg: Dictionary = style(component_path)
|
||||||
|
var key: String = JSON.stringify(cfg)
|
||||||
|
if not _style_cache.has(key):
|
||||||
|
var box := StyleBoxFlat.new()
|
||||||
|
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
|
||||||
|
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
|
||||||
|
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
|
||||||
|
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
|
||||||
|
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
|
||||||
|
box.content_margin_left = padding; box.content_margin_right = padding
|
||||||
|
box.content_margin_top = padding; box.content_margin_bottom = padding
|
||||||
|
_style_cache[key] = box
|
||||||
|
return (_style_cache[key] as StyleBoxFlat).duplicate()
|
||||||
|
|
||||||
|
func control_theme() -> Theme:
|
||||||
|
if _control_theme == null:
|
||||||
|
_control_theme = Theme.new()
|
||||||
|
_control_theme.default_font = font()
|
||||||
|
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
|
||||||
|
return _control_theme
|
||||||
|
|
||||||
|
func swatches() -> Array[Color]:
|
||||||
|
var result: Array[Color] = []
|
||||||
|
for item: Variant in value("widgets.swatches", []):
|
||||||
|
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
|
||||||
|
return result
|
||||||
|
|
||||||
|
func size_presets() -> Array[Dictionary]:
|
||||||
|
var result: Array[Dictionary] = []
|
||||||
|
for item: Variant in value("widgets.size_presets", []):
|
||||||
|
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
|
||||||
|
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
|
||||||
|
return result
|
||||||
|
|
||||||
|
func font_presets() -> Array[Dictionary]:
|
||||||
|
var result: Array[Dictionary] = []
|
||||||
|
var presets: Dictionary = value("fonts.presets", {})
|
||||||
|
for id: String in presets:
|
||||||
|
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
|
||||||
|
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
|
||||||
|
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
|
||||||
|
return result
|
||||||
|
|
||||||
|
func emoji_items() -> Array[String]:
|
||||||
|
var result: Array[String] = []
|
||||||
|
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
func invalidate_caches() -> void:
|
||||||
|
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
|
||||||
|
|
||||||
|
func _apply_legacy_aliases(source: Dictionary) -> void:
|
||||||
|
var modes: Dictionary = source.get("mode_colors", {})
|
||||||
|
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
|
||||||
|
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
|
||||||
|
var fonts: Dictionary = source.get("fonts", {})
|
||||||
|
var sizes: Dictionary = fonts.get("sizes", {})
|
||||||
|
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
|
||||||
|
var legacy: String = name + "_font_size"
|
||||||
|
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
|
||||||
|
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
|
||||||
|
|
||||||
|
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], overrides[key])
|
||||||
|
else: merged[key] = overrides[key]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
|
||||||
|
return Color.from_string(String(cfg.get(key, "")), fallback)
|
||||||
|
|
||||||
|
func _warn_once(key: String, message: String) -> void:
|
||||||
|
if not _warned.has(key): _warned[key] = true; push_warning(message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://02tfgtwtgfcw
|
||||||
@@ -95,6 +95,21 @@ func set_style(cfg: Dictionary) -> void:
|
|||||||
mark_dirty()
|
mark_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
## Resolved-theme entry point. `set_style` remains for legacy callers/tests.
|
||||||
|
func apply_style(style: Dictionary, fonts: Dictionary = {}) -> void:
|
||||||
|
var legacy: Dictionary = {"fonts": fonts.duplicate(true)}
|
||||||
|
if not fonts.is_empty():
|
||||||
|
var sizes: Dictionary = fonts.get("sizes", fonts)
|
||||||
|
legacy["fonts"] = {
|
||||||
|
"assignment_badge_font_size": sizes.get("assignment_badge", badge_icon_size),
|
||||||
|
"assignment_badge_radius": style.get("badge_radius", badge_radius),
|
||||||
|
"rule_label_font_size": sizes.get("rule_label", rule_label_font_size),
|
||||||
|
"rule_label_bold": fonts.get("flags", {}).get("rule_label_bold", rule_label_bold),
|
||||||
|
"badge_bold": fonts.get("flags", {}).get("badge_bold", badge_bold),
|
||||||
|
}
|
||||||
|
set_style(legacy)
|
||||||
|
|
||||||
|
|
||||||
func set_rules(r: Array[Dictionary]) -> void:
|
func set_rules(r: Array[Dictionary]) -> void:
|
||||||
rules = r
|
rules = r
|
||||||
mark_dirty()
|
mark_dirty()
|
||||||
|
|||||||
@@ -22,12 +22,25 @@ var action_target: Vector2 = Vector2.ZERO
|
|||||||
var action_valid: bool = true
|
var action_valid: bool = true
|
||||||
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||||
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
||||||
|
var line_width_px: float = 2.0
|
||||||
|
var dash_length_px: float = 8.0
|
||||||
|
var dash_gap_px: float = 5.0
|
||||||
|
|
||||||
const DASH_LENGTH_PX := 8.0
|
const DASH_LENGTH_PX := 8.0
|
||||||
const DASH_GAP_PX := 5.0
|
const DASH_GAP_PX := 5.0
|
||||||
const LINE_WIDTH_PX := 2.0
|
const LINE_WIDTH_PX := 2.0
|
||||||
const MARKER_RADIUS_PX := 10.0
|
const MARKER_RADIUS_PX := 10.0
|
||||||
|
|
||||||
|
func apply_style(style: Dictionary) -> void:
|
||||||
|
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
|
||||||
|
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
|
||||||
|
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
|
||||||
|
var defaults: Dictionary = style.get("style_defaults", {})
|
||||||
|
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
|
||||||
|
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
|
||||||
|
dash_gap_px = maxf(0.0, float(defaults.get("dash_gap", dash_gap_px)))
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
if terrain_guide_visible or action_visible:
|
if terrain_guide_visible or action_visible:
|
||||||
@@ -70,15 +83,15 @@ func _draw() -> void:
|
|||||||
|
|
||||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||||
var zoom := _zoom()
|
var zoom := _zoom()
|
||||||
var dash := DASH_LENGTH_PX / zoom
|
var dash := dash_length_px / zoom
|
||||||
var gap := DASH_GAP_PX / zoom
|
var gap := dash_gap_px / zoom
|
||||||
var dir := from.direction_to(to)
|
var dir := from.direction_to(to)
|
||||||
var total := from.distance_to(to)
|
var total := from.distance_to(to)
|
||||||
var dist := 0.0
|
var dist := 0.0
|
||||||
while dist < total:
|
while dist < total:
|
||||||
var start := from + dir * dist
|
var start := from + dir * dist
|
||||||
var len := minf(dash, total - dist)
|
var len := minf(dash, total - dist)
|
||||||
draw_line(start, start + dir * len, color, LINE_WIDTH_PX / zoom, true)
|
draw_line(start, start + dir * len, color, line_width_px / zoom, true)
|
||||||
dist += dash + gap
|
dist += dash + gap
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ func get_spawnable_ids() -> Array[String]:
|
|||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
## UI-safe registry view. Callers receive no templates or mutable registry state.
|
||||||
|
func get_palette_entries() -> Array[Dictionary]:
|
||||||
|
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯"}
|
||||||
|
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events"}
|
||||||
|
var result: Array[Dictionary] = []
|
||||||
|
for entry: Dictionary in _registry:
|
||||||
|
var id: String = String(entry.get("id", ""))
|
||||||
|
var kind: String = String(entry.get("kind", ""))
|
||||||
|
result.append({"placement_id":id, "kind":kind, "label":String(entry.get("label", id)), "icon":String(icons.get(id, "")), "category":String(categories.get(kind, "")), "opens_selector":kind == "prop" or kind == "stickman"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
func get_label(id: String) -> String:
|
func get_label(id: String) -> String:
|
||||||
var entry := _find_entry(id)
|
var entry := _find_entry(id)
|
||||||
return String(entry.get("label", id)) if not entry.is_empty() else id
|
return String(entry.get("label", id)) if not entry.is_empty() else id
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ extends RefCounted
|
|||||||
const STICKMEN_DIR := "user://thumbnails/stickmen"
|
const STICKMEN_DIR := "user://thumbnails/stickmen"
|
||||||
const PROP_DIR := "user://thumbnails/props"
|
const PROP_DIR := "user://thumbnails/props"
|
||||||
const PROP_VERSION := 1
|
const PROP_VERSION := 1
|
||||||
|
var _memory: Dictionary = {}
|
||||||
|
|
||||||
|
func get_texture(cache_path: String) -> Texture2D:
|
||||||
|
if _memory.has(cache_path):
|
||||||
|
return _memory[cache_path] as Texture2D
|
||||||
|
var texture: Texture2D = load_png(cache_path)
|
||||||
|
if texture != null:
|
||||||
|
_memory[cache_path] = texture
|
||||||
|
return texture
|
||||||
|
|
||||||
|
func store_texture(cache_path: String, texture: Texture2D) -> Error:
|
||||||
|
if texture == null:
|
||||||
|
return ERR_INVALID_PARAMETER
|
||||||
|
_memory[cache_path] = texture
|
||||||
|
return save_png(texture, cache_path)
|
||||||
|
|
||||||
|
func forget_memory(cache_path: String) -> void:
|
||||||
|
_memory.erase(cache_path)
|
||||||
|
|
||||||
|
func clear_memory() -> void:
|
||||||
|
_memory.clear()
|
||||||
|
|
||||||
|
|
||||||
func stickman_key(path: String) -> String:
|
func stickman_key(path: String) -> String:
|
||||||
@@ -19,12 +40,16 @@ func prop_png(id: String) -> String:
|
|||||||
|
|
||||||
|
|
||||||
func load_png(png_path: String) -> Texture2D:
|
func load_png(png_path: String) -> Texture2D:
|
||||||
|
if _memory.has(png_path):
|
||||||
|
return _memory[png_path] as Texture2D
|
||||||
if not FileAccess.file_exists(png_path):
|
if not FileAccess.file_exists(png_path):
|
||||||
return null
|
return null
|
||||||
var img := Image.load_from_file(png_path)
|
var img := Image.load_from_file(png_path)
|
||||||
if img == null:
|
if img == null:
|
||||||
return null
|
return null
|
||||||
return ImageTexture.create_from_image(img)
|
var texture: Texture2D = ImageTexture.create_from_image(img)
|
||||||
|
_memory[png_path] = texture
|
||||||
|
return texture
|
||||||
|
|
||||||
|
|
||||||
func save_png(tex: Texture2D, png_path: String) -> Error:
|
func save_png(tex: Texture2D, png_path: String) -> Error:
|
||||||
@@ -34,6 +59,7 @@ func save_png(tex: Texture2D, png_path: String) -> Error:
|
|||||||
if img == null:
|
if img == null:
|
||||||
return ERR_CANT_CREATE
|
return ERR_CANT_CREATE
|
||||||
ensure_dir(png_path.get_base_dir())
|
ensure_dir(png_path.get_base_dir())
|
||||||
|
_memory[png_path] = tex
|
||||||
return img.save_png(png_path)
|
return img.save_png(png_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
class_name AssetCard
|
||||||
|
extends Button
|
||||||
|
|
||||||
|
signal activated(payload: Dictionary)
|
||||||
|
signal drag_started(payload: Dictionary)
|
||||||
|
|
||||||
|
var _payload: Dictionary = {}
|
||||||
|
var _press_position: Vector2 = Vector2.ZERO
|
||||||
|
var _dragging: bool = false
|
||||||
|
var _drag_threshold: float = 8.0
|
||||||
|
var _icon: TextureRect = null
|
||||||
|
var _glyph: Label = null
|
||||||
|
var _label: Label = null
|
||||||
|
|
||||||
|
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Variant = null) -> void:
|
||||||
|
_payload = payload.duplicate(true)
|
||||||
|
_drag_threshold = theme.number("palette.drag_threshold", 8.0, 1.0, 128.0) if theme != null else 8.0
|
||||||
|
custom_minimum_size = Vector2(96, 108)
|
||||||
|
clip_contents = true
|
||||||
|
if theme != null:
|
||||||
|
var size_data: Variant = theme.value("palette.card_size", [96, 94])
|
||||||
|
if size_data is Array and (size_data as Array).size() == 2: custom_minimum_size = Vector2(float(size_data[0]), maxf(108.0, float(size_data[1])))
|
||||||
|
add_theme_stylebox_override("normal", theme.stylebox("palette"))
|
||||||
|
var thumbnail_size: Vector2 = _payload.get("thumbnail_size", Vector2(64, 64))
|
||||||
|
focus_mode = Control.FOCUS_ALL
|
||||||
|
tooltip_text = String(_payload.get("label", ""))
|
||||||
|
for child: Node in get_children(): child.queue_free()
|
||||||
|
var box := VBoxContainer.new(); box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT); box.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.clip_contents = true
|
||||||
|
box.alignment = BoxContainer.ALIGNMENT_CENTER; box.add_theme_constant_override("separation", 2); add_child(box)
|
||||||
|
_icon = TextureRect.new(); _icon.custom_minimum_size = thumbnail_size; _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE; _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED; _icon.texture = icon_texture; _icon.visible = icon_texture != null; _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_icon)
|
||||||
|
_glyph = Label.new(); _glyph.text = String(_payload.get("icon", "")); _glyph.visible = icon_texture == null; _glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE; _glyph.add_theme_font_size_override("font_size", 30); box.add_child(_glyph)
|
||||||
|
_label = Label.new(); _label.text = String(_payload.get("label", "")); _label.custom_minimum_size = Vector2(0, 28); _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; _label.clip_text = true; _label.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_label)
|
||||||
|
|
||||||
|
func set_thumbnail(texture: Texture2D) -> void:
|
||||||
|
if _icon != null: _icon.texture = texture; _icon.visible = texture != null
|
||||||
|
if _glyph != null and texture != null: _glyph.visible = false
|
||||||
|
|
||||||
|
func thumbnail_rect() -> TextureRect:
|
||||||
|
return _icon
|
||||||
|
|
||||||
|
func set_selected(selected: bool) -> void:
|
||||||
|
button_pressed = selected
|
||||||
|
|
||||||
|
func payload() -> Dictionary:
|
||||||
|
return _payload.duplicate(true)
|
||||||
|
|
||||||
|
func _gui_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if event.pressed:
|
||||||
|
_press_position = event.position; _dragging = false
|
||||||
|
elif _dragging:
|
||||||
|
accept_event()
|
||||||
|
elif event is InputEventMouseMotion and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and not _dragging:
|
||||||
|
if event.position.distance_to(_press_position) >= _drag_threshold:
|
||||||
|
_dragging = true; drag_started.emit(payload()); accept_event()
|
||||||
|
|
||||||
|
func _pressed() -> void:
|
||||||
|
if not _dragging: activated.emit(payload())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dayameygn6kdb
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class_name ColorSwatchSelector
|
||||||
|
extends HBoxContainer
|
||||||
|
signal color_selected(color: Color)
|
||||||
|
var _selected: Color = Color.WHITE
|
||||||
|
var _group: ButtonGroup = ButtonGroup.new()
|
||||||
|
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
|
||||||
|
for child: Node in get_children(): child.queue_free()
|
||||||
|
_selected = selected
|
||||||
|
for color: Color in colors:
|
||||||
|
var button := Button.new(); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group; button.tooltip_text = color.to_html(true)
|
||||||
|
var box := StyleBoxFlat.new(); box.bg_color = color; box.set_corner_radius_all(22); button.add_theme_stylebox_override("normal", box)
|
||||||
|
button.pressed.connect(func() -> void: set_selected(color, true)); add_child(button)
|
||||||
|
button.set_pressed_no_signal(color.is_equal_approx(selected))
|
||||||
|
func set_selected(color: Color, emit_signal: bool = false) -> void:
|
||||||
|
_selected = color
|
||||||
|
if emit_signal: color_selected.emit(color)
|
||||||
|
func get_selected() -> Color: return _selected
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://djy6yykmgtxk0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
class_name EmojiQuickGrid
|
||||||
|
extends GridContainer
|
||||||
|
signal emoji_selected(text: String)
|
||||||
|
func configure(items: Array[String], columns: int = 8, theme: Variant = null) -> void:
|
||||||
|
for child: Node in get_children(): child.queue_free()
|
||||||
|
self.columns = maxi(1, columns)
|
||||||
|
for text: String in items:
|
||||||
|
var button := Button.new(); button.text = text; button.custom_minimum_size = Vector2(44,44); button.focus_mode = Control.FOCUS_ALL
|
||||||
|
if theme != null: button.add_theme_font_override("font", theme.emoji_font())
|
||||||
|
button.pressed.connect(func() -> void: emoji_selected.emit(text)); add_child(button)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dlstr7bm0x258
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class_name FontPresetPicker
|
||||||
|
extends HBoxContainer
|
||||||
|
signal preset_selected(id: String)
|
||||||
|
var _selected_id: String = ""
|
||||||
|
var _group: ButtonGroup = ButtonGroup.new()
|
||||||
|
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
|
||||||
|
for child: Node in get_children(): child.queue_free()
|
||||||
|
_selected_id = selected_id
|
||||||
|
for item: Dictionary in presets:
|
||||||
|
var id: String = String(item.get("id", "")); if id.is_empty(): continue
|
||||||
|
var button := Button.new(); button.text = String(item.get("label", id)); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
|
||||||
|
if item.get("font", null) is Font: button.add_theme_font_override("font", item["font"])
|
||||||
|
button.pressed.connect(func() -> void: set_selected(id, true)); add_child(button); button.set_pressed_no_signal(id == selected_id)
|
||||||
|
func set_selected(id: String, emit_signal: bool = false) -> void:
|
||||||
|
_selected_id = id
|
||||||
|
if emit_signal: preset_selected.emit(id)
|
||||||
|
func get_selected_id() -> String: return _selected_id
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dfwkdo5sx6i4h
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
class_name PresetSizeToggle
|
||||||
|
extends HBoxContainer
|
||||||
|
signal preset_selected(id: String, value: int)
|
||||||
|
var _presets: Array[Dictionary] = []
|
||||||
|
var _selected_id: String = ""
|
||||||
|
var _group: ButtonGroup = ButtonGroup.new()
|
||||||
|
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
|
||||||
|
for child: Node in get_children(): child.queue_free()
|
||||||
|
_presets = presets.duplicate(true); _selected_id = selected_id
|
||||||
|
for item: Dictionary in _presets:
|
||||||
|
if String(item.get("id","")).is_empty(): continue
|
||||||
|
var button := Button.new(); button.text = String(item.get("label", item["id"])); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
|
||||||
|
button.pressed.connect(func() -> void: set_selected(String(item["id"]), true)); add_child(button); button.set_pressed_no_signal(String(item["id"]) == selected_id)
|
||||||
|
if not _presets.is_empty() and _selected_id.is_empty(): _selected_id = String(_presets[0].get("id", ""))
|
||||||
|
func set_selected(id: String, emit_signal: bool = false) -> void:
|
||||||
|
for item: Dictionary in _presets:
|
||||||
|
if String(item.get("id", "")) == id:
|
||||||
|
_selected_id = id
|
||||||
|
if emit_signal: preset_selected.emit(id, int(item.get("value", 0)))
|
||||||
|
return
|
||||||
|
func get_selected_id() -> String: return _selected_id
|
||||||
|
func get_selected_value() -> int:
|
||||||
|
for item: Dictionary in _presets:
|
||||||
|
if String(item.get("id", "")) == _selected_id: return int(item.get("value", 0))
|
||||||
|
return 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://fx38yavxtiv4
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
# Phase 7 UI Polish — Architecture Specification
|
||||||
|
|
||||||
|
Status: **proposed; awaiting user approval**
|
||||||
|
Source plan: `plans/PHASE_7_UI_POLISH.md`
|
||||||
|
Target: Godot 4.7.1 Sandbox Stage only. The configured Stickman Editor main scene is not changed.
|
||||||
|
|
||||||
|
## 1. Scope and current-state audit
|
||||||
|
|
||||||
|
Phase 7 is an incremental polish pass, not a rewrite of `SandboxStage`. The stage scene remains deliberately small and the runtime UI remains owned by `SandboxStage._build_ui()`. Existing tests also access several stage fields directly, so moving the full UI into a new scene would add migration risk without improving the requested behavior.
|
||||||
|
|
||||||
|
### Already implemented; preserve and restyle
|
||||||
|
|
||||||
|
| Plan item | Existing implementation |
|
||||||
|
|---|---|
|
||||||
|
| Three mode controls | `_mode_buttons` in `scripts/sandbox_stage.gd` |
|
||||||
|
| Mode badge, status text, live world coordinates | `_mode_badge`, `_status_label`, `_status_cursor_coords` |
|
||||||
|
| Mode cursor and target reticle | `_apply_cursor()`, `_build_action_cursor()` |
|
||||||
|
| Cursor tooltip and direct-action trajectory | `_tooltip`, `StagePlacementOverlay.set_action_trajectory()` |
|
||||||
|
| Terrain AABB stride, Bresenham painting, Shift lock | `_terrain_stride()`, `_bresenham_cells()`, `_update_terrain_drag()` |
|
||||||
|
| Three-state terrain ghosts | `_classify_cell()`, `_refresh_terrain_ghosts()` |
|
||||||
|
| Asset selector with thumbnail cards and dimmer | `asset_selector.tscn`, `asset_selector.gd`, `_selector_dim` |
|
||||||
|
| Sequential off-screen thumbnail rendering and disk cache | stage `_thumbnail_queue`; `scripts/thumbnails/*` |
|
||||||
|
| Popup and editor font overrides | `_apply_popup_theme()` and widget `apply_font()` methods |
|
||||||
|
|
||||||
|
These behaviors are integration points, not new Phase 7 systems. Their public behavior and existing headless tests must continue to pass.
|
||||||
|
|
||||||
|
### In Phase 7
|
||||||
|
|
||||||
|
1. Replace the stage-local JSON parsing and scattered style constants with a validated, cached `SandboxTheme` service and schema version 2.
|
||||||
|
2. Give the existing mode switcher, viewport frame, badge, status bar, tooltips, popups, dialogs, selector, and overlays a coherent theme-driven appearance.
|
||||||
|
3. Replace the top-row spawn text buttons with a scrollable graphical card palette. Clicking keeps the existing persistent placement behavior; dragging a card performs one placement.
|
||||||
|
4. Harden the existing thumbnail cache with a memory tier and stale-request protection. Rendering remains serialized on the main thread.
|
||||||
|
5. Add reusable color, size, font-preset, and emoji controls, with stable typed APIs, ready for later parameter dialogs.
|
||||||
|
|
||||||
|
### Explicitly deferred
|
||||||
|
|
||||||
|
- Speech bubble geometry/styles, custom speech rendering, and changes to persisted `speak` actions. Section 10 defines the future contract, but Phase 7 does not emit or consume it.
|
||||||
|
- Embedding the four new reusable widgets into the Speak dialog. That is the first consumer in the future speech-customization phase.
|
||||||
|
- True background-thread thumbnail rendering. Godot scene/viewport rendering remains main-thread work; Phase 7 limits generation to one job at a time and avoids duplicate/stale work.
|
||||||
|
- Dragging cards out of the modal `AssetSelector`. Direct drag uses the currently selected Prop or Stickman card in the persistent palette; the modal remains click-to-select.
|
||||||
|
- New bitmap icon art. Phase 7 uses registry-provided emoji glyphs plus existing generated thumbnails. Art assets can replace them later without changing card APIs.
|
||||||
|
- A full `.tscn` conversion of the code-built Sandbox UI.
|
||||||
|
|
||||||
|
## 2. Files
|
||||||
|
|
||||||
|
### Add
|
||||||
|
|
||||||
|
- `scripts/sandbox_theme.gd` — `class_name SandboxTheme`, schema validation, legacy aliases, typed accessors, font/style/theme caches.
|
||||||
|
- `scripts/ui/asset_card.gd` — `class_name AssetCard`, reusable palette/selector card and pointer-drag threshold handling.
|
||||||
|
- `scripts/ui/color_swatch_selector.gd` — `class_name ColorSwatchSelector`.
|
||||||
|
- `scripts/ui/preset_size_toggle.gd` — `class_name PresetSizeToggle`.
|
||||||
|
- `scripts/ui/font_preset_picker.gd` — `class_name FontPresetPicker`.
|
||||||
|
- `scripts/ui/emoji_quick_grid.gd` — `class_name EmojiQuickGrid`.
|
||||||
|
- `tests/test_phase7_theme.gd` — schema, fallback, alias, and resource/cache behavior.
|
||||||
|
- `tests/test_phase7_widgets.gd` — widget data contracts and signals.
|
||||||
|
- `tests/test_phase7_palette.gd` — card activation/drag state, mode visibility, and placement behavior.
|
||||||
|
|
||||||
|
The reusable controls are script-built `Control` subclasses, not new scenes. They have no project-specific node paths and can be instantiated in any popup.
|
||||||
|
|
||||||
|
### Change
|
||||||
|
|
||||||
|
- `sandbox_theme.json` — migrate the committed theme to schema `2.0`.
|
||||||
|
- `scripts/sandbox_stage.gd` — own `SandboxTheme`; build/stylize the chrome and palette; route card drag input; use theme accessors; apply theme to popups/dialogs; add thumbnail request generation tokens.
|
||||||
|
- `scripts/stage_spawner.gd` — expose registry-backed palette descriptors without exposing `_registry`.
|
||||||
|
- `scripts/asset_selector.gd` — build cells with `AssetCard`; accept a `SandboxTheme`; keep current signals and pagination.
|
||||||
|
- `scenes/asset_selector.tscn` — name layout containers used for theme spacing/margins and remove hard-coded visual values now owned by the theme. Node unique names and popup behavior remain compatible.
|
||||||
|
- `scripts/thumbnails/thumbnail_cache.gd` — add an in-memory texture tier and versioned cache API while preserving current path helpers.
|
||||||
|
- `scripts/stage_placement_overlay.gd` — accept resolved overlay colors/metrics through one style method; drawing/input behavior is unchanged.
|
||||||
|
- `scripts/stage_director_visuals.gd` — consume a resolved style dictionary rather than reading raw schema internals; preserve `set_style(Dictionary)` as a compatibility wrapper.
|
||||||
|
- `scripts/action_editor.gd`, `scripts/rule_editor.gd`, `scripts/queue_panel.gd`, `scripts/rule_panel.gd` — add `apply_theme(theme: SandboxTheme)` wrappers and retain current `apply_font(...)` methods for existing tests/callers.
|
||||||
|
- Existing Phase 3b/3c/4b tests only where assertions must reflect the always-visible mode frame or v2 key names; behavioral coverage is not removed.
|
||||||
|
|
||||||
|
### Not changed in Phase 7
|
||||||
|
|
||||||
|
- `scenes/sandbox_stage.tscn`
|
||||||
|
- `scripts/action_registry.gd`
|
||||||
|
- `scripts/stage_scene_io.gd`
|
||||||
|
- `scripts/stickman_rig.gd`
|
||||||
|
- `scripts/stickman_speech_bubble.gd`
|
||||||
|
|
||||||
|
## 3. `sandbox_theme.json` schema 2.0
|
||||||
|
|
||||||
|
The JSON remains hand-editable. Colors are HTML strings (`#RRGGBB` or `#RRGGBBAA`), resource paths are strings, dimensions are logical screen pixels, and preset IDs are stable lowercase strings.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "2.0",
|
||||||
|
"global": {
|
||||||
|
"font_preset": "ui",
|
||||||
|
"font_size": 18,
|
||||||
|
"text_color": "#F5F7FA",
|
||||||
|
"muted_text_color": "#AAB2C0",
|
||||||
|
"bg_color": "#171B24F2",
|
||||||
|
"surface_color": "#252B38FA",
|
||||||
|
"border_color": "#FFFFFF33",
|
||||||
|
"border_width": 1,
|
||||||
|
"corner_radius": 10,
|
||||||
|
"padding": 8,
|
||||||
|
"spacing": 8
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"ui_font": "",
|
||||||
|
"emoji_font": "",
|
||||||
|
"ui_font_bold": "",
|
||||||
|
"ui_font_italic": "",
|
||||||
|
"presets": {
|
||||||
|
"ui": { "label": "Standard", "path": "" },
|
||||||
|
"comic": { "label": "Comic", "path": "" },
|
||||||
|
"pixel": { "label": "Pixel", "path": "" },
|
||||||
|
"scary": { "label": "Scary", "path": "" }
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"action_popup": 24,
|
||||||
|
"action_popup_emoji": 22,
|
||||||
|
"assignment_badge": 20,
|
||||||
|
"rule_label": 16,
|
||||||
|
"status_pill": 16,
|
||||||
|
"tooltip": 18,
|
||||||
|
"queue_panel": 18,
|
||||||
|
"rule_panel": 18,
|
||||||
|
"action_editor": 18,
|
||||||
|
"rule_editor": 18,
|
||||||
|
"panel_row": 16,
|
||||||
|
"panel_title": 18,
|
||||||
|
"asset_label": 14,
|
||||||
|
"asset_badge": 12
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"panel_title_bold": true,
|
||||||
|
"rule_label_bold": false,
|
||||||
|
"badge_bold": true,
|
||||||
|
"action_popup_bold": false,
|
||||||
|
"action_popup_italic": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mode_colors": {
|
||||||
|
"edit": "#22C6FF",
|
||||||
|
"direct": "#FFB300",
|
||||||
|
"play": "#33DD77",
|
||||||
|
"guide": "#22C6FF"
|
||||||
|
},
|
||||||
|
"grid": { "snap_size": 15.0 },
|
||||||
|
"chrome": {
|
||||||
|
"style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 },
|
||||||
|
"top_bar": { "height": 48 },
|
||||||
|
"status_bar": { "height": 32 },
|
||||||
|
"mode_switcher": { "segment_min_width": 104 },
|
||||||
|
"mode_frame": { "border_width": 3 },
|
||||||
|
"mode_badge": { "corner_radius": 12, "padding": 10 },
|
||||||
|
"tooltip": { "bg_color": "#101010E6", "border_width": 1 }
|
||||||
|
},
|
||||||
|
"popups": {
|
||||||
|
"style_defaults": { "bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12 },
|
||||||
|
"action_menu": { "font_size": 24 },
|
||||||
|
"dialog": {},
|
||||||
|
"asset_selector": { "backdrop_alpha": 0.55 }
|
||||||
|
},
|
||||||
|
"palette": {
|
||||||
|
"style_defaults": { "bg_color": "#252B38FA", "corner_radius": 10, "padding": 6 },
|
||||||
|
"dock_width": 116,
|
||||||
|
"card_size": [96, 94],
|
||||||
|
"icon_size": 30,
|
||||||
|
"drag_threshold": 8,
|
||||||
|
"selected_border_width": 3
|
||||||
|
},
|
||||||
|
"overlays": {
|
||||||
|
"style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
|
||||||
|
"terrain_guide": { "color": "#22C6FF" },
|
||||||
|
"valid": { "color": "#4DE66680" },
|
||||||
|
"same_type": { "color": "#FFFFFF26" },
|
||||||
|
"invalid": { "color": "#FF4D4D80" }
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"],
|
||||||
|
"size_presets": [
|
||||||
|
{ "id": "small", "label": "Small", "value": 20 },
|
||||||
|
{ "id": "medium", "label": "Medium", "value": 28 },
|
||||||
|
{ "id": "huge", "label": "HUGE", "value": 40 }
|
||||||
|
],
|
||||||
|
"emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]
|
||||||
|
},
|
||||||
|
"speech_bubbles": {
|
||||||
|
"style_defaults": {
|
||||||
|
"font_preset": "ui",
|
||||||
|
"size_preset": "medium",
|
||||||
|
"color": "#FFFFFFFF",
|
||||||
|
"text_color": "#111111FF",
|
||||||
|
"border_color": "#00000099",
|
||||||
|
"border_width": 2,
|
||||||
|
"corner_radius": 8,
|
||||||
|
"padding": 14
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"standard": { "label": "Standard", "geometry": "rounded" },
|
||||||
|
"yell": { "label": "Yell", "geometry": "burst" },
|
||||||
|
"thought": { "label": "Thought", "geometry": "cloud" },
|
||||||
|
"whisper": { "label": "Whisper", "geometry": "dashed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Array values such as `card_size` must contain exactly two finite numbers. Unsupported or malformed values fall back independently; one bad field does not discard the rest of the file.
|
||||||
|
|
||||||
|
### Resolution order
|
||||||
|
|
||||||
|
For a component such as `popups.action_menu`, the service deep-merges, from lowest to highest priority:
|
||||||
|
|
||||||
|
1. Hard-coded `SandboxTheme.DEFAULT_CONFIG` value.
|
||||||
|
2. Loaded `global` value.
|
||||||
|
3. Loaded subsystem `popups.style_defaults` value.
|
||||||
|
4. Loaded component `popups.action_menu` value.
|
||||||
|
|
||||||
|
Font resolution is: component `font_preset`/explicit path → subsystem default → `global.font_preset` → `fonts.ui_font` → `ThemeDB.fallback_font`. An unavailable named preset falls back to `ui`. Emoji font resolution is `fonts.emoji_font` → resolved UI font → `ThemeDB.fallback_font`.
|
||||||
|
|
||||||
|
### Validation, warnings, and caching
|
||||||
|
|
||||||
|
- Missing, unreadable, malformed, or non-dictionary JSON produces one non-blocking `push_warning` and a complete default config.
|
||||||
|
- A wrong type, non-finite number, bad color, or missing/non-Font resource warns once per key and falls back only that key.
|
||||||
|
- Unknown keys are retained in `raw_config()` for forward compatibility but are ignored by typed accessors.
|
||||||
|
- Fonts are loaded once per normalized non-empty resource path in `_font_cache`.
|
||||||
|
- Resolved `StyleBoxFlat` templates are cached by the canonical resolved style dictionary. `stylebox()` returns a duplicate so mode-specific color mutation cannot leak into another control.
|
||||||
|
- The generated Godot `Theme` is cached. Call `invalidate_caches()` only after an explicit reload; Phase 7 does not add live file watching.
|
||||||
|
- No theme resource failure may prevent Sandbox startup.
|
||||||
|
|
||||||
|
## 4. `SandboxTheme` public contract
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name SandboxTheme
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const SCHEMA_VERSION: String = "2.0"
|
||||||
|
const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
||||||
|
|
||||||
|
func load_from(path: String = DEFAULT_PATH) -> bool
|
||||||
|
func raw_config() -> Dictionary
|
||||||
|
func value(path: String, fallback: Variant = null) -> Variant
|
||||||
|
func color(path: String, fallback: Color) -> Color
|
||||||
|
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float
|
||||||
|
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int
|
||||||
|
func font(component_path: String = "", preset_id: String = "ui") -> Font
|
||||||
|
func emoji_font() -> Font
|
||||||
|
func style(component_path: String) -> Dictionary
|
||||||
|
func stylebox(component_path: String) -> StyleBoxFlat
|
||||||
|
func control_theme() -> Theme
|
||||||
|
func swatches() -> Array[Color]
|
||||||
|
func size_presets() -> Array[Dictionary]
|
||||||
|
func font_presets() -> Array[Dictionary]
|
||||||
|
func emoji_items() -> Array[String]
|
||||||
|
func invalidate_caches() -> void
|
||||||
|
```
|
||||||
|
|
||||||
|
`raw_config()` returns a deep duplicate. The preset array entries returned by `size_presets()` are `{id: String, label: String, value: int}`; font preset entries are `{id: String, label: String, font: Font}`. Invalid entries are omitted, while a built-in medium size and UI font are always available.
|
||||||
|
|
||||||
|
No reload signal is needed in Phase 7 because the theme loads once before UI construction. A later live-theme editor may add `theme_reloaded()` without changing the accessor API.
|
||||||
|
|
||||||
|
## 5. Palette descriptors and controls
|
||||||
|
|
||||||
|
### `StageSpawner` API
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func get_palette_entries() -> Array[Dictionary]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each returned deep-duplicated entry has this shape:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
{
|
||||||
|
"placement_id": String, # existing id accepted by set_placement_mode()
|
||||||
|
"kind": String, # "terrain" | "prop" | "stickman" | "area"
|
||||||
|
"label": String,
|
||||||
|
"icon": String, # emoji fallback, not a resource path
|
||||||
|
"category": String, # visible badge/group label
|
||||||
|
"opens_selector": bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry defaults are Ground/🧱/Terrain, Ramp/📐/Terrain, Step/🪜/Terrain, Prop/📦/Props, Stickman/🧍/Cast, and Area/🎯/Events. `get_spawnable_ids()` and `get_label()` remain unchanged.
|
||||||
|
|
||||||
|
### `AssetCard`
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name AssetCard
|
||||||
|
extends Button
|
||||||
|
|
||||||
|
signal activated(payload: Dictionary)
|
||||||
|
signal drag_started(payload: Dictionary)
|
||||||
|
|
||||||
|
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: SandboxTheme = null) -> void
|
||||||
|
func set_thumbnail(texture: Texture2D) -> void
|
||||||
|
func set_selected(selected: bool) -> void
|
||||||
|
func payload() -> Dictionary
|
||||||
|
```
|
||||||
|
|
||||||
|
The payload is deep-copied on input and signal emission. The card owns icon/thumbnail, label, and optional category badge children. It emits `activated` on a release that stayed below `palette.drag_threshold`; after the threshold it emits `drag_started` once and suppresses activation. It does not mutate stage or spawner state directly.
|
||||||
|
|
||||||
|
### Reusable widget APIs
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# ColorSwatchSelector
|
||||||
|
signal color_selected(color: Color)
|
||||||
|
func configure(colors: Array[Color], selected: Color, theme: SandboxTheme = null) -> void
|
||||||
|
func set_selected(color: Color, emit_signal: bool = false) -> void
|
||||||
|
func get_selected() -> Color
|
||||||
|
|
||||||
|
# PresetSizeToggle
|
||||||
|
signal preset_selected(id: String, value: int)
|
||||||
|
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
|
||||||
|
func set_selected(id: String, emit_signal: bool = false) -> void
|
||||||
|
func get_selected_id() -> String
|
||||||
|
func get_selected_value() -> int
|
||||||
|
|
||||||
|
# FontPresetPicker
|
||||||
|
signal preset_selected(id: String)
|
||||||
|
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
|
||||||
|
func set_selected(id: String, emit_signal: bool = false) -> void
|
||||||
|
func get_selected_id() -> String
|
||||||
|
|
||||||
|
# EmojiQuickGrid
|
||||||
|
signal emoji_selected(text: String)
|
||||||
|
func configure(items: Array[String], columns: int = 8, theme: SandboxTheme = null) -> void
|
||||||
|
```
|
||||||
|
|
||||||
|
All configure methods replace prior children, tolerate empty inputs, use a `ButtonGroup` for exclusive preset selection, expose keyboard focus, and meet a minimum 44×44 logical-pixel pointer target.
|
||||||
|
|
||||||
|
## 6. Node ownership and layout
|
||||||
|
|
||||||
|
`SandboxStage` continues to create one `CanvasLayer` named `UI`. Its runtime ownership becomes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SandboxStage (Node2D; state and orchestration)
|
||||||
|
├── GridLayer / World / GhostHolder / PlacementOverlay / DirectorVisuals (world space)
|
||||||
|
└── UI (CanvasLayer)
|
||||||
|
├── ModeFrame (Panel; full rect; mouse ignore)
|
||||||
|
├── TopBar (PanelContainer)
|
||||||
|
│ └── FileMenu + ModeSwitcher + edit/direct utility controls
|
||||||
|
├── PaletteDock (PanelContainer; left, below TopBar and above StatusBar)
|
||||||
|
│ └── ScrollContainer/PaletteCards (VBoxContainer of AssetCard)
|
||||||
|
├── ModeBadge (PanelContainer; mouse ignore)
|
||||||
|
├── ActionTooltip (Label; mouse ignore)
|
||||||
|
├── BottomStatusBar (PanelContainer)
|
||||||
|
├── SelectorDim (ColorRect; visible only with selector)
|
||||||
|
└── Popups/dialogs/selectors/editors
|
||||||
|
```
|
||||||
|
|
||||||
|
`PaletteDock` is visible only in EDIT. Existing grid/snap/size controls remain in `TopBar`; spawn buttons leave the top row. `_palette_buttons` is retained and maps placement IDs to `AssetCard` (a `Button` subclass), preserving callers that inspect `button_pressed`/`visible`.
|
||||||
|
|
||||||
|
The mode segments share a `ButtonGroup`, have adjacent rounded styles, and retain `_on_mode_segment_toggled`. `_mode_frame` is visible in all three modes and uses the active accent; it remains behind all chrome and ignores mouse input. The badge remains below the top bar and displays the existing text.
|
||||||
|
|
||||||
|
The stage assigns `theme.control_theme()` to the UI root and explicitly to Window-derived popups/dialogs that do not reliably inherit across a native/embedded window boundary. Component styleboxes are then applied only where a named component override is needed.
|
||||||
|
|
||||||
|
## 7. Input and signal flow
|
||||||
|
|
||||||
|
### Click placement
|
||||||
|
|
||||||
|
1. `AssetCard.activated(payload)` reaches `SandboxStage._on_palette_card_activated(payload)`.
|
||||||
|
2. Terrain/Area calls existing `set_placement_mode(placement_id)`.
|
||||||
|
3. Prop/Stickman opens the existing selector; selecting an entry updates the spawner and arms the existing `prop`/`stickman` placement ID.
|
||||||
|
4. LMB remains repeated placement and RMB/Escape cancels, matching existing behavior.
|
||||||
|
|
||||||
|
### Direct card drag
|
||||||
|
|
||||||
|
Godot Control drag-and-drop is not used because the drop surface is a `Node2D` world and a full-screen `Control` would compete with existing `_unhandled_input` selection/gizmo routing.
|
||||||
|
|
||||||
|
1. `AssetCard.drag_started(payload)` calls `_begin_palette_drag(payload)`.
|
||||||
|
2. The stage stores `_palette_drag_payload`, snapshots the prior placement ID, resolves current Prop/Stickman selection, and creates the normal collision-disabled placement ghost.
|
||||||
|
3. While a palette drag is active, `SandboxStage._input` handles mouse motion and LMB release before pan/zoom routing. Tooltip/modal/rule input is not entered.
|
||||||
|
4. A release is valid only inside the stage viewport and outside visible interactive chrome (`gui_get_hovered_control() == null` or a mouse-ignore overlay). Valid release places exactly one object at `_viewport_to_world(event.position)`, using existing snap/spawn/finalize/signals/dirty-state code.
|
||||||
|
5. Release over UI, RMB, Escape, mode change, selector open, or focus loss cancels and frees the drag ghost.
|
||||||
|
6. A card drag is one-shot and restores the previous persistent placement selection. It never starts terrain line-painting; terrain drag-painting still starts from a stage press after click-arming the tool.
|
||||||
|
|
||||||
|
Add private state and helpers in `sandbox_stage.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var _palette_drag_payload: Dictionary = {}
|
||||||
|
var _palette_drag_previous_id: String = ""
|
||||||
|
|
||||||
|
func _on_palette_card_activated(payload: Dictionary) -> void
|
||||||
|
func _begin_palette_drag(payload: Dictionary) -> void
|
||||||
|
func _update_palette_drag(viewport_pos: Vector2) -> void
|
||||||
|
func _finish_palette_drag(viewport_pos: Vector2) -> void
|
||||||
|
func _cancel_palette_drag() -> void
|
||||||
|
func _is_stage_drop_position(viewport_pos: Vector2) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Priority while active is palette drag → modal suppression → pan/zoom → existing world tools. Existing rule/capture/terrain/gizmo priorities remain unchanged when no palette drag is active.
|
||||||
|
|
||||||
|
## 8. Thumbnail pipeline
|
||||||
|
|
||||||
|
The existing renderer nodes and queue remain stage-owned. Do not introduce threads.
|
||||||
|
|
||||||
|
Extend `ThumbnailCache` with:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func get_texture(cache_path: String) -> Texture2D
|
||||||
|
func store_texture(cache_path: String, texture: Texture2D) -> Error
|
||||||
|
func forget_memory(cache_path: String) -> void
|
||||||
|
func clear_memory() -> void
|
||||||
|
```
|
||||||
|
|
||||||
|
`get_texture()` checks an in-memory dictionary, then PNG, and memoizes success. `store_texture()` memoizes and persists. Existing `load_png()`/`save_png()` remain as compatibility wrappers.
|
||||||
|
|
||||||
|
The stage increments `_thumbnail_generation: int` whenever selector entries change or the selector closes. Each queued job carries `{generation, kind, entry, cache_path}`. After each `await`, results update the selector only when the generation still matches, the selector is open, and the entry is still present. Cache writes may complete for a stale UI generation because they remain useful. Queue keys prevent duplicate work in the same generation. One job at a time preserves frame stability and the existing renderer assumptions.
|
||||||
|
|
||||||
|
## 9. Overlay, popup, and dialog integration
|
||||||
|
|
||||||
|
- `StagePlacementOverlay.apply_style(style: Dictionary)` receives resolved guide/valid/invalid metrics. Existing public setters and screen-constant line math remain intact.
|
||||||
|
- The three terrain ghost modulates come from `overlays.valid`, `overlays.same_type`, and `overlays.invalid`; classification values and commit rules do not change.
|
||||||
|
- `StageDirectorVisuals.apply_style(style: Dictionary, fonts: Dictionary)` receives already-resolved sizes/colors/fonts. Keep `set_style(Dictionary)` as a legacy wrapper through Phase 7 tests.
|
||||||
|
- All PopupMenus use `popups.action_menu`; Accept/Confirmation/File dialogs use `popups.dialog`; `AssetSelector` uses `popups.asset_selector`.
|
||||||
|
- `_apply_popup_theme()` remains temporarily as a wrapper around `SandboxTheme` so existing direct tests do not break.
|
||||||
|
- Tooltip positioning continues clamping to the visible viewport. Palette dock bounds are also treated as UI, so a tooltip never becomes a valid world drop region.
|
||||||
|
|
||||||
|
## 10. Future speech customization contract (blueprint only)
|
||||||
|
|
||||||
|
When implemented in a later phase, a flat queue action will optionally carry one nested visual object:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
{
|
||||||
|
"type": "speak",
|
||||||
|
"text": String,
|
||||||
|
"duration": float,
|
||||||
|
"stop_current": bool, # optional, existing
|
||||||
|
"visual": { # optional; absent means theme defaults
|
||||||
|
"font_preset": String,
|
||||||
|
"size_preset": String,
|
||||||
|
"color": String, # HTML RGBA
|
||||||
|
"bubble_style": String # standard/yell/thought/whisper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rule actions will store the same `visual` dictionary under their existing `params`. `ActionRegistry.to_rule_action()`/`from_rule_action()` will copy it unchanged, `StageSceneIO` will validate known scalar fields while preserving absence, and `SpeechBubble.show_text()` will eventually accept a style dictionary. Unknown preset/style IDs must resolve to theme defaults rather than invalidating the action.
|
||||||
|
|
||||||
|
Phase 7 only reserves the theme IDs and supplies the reusable controls. It deliberately does not modify save files or runtime speech behavior.
|
||||||
|
|
||||||
|
## 11. Compatibility and migration
|
||||||
|
|
||||||
|
- Theme schema `1.0`, missing version, and `2.0` are accepted. Unsupported future major versions warn and use recognized keys plus defaults.
|
||||||
|
- Legacy aliases are applied before validation:
|
||||||
|
- `mode_colors.edit_accent/direct_accent/play_accent/guide_line` → `mode_colors.edit/direct/play/guide`.
|
||||||
|
- Existing flat `fonts.*_font_size` keys → matching `fonts.sizes.*` keys.
|
||||||
|
- `fonts.action_popup.{size,bold,italic}` → `fonts.sizes.action_popup` and `fonts.flags.action_popup_*`.
|
||||||
|
- Existing bold flags and `assignment_badge_radius` retain their current meaning.
|
||||||
|
- A v2 key wins when both v2 and its legacy alias exist.
|
||||||
|
- No automatic write-back of user theme files occurs.
|
||||||
|
- Existing `user://sandbox_settings.json` values win over theme defaults. If a settings key is absent, the loaded theme value—not the old hard-coded constant—is used.
|
||||||
|
- `.scene` serialization remains byte-contract compatible because Phase 7 changes no scene payload fields.
|
||||||
|
- Existing public stage signals (`mode_changed`, `object_placed`, selection/deletion signals), `AssetSelector` signals, placement IDs, and action dictionaries remain unchanged.
|
||||||
|
- Existing private test-visible fields remain present. `_palette_buttons` values remain `Button`-compatible.
|
||||||
|
- Missing emoji/custom fonts degrade to `ThemeDB.fallback_font`; labels stay usable even if a glyph renders as a fallback box.
|
||||||
|
|
||||||
|
## 12. Delivery phases and acceptance criteria
|
||||||
|
|
||||||
|
### 7.1 Theme service and schema
|
||||||
|
|
||||||
|
- `SandboxTheme` loads the committed v2 file and produces complete resolved values.
|
||||||
|
- Missing/malformed files, invalid individual keys, and missing font paths never abort startup.
|
||||||
|
- v1 aliases reproduce current sizes/colors.
|
||||||
|
- Font/style/theme caches reuse resources; caller mutation of one returned stylebox does not affect another.
|
||||||
|
- Existing grid setting precedence is preserved with the corrected theme fallback for missing settings keys.
|
||||||
|
|
||||||
|
### 7.2 Chrome and mode polish
|
||||||
|
|
||||||
|
- Mode segments are one exclusive group with clear active accent and keyboard focus.
|
||||||
|
- Frame, badge, and status bar update immediately in EDIT/DIRECT/PLAY; the frame uses each mode color in every mode.
|
||||||
|
- Existing cursor, tooltip, target trajectory, status coordinate, and Escape behavior pass unchanged.
|
||||||
|
- At 1280×720, top/bottom chrome and the palette do not cover each other; at smaller windows, the palette scrolls instead of overflowing.
|
||||||
|
|
||||||
|
### 7.3 Reusable widgets
|
||||||
|
|
||||||
|
- All four controls configure from the theme contract, rebuild safely, emit exactly once per user choice, and expose their selected value.
|
||||||
|
- Empty/malformed preset arrays fall back safely.
|
||||||
|
- Every interactive item has keyboard focus and a minimum 44×44 target.
|
||||||
|
|
||||||
|
### 7.4 Palette, selector, and thumbnail cache
|
||||||
|
|
||||||
|
- Six registry-driven graphical cards appear only in EDIT and preserve `_palette_buttons` state semantics.
|
||||||
|
- Click-arming, modal Prop/Stickman selection, repeated placement, RMB/Escape cancellation, and mode transitions behave as before.
|
||||||
|
- Dragging a palette card over the stage places exactly one item; release over UI/cancel places none and leaves no ghost.
|
||||||
|
- The selector retains pagination, dimming, Browse/Refresh, generated previews, and click selection.
|
||||||
|
- Cached textures are reused from memory/disk, duplicate jobs are suppressed, and stale async completion never updates a closed or repopulated selector.
|
||||||
|
|
||||||
|
### 7.5 Popup/overlay consistency and regression
|
||||||
|
|
||||||
|
- Popup menus, dialogs, panels, selector cards, tooltip, terrain guides, ghosts, and action trajectories use the resolved theme.
|
||||||
|
- Terrain painting still uses template AABB stride, Bresenham cells, three classifications, Shift axis lock, and skip-on-conflict commits.
|
||||||
|
- Queue/rule/action editor behavior and font APIs remain compatible.
|
||||||
|
- No changes occur to speech rendering or saved action schemas.
|
||||||
|
|
||||||
|
## 13. Validation strategy
|
||||||
|
|
||||||
|
### Headless tests
|
||||||
|
|
||||||
|
Run the new suites plus the regression suites most directly touched:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_theme.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_widgets.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_palette.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_stage.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_logic.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_terrain.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b1_fixes.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_library.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_ui_fixes.gd
|
||||||
|
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3c_bugfix.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `--check-only` on every added/changed `.gd` script before those suites. Tests should exercise public methods/signals where possible; source-text assertions are only a last resort for engine behavior that cannot be driven headlessly.
|
||||||
|
|
||||||
|
### User verification in Godot (spec-pipeline hand-off)
|
||||||
|
|
||||||
|
After implementation and green headless tests, stop and ask the user to verify:
|
||||||
|
|
||||||
|
1. Resize the Sandbox Stage from 1280×720 down to a small window and confirm chrome/palette remain usable.
|
||||||
|
2. Switch all modes and confirm frame, badge, segment, status, cursor, and visibility changes.
|
||||||
|
3. Click-arm and drag-drop each card type; cancel by UI release, RMB, Escape, and a mode change.
|
||||||
|
4. Open Prop and Stickman selectors twice to confirm thumbnail reuse and no visible stale updates.
|
||||||
|
5. Exercise Direct-mode Walk To and rule targets to verify tooltip/trajectory/reticle layering.
|
||||||
|
6. Terrain-paint empty, same-type, and conflicting cells with and without Shift.
|
||||||
|
7. Open Speak/Wait/Rename, queue/rule editors, and context menus to judge font size, contrast, and emoji fallback.
|
||||||
|
|
||||||
|
Do not update README/project documentation until the user explicitly confirms the feature works.
|
||||||
|
|
||||||
|
## 14. Design decisions and trade-offs requiring approval
|
||||||
|
|
||||||
|
1. **Keep the Sandbox UI code-built.** This minimizes churn in a 4,000-line controller and preserves existing test-visible fields. Replacing it with a complete UI scene is deferred.
|
||||||
|
2. **Use a dedicated theme service, not raw dictionary reads or a JSON-to-Theme singleton.** The stage owns one instance, making tests deterministic and avoiding global state; a cached Godot `Theme` still supplies normal Control inheritance.
|
||||||
|
3. **Use custom card pointer capture for world drops.** A full-screen Control drop target risks intercepting the existing Node2D world input stack. Drag placement is intentionally one-shot; click placement remains persistent.
|
||||||
|
4. **Show the mode frame in all modes.** The plan says it reflects the active mode; the current implementation shows it only in DIRECT. Phase 7 changes that behavior.
|
||||||
|
5. **Reserve but do not implement speech customization.** Theme presets and reusable controls land now, while action persistence/runtime rendering stay unchanged until a dedicated speech phase.
|
||||||
|
6. **Serialized, cached thumbnail work is preferred over threading.** It is safer with Godot viewports and already matches the working architecture; memory caching and generation tokens address the actual stutter/stale-update risks.
|
||||||
|
|
||||||
@@ -67,6 +67,10 @@ func _initialize() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _run() -> void:
|
func _run() -> void:
|
||||||
|
# SandboxStage opens a recovery dialog when a prior interactive run left an
|
||||||
|
# autosave. That native child window survives briefly across this suite's
|
||||||
|
# short-lived stage fixtures and contaminates camera/navigation assertions.
|
||||||
|
DirAccess.remove_absolute("user://sandbox_autosave.scene")
|
||||||
print("")
|
print("")
|
||||||
print("========================================================")
|
print("========================================================")
|
||||||
print(" PHASE 4b.1 FIX REGRESSION TEST (headless)")
|
print(" PHASE 4b.1 FIX REGRESSION TEST (headless)")
|
||||||
@@ -439,18 +443,15 @@ func _test_bug5_ghost_lifecycle() -> void:
|
|||||||
_check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World")
|
_check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World")
|
||||||
_check(world.get_children().has(ghost) == false, "ghost is not a selectable World child")
|
_check(world.get_children().has(ghost) == false, "ghost is not a selectable World child")
|
||||||
_check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)")
|
_check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)")
|
||||||
# Block-unit snapped ghost positioning (strides, not the 16-px grid/cursor).
|
# Single-placement preview follows the configured snap grid; footprint stride
|
||||||
|
# remains exclusively for drag painting.
|
||||||
|
stage._snap_enabled = true
|
||||||
var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \
|
var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \
|
||||||
+ stage._spawner.get_spawn_offset("ground")
|
+ stage._spawner.get_spawn_offset("ground")
|
||||||
var expected_cell: Vector2i = stage._world_to_terrain_cell(raw_pos)
|
var expected_pos: Vector2 = stage._snap_to_grid(raw_pos)
|
||||||
var expected_pos: Vector2 = stage._terrain_cell_center(expected_cell)
|
|
||||||
stage._update_ghost_position()
|
stage._update_ghost_position()
|
||||||
_check(ghost.position.distance_to(expected_pos) <= 0.01,
|
_check(ghost.position.distance_to(expected_pos) <= 0.01,
|
||||||
"ghost positioned at the block-unit cell center (got %s, expected %s)" % [ghost.position, expected_pos])
|
"ghost positioned at the configured snap-grid point (got %s, expected %s)" % [ghost.position, expected_pos])
|
||||||
_check(is_equal_approx(fmod(absf(ghost.position.x), GROUND_STRIDE_X), 0.0),
|
|
||||||
"ghost x is a multiple of the %dpx stride (got %.1f)" % [GROUND_STRIDE_X, ghost.position.x])
|
|
||||||
_check(is_equal_approx(fmod(absf(ghost.position.y), GROUND_STRIDE_Y), 0.0),
|
|
||||||
"ghost y is a multiple of the 32px stride (got %.1f)" % ghost.position.y)
|
|
||||||
|
|
||||||
# Drag begin frees the single cursor ghost and builds per-cell ghosts.
|
# Drag begin frees the single cursor ghost and builds per-cell ghosts.
|
||||||
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0)))
|
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0)))
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const SPAWNER := preload("res://scripts/stage_spawner.gd")
|
||||||
|
const CARD := preload("res://scripts/ui/asset_card.gd")
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var world := Node2D.new()
|
||||||
|
var spawner = SPAWNER.new(world)
|
||||||
|
var entries: Array[Dictionary] = spawner.get_palette_entries()
|
||||||
|
_check(entries.size() == 6, "six registry cards")
|
||||||
|
_check(String(entries[0].get("placement_id", "")) == "ground", "registry id retained")
|
||||||
|
var card = CARD.new(); card.configure(entries[0]); _check(card.payload().get("kind", "") == "terrain", "card copies payload")
|
||||||
|
print("PASS" if _failures == 0 else "FAIL: %d" % _failures); quit(0 if _failures == 0 else 1)
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if not condition: _failures += 1; push_error("Phase 7 palette: " + message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dt5c3h6p0msb2
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const THEME_SCRIPT := preload("res://scripts/sandbox_theme.gd")
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var theme = THEME_SCRIPT.new()
|
||||||
|
_check(theme.load_from(), "committed v2 theme loads")
|
||||||
|
_check(theme.integer("fonts.sizes.tooltip", 0) == 18, "v2 font sizes resolve")
|
||||||
|
_check(theme.color("mode_colors.edit", Color.BLACK) == Color("#22c6ff"), "mode color resolves")
|
||||||
|
var first: StyleBoxFlat = theme.stylebox("palette")
|
||||||
|
first.bg_color = Color.RED
|
||||||
|
var second: StyleBoxFlat = theme.stylebox("palette")
|
||||||
|
_check(second.bg_color != Color.RED, "stylebox callers receive isolated copies")
|
||||||
|
_check(not theme.swatches().is_empty() and not theme.size_presets().is_empty(), "widget defaults available")
|
||||||
|
print("PASS" if _failures == 0 else "FAIL: %d" % _failures)
|
||||||
|
quit(0 if _failures == 0 else 1)
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if not condition:
|
||||||
|
_failures += 1
|
||||||
|
push_error("Phase 7 theme: " + message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dho8gbkh2u0k4
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
DirAccess.remove_absolute("user://sandbox_autosave.scene")
|
||||||
|
var stage: Node2D = STAGE_SCENE.instantiate()
|
||||||
|
root.add_child(stage)
|
||||||
|
stage.get_window().size = Vector2i(1280, 720)
|
||||||
|
await process_frame
|
||||||
|
_check(stage._mode_badge.position.x >= stage._palette_dock.size.x, "badge is outside palette dock")
|
||||||
|
_check(stage._mode_frame.offset_left >= stage._palette_dock.size.x, "mode frame is inset beyond palette")
|
||||||
|
_check(stage._mode_buttons.size() == 3 and (stage._mode_buttons[0] as Button).custom_minimum_size.y >= 44.0, "mode buttons are kid-sized")
|
||||||
|
_check(stage._file_menu.get_theme_font_size("font_size") == 22, "File menu uses themed font size")
|
||||||
|
_check((stage._mode_buttons[0] as Button).get_theme_font_size("font_size") == 24, "mode switcher uses themed font size")
|
||||||
|
var mode_switcher: Control = (stage._mode_buttons[0] as Button).get_parent() as Control
|
||||||
|
_check(absf(mode_switcher.get_global_rect().get_center().x - 640.0) <= 1.0, "mode switcher remains horizontally centered at a known viewport width")
|
||||||
|
_check(mode_switcher.get_parent().mouse_filter == Control.MOUSE_FILTER_IGNORE, "full-width mode centering wrapper is mouse-transparent")
|
||||||
|
_check(stage._file_menu.get_popup().get_theme_font_size("font_size") == 26, "File popup uses independently themed font size")
|
||||||
|
_check(stage._unsaved_changes_dialog.get_theme_font_size("font_size") == 22, "dialog font-size theme metric is applied")
|
||||||
|
_check(stage._unsaved_changes_dialog.get_ok_button().get_theme_font_size("font_size") == 22, "dialog button inherits themed font size")
|
||||||
|
_check(stage._unsaved_changes_dialog.get_label().horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER and stage._unsaved_changes_dialog.get_label().get_theme_font_size("font_size") == 26, "dialog message is centered and larger")
|
||||||
|
var card: Button = stage._palette_buttons["area"]
|
||||||
|
_check(card.clip_contents and card.custom_minimum_size.y >= 100.0, "palette cards clip and reserve vertical space")
|
||||||
|
stage.set_mode(stage.StageMode.DIRECT)
|
||||||
|
_check(is_zero_approx(stage._mode_frame.offset_left), "Direct frame reclaims hidden palette width")
|
||||||
|
stage.set_mode(stage.StageMode.EDIT)
|
||||||
|
_check(stage._mode_frame.offset_left >= stage._palette_dock.size.x, "Edit frame restores palette inset")
|
||||||
|
_check(not stage.is_point_in_stage_frame(Vector2(1, 80), Rect2(100, 50, 600, 400)), "frame helper rejects a palette-side position")
|
||||||
|
_check(stage.is_point_in_stage_frame(Vector2(200, 200), Rect2(100, 50, 600, 400)), "stage frame helper accepts an inner viewport position")
|
||||||
|
stage._grid_size = 15.0
|
||||||
|
stage._snap_enabled = true
|
||||||
|
stage.set_placement_mode("step")
|
||||||
|
stage._place_at(Vector2(22.0, 23.0))
|
||||||
|
var placed: Node2D = stage._world.get_child(stage._world.get_child_count() - 1) as Node2D
|
||||||
|
_check(placed.position == Vector2(15.0, 30.0), "single terrain uses configured grid snap")
|
||||||
|
stage._open_selector("prop")
|
||||||
|
await process_frame
|
||||||
|
var selector_card: Variant = stage._selector._grid.get_child(0)
|
||||||
|
_check(selector_card.custom_minimum_size.x >= 190.0 and selector_card.custom_minimum_size.y >= 220.0, "selector uses enlarged themed card metrics")
|
||||||
|
_check(String(selector_card._label.text).length() > 0 and selector_card.thumbnail_rect().custom_minimum_size.x >= 156.0, "selector card shows a name beneath an enlarged thumbnail")
|
||||||
|
stage._on_selector_cancelled()
|
||||||
|
stage.set_mode(stage.StageMode.DIRECT)
|
||||||
|
stage._pending_walk_target = true
|
||||||
|
var right := InputEventMouseButton.new(); right.button_index = MOUSE_BUTTON_RIGHT; right.pressed = true
|
||||||
|
stage._handle_world_click(right)
|
||||||
|
_check(not stage._pending_walk_target, "RMB cancels an active direct target")
|
||||||
|
stage.queue_free()
|
||||||
|
await process_frame
|
||||||
|
print("PASS" if _failures == 0 else "FAIL: %d" % _failures)
|
||||||
|
quit(0 if _failures == 0 else 1)
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if not condition:
|
||||||
|
_failures += 1
|
||||||
|
push_error("Phase 7 UI follow-up: " + message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cnnlc36tnlqil
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const COLOR_SWATCH := preload("res://scripts/ui/color_swatch_selector.gd")
|
||||||
|
const SIZE_TOGGLE := preload("res://scripts/ui/preset_size_toggle.gd")
|
||||||
|
const FONT_PICKER := preload("res://scripts/ui/font_preset_picker.gd")
|
||||||
|
const EMOJI_GRID := preload("res://scripts/ui/emoji_quick_grid.gd")
|
||||||
|
var _failures: int = 0
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var swatch = COLOR_SWATCH.new(); swatch.configure([Color.RED], Color.RED); _check(swatch.get_selected() == Color.RED, "swatch selection")
|
||||||
|
var sizes = SIZE_TOGGLE.new(); sizes.configure([{"id":"small","label":"Small","value":20}], "small"); _check(sizes.get_selected_value() == 20, "size value")
|
||||||
|
var fonts = FONT_PICKER.new(); fonts.configure([{"id":"ui","label":"UI"}], "ui"); _check(fonts.get_selected_id() == "ui", "font id")
|
||||||
|
var emoji = EMOJI_GRID.new(); emoji.configure(["😀"], 1); _check(emoji.get_child_count() == 1, "emoji controls")
|
||||||
|
print("PASS" if _failures == 0 else "FAIL: %d" % _failures); quit(0 if _failures == 0 else 1)
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if not condition: _failures += 1; push_error("Phase 7 widgets: " + message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://btjgad0h7xge3
|
||||||
Reference in New Issue
Block a user