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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user