diff --git a/.codex/agents/uiux.toml b/.codex/agents/uiux.toml index c3e3c9e..dfa0292 100644 --- a/.codex/agents/uiux.toml +++ b/.codex/agents/uiux.toml @@ -15,6 +15,19 @@ developer_instructions = """ for dialogs or other editor workflows, reuse its visual principles only when their layout and interaction model fit, and call out any exception. +* **Dialogs & Popups:** Before designing a child-facing dialog, read + `docs/specs/dialog_design_principles.md`. Carry its rounded controls, thicker borders, blue + primary actions with bold white labels, comfortable button spacing, and large readable type + into the proposed UI. Prefer visible, labeled dismissal actions such as Cancel, Done, or Back; + do not default to a small corner × as the only way to close a dialog. Use the Choose Scenery + dialog and its theme tokens as the current implementation reference. + +* **Stable Dialog Anchoring:** When tabs, filters, or other in-place choices change a dialog's + size, keep its current anchor so the clicked control stays under the pointer. Grow from a + stable edge while space allows; when the safe viewport is reached, cap the dialog and scroll + its content. Nudge it only as much as needed to keep essential controls visible. Do not + recenter after every content change. + * **Touch & Click Target Rules:** * Minimum pointer hit-target: **44×44 logical pixels** (never build tiny 16px/24px buttons). * Control heights (buttons, dropdowns, rows) must obey `button_min_height` tokens from the theme. @@ -48,15 +61,17 @@ developer_instructions = """ * **Accessible Inputs:** * Prefer simple explicit buttons (e.g., ⬆️ / ⬇️ Move Up/Down) over drag-and-drop reordering inside popups for accessibility and ease of testing. - * Always provide prominent, clickable `[X Cancel]` buttons on active workflows so users don't need to know the `Esc` key. + * Provide a prominent, clickable, visibly labeled action such as **Cancel** on active workflows so users do not need to know the `Esc` key. Do not rely on a corner × as the sole dismissal control. ## 3. Pipeline Output Protocol When invoked during Phase 0a (UI/UX Proposal & Alternatives): 1. Analyze the initial proposal for usability hurdles, tiny click targets, or hidden menus. -2. Pitch 1 to 3 alternate visual/interaction approaches (e.g., compare floating cursor badges vs. top notification bars). -3. Mark your recommended choice with [Recommended] and ask up to 3 simple clarification questions. -4. STOP and wait for user feedback. +2. Present about three distinct UI concepts by default (usually exactly three). Apply the same default when the user directly asks you for UI ideas or mockups outside a formal spec-pipeline run. +3. Give each concept a visual mockup, not only a prose description. Use one comparison board when all three remain legible; otherwise create a separate mockup for each. Use supplied screenshots and the established product style as visual references when available. Make the concepts differ in interaction or information hierarchy, not only colors or decoration. If visual mockup generation is unavailable, provide annotated wireframes and say so. +4. Pair each mockup with a short title and concise tradeoff. Mark the recommended option with [Recommended] and explain why. Ask up to 3 focused clarification questions after presenting the concepts. +5. Do not require alternatives or mockups for implementation-only requests, narrow bug fixes, or critique-only questions unless the user asks. Follow any user-specified number or format. +6. STOP and wait for user feedback before writing the UI spec. When invoked during Phase 0b (UI Spec Generation): Output a structured spec covering: diff --git a/.codex/skills/spec-pipeline/SKILL.md b/.codex/skills/spec-pipeline/SKILL.md index f3d68df..92b7625 100644 --- a/.codex/skills/spec-pipeline/SKILL.md +++ b/.codex/skills/spec-pipeline/SKILL.md @@ -8,8 +8,7 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im 1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)** - `@uiux` analyzes the user's initial idea for cognitive friction, accessibility, and kid-friendliness. - For an in-stage selected-object inspector, `@uiux` first reviews `docs/specs/stage_panel_ui_system.md` and the current shared controls under `scripts/shared/stage_ui/`; proposals should build on that system unless the interaction needs justify a specific exception. - - `@uiux` pitches **1–3 alternative interaction approaches** (e.g., comparing floating tooltips vs. top-center banners, custom cursor reticles, or streamlined dialogs). - - `@uiux` highlights the most intuitive option with `[Recommended]` and asks up to 3 clarification questions. + - Follow the concept-count and visual-mockup protocol in `.codex/agents/uiux.toml`; it also applies when the user asks `@uiux` for ideas outside a formal pipeline run. - `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec. - _Bypass Rule:_ If the feature is strictly internal engine/backend logic (e.g., physics, serialization, scanning), skip directly to Phase 1a. diff --git a/.gitignore b/.gitignore index d19df72..38de615 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ addons/ *.tmp .opencode/skills/playwright-cli *.import +# Local scratch captures, logs, and temporary browser profiles +/tmp/ content/review/artist/ content/examples/ /tests/godot_test_user_data/ diff --git a/AGENTS.md b/AGENTS.md index 9f0ae96..e8a295c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,25 @@ the configured `run/main_scene`. Keep the prompt and visible Cancel together, leave the mode pill unobstructed, and reuse or generalize `StageUseTargetCaptureBar` instead of copying its styling. +## Child-facing dialogs and popups + +- When a user prefixes a UI/UX question or request with `uiux:` or `uiux,`, route it to the + custom UI/UX agent defined in `.codex/agents/uiux.toml`. Treat that prefix as an explicit + delegation request. The UI/UX agent is read-only; carry its design guidance into implementation + separately when implementation is requested. +- For new or revised child-facing dialogs, popups, and short setup flows, follow the + [Dialog Design Principles](docs/specs/dialog_design_principles.md). Use rounded containers + and controls, clear borders, blue primary actions with bold white labels, comfortable button + spacing, and large readable type. Prefer a visible, labeled action such as Cancel, Done, or + Back; do not make a small corner × the default or only way to close a dialog. +- When a dialog resizes after a tab, filter, or other in-place choice, preserve its current + anchor so the clicked control does not jump away from the pointer. Keep the dialog within the + safe viewport by limiting its height and scrolling content; nudge it only as much as needed + to keep essential controls visible. Avoid recentering after every content change. +- Keep reusable values in `SandboxTheme` or `PropTheme`. The Choose Scenery dialog in + `scripts/shared/ui/asset_selector.gd` and the `popups.asset_selector` tokens are the current + implementation reference. Adapt its layout to each workflow. + ## Required addon - **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/` diff --git a/content/stages/artifacts.scene b/content/stages/artifacts.scene new file mode 100644 index 0000000..c83bbb3 --- /dev/null +++ b/content/stages/artifacts.scene @@ -0,0 +1,372 @@ +{ + "version": "1.6", + "scene_name": "Blank Scene", + "sky_id": "sky_neutral", + "camera": { + "position": { + "x": 1863.90759277344, + "y": -391.476318359375 + }, + "zoom": 0.665500044822693 + }, + "objects": [ + { + "file_id": "wall_panel_1", + "type": "wall_panel", + "position": { + "x": 448.0, + "y": -320.0 + }, + "rotation": 0.0, + "size": { + "x": 5120.0, + "y": 2816.0 + }, + "finish_id": "finish_wall_brick", + "color": "#b95f3dff" + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -128.0, + "y": -129.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "move_to", + "target": { + "x": 2404.91528320313, + "y": 141.710083007813 + }, + "style": "walk" + } + ] + }, + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -384.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -384.0, + "y": 288.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": 192.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": 384.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": 576.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": 768.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": 960.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": 1152.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 1344.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": 1536.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 1728.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 1920.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 2112.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 2304.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "x": 2496.0, + "y": 224.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "x": -192.0, + "y": 320.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "x": 0.0, + "y": 320.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "x": 192.0, + "y": 288.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_19", + "type": "terrain", + "position": { + "x": 384.0, + "y": 288.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "terrain_20", + "type": "terrain", + "position": { + "x": 576.0, + "y": 288.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_asphalt" + }, + { + "file_id": "wall_panel_2", + "type": "wall_panel", + "position": { + "x": 1152.0, + "y": -64.0 + }, + "rotation": 0.0, + "size": { + "x": 9984.0, + "y": 5504.0 + }, + "finish_id": "finish_wall_brick", + "color": "#b95f3dff" + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1792022400, + "modified_unix": 1792470929.846, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/content/stages/distance.scene b/content/stages/distance.scene new file mode 100644 index 0000000..70bf211 --- /dev/null +++ b/content/stages/distance.scene @@ -0,0 +1,140 @@ +{ + "version": "1.6", + "scene_name": "Untitled", + "sky_id": "sky_neutral", + "camera": { + "position": { + "x": 0.0, + "y": -400.0 + }, + "zoom": 0.5 + }, + "objects": [ + { + "file_id": "wall_panel_1", + "type": "wall_panel", + "position": { + "x": -768.0, + "y": -608.0 + }, + "rotation": 0.0, + "size": { + "x": 1152.0, + "y": 832.0 + }, + "finish_id": "finish_wall_brick", + "color": "#b95f3dff" + }, + { + "file_id": "scenery_artwork_1", + "type": "scenery_artwork", + "position": { + "x": -64.0, + "y": -868.5 + }, + "rotation": 0.0, + "asset_id": "res://content/props/art_city_skyline-v1.prop", + "display_name": "City Skyline", + "depth": "far", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "last_known_local_bounds": { + "position": { + "x": -321.5, + "y": -126.5 + }, + "size": { + "x": 643.0, + "y": 198.0 + } + } + }, + { + "file_id": "scenery_artwork_2", + "type": "scenery_artwork", + "position": { + "x": 320.0, + "y": -804.5 + }, + "rotation": 0.0, + "asset_id": "res://content/props/art_city_skyline-v1.prop", + "display_name": "City Skyline", + "depth": "floor", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "last_known_local_bounds": { + "position": { + "x": -321.5, + "y": -126.5 + }, + "size": { + "x": 643.0, + "y": 198.0 + } + } + }, + { + "file_id": "scenery_artwork_3", + "type": "scenery_artwork", + "position": { + "x": 768.0, + "y": -804.5 + }, + "rotation": 0.0, + "asset_id": "res://content/props/art_city_skyline-v1.prop", + "display_name": "City Skyline", + "depth": "near", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "last_known_local_bounds": { + "position": { + "x": -321.5, + "y": -126.5 + }, + "size": { + "x": 643.0, + "y": 198.0 + } + } + }, + { + "file_id": "scenery_artwork_4", + "type": "scenery_artwork", + "position": { + "x": -448.0, + "y": -1600.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/baseball.prop", + "display_name": "Baseball", + "depth": "near", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "last_known_local_bounds": { + "position": { + "x": -26.1999988555908, + "y": -26.1999988555908 + }, + "size": { + "x": 52.4000015258789, + "y": 52.4000015258789 + } + } + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1792555445.055, + "modified_unix": 1792555445.055, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/content/stages/fps.scene b/content/stages/fps.scene new file mode 100644 index 0000000..5156ae8 --- /dev/null +++ b/content/stages/fps.scene @@ -0,0 +1,417 @@ +{ + "version": "1.6", + "scene_name": "Untitled", + "sky_id": "sky_sunset", + "camera": { + "position": { + "x": -85.0290832519531, + "y": -612.394409179688 + }, + "zoom": 0.732050120830536 + }, + "objects": [ + { + "file_id": "wall_panel_1", + "type": "wall_panel", + "position": { + "x": 288.0, + "y": -224.0 + }, + "rotation": 0.0, + "size": { + "x": 4160.0, + "y": 2368.0 + }, + "finish_id": "finish_wall_brick", + "color": "#b95f3dff" + }, + { + "file_id": "wall_panel_2", + "type": "wall_panel", + "position": { + "x": 288.0, + "y": -352.0 + }, + "rotation": 0.0, + "size": { + "x": 5568.0, + "y": 3136.0 + }, + "finish_id": "finish_wall_wood", + "color": "#c87137ff" + }, + { + "file_id": "background_ground_1", + "type": "background_ground", + "position": { + "x": 256.0, + "y": -352.0 + }, + "rotation": 0.0, + "size": { + "x": 6144.0, + "y": 3392.0 + }, + "finish_id": "finish_background_concrete", + "color": "#a6aaa3ff" + }, + { + "file_id": "wall_panel_3", + "type": "wall_panel", + "position": { + "x": 128.0, + "y": -320.0 + }, + "rotation": 0.0, + "size": { + "x": 6016.0, + "y": 3456.0 + }, + "finish_id": "finish_wall_brick", + "color": "#22c6ffff" + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -1088.0, + "y": -641.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "move_to", + "target": { + "x": 1562.8154296875, + "y": -145.01513671875 + }, + "style": "walk" + } + ] + }, + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -2112.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -1920.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -1728.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -1536.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -1344.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -1152.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": -960.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": -768.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": -576.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": -384.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": -192.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 0.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 192.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 384.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "x": 576.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "x": 768.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "x": 960.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "x": 1152.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_19", + "type": "terrain", + "position": { + "x": 1344.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_20", + "type": "terrain", + "position": { + "x": 1536.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_21", + "type": "terrain", + "position": { + "x": 1728.0, + "y": -32.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1792467978.761, + "modified_unix": 1792467978.761, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/docs/architecture/background_rendering_guide.md b/docs/architecture/background_rendering_guide.md new file mode 100644 index 0000000..3621f77 --- /dev/null +++ b/docs/architecture/background_rendering_guide.md @@ -0,0 +1,87 @@ +# Patterned scenery rendering + +A patterned wall or background uses one small repeating image for each finish and tint. The camera samples that same image wherever the surface appears. A pan or zoom changes the visible rectangle and sampling scale; it does not create a new image for each camera position. + +![Patterned scenery render and cache pipeline](diagrams/background_pipeline.svg) + +## Render pipeline + +1. A visible, committed patterned surface requests its style from the scene's `StageSurfaceBakePool`. +2. The cache checks the style key. A ready entry is reused; a missing entry is queued for the shared baker. +3. At most one queued style is baked per process frame. The baker draws a four-world-unit overscan around the material period so edge details can be cropped without gaps. +4. After the submitted drawing reaches `frame_post_draw`, the pool captures the image, crops the period, generates mipmaps, and publishes an `ImageTexture`. +5. The temporary vector commands are cleared. The surface draws one camera-clipped quad using the shared image, repeat sampling, and mipmapped filtering. + +The GPU still renders the textured rectangle every frame. When the view changes, the CPU also updates that rectangle and its texture coordinates. This is a small drawing command; it does not rebuild brick faces, grain, pores, or other vector details. The cache counters distinguish image builds and readbacks from ordinary redraws. + +## Shared style images + +The cache is owned by a scene-scoped pool. Its key includes finish, tint, wall/background kind, material version, repeat period, pixel density, and mipmap configuration. It excludes surface position, camera position, zoom, rectangle size, and erase fragment. Thus same-style surfaces and fragments share one image, while a tint change correctly requests a different image. + +![Many surfaces sharing one finished material image](diagrams/background_reuse.svg) + +The current periods are finite, seamless repeat units baked at two bitmap pixels per world unit: + +| Finish | Repeat period in world units | Bitmap size | +| --- | ---: | ---: | +| Brick | 800 × 304 | 1600 × 608 | +| Wood | 960 × 384 | 1920 × 768 | +| Grass | 704 × 576 | 1408 × 1152 | +| Carpet | 544 × 416 | 1088 × 832 | +| Concrete | 1120 × 760 | 2240 × 1520 | + +For example, a 100-world-unit brick remains 100 world units wide. At 0.4× zoom it covers about 40 screen pixels; zoom changes how the GPU samples the image, not the brick geometry. The finite repeat unit makes detail bounded, so landmarks can become noticeable on very large surfaces. + +## Zoom and mipmaps + +The renderer captures the material once at fixed density, then builds smaller mip levels from that image. During zoom-out the GPU samples a smaller level to reduce shimmer; during zoom-in it samples a larger level. This replaces separately drawing the material at several CPU-selected zoom levels. At the Stage's maximum 6× zoom, the source image is magnified three times and can look softer. The current quality/performance tradeoff is fixed in the cache configuration. + +![Fixed material image with mipmap levels](diagrams/background_mipmaps.svg) + +## Erasing and pattern alignment + +The eraser still cuts a rectangle into up to four rectangular fragments. Each fragment draws only its own bounds, but requests the same style image; it does not bake a fragment-specific copy. Cancel restores the original geometry, and Commit retains the new fragments. Both use the existing material sampling rules. + +The sampling origin is significant: walls use `global_position`; background ground uses `global_position + motif_origin_offset`. Each surface computes its source UV from its local visible position plus that draw origin, wrapped by the material period. This preserves the current alignment convention across cuts. Avoid changing the origin rule as a generic transform correction: unusual transformed fragments need separate verification before making a broader alignment claim. + +![Erase fragments clipping the same repeating image](diagrams/background_erase.svg) + +## Memory and rebuild boundaries + +The scene cache has a 64 MiB resident texture budget. Accounting includes mip levels, textures waiting for safe retirement, and reserved space for a capture. Unused entries are evicted by least-recent use when needed. A style held by active surfaces can prevent admission of another style; the pool reports this as budget pressure rather than exceeding the cap. Under ordinary use, returning to a recently used style should reuse its warm image. Many tint variants can force eviction and a later rebuild. + +A style's first capture still costs CPU/GPU work and can briefly affect a frame. Once ready, moving the camera across a large wall does not enqueue position tiles or read back new images. The shared baker also clears its vector command list after capture, preventing the native drawing resources that previously accumulated in many per-tile viewports. + +These images are temporary runtime data. A saved `.scene` keeps surface settings and geometry; the cache recreates its images when needed in a new Stage session. Closing the scene releases its pool. + +## What causes another bake? + +| Action | New image work? | +| --- | --- | +| Pan left, then return right | No, while the style remains cached. | +| Zoom in or out | No. The GPU samples existing mipmaps. | +| Resize a wall | No. Change the rectangle and texture coordinates. | +| Erase, Cancel, or Commit | No, if the style is ready. Change geometry and clipping. | +| Use the same finish and tint on another wall | No, if the style is ready. Share its image. | +| Choose a new finish or tint | Bake once if that style is missing. | +| Return to an evicted style, or open a fresh session | Recreate the missing image. | + +## Terms used here + +- **Bake:** render the vector artwork once to make an image. +- **Texture:** the image the GPU samples when drawing. +- **Quad:** one rectangle, represented by two triangles. +- **UVs:** coordinates that select which part of a texture to sample. +- **Mipmaps:** smaller copies of an image, prepared in advance for zooming out. +- **Readback:** copy the rendered image from the GPU so the cache can keep an independent texture. + +## Scope and code map + +The bitmap cache is for committed patterned walls and backgrounds in render-capable runs. Plain paint surfaces, interactive placement previews, and headless runs retain the direct renderer path. Fully opaque scenery can suppress a fully hidden surface's texture request; partial coverage remains conservative. + +- [StageSurfaceCache](../../scripts/stage/scenery/stage_surface_cache.gd) owns style handles, the scene pool, bake queue, image capture, mipmaps, cache keys, and budget statistics. +- [StageRectSurface](../../scripts/stage/scenery/stage_rect_surface.gd) requests the style, clips the surface to the visible camera area, and draws the repeated texture. +- [StageSurfaceRenderer](../../scripts/stage/scenery/stage_surface_renderer.gd) defines patterned artwork, material periods, and the direct-drawing path. +- [Repeating scenery material cache spec](../specs/scenery_bitmap_tile_cache.md) records implementation constraints and acceptance checks. + +In one observed warm run of `fps.scene`, four style entries used 36,399,144 bytes (about 34.71 MiB); 720 warm camera frames recorded zero builds, readbacks, or evictions. This is a run snapshot, not a guarantee for other scenes, tint combinations, or hardware. diff --git a/docs/architecture/diagrams/background_erase.svg b/docs/architecture/diagrams/background_erase.svg new file mode 100644 index 0000000..ceb592f --- /dev/null +++ b/docs/architecture/diagrams/background_erase.svg @@ -0,0 +1,43 @@ + + Erasing changes rectangles while sharing the same background texture + A rectangular cut turns one wall into top, bottom, left and right fragments. All fragments sample the existing image using their pattern coordinates. The image itself is not painted over or copied. + + + + + + + Erase the wall geometry. Keep the shared image. + Fragments crop their own rectangles and use the existing pattern coordinates. + Before: one wall rectangle + + + + + Cut here + + + Erase area + + After: up to four fragments + + + + + + + + + + Top + Bottom + Left + Right + Empty area + Scene behind + + + + All remaining pieces still use the same cached texture. + Cancel restores the original geometry. Commit keeps the fragments. Neither paints into the image. + diff --git a/docs/architecture/diagrams/background_mipmaps.svg b/docs/architecture/diagrams/background_mipmaps.svg new file mode 100644 index 0000000..6504fd9 --- /dev/null +++ b/docs/architecture/diagrams/background_mipmaps.svg @@ -0,0 +1,39 @@ + + Zoom uses smaller copies already stored in the material texture + The brick material starts at 1600 by 608 bitmap pixels. Its mip chain includes 800 by 304, 400 by 152 and 200 by 76, continuing down to one pixel. These copies are prepared once. Brick world dimensions stay fixed. + + + + + + + + + + Zoom samples ready-made sizes of the same image. + Mipmaps are generated once with the material. The GPU selects and blends them during rendering. + + + + + + + + + … + + 1600 × 608 + Base image: 2 pixels / world unit + 800 × 304 + Half size + 400 × 152 + Quarter size + 200 × 76 + Eighth size + The actual chain continues down to 1 × 1. Diagram sizes are scaled for illustration. + + + World size, image size and screen size are different things. + A brick stays 100 world units wide. At 40% zoom, it covers about 40 screen pixels. + Camera zoom changes sampling detail; it does not bake another image or resize the brick geometry. + diff --git a/docs/architecture/diagrams/background_pipeline.svg b/docs/architecture/diagrams/background_pipeline.svg new file mode 100644 index 0000000..2171c1c --- /dev/null +++ b/docs/architecture/diagrams/background_pipeline.svg @@ -0,0 +1,51 @@ + + Background rendering: prepare a style once, reuse it while moving the camera + A visible surface requests its finish and tint from the scene cache. A cache hit goes directly to the GPU repeat sampler. A cache miss uses one temporary baker, captures an image and mipmaps, and releases vector commands. + + + + + + Prepare a material once. Reuse it as the camera moves. + Only visible, committed patterned scenery requests a cached material. + + + Visible surface + Finish + tint + Example: blue brick + + + + Scene style cache + Already have this image? + Shared by all matching surfaces + + Hit + + + GPU draws the visible rectangle + Repeat texture + sample its mipmaps + Pan / zoom updates sampling coordinates + + + Miss: prepare this style + + One temporary baker + Draw one pattern period + Wait for GPU; capture image + Then clear vector commands + + + + Store the reusable texture + Crop overscan; generate mipmaps + Keep the image, not the vector artwork + Ready styles stay warm within the cache budget + + Ready + + + Blue route: reuse during ordinary pan and zoom + + Purple route: bake only when a style is missing + diff --git a/docs/architecture/diagrams/background_reuse.svg b/docs/architecture/diagrams/background_reuse.svg new file mode 100644 index 0000000..d9ef1db --- /dev/null +++ b/docs/architecture/diagrams/background_reuse.svg @@ -0,0 +1,44 @@ + + Many wall rectangles share one material image + One blue-brick image supplies Wall A, Wall B, and an erased fragment. Different sizes, positions, and camera views do not create more images. A different tint or finish is a different cached style. + + + + + + + + + + Wall area can grow. The number of style images stays the same. + Position, wall size, camera position and zoom are not part of the style key. + + + + One shared image + Blue brick + Varied brick faces + Repeats both ways + + + + + + Wall A + + + + Wall B + + Same + style + + + Erased fragment + + Only the visible + rectangle changes + + A different finish or tint is a separate style. A matching warm style reuses the existing image. + Patterns shown here are schematic; the actual brick image contains 8 columns × 8 rows of varied artwork. + diff --git a/docs/architecture/scenery_surface_caching.md b/docs/architecture/scenery_surface_caching.md new file mode 100644 index 0000000..7e009dc --- /dev/null +++ b/docs/architecture/scenery_surface_caching.md @@ -0,0 +1,42 @@ +# Scenery surface caching + +`StageRectSurface` renders committed patterned scenery through reusable texture tiles. +Drawing previews use the adaptive lightweight renderer. Paint remains a single rectangle. + +## Camera movement + +Tiles use a fixed grid in the surface's local coordinates. Texture resolution changes in +powers of two, so adjacent wheel steps reuse existing tiles. Panning requests only newly +exposed tiles. Existing textures stay visible while missing tiles are prepared, with a +lightweight surface motif beneath them to prevent blank areas. + +`StageSurfaceCache` keeps queued tiles as metadata. A shared queue creates and renders at +most one tile per process frame across all surfaces. It also retires at most two obsolete +render targets per frame. This avoids large allocations, vector rebuilds, and batches of +GPU resource releases during camera input. + +Each tile has a 256 × 128 pixel core and a four pixel gutter. The gutter is cropped during +compositing to prevent seams. Each surface requests at most 192 visible tiles and retains +at most 224 tiles, evicting older unused entries. Half-resolution tiles retain full brick +texture and wood grain. Only quarter-resolution tiles and below omit fine texture, for +distant views. Brick and plank positions remain fixed. Detail can fill in progressively +after movement, including when returning from a distant view to a normal zoom level. + +## Invalidation and coverage + +Geometry, finish, tint, and motif-origin changes invalidate that surface's tiles. Camera +movement selects tiles without invalidating their artwork. Render targets belong to the +surface node; queued references are weak so deleted surfaces cannot keep jobs alive. + +Fully opaque rectangular sibling surfaces can suppress artwork entirely hidden behind +them. Coverage checks account for transforms, draw order, and visibility. Transparent +surfaces, custom materials, differing relative-Z conventions, and Y-sorted parents disable +this optimization conservatively. Revealing a surface resumes its pending tiles. + +## Verification + +`tests/test_scenery_surface_cache.gd` checks rendered appearance against the direct vector +renderer, tile reuse and invalidation, the shared build limit, allocation and retention +bounds, and covered-surface behavior. Run it with a graphical Godot renderer for pixel +comparisons. The adaptive preview regressions remain in +`tests/test_scenery_surface_preview.gd` and `tests/test_scenery_brick_preview.gd`. diff --git a/docs/architecture/tech_debt_and_optimizations.md b/docs/architecture/tech_debt_and_optimizations.md index e1f4384..bebac43 100644 --- a/docs/architecture/tech_debt_and_optimizations.md +++ b/docs/architecture/tech_debt_and_optimizations.md @@ -178,4 +178,8 @@ This document tracks known technical debt, optimization opportunities, and minor | 2026-10-17 | Wrote the Phase 19 selected-artwork depth inspector specification (`docs/specs/phase_19_selected_artwork_depth_inspector.md`) for final sign-off. It reuses the existing `StageInspectorPanel` and `StagePanelChoiceGroup`, applies Far/Near/On the floor immediately to one live or missing Scenery placement through `StageSceneryArtwork.set_depth()`, and follows the Stage's existing dirty/save path. The current `.scene` depth field, `StageDrawOrder`, and `stage_panels` tokens need no changes. Updated #72 to include immediate depth edits in a future Stage-wide Undo phase. No runtime code changed by this specification entry. | | 2026-10-17 | Wrote the Phase 19 Background ground and wall color architecture specification (`docs/specs/phase_19_background_ground_and_wall_color.md`) for final sign-off. It defines distinct inert wall/backdrop scene types over a small shared rectangle contract, world-aligned bounded vector materials, selected-piece color preview without mutating saved tint, one eight-color Stage settings list, scene v1.6 migration of old Brick/Wood placeholder green, shared Stage inspector controls, and confirmed single-piece backdrop erasure. Logged #74 for zoom-consistent motif budgets and #75 for the old unused wall-color ambiguity. No runtime code changed. | +| 2026-10-20 | Approved the scenery bitmap tile cache architecture (`docs/specs/scenery_bitmap_tile_cache.md`). Lightweight `StageSurfaceCache` handles share immutable bitmap tiles through one `StageSurfaceBakePool` under the current scene, with a 64 MiB resident bitmap budget and one reusable offscreen baker. Camera-visible tiles use four fixed zoom levels (0.125, 0.5, 1, 2); the coarsest tile bakes first and refined detail overlays it. At maximum 6× zoom, the 2× bitmap may look softer because it is magnified threefold. Erased fragments clip shared tiles only when their complete pattern sampling phase matches. The previous per-surface viewport cache retained vector commands and had no scene-wide resource limit, a likely contributor to `Element limit reached`; the exact RID pool remains unconfirmed. GPU readback during bitmap capture and regeneration after budget eviction are accepted costs to measure during implementation. | + +| 2026-10-20 | Replaced the positional scenery tile design after the user observed continuous buffering when panning after zoom. The accepted revision in `docs/specs/scenery_bitmap_tile_cache.md` bakes one seamless, 2× bitmap per finish/tint period and uses GPU repeat plus generated mipmaps for all camera positions and zooms. Erased fragments share the style texture and crop it using their existing sample coordinate. The scene-wide 64 MiB limit includes mipmaps; inactive style variants may be evicted, while recently used styles stay warm when space allows. This trades unique random detail across an unlimited wall for finite repeated variation and keeps the existing possible 3× bitmap enlargement at maximum zoom. A style change can bake once; repeated camera movement must not increment build or readback counts after styles are ready. | + _End of Log_ diff --git a/docs/review/choose_scenery_artwork_option_a.png b/docs/review/choose_scenery_artwork_option_a.png new file mode 100644 index 0000000..26fe305 Binary files /dev/null and b/docs/review/choose_scenery_artwork_option_a.png differ diff --git a/docs/review/choose_scenery_artwork_option_b.png b/docs/review/choose_scenery_artwork_option_b.png new file mode 100644 index 0000000..7bfb3e3 Binary files /dev/null and b/docs/review/choose_scenery_artwork_option_b.png differ diff --git a/docs/review/choose_scenery_artwork_option_c.png b/docs/review/choose_scenery_artwork_option_c.png new file mode 100644 index 0000000..c5229a4 Binary files /dev/null and b/docs/review/choose_scenery_artwork_option_c.png differ diff --git a/docs/specs/dialog_design_principles.md b/docs/specs/dialog_design_principles.md new file mode 100644 index 0000000..c0f7c56 --- /dev/null +++ b/docs/specs/dialog_design_principles.md @@ -0,0 +1,32 @@ +# Child-Facing Dialog Design Principles + +Use this guide when creating or revising child-facing dialogs, popups, and short setup flows. It captures the shared visual language established in the Choose Scenery dialog. In-stage selected-object inspectors continue to follow the [Stage panel UI system](stage_panel_ui_system.md). + +## Visual style + +- Use rounded dialog shells, buttons, choice tiles, and preview containers. A corner radius around 16–22 logical pixels is a useful starting point for large controls; use smaller radii for compact swatches. +- Give controls a clear outline. Use about a 2 px border at rest and 3 px for selected, hovered, or focused states. Keep focus visually distinct from selection. +- Style the main action with a saturated blue fill, bold white text, and a visible border. Use the theme’s action color or edit-mode blue token rather than adding a one-off color. +- Use dark neutral fills and light text for secondary actions. Leave about 12–16 logical pixels between separate footer buttons so they read as individual choices. +- Use large, readable type: roughly 30 px for a dialog title, 22–26 px for section headings, and 20–22 px for main control labels and body copy. Helper text can be smaller when it remains easy to read. +- Keep pointer targets at least 44 × 44 logical pixels. Provide generous padding around labels and icons. + +## Dismissal and action labels + +- Give the child a visible, labeled action that describes what happens, such as **Cancel**, **Done**, **Back**, or **Close**. +- Avoid making a small corner **×** the default or only way to dismiss a dialog. Use it only when it adds a useful secondary shortcut; the labeled action remains available. +- Make the primary action’s wording describe the result, such as **Draw wall panel** or **Use color**. Keep Cancel separate from the primary action and visually secondary. +- Escape can remain a keyboard shortcut, but it does not replace a visible action. + +## Theme and implementation + +- Put reusable colors, sizes, spacing, radii, and border widths in `SandboxTheme` or `PropTheme` tokens. Apply local stylebox overrides instead of mutating a shared theme resource. +- Keep larger text from clipping: use containers that can expand or wrap, and size the dialog after its visible content has been laid out. +- Preserve clear hover, pressed, selected, focused, and disabled states. Do not use color as the only indication of a state. +- Adapt the arrangement to each workflow while keeping this shared visual language. The Choose Scenery dialog in `scripts/shared/ui/asset_selector.gd` and its `popups.asset_selector` theme tokens are the current implementation reference. + +## Stable position during dynamic resizing + +- When a dialog changes size after a tab, filter, or other in-place choice, preserve its current anchor so the clicked control stays under the pointer. +- Let the dialog grow from its stable edge while there is room. Before expanding, account for the usable viewport; if the full content will not fit, cap the dialog and let its content area scroll. +- Move the dialog only as much as needed to keep essential controls visible. Avoid recentering after every content change, which makes controls jump away from the pointer. diff --git a/docs/specs/phase_19_scenery_backgrounds_ui_ux.md b/docs/specs/phase_19_scenery_backgrounds_ui_ux.md index f04d0ac..a2be85d 100644 --- a/docs/specs/phase_19_scenery_backgrounds_ui_ux.md +++ b/docs/specs/phase_19_scenery_backgrounds_ui_ux.md @@ -118,8 +118,8 @@ These are relationship examples, not fixed pixel geometry. On a narrow viewport ### Wall panel -- **Backgrounds → Wall panel** offers picture choices **Paint**, **Brick**, and **Wood**, plus a large **Color** swatch/button. Common colors appear first; **More colors…** opens a themed color dialog with a visible Cancel. Finish and color choices are reflected in a small preview and then in the translucent Stage draft. -- Choosing the look closes the chooser and arms **Draw wall panel**. The capture bar says **“Drag across the space for a wall panel”**, with **Done** and **✕ Cancel**. The pointer has a corner reticle and a nearby pill **“Drag to draw wall”**. A press sets the first corner; dragging draws a world-anchored translucent patterned rectangle; release commits one finite panel. The pattern preview remains at sensible scale rather than stretching with the draft. +- **Backgrounds → Wall panel** offers picture choices **Paint**, **Brick**, and **Wood**, plus a large preview of only the selected finish (Paint is a flat color; Brick and Wood show their texture). The selected-color tile replaces the chooser content and footer with the themed custom color view; **Back to wall panel** discards its draft and returns to setup, while **Use color** applies it and returns. The chooser's **Recently used** row is the only recent-color list. In the expanded setup chooser, **Cancel** exits and **Draw wall panel** starts placement; there is no separate Back or close-X action in that state. +- Choosing a look updates its preview. Pressing **Draw wall panel** closes the chooser and arms placement. The capture bar says **“Drag across the space for a wall panel”**, with **Done** and **✕ Cancel**. The pointer has a corner reticle and a nearby pill **“Drag to draw wall”**. A press sets the first corner; dragging draws a world-anchored translucent patterned rectangle; release commits one finite panel. The pattern preview remains at sensible scale rather than stretching with the draft. - Snap, when enabled, visibly aligns the draft to nearby grid lines. An unusably small draft shows a red outline and **“Make the wall bigger”**; release leaves no panel. A valid draft uses the EDIT accent plus a neutral outline and dimension cue. This is appearance only: a helper in the chooser says **“Looks like a wall. Stickmen can pass through.”** - Touch can drag or tap the first and second corners. The first tap leaves a visible anchored corner and continuous rectangle preview; the second tap commits. The same visible Cancel and Done remain available. Right-click cancels a draft and exits the wall tool under the exit rules below. @@ -219,7 +219,7 @@ This matrix identifies current UI that already exists. “Extend/share” is a d | Scenery chooser and Artwork cards | `AssetSelector`, `AssetCard`, `StageAssetBrowser`, thumbnail cache/renderers, `scenes/stage/asset_selector.tscn` | **Extend/share the gallery shell and card behavior, reuse browsing/thumbnail work.** The current selector is a fixed four-column paged popup and sets card focus to `FOCUS_NONE`; these must be corrected for responsive layout and keyboard use in the common path rather than copied into a Scenery-only popup. Backgrounds/Artwork tabs use native button choice states and shared popup styling; placement-role text stays feature-local. | | Sky and wall/Ground finish cards | `AssetCard` and the responsive gallery behavior above; `SandboxTheme` popup/card tokens | **Share the same preview-card and focus/selected-state rules.** Sky and finishes supply their own preview imagery and one-tap selection meaning; card dragging is disabled for these choices. They do not pretend to be `.prop` assets or reuse `StageAssetBrowser` asset scanning. Wall and Ground share names/preview motifs for Paint, Brick, and Wood. | | Artwork tags, Props filters, authoring tags | No existing reusable tag-chip control; `StagePanelChoiceGroup` is a vertical exclusive inspector control; Stage speech UI demonstrates `HFlowContainer` wrapping | **Extract one shared wrapping filter/choice treatment.** Use native focusable buttons and one theme contract across Scenery browsing, Props browsing, and optional multi-tag authoring. Single-choice browser filters and multi-choice authoring use the same visual states but different selection rules. Do not clone local chip styling or force the vertical inspector group into a wrapping browser row. | -| Wall Color and More colors dialog | `ColorSwatchSelector`; native `ColorPickerButton` already used by the Prop Editor; `StageModalViews` dialog theme | **Extend/share the color-swatch treatment** so it wraps on narrow screens, has visible chosen/focus states, and uses Stage tokens. Use native color picker behavior in a themed Stage dialog. The Prop Editor's `PropTheme` styling is not a Stage style source; wall/color values remain feature-local. | +| Wall Color and custom color view | `ColorSwatchSelector`; native `ColorPicker` in the themed Stage view | **Extend/share the color-swatch treatment** so it wraps on narrow screens, has visible chosen/focus states, and uses Stage tokens. Open custom color selection from the selected-color tile, keep one recent-color row in the chooser, and provide **Back to wall panel** / **Use color** actions. The Prop Editor's `PropTheme` styling is not a Stage style source; wall/color values remain feature-local. | | Scenery overlap **Pieces here** | `StageModalViews.show_objects_here()` and its themed, viewport-clamped, scrollable `PopupPanel` | **Generalize the existing overlap chooser.** Supply Scenery-only candidates and child-facing piece names/depth, while retaining the same placement, 44 px rows, dismiss, and focus behavior. Do not create a second overlapping-items popup. | | Missing Scenery artwork | Existing `MissingPropPlaceholder`/`MissingStickmanPlaceholder` preserve missing placements; `StageModalViews` missing-assets dialog; `StageInspectorPanel`; `StageAssetBrowser` gallery/file chooser | **Reuse and generalize the recovery path.** Keep saved placement continuity and one themed load summary, but give Scenery its neutral labeled marker, selection-card repair actions, and the existing artwork/file choosers. Do not copy the older red marker styling or create a second asset gallery or dialog skin. | | **Erase this piece?** | `StageModalViews` confirmation/dialog theming; existing Erase Use spot `ConfirmationDialog` and safe/danger treatment in `SandboxStage` | **Reuse the confirmation pattern.** Share the button sizing, themed window, and danger/safe styles instead of copying the spot-erase styling into another private function. The affected piece and wording are local. | diff --git a/docs/specs/scenery_bitmap_tile_cache.md b/docs/specs/scenery_bitmap_tile_cache.md new file mode 100644 index 0000000..487ff48 --- /dev/null +++ b/docs/specs/scenery_bitmap_tile_cache.md @@ -0,0 +1,78 @@ +# Repeating scenery material cache + +Status: accepted architecture for the 2026-10-20 scenery cache follow-up. + +## Problem and result + +`content/stages/fps.scene` exposed two related costs. The old vector tile cache could accumulate enough native drawing resources to report `Element limit reached`. Its replacement bounds those resources, but panning after zoom still reveals new world-position tiles and continuously starts more bitmap captures. Brick, wood, grass, carpet, and concrete are repeated finishes. Bake **one finite, seamless material texture per finish and tint** and repeat it across every visible rectangle. Once that style is ready, camera pan and zoom update only the draw quad and sampling coordinates; they do not build or read back more imagery. + +Keep the scene-scoped `StageSurfaceCache.BakePool` under `SceneTree.current_scene` as a sibling of `World`. `StageRectSurface` owns a lightweight `StageSurfaceCache` handle for its style. A single reusable `SubViewport` baker is enough. Paint and interactive placement keep their existing direct drawing paths. No `.scene` schema or child-facing control changes are needed. + +```text +current_scene +├── World +│ ├── StageRectSurface ──┐ +│ └── erased fragment ──┼── handles for the same finish + tint +└── StageSurfaceBakePool ─┴── one repeating ImageTexture + └── SurfaceBitmapBaker (temporary vector commands) +``` + +## Material periods and appearance + +Bake every period at **2 bitmap pixels per world unit**. The material repeats in both directions. These dimensions are multiples of the current renderer's natural cells: + +| Finish | Repeated cells | World period | Bitmap size at 2× | +| --- | ---: | ---: | ---: | +| Brick wall | 8 columns × 8 rows | 800 × 304 | 1600 × 608 | +| Wood wall | 3 columns × 6 rows | 960 × 384 | 1920 × 768 | +| Grass background | 16 × 16 | 704 × 576 | 1408 × 1152 | +| Carpet background | 16 × 16 | 544 × 416 | 1088 × 832 | +| Concrete background | 4 × 4 slabs | 1120 × 760 | 2240 × 1520 | + +The 8-row brick and 6-row wood periods preserve stagger parity; the wood period also preserves its three-tone cycle. Keep brick size, plank size, color relationships, rough outlines, grain, pores, and other material features at their current world scale. Remove view-size-driven changes to brick or plank dimensions from the material bake: zoom must sample the same pattern. The resulting bitmap keeps full artwork at 40% zoom through GPU minification. At the Stage's maximum 6× zoom, the 2× bitmap is magnified threefold and may appear softer; this is an accepted quality trade-off. + +The renderer uses absolute pattern cell coordinates for geometry and `posmod(cell_index, period_cell_count)` for **every decorative seed and tone choice**, including negative world coordinates. Wrap row and column before computing brick jitter, palette, marks, pores, wood tone, grain, knots, grass tufts, carpet fibers, and concrete speckles. Features crossing a period edge must be drawn from the matching wrapped neighbor, then clipped to the exact period bitmap. Check the first and last rows and columns together; a matching cell seed alone does not repair geometry or filtering at the seam. A finite period makes the former never-ending random variation visibly repeat; the chosen periods balance variety with bounded bitmap size. + +## Rendering and erase coordinates + +Generate mipmaps on the captured `Image` before creating its `ImageTexture`. On each patterned `StageRectSurface`, enable `CanvasItem.TEXTURE_REPEAT_ENABLED`. Draw the intersection of surface bounds and camera view as a single clipped quad with `draw_texture_rect_region(..., clip_uv = false)`; its source rectangle can span many repetitions. Compute the source position from the same renderer sample coordinate used today: **surface-local position plus `get_surface_draw_origin_world()`**, reduced with `fposmod` by the period before converting to bitmap pixels. Reducing the offset avoids large UV coordinates during long camera pans. Source width and height come from the visible local rectangle at two bitmap pixels per world unit. Godot's repeat sampler and mipmaps handle zoom without a cached zoom level. + +The eraser continues splitting one rectangular surface into up to four fragments. Each fragment clips its own quad to its bounds and the camera. It requests the same style texture; the bitmap key is independent of fragment position, size, camera position, and zoom. Preserve the existing sampling convention: wall draw origin is `global_position`; background ground draw origin is `global_position + motif_origin_offset`. Moving, rotating, saving, reloading, erasing, Cancel, and Commit must keep each material's pattern aligned under those existing rules. A fragment's differing sample offset changes only its source UV, never the style bitmap. + +Only request a style when some of that surface is camera-visible and not proven fully covered by later opaque scenery. Keep conservative coverage checks; partial union of multiple cover surfaces is optional. A surface with no texture yet draws its base color while its one style capture is queued. An already ready style remains warm through camera movement, including when the surface temporarily leaves the viewport. + +## Cache and baker contracts + +`StageSurfaceCache` remains a `RefCounted` handle with `signal updated`, `attach_to(owner_node: Node)`, `get_texture() -> Texture2D`, `dispose()`, and `static get_stats(owner_node: Node = null) -> Dictionary`. Its build request changes from a positional tile to a style request. The payload is finish (`StringName`), tint (`Color`), wall/background kind (`bool`), renderer/material revision, and the fixed period and density derived from finish. `StageRectSurface` no longer retains a dictionary of camera-position tiles. One handle may be replaced when tint or finish changes; other surfaces with the same key keep their texture. + +`StageSurfaceCache.BakePool` has one entry per requested style. Entry identity includes all values that alter baked pixels, especially finish, tint, wall/background kind, and renderer revision. It excludes camera transform, surface bounds, eraser fragment, and sample phase. A queued entry with no live visible interested owner can be canceled. The pool serializes captures through one `SubViewport`, waits until the GPU has consumed its vector commands, reads the completed image, generates mipmaps, publishes an immutable `ImageTexture`, and clears baker vector commands. A failed capture leaves the base color visible and permits retry; it must not publish an empty ready texture. + +Keep a **64 MiB global resident texture budget per scene**, including all mip levels, retired textures awaiting a safe frame, and reserved bytes for a build. One copy each of the five listed periods is about 32 MiB before mipmaps and 43 MiB with mipmaps at RGBA8; additional tint variants can require eviction. Evict styles without visible interested owners before styles in active use, and release canvas references safely before freeing a texture. Keep recently used styles warm so panning away and back does not trigger a new capture under normal budget. If active styles collectively exceed the budget, retain the hard cap and show base color for a style that cannot be admitted; expose this through stats rather than silently exceeding the budget. Revisit period size or density if this occurs in ordinary scenes. + +`get_stats()` must expose at least resident texture bytes, entries, queued styles, active baker count, builds, readbacks, cache hits, and budget refusals. These counters make the reuse behavior observable without adding UI. + +## Data flow + +```mermaid +flowchart LR + Visible[Visible patterned surface] -->|finish + tint| Cache[Scene style cache] + Cache -->|ready texture| UV[Sample repeated texture at existing pattern offset] + Cache -->|first style request| Baker[One temporary bitmap baker] + Baker -->|capture + mipmaps| Cache + UV --> Clip[Clip quad to surface and camera] + Clip --> Stage[Stage canvas] + Camera[Pan or zoom] --> UV +``` + +## Observable acceptance and verification + +1. Open `content/stages/fps.scene` in EDIT, DIRECT, and PLAY. Allow visible brick, wood, and concrete styles to become ready. Pan left and right repeatedly and zoom in and out through the same region. `builds` and `readbacks` stay unchanged after those styles are ready; only a new finish or tint may add a style capture. Frame movement does not pause for repeated tile buffering, and `Element limit reached` does not recur. +2. Inspect the left/right and top/bottom period boundaries for every finish at distant, 40%, normal, and close zoom. There are no lines, shifted brick courses, broken planks, or mismatched decorative marks. Zoom changes sampling detail without changing motif geometry. +3. Erase through a brick wall and background ground, Cancel, then erase and Commit. Inspect both sides of each fragment edge, then save and reload. The cut stays clear, surrounding patterns stay aligned, and the cache still has one entry per shared style. +4. Change a wall's tint and finish. The changed style may bake once; unchanged surfaces keep their ready texture. Return to the earlier style and pan again to check that recently used styles stay warm within the 64 MiB budget. Observe total bytes, pending styles, and baker lifetime during a long Stage session. + +Use a render-capable run of the actual Stage for appearance and frame-time observations. This architecture update does not require a new test file. + +## Trade-offs + +A periodic material replaces unique random detail across an arbitrarily large wall with a bounded, repeating pattern. The chosen periods and seeded variation should make repetition unobtrusive at ordinary framing, but repeated landmarks can become noticeable on very large surfaces. GPU mipmaps replace four separately baked zoom levels and keep pan/zoom free of new builds. A one-time GPU readback remains when a finish/tint style first appears. Multiple color variants can evict warm styles under the 64 MiB cap, so those cases need measurement on representative scenes. diff --git a/project.godot b/project.godot index 1830b33..8d4b075 100644 --- a/project.godot +++ b/project.godot @@ -23,3 +23,4 @@ config/icon="res://icon.svg" window/size/initial_position_type=0 window/size/initial_position=Vector2i(4000, 200) +window/size/initial_screen=1 diff --git a/sandbox_theme.json b/sandbox_theme.json index c9e1a2c..7ea8d29 100644 --- a/sandbox_theme.json +++ b/sandbox_theme.json @@ -114,13 +114,57 @@ "surface_color_picker_min_size": [320, 340], "action_min_target_size": 48 }, + "confirmation": { + "scenery_erase_size": [520, 208], + "padding": 18, + "body_gap": 10, + "footer_gap": 12, + "corner_radius": 16, + "border_width": 2, + "title_font_size": 26, + "message_font_size": 20, + "button_font_size": 20, + "button_height": 48, + "button_radius": 12, + "button_border_width": 2, + "remove_button_width": 170, + "keep_button_width": 132, + "surface_color": "#202633FA", + "border_color": "#22C6FF", + "accent_color": "#22C6FF", + "action_color": "#087DBA", + "danger_color": "#FF626B", + "danger_hover_surface": "#3B252C", + "danger_pressed_surface": "#4A252D" + }, "asset_selector": { "backdrop_alpha": 0.55, "cell_size": [190, 220], "thumbnail_size": [156, 156], "surface_preview_size": [420, 132], "look_cue_size": [80, 42], - "setup_gap": 10 + "setup_gap": 8, + "setup_title_size": 30, + "setup_heading_size": 26, + "setup_choice_font_size": 22, + "setup_choice_height": 68, + "setup_choice_corner_radius": 22, + "setup_choice_border_width": 2, + "tab_font_size": 22, + "tab_height": 60, + "filter_chip_font_size": 20, + "filter_chip_height": 48, + "filter_chip_gap": 8, + "setup_button_font_size": 20, + "button_border_width": 2, + "footer_gap": 16, + "surface_corner_radius": 14, + "look_corner_radius": 8, + "corner_radius": 16, + "compact_swatch_size": 42, + "color_group_gap": 8, + "action_color": "#087DBA", + "separator_color": "#AAB2C0A0" } }, "palette": { @@ -185,7 +229,13 @@ "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", - "selected_surface": "#12394A" + "selected_surface": "#12394A", + "primary_color": "#087DBA", + "primary_hover_color": "#1596D3", + "primary_pressed_color": "#066B9F", + "destructive_color": "#FF626B", + "destructive_hover_surface": "#3B252C", + "destructive_pressed_surface": "#4A252D" }, "prop": { "stickman_cue_color": "#F5F7FA", diff --git a/scenes/stage/asset_selector.tscn b/scenes/stage/asset_selector.tscn index e4956a4..ac70ce7 100644 --- a/scenes/stage/asset_selector.tscn +++ b/scenes/stage/asset_selector.tscn @@ -29,21 +29,18 @@ size_flags_horizontal = 3 theme_override_font_sizes/font_size = 18 text = "Choose" -[node name="CloseButton" type="Button" parent="MarginContainer/VBoxContainer/TitleBar"] -unique_name_in_owner = true -layout_mode = 2 -text = "×" - -[node name="GalleryTabs" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +[node name="GalleryTabs" type="HFlowContainer" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 visible = false +h_separation = 8 +v_separation = 8 [node name="BackgroundsTab" type="Button" parent="MarginContainer/VBoxContainer/GalleryTabs"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 -text = "🌤 Backgrounds" +text = "☀️ Backgrounds" [node name="ArtworkTab" type="Button" parent="MarginContainer/VBoxContainer/GalleryTabs"] unique_name_in_owner = true @@ -51,11 +48,17 @@ layout_mode = 2 size_flags_horizontal = 3 text = "🎨 Artwork" -[node name="TagFilter" type="OptionButton" parent="MarginContainer/VBoxContainer/GalleryTabs"] +[node name="EditSceneryButton" type="Button" parent="MarginContainer/VBoxContainer/GalleryTabs"] +unique_name_in_owner = true +layout_mode = 2 +text = "✋ Edit placed scenery" + +[node name="ArtworkFilters" type="HFlowContainer" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 3 visible = false +h_separation = 8 +v_separation = 8 [node name="GridScroll" type="ScrollContainer" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true @@ -77,6 +80,14 @@ theme_override_constants/h_separation = 8 theme_override_constants/v_separation = 8 columns = 4 +[node name="SurfaceCardsRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/GridScroll/ContentStack"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +alignment = 1 +theme_override_constants/separation = 8 +visible = false + [node name="EmptyLabel" type="Label" parent="MarginContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 @@ -89,6 +100,18 @@ vertical_alignment = 1 [node name="Footer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] layout_mode = 2 alignment = 1 +theme_override_constants/separation = 16 + +[node name="CloseButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Close" + +[node name="FooterSpacer" type="Control" parent="MarginContainer/VBoxContainer/Footer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +visible = false [node name="PrevButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"] unique_name_in_owner = true @@ -119,10 +142,4 @@ text = "Refresh" unique_name_in_owner = true layout_mode = 2 visible = false -text = "✏️ Make artwork" - -[node name="EditSceneryButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"] -unique_name_in_owner = true -layout_mode = 2 -visible = false -text = "🎛 Edit placed Scenery" +text = "✏️ Make artwork…" diff --git a/scripts/editors/stage/sandbox_stage.gd b/scripts/editors/stage/sandbox_stage.gd index c0399dc..11eba1f 100644 --- a/scripts/editors/stage/sandbox_stage.gd +++ b/scripts/editors/stage/sandbox_stage.gd @@ -62,6 +62,8 @@ const STAGE_PANEL_CHOICE_GROUP_SCRIPT := preload("res://scripts/shared/stage_ui/ const STAGE_SURFACE_COLOR_CONTROLS_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd") const STAGE_SURFACE_COLOR_DIALOG_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd") const STAGE_SURFACE_PREVIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_preview.gd") +const STAGE_DEPTH_CHOICE_PREVIEW_SCRIPT := preload("res://scripts/shared/stage_ui/stage_depth_choice_preview.gd") +const PROP_FILE_SCRIPT := preload("res://scripts/editors/prop/prop_file.gd") const PROP_EDITOR_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn") # Phase 3c editor tools. @@ -287,10 +289,20 @@ var _scenery_depth_section: VBoxContainer var _scenery_depth_heading: Label var _scenery_depth_group: StagePanelChoiceGroup var _scenery_depth_helper: Label +var _scenery_depth_choice_cues: Array[Control] = [] +var _scenery_depth_preview_texture: Texture2D +var _scenery_depth_preview_path: String = "" +var _scenery_depth_preview_busy: bool = false +var _scenery_depth_preview_generation: int = 0 +var _scenery_depth_preview_scheduled_generation: int = -1 +var _scenery_depth_thumbnail_renderer: PropThumbnail +var _scenery_depth_cursor_badge: PanelContainer +var _scenery_depth_cursor_badge_label: Label var _scenery_size_row: HBoxContainer var _scenery_smaller_button: Button var _scenery_bigger_button: Button var _scenery_erase_wall_area_button: Button +var _scenery_erase_background_ground_area_button: Button var _scenery_erase_piece_button: Button var _scenery_find_button: Button var _scenery_choose_button: Button @@ -300,7 +312,13 @@ var _scenery_repair_candidate: StageSceneryArtwork var _scenery_repair_path: String = "" var _missing_scenery_dialog: ConfirmationDialog var _scenery_repair_confirm_dialog: ConfirmationDialog -var _scenery_erase_piece_dialog: ConfirmationDialog +var _scenery_erase_piece_dialog: Control +var _scenery_erase_piece_dialog_panel: PanelContainer +var _scenery_erase_piece_dialog_title: Label +var _scenery_erase_piece_dialog_message: Label +var _scenery_erase_piece_dialog_actions: BoxContainer +var _scenery_erase_piece_keep_button: Button +var _scenery_erase_piece_remove_button: Button var _scenery_erase_piece_targets: Array[WeakRef] = [] var _surface_color_dialog: StageSurfaceColorDialog var _surface_color_session: Dictionary = {} @@ -1376,6 +1394,7 @@ func _process(delta: float) -> void: _update_action_overlay() _sync_awaiting_cursor() _update_speech_badge_cursor() + _update_scenery_depth_cursor_badge() if _toast_timer > 0.0: _toast_timer -= delta if _toast_timer <= 0.0: @@ -1521,6 +1540,11 @@ func _unhandled_key_input(event: InputEvent) -> void: return if not (event is InputEventKey) or not event.pressed or event.echo: return + if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible: + if event.keycode == KEY_ESCAPE: + _dismiss_scenery_erase_piece_dialog() + get_viewport().set_input_as_handled() + return if _speech_composer_is_open(): if event.keycode == KEY_ESCAPE: if bool(_speech_composer.call("is_message_dialog_open")): @@ -1692,9 +1716,7 @@ func _handle_world_click(mb: InputEventMouseButton) -> void: _refresh_scenery_inspector() _refresh_scenery_capture_bar() elif _scenery_controller != null and _scenery_controller.is_group_preview_pending(): - _scenery_controller.cancel_group() - _scenery_group_mode = &"" - _refresh_scenery_capture_bar() + _keep_scenery_group_preview() else: _exit_scenery_edit_mode() get_viewport().set_input_as_handled() @@ -2557,6 +2579,8 @@ func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool: _pending_file_action = PendingFileAction.NONE return false if not is_autosave: + if _scenery_controller != null: + _scenery_controller.commit_placement_session() _paused_edit_autosave_remaining = -1.0 _set_scene_dirty(false) _save_settings() @@ -3211,15 +3235,21 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void: _scenery_depth_section.add_child(_scenery_depth_heading) _scenery_depth_group = STAGE_PANEL_CHOICE_GROUP_SCRIPT.new() as StagePanelChoiceGroup _scenery_depth_group.name = "SceneryDepthChoices" - _scenery_depth_group.add_choice(&"far", "Far") - _scenery_depth_group.add_choice(&"near", "Near") - _scenery_depth_group.add_choice(&"floor", "On the floor") + _scenery_depth_group.configure_layout(true, 118.0, 24, 16, 1, Color("#087DBA")) + _scenery_depth_choice_cues.clear() + var back_cue: Control = _make_scenery_depth_choice_cue(&"far", Vector2(128.0, 82.0)) + var middle_cue: Control = _make_scenery_depth_choice_cue(&"near", Vector2(128.0, 82.0)) + var front_cue: Control = _make_scenery_depth_choice_cue(&"floor", Vector2(128.0, 82.0)) + _scenery_depth_choice_cues = [back_cue, middle_cue, front_cue] + _scenery_depth_group.add_choice(&"far", "Back", back_cue, "Behind other scenery") + _scenery_depth_group.add_choice(&"near", "Middle", middle_cue, "In front of Back · behind tiles") + _scenery_depth_group.add_choice(&"floor", "Front", front_cue, "In front of ground tiles") _scenery_depth_group.apply_theme(_sandbox_theme as SandboxTheme) _scenery_depth_group.choice_selected.connect(_on_selected_scenery_depth_choice) _scenery_depth_section.add_child(_scenery_depth_group) _scenery_depth_helper = Label.new() _scenery_depth_helper.name = "SceneryDepthHelper" - _scenery_depth_helper.text = "Far is behind Near. On the floor is in front of the ground." + _scenery_depth_helper.text = "The pictures show what can cover the artwork." _scenery_depth_helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _scenery_depth_helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32)) _scenery_depth_helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0"))) @@ -3237,9 +3267,14 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void: _scenery_size_row.add_child(_scenery_bigger_button) body.add_child(_scenery_size_row) _scenery_erase_wall_area_button = _make_scenery_repair_button("EraseWallArea", "Erase wall area", true) + _scenery_inspector.style_primary_action_button(_scenery_erase_wall_area_button) _scenery_erase_wall_area_button.pressed.connect(_begin_selected_wall_erase) body.add_child(_scenery_erase_wall_area_button) - _scenery_erase_piece_button = _make_scenery_repair_button("EraseSceneryPiece", "Erase wall panel", true) + _scenery_erase_background_ground_area_button = _make_scenery_repair_button("EraseBackgroundGroundArea", "Erase background ground area", true) + _scenery_inspector.style_primary_action_button(_scenery_erase_background_ground_area_button) + _scenery_erase_background_ground_area_button.pressed.connect(_begin_selected_background_ground_erase) + body.add_child(_scenery_erase_background_ground_area_button) + _scenery_erase_piece_button = _make_scenery_repair_button("EraseSceneryPiece", "Remove wall panel", false, true) _scenery_erase_piece_button.pressed.connect(_request_erase_selected_scenery) body.add_child(_scenery_erase_piece_button) _scenery_find_button = _make_scenery_repair_button("FindArtwork", "Find artwork…", true) @@ -3272,16 +3307,7 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void: _scenery_repair_confirm_dialog.canceled.connect(_cancel_scenery_repair_preview) ui.add_child(_scenery_repair_confirm_dialog) _apply_dialog_theme(_scenery_repair_confirm_dialog) - _scenery_erase_piece_dialog = ConfirmationDialog.new() - _scenery_erase_piece_dialog.name = "EraseSceneryPieceDialog" - _scenery_erase_piece_dialog.title = "Erase this piece?" - _scenery_erase_piece_dialog.ok_button_text = "Erase" - _scenery_erase_piece_dialog.cancel_button_text = "Keep it" - _scenery_erase_piece_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(520, 260)) - _scenery_erase_piece_dialog.confirmed.connect(_confirm_erase_selected_scenery) - _scenery_erase_piece_dialog.canceled.connect(_clear_erase_scenery_targets) - ui.add_child(_scenery_erase_piece_dialog) - _apply_dialog_theme(_scenery_erase_piece_dialog) + _build_scenery_erase_piece_dialog(ui) _surface_color_dialog = STAGE_SURFACE_COLOR_DIALOG_SCRIPT.new() as StageSurfaceColorDialog _surface_color_dialog.name = "ScenerySurfaceColorDialog" ui.add_child(_surface_color_dialog) @@ -3301,10 +3327,314 @@ func _make_scenery_section_heading(label_text: String) -> Label: return heading +func _make_scenery_depth_choice_cue(depth: StringName, cue_size: Vector2) -> Control: + var cue: StageDepthChoicePreview = STAGE_DEPTH_CHOICE_PREVIEW_SCRIPT.new() as StageDepthChoicePreview + cue.configure(depth, _scenery_depth_preview_texture, cue_size) + return cue + + +func _make_scenery_depth_capture_action(action_id: StringName, depth: StringName, label: String, selected_depth: StringName) -> Dictionary: + return { + "id": action_id, + "label": label, + "choice": true, + "selected": depth == selected_depth, + "cue": _make_scenery_depth_choice_cue(depth, Vector2(38.0, 34.0)), + } + + +func _set_scenery_depth_preview_path(path: String) -> void: + var canonical_path: String = CONTENT_PATHS.canonicalize(path) if not path.is_empty() else "" + if canonical_path == _scenery_depth_preview_path and (_scenery_depth_preview_texture != null or _scenery_depth_preview_busy or _scenery_depth_preview_scheduled_generation == _scenery_depth_preview_generation): + return + if canonical_path != _scenery_depth_preview_path: + _scenery_depth_preview_path = canonical_path + _scenery_depth_preview_texture = null + _scenery_depth_preview_generation += 1 + _update_scenery_depth_choice_cues() + if canonical_path.is_empty() or not FileAccess.file_exists(canonical_path): + return + if not _scenery_depth_preview_busy: + _scenery_depth_preview_scheduled_generation = _scenery_depth_preview_generation + call_deferred("_render_scenery_depth_preview", canonical_path, _scenery_depth_preview_generation) + + +func _render_scenery_depth_preview(path: String, generation: int) -> void: + if _scenery_depth_preview_busy or path.is_empty(): + return + _scenery_depth_preview_scheduled_generation = -1 + _scenery_depth_preview_busy = true + if _scenery_depth_thumbnail_renderer == null: + _scenery_depth_thumbnail_renderer = PROP_THUMBNAIL.new() as PropThumbnail + _scenery_depth_thumbnail_renderer.name = "SceneryDepthThumbnailRenderer" + add_child(_scenery_depth_thumbnail_renderer) + await get_tree().process_frame + var parsed: Dictionary = PROP_FILE_SCRIPT.from_file_dict(PROP_FILE_SCRIPT.load(path)) + var shapes: Array[Dictionary] = parsed.get("shapes", []) as Array[Dictionary] + var texture: Texture2D + if not shapes.is_empty() and _scenery_depth_thumbnail_renderer != null: + texture = await _scenery_depth_thumbnail_renderer.render_shapes(shapes) + if generation == _scenery_depth_preview_generation and path == _scenery_depth_preview_path and texture != null: + _scenery_depth_preview_texture = texture + _update_scenery_depth_choice_cues() + if _scenery_controller != null and _scenery_controller.get_active_tool() == &"artwork": + _refresh_scenery_capture_bar() + _scenery_depth_preview_busy = false + if not _scenery_depth_preview_path.is_empty() and _scenery_depth_preview_path != path: + _scenery_depth_preview_scheduled_generation = _scenery_depth_preview_generation + call_deferred("_render_scenery_depth_preview", _scenery_depth_preview_path, _scenery_depth_preview_generation) + + +func _update_scenery_depth_choice_cues() -> void: + for cue_control: Control in _scenery_depth_choice_cues: + if cue_control is StageDepthChoicePreview: + (cue_control as StageDepthChoicePreview).set_artwork_texture(_scenery_depth_preview_texture) + + +func _build_scenery_erase_piece_dialog(ui: CanvasLayer) -> void: + _scenery_erase_piece_dialog = Control.new() + _scenery_erase_piece_dialog.name = "EraseSceneryPieceDialog" + _scenery_erase_piece_dialog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _scenery_erase_piece_dialog.mouse_filter = Control.MOUSE_FILTER_STOP + _scenery_erase_piece_dialog.z_index = 100 + _scenery_erase_piece_dialog.visible = false + ui.add_child(_scenery_erase_piece_dialog) + + var backdrop: ColorRect = ColorRect.new() + backdrop.name = "DialogBackdrop" + backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + backdrop.color = _confirmation_dialog_color("backdrop_color", Color("#10151D66")) + backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + _scenery_erase_piece_dialog.add_child(backdrop) + + var center: CenterContainer = CenterContainer.new() + center.name = "DialogCenter" + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + _scenery_erase_piece_dialog.add_child(center) + + _scenery_erase_piece_dialog_panel = PanelContainer.new() + _scenery_erase_piece_dialog_panel.name = "DialogCard" + center.add_child(_scenery_erase_piece_dialog_panel) + + var margin: MarginContainer = MarginContainer.new() + margin.name = "DialogMargin" + var padding: int = _confirmation_dialog_integer("padding", 18, 12, 32) + margin.add_theme_constant_override("margin_left", padding) + margin.add_theme_constant_override("margin_right", padding) + margin.add_theme_constant_override("margin_top", padding) + margin.add_theme_constant_override("margin_bottom", padding) + _scenery_erase_piece_dialog_panel.add_child(margin) + + var layout: VBoxContainer = VBoxContainer.new() + layout.name = "DialogLayout" + layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL + layout.size_flags_vertical = Control.SIZE_EXPAND_FILL + layout.add_theme_constant_override("separation", _confirmation_dialog_integer("body_gap", 10, 4, 24)) + margin.add_child(layout) + + _scenery_erase_piece_dialog_title = Label.new() + _scenery_erase_piece_dialog_title.name = "DialogTitle" + _scenery_erase_piece_dialog_title.text = "Remove this scenery?" + _scenery_erase_piece_dialog_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _scenery_erase_piece_dialog_title.add_theme_font_size_override("font_size", _confirmation_dialog_integer("title_font_size", 26, 20, 40)) + _scenery_erase_piece_dialog_title.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA"))) + _apply_ui_font(_scenery_erase_piece_dialog_title) + var title_font: Font = _ui_font + if title_font != null: + var strong_title_font: FontVariation = FontVariation.new() + strong_title_font.base_font = title_font + strong_title_font.variation_embolden = 0.55 + _scenery_erase_piece_dialog_title.add_theme_font_override("font", strong_title_font) + layout.add_child(_scenery_erase_piece_dialog_title) + + _scenery_erase_piece_dialog_message = Label.new() + _scenery_erase_piece_dialog_message.name = "DialogMessage" + _scenery_erase_piece_dialog_message.text = "Remove this whole piece? You can’t undo this." + _scenery_erase_piece_dialog_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _scenery_erase_piece_dialog_message.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _scenery_erase_piece_dialog_message.add_theme_font_size_override("font_size", _confirmation_dialog_integer("message_font_size", 20, 16, 32)) + _scenery_erase_piece_dialog_message.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA"))) + _apply_ui_font(_scenery_erase_piece_dialog_message) + layout.add_child(_scenery_erase_piece_dialog_message) + var actions_spacer: Control = Control.new() + actions_spacer.name = "ActionsSpacer" + actions_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + layout.add_child(actions_spacer) + + _scenery_erase_piece_dialog_actions = BoxContainer.new() + _scenery_erase_piece_dialog_actions.name = "DialogActions" + _scenery_erase_piece_dialog_actions.alignment = BoxContainer.ALIGNMENT_END + _scenery_erase_piece_dialog_actions.add_theme_constant_override("separation", _confirmation_dialog_integer("footer_gap", 12, 8, 24)) + layout.add_child(_scenery_erase_piece_dialog_actions) + + _scenery_erase_piece_keep_button = Button.new() + _scenery_erase_piece_keep_button.name = "KeepSceneryButton" + _scenery_erase_piece_keep_button.text = "Keep it" + _scenery_erase_piece_keep_button.focus_mode = Control.FOCUS_ALL + _scenery_erase_piece_keep_button.pressed.connect(_dismiss_scenery_erase_piece_dialog) + _scenery_erase_piece_dialog_actions.add_child(_scenery_erase_piece_keep_button) + + _scenery_erase_piece_remove_button = Button.new() + _scenery_erase_piece_remove_button.name = "RemoveSceneryButton" + _scenery_erase_piece_remove_button.text = "Remove scenery" + _scenery_erase_piece_remove_button.focus_mode = Control.FOCUS_ALL + _scenery_erase_piece_remove_button.pressed.connect(_confirm_erase_scenery_from_dialog) + _scenery_erase_piece_dialog_actions.add_child(_scenery_erase_piece_remove_button) + _apply_scenery_erase_piece_dialog_style() + if not get_tree().root.size_changed.is_connected(_refresh_scenery_erase_piece_dialog_size): + get_tree().root.size_changed.connect(_refresh_scenery_erase_piece_dialog_size) + + +func _apply_scenery_erase_piece_dialog_style() -> void: + if _scenery_erase_piece_dialog == null: + return + var shell: StyleBoxFlat = StyleBoxFlat.new() + shell.bg_color = _confirmation_dialog_color("surface_color", Color("#202633FA")) + shell.border_color = _confirmation_dialog_color("border_color", Color("#AAB2C066")) + shell.set_border_width_all(_confirmation_dialog_integer("border_width", 1, 1, 4)) + shell.set_corner_radius_all(_confirmation_dialog_integer("corner_radius", 16, 10, 28)) + _scenery_erase_piece_dialog_panel.add_theme_stylebox_override("panel", shell) + _apply_scenery_erase_piece_action_style(_scenery_erase_piece_keep_button, true) + _apply_scenery_erase_piece_action_style(_scenery_erase_piece_remove_button, false) + + +func _apply_scenery_erase_piece_action_style(button: Button, primary: bool) -> void: + if button == null: + return + var accent: Color = _confirmation_dialog_color("accent_color", Color("#22C6FF")) + var blue: Color = _confirmation_dialog_color("action_color", Color("#087DBA")) + var danger: Color = _confirmation_dialog_color("danger_color", Color("#FF626B")) + var neutral: Color = _stage_panel_color("action.surface_color", Color("#171B24")) + var hover_neutral: Color = _confirmation_dialog_color("danger_hover_surface", Color("#3B252C")) + var pressed_neutral: Color = _confirmation_dialog_color("danger_pressed_surface", Color("#4A252D")) + var border_width: int = _confirmation_dialog_integer("button_border_width", 2, 1, 5) + var radius: int = _confirmation_dialog_integer("button_radius", 12, 8, 24) + var normal_color: Color = blue if primary else neutral + var hover_color: Color = blue.lightened(0.12) if primary else hover_neutral + var pressed_color: Color = blue.darkened(0.12) if primary else pressed_neutral + var edge_color: Color = accent if primary else danger + button.add_theme_stylebox_override("normal", _make_scenery_erase_button_style(normal_color, edge_color, border_width, radius)) + button.add_theme_stylebox_override("hover", _make_scenery_erase_button_style(hover_color, edge_color.lightened(0.12), border_width + 1, radius)) + button.add_theme_stylebox_override("pressed", _make_scenery_erase_button_style(pressed_color, edge_color, border_width + 1, radius)) + button.add_theme_stylebox_override("hover_pressed", _make_scenery_erase_button_style(pressed_color.lightened(0.05), edge_color, border_width + 1, radius)) + button.add_theme_stylebox_override("focus", _make_scenery_erase_button_style(normal_color, accent, border_width + 1, radius)) + var label_color: Color = Color.WHITE if primary else danger + button.add_theme_color_override("font_color", label_color) + button.add_theme_color_override("font_hover_color", label_color) + button.add_theme_color_override("font_pressed_color", label_color) + button.add_theme_font_size_override("font_size", _confirmation_dialog_integer("button_font_size", 20, 16, 32)) + button.custom_minimum_size = Vector2( + float(_confirmation_dialog_integer("remove_button_width", 170, 128, 240)) if not primary else float(_confirmation_dialog_integer("keep_button_width", 132, 112, 220)), + float(_confirmation_dialog_integer("button_height", 48, 44, 80)) + ) + var font: Font = _ui_font + if primary and font != null: + var strong_font: FontVariation = FontVariation.new() + strong_font.base_font = font + strong_font.variation_embolden = 0.55 + button.add_theme_font_override("font", strong_font) + else: + _apply_ui_font(button) + + +func _make_scenery_erase_button_style(background: Color, border: Color, width: int, radius: int) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = background + style.border_color = border + style.set_border_width_all(width) + style.set_corner_radius_all(radius) + style.content_margin_left = 12.0 + style.content_margin_right = 12.0 + style.content_margin_top = 8.0 + style.content_margin_bottom = 8.0 + return style + + +func _confirmation_dialog_integer(token: String, fallback: int, minimum: int, maximum: int) -> int: + return fallback if _sandbox_theme == null else _sandbox_theme.integer("popups.confirmation." + token, fallback, minimum, maximum) + + +func _confirmation_dialog_color(token: String, fallback: Color) -> Color: + return fallback if _sandbox_theme == null else _sandbox_theme.color("popups.confirmation." + token, fallback) + + +func _open_scenery_erase_piece_dialog() -> void: + if _scenery_erase_piece_dialog == null: + return + var popup_size: Vector2i = _scenery_erase_piece_dialog_size() + _apply_scenery_erase_piece_dialog_layout(popup_size.x) + if _scenery_erase_piece_dialog_panel != null: + _scenery_erase_piece_dialog_panel.custom_minimum_size = Vector2(popup_size) + _scenery_erase_piece_dialog.visible = true + call_deferred("_focus_scenery_erase_piece_keep_button") + + +func _scenery_erase_piece_dialog_size() -> Vector2i: + var preferred: Vector2i = Vector2i(520, 208) + var raw_size: Variant = _sandbox_theme.value("popups.confirmation.scenery_erase_size", [520, 208]) if _sandbox_theme != null else [520, 208] + if raw_size is Array and (raw_size as Array).size() == 2: + preferred = Vector2i(int((raw_size as Array)[0]), int((raw_size as Array)[1])) + var available: Vector2i = Vector2i(get_tree().root.size) + var usable_rect: Rect2i = DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen()) + if usable_rect.size.x > 0 and usable_rect.size.y > 0: + available.x = mini(available.x, usable_rect.size.x) + available.y = mini(available.y, usable_rect.size.y) + available = Vector2i(maxi(1, available.x - 32), maxi(1, available.y - 32)) + var width: int = mini(preferred.x, available.x) + var height: int = mini(preferred.y, available.y) + if width < 440: + height = mini(available.y, height + 56) + return Vector2i(width, height) + + +func _apply_scenery_erase_piece_dialog_layout(dialog_width: int) -> void: + if _scenery_erase_piece_dialog_actions == null or _scenery_erase_piece_keep_button == null or _scenery_erase_piece_remove_button == null: + return + var stacked: bool = dialog_width < 440 + _scenery_erase_piece_dialog_actions.vertical = stacked + _scenery_erase_piece_dialog_actions.alignment = BoxContainer.ALIGNMENT_END + _scenery_erase_piece_dialog_actions.add_theme_constant_override("separation", 8 if stacked else _confirmation_dialog_integer("footer_gap", 12, 8, 24)) + if stacked: + var button_width: float = maxf(1.0, float(dialog_width - _confirmation_dialog_integer("padding", 18, 12, 32) * 2)) + var button_height: float = float(_confirmation_dialog_integer("button_height", 48, 44, 80)) + _scenery_erase_piece_remove_button.custom_minimum_size = Vector2(button_width, button_height) + _scenery_erase_piece_keep_button.custom_minimum_size = Vector2(button_width, button_height) + else: + _scenery_erase_piece_remove_button.custom_minimum_size.x = float(_confirmation_dialog_integer("remove_button_width", 170, 128, 240)) + _scenery_erase_piece_keep_button.custom_minimum_size.x = float(_confirmation_dialog_integer("keep_button_width", 132, 112, 220)) + + +func _refresh_scenery_erase_piece_dialog_size() -> void: + if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible: + _open_scenery_erase_piece_dialog() + + +func _focus_scenery_erase_piece_keep_button() -> void: + if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible and _scenery_erase_piece_keep_button != null: + _scenery_erase_piece_keep_button.grab_focus() + + +func _dismiss_scenery_erase_piece_dialog() -> void: + if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible: + _scenery_erase_piece_dialog.hide() + _clear_erase_scenery_targets() + + +func _confirm_erase_scenery_from_dialog() -> void: + _confirm_erase_selected_scenery() + if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible: + _scenery_erase_piece_dialog.hide() + + func _make_surface_choice_cue(kind: StringName, finish: StringName) -> StageSurfacePreview: var cue: StageSurfacePreview = STAGE_SURFACE_PREVIEW_SCRIPT.new() as StageSurfacePreview cue.custom_minimum_size = Vector2(64.0, 40.0) cue.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + cue.set_rounded_frame( + float(_sandbox_theme.integer("popups.asset_selector.look_corner_radius", 8, 2, 20)) if _sandbox_theme != null else 8.0, + _sandbox_theme.color("popups.style_defaults.bg_color", Color("#202633FA")) if _sandbox_theme != null else Color("#202633FA") + ) cue.configure(kind, finish, STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish)) return cue @@ -3319,14 +3649,17 @@ func _surface_color_presets() -> Array[Color]: return colors -func _make_scenery_repair_button(node_name: String, label: String, prominent: bool) -> Button: +func _make_scenery_repair_button(node_name: String, label: String, prominent: bool, destructive: bool = false) -> Button: var button: Button = Button.new() button.name = node_name button.text = label button.focus_mode = Control.FOCUS_ALL button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.custom_minimum_size.y = float(_stage_panel_integer("action.min_target_size", 48, 44, 96)) - _scenery_inspector.style_action_button(button, prominent) + if destructive: + _scenery_inspector.style_destructive_button(button) + else: + _scenery_inspector.style_action_button(button, prominent) return button @@ -3365,6 +3698,65 @@ func _build_use_target_capture_bar(ui: CanvasLayer) -> void: _use_target_capture_bar.change_prop_requested.connect(_choose_different_use_prop) _use_target_capture_bar.capture_action_requested.connect(_on_capture_action_requested) ui.add_child(_use_target_capture_bar) + _build_scenery_depth_cursor_badge(ui) + + +func _build_scenery_depth_cursor_badge(ui: CanvasLayer) -> void: + _scenery_depth_cursor_badge = PanelContainer.new() + _scenery_depth_cursor_badge.name = "SceneryDepthCursorBadge" + _scenery_depth_cursor_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE + _scenery_depth_cursor_badge.z_index = 2 + var style := StyleBoxFlat.new() + style.bg_color = _stage_panel_color("choice.selected_surface", Color("#12394A")) + style.border_color = _sandbox_theme.color("mode_colors.edit", Color("#22C6FF")) if _sandbox_theme != null else Color("#22C6FF") + style.set_border_width_all(1) + style.set_corner_radius_all(10) + style.content_margin_left = 9.0 + style.content_margin_right = 9.0 + style.content_margin_top = 3.0 + style.content_margin_bottom = 3.0 + _scenery_depth_cursor_badge.add_theme_stylebox_override("panel", style) + _scenery_depth_cursor_badge_label = Label.new() + _scenery_depth_cursor_badge_label.name = "DepthLabel" + _scenery_depth_cursor_badge_label.text = "MIDDLE" + _scenery_depth_cursor_badge_label.add_theme_font_size_override("font_size", 13) + _scenery_depth_cursor_badge_label.add_theme_color_override("font_color", Color("#F5F7FA")) + _apply_ui_font(_scenery_depth_cursor_badge_label) + _scenery_depth_cursor_badge.add_child(_scenery_depth_cursor_badge_label) + _scenery_depth_cursor_badge.visible = false + ui.add_child(_scenery_depth_cursor_badge) + + +func _update_scenery_depth_cursor_badge() -> void: + if _scenery_depth_cursor_badge == null or _scenery_controller == null: + return + if current_mode != StageMode.EDIT or _scenery_controller.get_active_tool() != &"artwork": + _scenery_depth_cursor_badge.visible = false + return + var mouse_position: Vector2 = get_viewport().get_mouse_position() + if _use_target_capture_bar != null and _use_target_capture_bar.visible and _use_target_capture_bar.get_global_rect().has_point(mouse_position): + _scenery_depth_cursor_badge.visible = false + return + var ghost_bounds: Rect2 = _scenery_controller.get_active_artwork_preview_bounds() + if ghost_bounds.size.x <= 0.0 or ghost_bounds.size.y <= 0.0: + _scenery_depth_cursor_badge.visible = false + return + var depth: StringName = _scenery_controller.get_artwork_depth() + var depth_label: String = "Back" if depth == &"far" else "Front" if depth == &"floor" else "Middle" + _scenery_depth_cursor_badge_label.text = depth_label.to_upper() + var badge_size: Vector2 = _scenery_depth_cursor_badge.get_combined_minimum_size() + _scenery_depth_cursor_badge.size = badge_size + var viewport_size: Vector2 = get_viewport_rect().size + var screen_top_left: Vector2 = _world_to_screen(ghost_bounds.position) + var screen_bottom_right: Vector2 = _world_to_screen(ghost_bounds.end) + var ghost_screen_rect: Rect2 = Rect2(screen_top_left, screen_bottom_right - screen_top_left).abs() + var badge_position := Vector2(ghost_screen_rect.get_center().x - badge_size.x * 0.5, ghost_screen_rect.position.y - badge_size.y - 8.0) + if badge_position.y < 8.0: + badge_position.y = ghost_screen_rect.position.y + 8.0 + badge_position.x = clampf(badge_position.x, 8.0, maxf(8.0, viewport_size.x - badge_size.x - 8.0)) + badge_position.y = clampf(badge_position.y, 8.0, maxf(8.0, viewport_size.y - badge_size.y - 8.0)) + _scenery_depth_cursor_badge.position = badge_position + _scenery_depth_cursor_badge.visible = true func _build_ground_finish_popup(ui: CanvasLayer) -> void: @@ -4527,7 +4919,7 @@ func _refresh_scenery_inspector() -> void: _scenery_wall_look_group.visible = is_wall _scenery_background_ground_look_heading.visible = is_background_ground _scenery_background_ground_look_group.visible = is_background_ground - _scenery_surface_helper.visible = surface != null + _scenery_surface_helper.visible = is_wall _scenery_surface_colors.visible = false if surface != null: if is_wall: @@ -4535,7 +4927,6 @@ func _refresh_scenery_inspector() -> void: _scenery_surface_helper.text = "Looks like a wall · Stickmen can pass through." else: _scenery_background_ground_look_group.select_no_signal(surface.finish_id) - _scenery_surface_helper.text = "Decorative only · Stickmen don’t walk here." var color_editable: bool = is_wall or surface.finish_id == &"finish_background_carpet" _scenery_surface_colors.visible = color_editable if color_editable: @@ -4543,16 +4934,18 @@ func _refresh_scenery_inspector() -> void: _scenery_surface_colors.set_recent_colors(_recent_surface_colors) _scenery_depth_section.visible = is_artwork if artwork != null: + _set_scenery_depth_preview_path(artwork.asset_path) _scenery_depth_group.select_no_signal(artwork.depth) _scenery_size_row.visible = not is_missing or artwork.has_known_bounds _scenery_smaller_button.disabled = is_missing and not artwork.has_known_bounds _scenery_bigger_button.disabled = is_missing and not artwork.has_known_bounds _scenery_erase_wall_area_button.visible = is_wall + _scenery_erase_background_ground_area_button.visible = is_background_ground _scenery_erase_piece_button.visible = surface != null if is_background_ground: - _scenery_erase_piece_button.text = "Erase background ground" + _scenery_erase_piece_button.text = "Remove background ground" else: - _scenery_erase_piece_button.text = "Erase wall panel" + _scenery_erase_piece_button.text = "Remove wall panel" _scenery_find_button.visible = is_missing _scenery_choose_button.visible = is_missing _scenery_keep_button.visible = is_missing @@ -4595,6 +4988,16 @@ func _is_scenery_node(node: Node2D) -> bool: return node != null and (node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT or node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT) +func _has_remaining_scenery() -> bool: + if _world == null or not is_instance_valid(_world): + return false + for child: Node in _world.get_children(): + var node: Node2D = child as Node2D + if node != null and not node.is_queued_for_deletion() and _is_scenery_node(node): + return true + return false + + func _on_selected_surface_look_choice(finish: StringName) -> void: var surface: StageRectSurface = _selected_rect_surface() if surface == null: @@ -4737,10 +5140,10 @@ func _request_erase_selected_scenery() -> void: _scenery_erase_piece_targets.append(weakref(node)) var count: int = targets.size() if count == 1: - _scenery_erase_piece_dialog.dialog_text = "Erase this piece? You can’t undo this." + _scenery_erase_piece_dialog_message.text = "Remove this whole piece? You can’t undo this." else: - _scenery_erase_piece_dialog.dialog_text = "Erase these %d scenery pieces? You can’t undo this." % count - _scenery_erase_piece_dialog.popup_centered() + _scenery_erase_piece_dialog_message.text = "Remove these %d scenery pieces? You can’t undo this." % count + _open_scenery_erase_piece_dialog() func _confirm_erase_selected_scenery() -> void: @@ -4764,7 +5167,10 @@ func _confirm_erase_selected_scenery() -> void: _director_visuals.mark_dirty() object_deleted.emit(erased) _mark_scene_dirty() - _refresh_scenery_inspector() + if not _has_remaining_scenery(): + _exit_scenery_edit_mode() + else: + _refresh_scenery_inspector() _refresh_status() @@ -4790,13 +5196,31 @@ func _begin_selected_wall_erase() -> void: _begin_wall_erase_tool() +func _begin_selected_background_ground_erase() -> void: + var selected: Array[Node2D] = _selection.get_selected() if _selection != null else [] + if selected.size() != 1 or selected[0].get_script() != STAGE_BACKGROUND_GROUND_SCRIPT or _scenery_controller == null: + return + _begin_background_ground_erase_tool() + + func _begin_wall_erase_tool() -> void: + _begin_surface_erase_tool(&"wall") + + +func _begin_background_ground_erase_tool() -> void: + _begin_surface_erase_tool(&"background_ground") + + +func _begin_surface_erase_tool(surface_kind: StringName) -> void: if _scenery_controller == null or not _scenery_edit_mode: return _selection.clear_selection() _scenery_inspector.hide() - if not _scenery_controller.begin_wall_eraser(_viewport_to_world(get_viewport().get_mouse_position())): - _show_toast("Could not start the wall eraser. Try again.") + var pointer_world: Vector2 = _viewport_to_world(get_viewport().get_mouse_position()) + var started: bool = _scenery_controller.begin_surface_eraser(pointer_world, surface_kind) + if not started: + var surface_label: String = "wall and background ground" if surface_kind == &"all" else ("wall" if surface_kind == &"wall" else "background ground") + _show_toast("Could not start the %s eraser. Try again." % surface_label) _refresh_scenery_capture_bar() @@ -4926,7 +5350,6 @@ func _on_scenery_placement_committed(node: Node2D) -> void: if node == null or not is_instance_valid(node): return _scenery_placed_count += 1 - _mark_scene_dirty() _refresh_scenery_capture_bar() @@ -4941,34 +5364,40 @@ func _refresh_scenery_capture_bar() -> void: _use_target_capture_bar.clear_horizontal_safe_bounds() var actions: Array[Dictionary] = [] if _scenery_controller.get_active_tool() == &"artwork": - actions.append({"id": "depth_far", "label": "Far"}) - actions.append({"id": "depth_near", "label": "Near"}) - actions.append({"id": "depth_floor", "label": "On the floor"}) + _set_scenery_depth_preview_path(_scenery_controller.get_active_artwork_path()) + var selected_depth: StringName = _scenery_controller.get_artwork_depth() + actions.append(_make_scenery_depth_capture_action(&"depth_far", &"far", "Back", selected_depth)) + actions.append(_make_scenery_depth_capture_action(&"depth_near", &"near", "Middle", selected_depth)) + actions.append(_make_scenery_depth_capture_action(&"depth_floor", &"floor", "Front", selected_depth)) var artwork_name: String = String(_scenery_controller.get_active_artwork_name()) - var prompt: String = "Click to place %s" % artwork_name if _scenery_placed_count == 0 else "Place another %s · placed pieces stay" % artwork_name + var prompt: String = "Click to place %s" % artwork_name if _scenery_placed_count == 0 else "Place another · Cancel undoes" actions.append({"id": "done_tool", "label": "Done", "primary": true}) - actions.append({"id": "cancel_tool", "label": "✕ Cancel"}) - _use_target_capture_bar.set_capture_actions(prompt, actions) - elif _scenery_controller.get_active_tool() == &"wall_eraser": - var erase_prompt: String = "Drag over the wall · release to erase" - if _scenery_controller.get_wall_areas_erased() > 0: - erase_prompt = "Erase another area · earlier openings stay" + actions.append({"id": "cancel_tool", "label": "Cancel"}) + _use_target_capture_bar.set_scenery_capture_actions(prompt, actions) + elif _scenery_controller.get_active_tool() in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"]: + var erase_surface: String = _scenery_controller.get_active_eraser_surface_label() + var erase_prompt: String = "Drag over the %s · release to erase" % erase_surface + if _scenery_controller.get_active_tool() == &"scenery_eraser": + erase_prompt = "Drag over wall or ground to erase" + if _scenery_controller.get_surface_areas_erased() > 0: + erase_prompt = "Erase another area · Cancel undoes" actions.append({"id": "done_tool", "label": "Done", "primary": true}) - actions.append({"id": "cancel_tool", "label": "✕ Cancel"}) - _use_target_capture_bar.set_capture_actions(erase_prompt, actions) + actions.append({"id": "cancel_tool", "label": "Cancel"}) + _use_target_capture_bar.set_scenery_capture_actions(erase_prompt, actions) else: + actions.append({"id": "choose_scenery", "label": "Choose scenery"}) actions.append({"id": "done_tool", "label": "Done", "primary": true}) - actions.append({"id": "cancel_tool", "label": "✕ Cancel"}) + actions.append({"id": "cancel_tool", "label": "Cancel"}) if _scenery_controller.get_active_tool() == &"background_ground": var ground_prompt: String = "Draw background ground · No walking" if _scenery_placed_count > 0: - ground_prompt = "Draw another · placed pieces stay" - _use_target_capture_bar.set_capture_actions(ground_prompt, actions) + ground_prompt = "Draw another · Cancel undoes" + _use_target_capture_bar.set_scenery_capture_actions(ground_prompt, actions) else: var wall_prompt: String = "Draw wall panel · Stickmen pass through" if _scenery_placed_count > 0: - wall_prompt = "Draw another · placed pieces stay" - _use_target_capture_bar.set_capture_actions(wall_prompt, actions) + wall_prompt = "Draw another · Cancel undoes" + _use_target_capture_bar.set_scenery_capture_actions(wall_prompt, actions) return if _scenery_controller != null and _scenery_controller.is_group_preview_pending(): _use_target_capture_bar.clear_horizontal_safe_bounds() @@ -4976,52 +5405,24 @@ func _refresh_scenery_capture_bar() -> void: {"id": "keep_group", "label": "Keep changes", "primary": true}, {"id": "cancel_group", "label": "✕ Cancel"}, ] - var group_prompt: String = "Move together · Ground and Props stay put" if _scenery_group_mode == &"move" else "Resize together · Ground and Props stay put" - _use_target_capture_bar.set_capture_actions(group_prompt, preview_actions) + var group_count: int = _selection.get_selected().size() + var group_verb: String = "Move" if _scenery_group_mode == &"move" else "Resize" + var group_prompt: String = "%s %d scenery pieces\nGround and Props stay put" % [group_verb, group_count] + _use_target_capture_bar.set_scenery_capture_actions(group_prompt, preview_actions) return if _scenery_controller != null and _scenery_controller.is_single_transform_active(): - _set_scenery_selection_capture_bounds() - var target: Node2D = _scenery_controller.get_single_transform_node() - var target_name: String = "scenery" - if target != null and target.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT: - target_name = (target as StageSceneryArtwork).display_name - elif target != null and target.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT: - target_name = "background ground" - elif target != null: - target_name = "wall panel" - var is_move: bool = _scenery_controller.get_single_transform_kind() == &"move" - var prompt_verb: String = "Move" if is_move else "Resize" - var release_verb: String = "place" if is_move else "resize" - var cancel_actions: Array[Dictionary] = [{"id": "cancel_single", "label": "✕ Cancel"}] - _use_target_capture_bar.set_capture_actions("%s %s · release to %s" % [prompt_verb, target_name, release_verb], cancel_actions) + _hide_scenery_capture_bar() return if _scenery_edit_mode: var count: int = _selection.get_selected().size() - if count == 1: - _set_scenery_selection_capture_bounds() - else: - _use_target_capture_bar.clear_horizontal_safe_bounds() - var prompt: String = "%d scenery piece%s selected" % [count, "" if count == 1 else "s"] - var edit_actions: Array[Dictionary] = [] - if count > 1: - edit_actions.append({"id": "move_group", "label": "Move together"}) - edit_actions.append({"id": "resize_group", "label": "Resize together"}) - edit_actions.append({"id": "clear_selection", "label": "Clear selection"}) - else: - edit_actions.append({"id": "select_several", "label": "Select several"}) - edit_actions.append({"id": "select_all", "label": "Select all scenery"}) - if count > 0: - edit_actions.append({"id": "clear_selection", "label": "Clear selection"}) - else: - edit_actions.append({"id": "erase_wall_area", "label": "Erase wall area"}) - edit_actions.append({"id": "done_edit", "label": "Done editing", "primary": true}) - _use_target_capture_bar.set_capture_actions(prompt, edit_actions) + _set_scenery_selection_capture_bounds(count == 1) + _use_target_capture_bar.set_scenery_edit_state(count) return _use_target_capture_bar.clear_horizontal_safe_bounds() _use_target_capture_bar.hide_capture() -func _set_scenery_selection_capture_bounds() -> void: +func _set_scenery_selection_capture_bounds(include_inspector: bool = true) -> void: if _use_target_capture_bar == null: return var viewport_width: float = get_viewport_rect().size.x @@ -5030,7 +5431,9 @@ func _set_scenery_selection_capture_bounds() -> void: var palette_width: float = float(palette_config.get("dock_width", 116.0)) var panel_width: float = float(_stage_panel_integer("shell.preferred_width", 380, 220, 720)) var left: float = palette_width + inset - var right: float = viewport_width - panel_width - inset * 2.0 + var right: float = viewport_width - inset + if include_inspector: + right = viewport_width - panel_width - inset * 2.0 _use_target_capture_bar.set_horizontal_safe_bounds(left, right) @@ -5042,6 +5445,8 @@ func _hide_scenery_capture_bar() -> void: func _on_capture_action_requested(action_id: StringName) -> void: match action_id: + &"choose_scenery": + _open_selector("scenery") &"depth_far": _scenery_controller.set_depth(&"far") _refresh_scenery_capture_bar() @@ -5051,9 +5456,12 @@ func _on_capture_action_requested(action_id: StringName) -> void: &"depth_floor": _scenery_controller.set_depth(&"floor") _refresh_scenery_capture_bar() - &"done_tool", &"cancel_tool": + &"done_tool": _scenery_controller.exit_tool() _hide_scenery_capture_bar() + &"cancel_tool": + _scenery_controller.cancel_active_tool() + _hide_scenery_capture_bar() &"select_several": _scenery_multi_select = true _show_toast("Tap scenery pieces to add them. Shift-click also works.") @@ -5065,27 +5473,30 @@ func _on_capture_action_requested(action_id: StringName) -> void: _refresh_scenery_capture_bar() &"erase_wall_area": _begin_wall_erase_tool() + &"erase_area": + _begin_surface_erase_tool(&"all") &"move_group": _begin_scenery_group_transform(&"move") &"resize_group": _begin_scenery_group_transform(&"resize") &"keep_group": - _scenery_controller.commit_group() - _scenery_group_mode = &"" - _refresh_scenery_capture_bar() + _keep_scenery_group_preview() &"cancel_group": _scenery_controller.cancel_group() _scenery_group_mode = &"" _refresh_scenery_capture_bar() - &"cancel_single": - _scenery_controller.cancel_single_transform() - _scenery_single_dragging = false - _refresh_scenery_inspector() - _refresh_scenery_capture_bar() &"done_edit": _exit_scenery_edit_mode() +func _keep_scenery_group_preview() -> void: + if _scenery_controller == null or not _scenery_controller.is_group_preview_pending(): + return + _scenery_controller.commit_group() + _scenery_group_mode = &"" + _refresh_scenery_capture_bar() + + func _begin_scenery_group_transform(kind: StringName) -> void: var selected: Array[Node2D] = _selection.get_selected().duplicate() if _scenery_controller == null or not _scenery_controller.begin_group_transform(kind, selected): diff --git a/scripts/editors/stage/stage_asset_browser.gd b/scripts/editors/stage/stage_asset_browser.gd index d1f0b20..f02458c 100644 --- a/scripts/editors/stage/stage_asset_browser.gd +++ b/scripts/editors/stage/stage_asset_browser.gd @@ -157,7 +157,7 @@ func _scenery_background_entries() -> Array[Dictionary]: {"id": "sky_sunset", "name": "Sunset", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌅"}, {"id": "sky_starry_night", "name": "Starry Night", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌙"}, {"id": "wall_panel_setup", "name": "Wall panel", "category": "Scenery", "gallery_tab": "backgrounds", "background_kind": "wall_setup", "icon": "🧱"}, - {"id": "background_ground_setup", "name": "Background ground", "category": "Scenery", "description": "Decorative · No walking", "gallery_tab": "backgrounds", "background_kind": "background_ground_setup", "icon": "🌿"} + {"id": "background_ground_setup", "name": "Background ground", "category": "Scenery", "gallery_tab": "backgrounds", "background_kind": "background_ground_setup", "icon": "🌿"} ] diff --git a/scripts/editors/stage/stage_scene_adapter.gd b/scripts/editors/stage/stage_scene_adapter.gd index a7a34ad..286fff0 100644 --- a/scripts/editors/stage/stage_scene_adapter.gd +++ b/scripts/editors/stage/stage_scene_adapter.gd @@ -60,6 +60,8 @@ func collect( entry["size"] = STAGE_SCENE_IO.vector_to_data(background.panel_size) entry["finish_id"] = String(background.finish_id) entry["color"] = "#" + background.tint.to_html(true) + if not background.motif_origin_offset.is_zero_approx(): + entry["motif_origin_offset"] = STAGE_SCENE_IO.vector_to_data(background.motif_origin_offset) elif stage_node.get_script() == STAGE_WALL_PANEL: var wall: StageWallPanel = stage_node as StageWallPanel entry["size"] = STAGE_SCENE_IO.vector_to_data(wall.panel_size) @@ -182,6 +184,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary StringName(String(entry.get("finish_id", "finish_background_grass"))), STAGE_SCENE_IO.color_from_data(entry.get("color", "#72A84CFF"), Color("#72A84C")) ) + if entry.has("motif_origin_offset") and STAGE_SCENE_IO.is_vector_data(entry.get("motif_origin_offset")): + background.motif_origin_offset = STAGE_SCENE_IO.data_to_vector(entry.get("motif_origin_offset"), Vector2.ZERO) world.add_child(background) node = background "scenery_artwork": diff --git a/scripts/editors/stage/stage_scenery_controller.gd b/scripts/editors/stage/stage_scenery_controller.gd index f479f82..8a87d9f 100644 --- a/scripts/editors/stage/stage_scenery_controller.gd +++ b/scripts/editors/stage/stage_scenery_controller.gd @@ -1,6 +1,6 @@ class_name StageSceneryController extends Node -## Owns inert Scenery placement drafts and snapshot-based group transforms. +## Owns scenery placement transactions, eraser transactions, and snapshot-based transforms. signal placement_committed(node: Node2D) signal document_change_requested @@ -17,7 +17,6 @@ const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd") const MIN_PANEL_SIZE: float = 24.0 const MIN_WALL_ERASE_SIZE: float = 8.0 const DEFAULT_PANEL_SIZE: Vector2 = Vector2(240.0, 280.0) -const DEFAULT_BACKGROUND_SIZE: Vector2 = Vector2(800.0, 320.0) const WALL_ERASER_CURSOR_SIZE: Vector2 = Vector2(120.0, 120.0) const MIN_ART_SCALE: float = 0.08 const MAX_ART_SCALE: float = 12.0 @@ -28,6 +27,7 @@ var _camera: Camera2D var _ghost: Node2D var _active_tool: StringName = &"" var _asset_path: String = "" +var _placement_nodes: Array[Node2D] = [] var _depth: StringName = &"near" var _wall_finish: StringName = &"finish_wall_paint" var _wall_tint: Color = Color("#5A9D70") @@ -38,7 +38,12 @@ var _wall_anchor_active: bool = false var _wall_erase_start: Vector2 = Vector2.INF var _wall_erase_dragging: bool = false var _wall_erase_pointer: Vector2 = Vector2.ZERO -var _wall_areas_erased: int = 0 +var _surface_erase_kind: StringName = &"wall" +var _surface_areas_erased: int = 0 +# Keep original surfaces detached during an eraser session so Cancel can restore them. +var _surface_erase_originals: Array[Dictionary] = [] +var _surface_erase_detached: Array[Node2D] = [] +var _surface_erase_changed: bool = false var _snap_enabled: bool = false var _grid_size: float = 15.0 var _group_kind: StringName = &"" @@ -102,7 +107,21 @@ func get_active_tool() -> StringName: func get_wall_areas_erased() -> int: - return _wall_areas_erased + return _surface_areas_erased + + +func get_surface_areas_erased() -> int: + return _surface_areas_erased + + +func is_surface_eraser_active() -> bool: + return _is_surface_eraser() + + +func get_active_eraser_surface_label() -> String: + if _surface_erase_kind == &"all": + return "wall or ground" + return "background ground" if _surface_erase_kind == &"background_ground" else "wall" func get_group_bounds() -> Rect2: @@ -115,6 +134,20 @@ func get_active_artwork_name() -> String: return _asset_path.get_file().get_basename() +func get_active_artwork_path() -> String: + return _asset_path if _active_tool == &"artwork" else "" + + +func get_active_artwork_preview_bounds() -> Rect2: + if _active_tool != &"artwork" or _ghost == null or not is_instance_valid(_ghost) or _ghost.get_script() != SCENERY_ARTWORK: + return Rect2() + return (_ghost as StageSceneryArtwork).get_visual_world_bounds() + + +func get_artwork_depth() -> StringName: + return _depth + + func get_active_surface_label() -> String: if _active_tool == &"background_ground": return "background ground" @@ -123,7 +156,7 @@ func get_active_surface_label() -> String: func handle_key(event: InputEventKey) -> bool: if is_active() and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: - exit_tool() + cancel_active_tool() return true if _group_pending and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: cancel_group() @@ -171,13 +204,12 @@ func _begin_rect_surface(tool: StringName, finish: StringName, initial_world_pos _wall_dragging = false _wall_anchor_active = false var panel: StageRectSurface - var initial_size: Vector2 = DEFAULT_PANEL_SIZE if tool == &"background_ground": panel = BACKGROUND_GROUND.new() as StageBackgroundGround - initial_size = DEFAULT_BACKGROUND_SIZE else: panel = WALL_PANEL.new() as StageWallPanel - panel.configure(initial_size, _wall_finish, _wall_tint) + panel.interactive_preview = true + panel.configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint) panel.modulate = Color(1.0, 1.0, 1.0, 0.45) _ghost_holder.add_child(panel) _ghost = panel @@ -188,16 +220,37 @@ func _begin_rect_surface(tool: StringName, finish: StringName, initial_world_pos func begin_wall_eraser(initial_world_position: Vector2) -> bool: + return begin_surface_eraser(initial_world_position, &"wall") + + +func begin_background_ground_eraser(initial_world_position: Vector2) -> bool: + return begin_surface_eraser(initial_world_position, &"background_ground") + + +func begin_surface_eraser(initial_world_position: Vector2, surface_kind: StringName) -> bool: if _world == null or _ghost_holder == null: return false + if surface_kind not in [&"wall", &"background_ground", &"all"]: + return false exit_tool() - _active_tool = &"wall_eraser" + _surface_erase_kind = surface_kind + match surface_kind: + &"background_ground": + _active_tool = &"background_ground_eraser" + &"all": + _active_tool = &"scenery_eraser" + _: + _active_tool = &"wall_eraser" _wall_erase_start = Vector2.INF _wall_erase_dragging = false - _wall_areas_erased = 0 + _surface_areas_erased = 0 + _surface_erase_originals.clear() + _surface_erase_detached.clear() + _surface_erase_changed = false + _capture_surface_eraser_snapshot(surface_kind) _wall_erase_pointer = initial_world_position var preview := Node2D.new() - preview.name = "WallEraserPreview" + preview.name = "BackgroundGroundEraserPreview" if surface_kind == &"background_ground" else "WallEraserPreview" var fill := Polygon2D.new() fill.name = "EraseArea" fill.color = Color("#FF5B62", 0.2) @@ -237,7 +290,7 @@ func update_pointer(world_position: Vector2) -> void: elif _is_rect_placement_tool(): var preview_top_left: Vector2 = _snap(world_position) if _snap_enabled else world_position _ghost.global_position = _move_node_visible_top_left(_ghost, preview_top_left) - elif _active_tool == &"wall_eraser": + elif _is_surface_eraser(): _wall_erase_pointer = world_position if _wall_erase_dragging: _set_wall_eraser_preview_rect(_drag_rect(_wall_erase_start, _snap(world_position))) @@ -252,13 +305,17 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2, if not is_active(): return false if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: - if _wall_dragging or _wall_anchor_active: - cancel_active_gesture() - exit_tool() + # Right-click finishes placement and area-erasure sessions. This commits + # completed work while dropping any unfinished preview gesture. Cancel + # and Escape remain the explicit rollback paths. + if _is_placement_tool() or _is_surface_eraser(): + exit_tool() + else: + cancel_active_tool() return true var completes_drag: bool = not event.pressed and event.button_index == MOUSE_BUTTON_LEFT and ( (_is_rect_placement_tool() and _wall_dragging) - or (_active_tool == &"wall_eraser" and _wall_erase_dragging) + or (_is_surface_eraser() and _wall_erase_dragging) ) if (over_ui and not completes_drag) or event.button_index != MOUSE_BUTTON_LEFT: return false @@ -292,7 +349,7 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2, _wall_anchor_active = false _restore_rect_surface_ghost(world_position) return true - if _active_tool == &"wall_eraser": + if _is_surface_eraser(): if event.pressed: _wall_erase_start = _snap(world_position) if _snap_enabled else world_position _wall_erase_dragging = true @@ -304,10 +361,10 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2, _wall_erase_dragging = false _wall_erase_start = Vector2.INF if erase_rect.size.x >= MIN_WALL_ERASE_SIZE and erase_rect.size.y >= MIN_WALL_ERASE_SIZE: - var changed: int = _erase_wall_region(erase_rect) + var changed: int = _erase_surface_region(erase_rect, _surface_erase_kind) if changed > 0: - _wall_areas_erased += 1 - document_change_requested.emit() + _surface_areas_erased += 1 + _surface_erase_changed = true update_pointer(world_position) return true return false @@ -329,13 +386,17 @@ func cancel_active_gesture() -> void: _wall_erase_start = Vector2.INF if _is_rect_placement_tool() and _ghost != null and is_instance_valid(_ghost): (_ghost as StageRectSurface).configure(_default_size_for_active_surface(), _wall_finish, _wall_tint) - elif _active_tool == &"wall_eraser" and _ghost != null and is_instance_valid(_ghost): + elif _is_surface_eraser() and _ghost != null and is_instance_valid(_ghost): _set_wall_eraser_preview_rect(Rect2(_wall_erase_pointer, WALL_ERASER_CURSOR_SIZE)) func exit_tool() -> void: if _single_active: cancel_single_transform() + if _is_placement_tool(): + commit_placement_session() + if _is_surface_eraser(): + _commit_surface_eraser() cancel_active_gesture() _free_ghost() _active_tool = &"" @@ -346,6 +407,115 @@ func exit_tool() -> void: preview_state_changed.emit(false) +func cancel_active_tool() -> void: + if _is_surface_eraser(): + cancel_surface_eraser() + return + if _is_placement_tool(): + _rollback_placement_session() + exit_tool() + + +func commit_placement_session() -> void: + if not _is_placement_tool(): + return + var has_placed_nodes: bool = false + for node: Node2D in _placement_nodes: + if node != null and is_instance_valid(node) and node.get_parent() != null: + has_placed_nodes = true + _placement_nodes.clear() + if has_placed_nodes: + document_change_requested.emit() + + +func _rollback_placement_session() -> void: + for node: Node2D in _placement_nodes: + if node == null or not is_instance_valid(node): + continue + var parent: Node = node.get_parent() + if parent != null: + parent.remove_child(node) + node.queue_free() + _placement_nodes.clear() + + +func cancel_surface_eraser() -> void: + if not _is_surface_eraser(): + return + if _surface_erase_changed: + _restore_surface_eraser_snapshot() + _surface_areas_erased = 0 + _clear_surface_eraser_transaction() + exit_tool() + + +func _commit_surface_eraser() -> void: + if _surface_erase_changed: + document_change_requested.emit() + for detached: Node2D in _surface_erase_detached: + if detached != null and is_instance_valid(detached): + detached.queue_free() + _clear_surface_eraser_transaction() + + +func _clear_surface_eraser_transaction() -> void: + _surface_erase_originals.clear() + _surface_erase_detached.clear() + _surface_erase_changed = false + + +func _capture_surface_eraser_snapshot(surface_kind: StringName) -> void: + if _world == null or not is_instance_valid(_world): + return + var target_scripts: Array[Script] = _surface_erase_target_scripts(surface_kind) + for child: Node in _world.get_children(): + if not target_scripts.has(child.get_script()): + continue + var surface: Node2D = child as Node2D + if surface == null: + continue + _surface_erase_originals.append({ + "node": surface, + "parent": surface.get_parent(), + "index": surface.get_index(), + }) + + +func _restore_surface_eraser_snapshot() -> void: + if _world == null or not is_instance_valid(_world): + return + var original_ids: Dictionary = {} + for snapshot: Dictionary in _surface_erase_originals: + var original: Node2D = snapshot.get("node") as Node2D + if original != null and is_instance_valid(original): + original_ids[original.get_instance_id()] = true + var target_scripts: Array[Script] = _surface_erase_target_scripts(_surface_erase_kind) + for child: Node in _world.get_children().duplicate(): + if not target_scripts.has(child.get_script()): + continue + _world.remove_child(child) + if not original_ids.has(child.get_instance_id()): + child.queue_free() + for detached: Node2D in _surface_erase_detached: + if detached == null or not is_instance_valid(detached): + continue + if not original_ids.has(detached.get_instance_id()): + detached.queue_free() + var restore_order: Array[Dictionary] = _surface_erase_originals.duplicate() + restore_order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.get("index", 0)) < int(b.get("index", 0))) + for snapshot: Dictionary in restore_order: + var original: Node2D = snapshot.get("node") as Node2D + var parent: Node = snapshot.get("parent") as Node + if original == null or not is_instance_valid(original): + continue + if parent == null or not is_instance_valid(parent): + parent = _world + if original.get_parent() != null: + original.get_parent().remove_child(original) + parent.add_child(original) + parent.move_child(original, clampi(int(snapshot.get("index", 0)), 0, parent.get_child_count() - 1)) + + func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool: if _single_active or members.is_empty() or kind not in [&"move", &"resize"]: return false @@ -591,8 +761,8 @@ func _commit_artwork(position: Vector2) -> void: artwork.set_depth(_depth) artwork.global_position = _snap_visual_center(artwork, position) _world.add_child(artwork) + _placement_nodes.append(artwork) placement_committed.emit(artwork) - document_change_requested.emit() func _commit_rect_surface(first: Vector2, second: Vector2) -> void: @@ -605,8 +775,8 @@ func _commit_rect_surface(first: Vector2, second: Vector2) -> void: panel.configure(size, _wall_finish, _wall_tint) panel.global_position = (first + second) * 0.5 _world.add_child(panel) + _placement_nodes.append(panel) placement_committed.emit(panel) - document_change_requested.emit() func _restore_rect_surface_ghost(world_position: Vector2) -> void: @@ -620,23 +790,39 @@ func _is_rect_placement_tool() -> bool: return _active_tool in [&"wall", &"background_ground"] +func _is_placement_tool() -> bool: + return _active_tool in [&"artwork", &"wall", &"background_ground"] + + func _default_size_for_active_surface() -> Vector2: - return DEFAULT_BACKGROUND_SIZE if _active_tool == &"background_ground" else DEFAULT_PANEL_SIZE + return DEFAULT_PANEL_SIZE + + +func _is_surface_eraser() -> bool: + return _active_tool in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"] func _erase_wall_region(world_rect: Rect2) -> int: + return _erase_surface_region(world_rect, &"wall") + + +func _erase_surface_region(world_rect: Rect2, surface_kind: StringName) -> int: if _world == null or world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0: return 0 var changed_panels: int = 0 + var target_scripts: Array[Script] = _surface_erase_target_scripts(surface_kind) for child: Node in _world.get_children().duplicate(): - if child.get_script() != WALL_PANEL: + if not target_scripts.has(child.get_script()): continue - var wall: StageWallPanel = child as StageWallPanel - var panel_rect: Rect2 = wall.get_local_bounds() + var surface: StageRectSurface = child as StageRectSurface + if surface == null: + continue + var is_background_ground: bool = child.get_script() == BACKGROUND_GROUND + var panel_rect: Rect2 = surface.get_local_bounds() var erase_corners: Array[Vector2] = [world_rect.position, Vector2(world_rect.end.x, world_rect.position.y), world_rect.end, Vector2(world_rect.position.x, world_rect.end.y)] var local_erase := Rect2() for corner_index: int in erase_corners.size(): - var local_point: Vector2 = wall.to_local(erase_corners[corner_index]) + var local_point: Vector2 = surface.to_local(erase_corners[corner_index]) if corner_index == 0: local_erase = Rect2(local_point, Vector2.ZERO) else: @@ -647,27 +833,52 @@ func _erase_wall_region(world_rect: Rect2) -> int: if cut.size.x <= 0.0 or cut.size.y <= 0.0: continue var fragments: Array[Rect2] = _wall_rect_fragments(panel_rect, cut) - var parent: Node = wall.get_parent() + var parent: Node = surface.get_parent() if parent == null: continue - var sibling_index: int = wall.get_index() - var source_transform: Transform2D = wall.transform - var source_motif_origin: Vector2 = wall.get_effective_motif_origin_world() + var sibling_index: int = surface.get_index() + var source_transform: Transform2D = surface.transform + var source_draw_origin: Vector2 = surface.get_surface_draw_origin_world() + if surface is StageWallPanel: + source_draw_origin = (surface as StageWallPanel).get_effective_motif_origin_world() for fragment_rect: Rect2 in fragments: - var fragment := WALL_PANEL.new() as StageWallPanel + var fragment: StageRectSurface + if is_background_ground: + fragment = BACKGROUND_GROUND.new() as StageBackgroundGround + else: + fragment = WALL_PANEL.new() as StageWallPanel fragment.transform = source_transform fragment.position += source_transform.basis_xform(fragment_rect.get_center()) - fragment.configure(fragment_rect.size, wall.finish_id, wall.tint) + fragment.configure(fragment_rect.size, surface.finish_id, surface.tint) parent.add_child(fragment) - fragment.motif_origin_offset = source_motif_origin - fragment.global_position + if fragment is StageWallPanel: + (fragment as StageWallPanel).motif_origin_offset = source_draw_origin - fragment.global_position + elif fragment is StageBackgroundGround: + (fragment as StageBackgroundGround).motif_origin_offset = source_draw_origin - fragment.global_position parent.move_child(fragment, sibling_index) sibling_index += 1 - parent.remove_child(wall) - wall.queue_free() + parent.remove_child(surface) + if _is_surface_eraser(): + _surface_erase_detached.append(surface) + else: + surface.queue_free() changed_panels += 1 return changed_panels +func _surface_erase_target_scripts(surface_kind: StringName) -> Array[Script]: + var target_scripts: Array[Script] = [] + match surface_kind: + &"background_ground": + target_scripts.append(BACKGROUND_GROUND) + &"all": + target_scripts.append(WALL_PANEL) + target_scripts.append(BACKGROUND_GROUND) + _: + target_scripts.append(WALL_PANEL) + return target_scripts + + func _wall_rect_fragments(panel_rect: Rect2, cut: Rect2) -> Array[Rect2]: var fragments: Array[Rect2] = [] _add_wall_fragment(fragments, Rect2(panel_rect.position, Vector2(panel_rect.size.x, cut.position.y - panel_rect.position.y))) diff --git a/scripts/editors/stage/stage_use_target_capture_bar.gd b/scripts/editors/stage/stage_use_target_capture_bar.gd index c984058..e61f0a2 100644 --- a/scripts/editors/stage/stage_use_target_capture_bar.gd +++ b/scripts/editors/stage/stage_use_target_capture_bar.gd @@ -7,12 +7,16 @@ signal change_prop_requested signal capture_action_requested(action_id: StringName) const MAX_BAR_WIDTH: float = 800.0 +const SCENERY_CAPTURE_MAX_WIDTH: float = 900.0 const VIEWPORT_WIDTH_RATIO: float = 0.68 const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0 const ACTION_ROW_MIN_WIDTH: float = 480.0 const ACTION_BUTTON_WIDTH: float = 200.0 const DEFAULT_BUTTON_HEIGHT: float = 56.0 const NARROW_COMPACT_WIDTH: float = 244.0 +const SCENERY_EDIT_MAX_WIDTH: float = 920.0 +const SCENERY_EDIT_BUTTON_HEIGHT: float = 54.0 +const SCENERY_CAPTURE_BUTTON_HEIGHT: float = 56.0 var _stage_theme: SandboxTheme var _top_bar_height: float = 64.0 @@ -25,12 +29,28 @@ var _actions_row: HBoxContainer var _actions_column: VBoxContainer var _actions_parent: BoxContainer var _prompt: Label +var _scenery_edit_layout: VBoxContainer +var _scenery_edit_prompt: Label +var _scenery_edit_count: Label +var _scenery_edit_count_badge: PanelContainer +var _scenery_selection_group: PanelContainer +var _scenery_edit_actions: HBoxContainer +var _scenery_selection_actions: HBoxContainer +var _scenery_select_several_button: Button +var _scenery_select_all_button: Button +var _scenery_move_group_button: Button +var _scenery_resize_group_button: Button +var _scenery_clear_selection_button: Button +var _scenery_erase_area_button: Button +var _scenery_done_button: Button var _change_prop_button: Button var _cancel_button: Button var _custom_action_buttons: Array[Button] = [] var _has_horizontal_safe_bounds: bool = false var _safe_left: float = 0.0 var _safe_right: float = 0.0 +var _scenery_edit_active: bool = false +var _scenery_capture_active: bool = false func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void: @@ -51,6 +71,7 @@ func _ready() -> void: func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void: if _prompt == null: return + _show_standard_capture_layout() _clear_custom_actions() if not prop_selected: _prompt.text = "Choose a prop to use" @@ -65,8 +86,17 @@ func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void: + _set_capture_actions(prompt, actions, false) + + +func set_scenery_capture_actions(prompt: String, actions: Array[Dictionary]) -> void: + _set_capture_actions(prompt, actions, true) + + +func _set_capture_actions(prompt: String, actions: Array[Dictionary], scenery_capture_style: bool) -> void: if _prompt == null: return + _show_standard_capture_layout() _prompt.text = prompt _change_prop_button.visible = false _cancel_button.visible = false @@ -77,14 +107,75 @@ func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void: continue var button := Button.new() button.name = "Action_%s" % String(action_id) - button.text = String(action.get("label", action_id)) + var action_label: String = String(action.get("label", action_id)) + var cue: Control = action.get("cue") as Control + button.text = "" if cue != null else action_label button.tooltip_text = button.text + if cue != null: + button.tooltip_text = action_label + var margin := MarginContainer.new() + margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + margin.add_theme_constant_override("margin_left", 4) + margin.add_theme_constant_override("margin_right", 4) + margin.add_theme_constant_override("margin_top", 3) + margin.add_theme_constant_override("margin_bottom", 3) + margin.mouse_filter = Control.MOUSE_FILTER_IGNORE + var cue_layout := BoxContainer.new() + cue_layout.vertical = true + cue_layout.add_theme_constant_override("separation", 2) + cue_layout.alignment = BoxContainer.ALIGNMENT_CENTER + cue_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE + cue.mouse_filter = Control.MOUSE_FILTER_IGNORE + cue_layout.add_child(cue) + var cue_label := Label.new() + cue_label.text = action_label + cue_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + cue_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + cue_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + cue_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + cue_layout.add_child(cue_label) + margin.add_child(cue_layout) + button.add_child(margin) button.custom_minimum_size.x = ACTION_BUTTON_WIDTH button.focus_mode = Control.FOCUS_ALL + button.set_meta("capture_primary", bool(action.get("primary", false))) + button.set_meta("capture_choice", bool(action.get("choice", false))) + button.set_meta("capture_selected", bool(action.get("selected", false))) button.pressed.connect(func() -> void: capture_action_requested.emit(action_id)) _style_button(button, bool(action.get("primary", false))) _custom_action_buttons.append(button) _actions_parent.add_child(button) + _scenery_capture_active = scenery_capture_style + if _scenery_capture_active: + _set_layout(true, false) + visible = true + apply_theme() + _layout_capture_bar() + + +func set_scenery_edit_state(selected_count: int) -> void: + if _scenery_edit_layout == null: + return + _scenery_capture_active = false + _scenery_edit_active = true + _clear_custom_actions() + if _active_layout.get_parent() == self: + _active_layout.visible = false + remove_child(_active_layout) + _layout_stash.add_child(_active_layout) + if _scenery_edit_layout.get_parent() == _layout_stash: + _layout_stash.remove_child(_scenery_edit_layout) + add_child(_scenery_edit_layout) + _scenery_edit_layout.visible = true + _scenery_edit_prompt.text = "Tap scenery to select it" + _scenery_edit_count.text = "%d selected" % maxi(0, selected_count) + var has_group: bool = selected_count > 1 + _scenery_select_several_button.visible = not has_group + _scenery_select_all_button.visible = not has_group + _scenery_move_group_button.visible = has_group + _scenery_resize_group_button.visible = has_group + _scenery_erase_area_button.visible = selected_count == 0 + _scenery_clear_selection_button.visible = selected_count > 0 visible = true apply_theme() _layout_capture_bar() @@ -104,11 +195,18 @@ func clear_horizontal_safe_bounds() -> void: func hide_capture() -> void: visible = false + _show_standard_capture_layout() func apply_theme() -> void: if not is_node_ready() or _prompt == null: return + if _scenery_edit_active: + _apply_scenery_edit_theme() + return + if _scenery_capture_active: + _apply_scenery_capture_theme() + return theme = _stage_theme.control_theme() if _stage_theme != null else null var accent: Color = _color("mode_colors.direct", Color("#FFB300")) var shell: StyleBoxFlat = StyleBoxFlat.new() @@ -155,6 +253,197 @@ func apply_theme() -> void: _layout_capture_bar() +func _apply_scenery_edit_theme() -> void: + theme = _stage_theme.control_theme() if _stage_theme != null else null + var edit_accent: Color = _color("mode_colors.edit", Color("#22C6FF")) + var panel_style: StyleBoxFlat = StyleBoxFlat.new() + panel_style.bg_color = _color("stage_panels.shell.surface_color", Color("#202633FA")) + panel_style.border_color = Color("#667386") + panel_style.set_border_width_all(2) + panel_style.set_corner_radius_all(18) + panel_style.content_margin_left = 18.0 + panel_style.content_margin_right = 18.0 + panel_style.content_margin_top = 14.0 + panel_style.content_margin_bottom = 14.0 + panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.34) + panel_style.shadow_size = 8 + panel_style.shadow_offset = Vector2(0.0, 4.0) + add_theme_stylebox_override("panel", panel_style) + var font: Font = _stage_theme.font() if _stage_theme != null else ThemeDB.fallback_font + if font != null: + _scenery_edit_prompt.add_theme_font_override("font", font) + _scenery_edit_count.add_theme_font_override("font", font) + for button: Button in [_scenery_select_several_button, _scenery_select_all_button, _scenery_move_group_button, _scenery_resize_group_button, _scenery_clear_selection_button, _scenery_erase_area_button, _scenery_done_button]: + if font != null: + button.add_theme_font_override("font", font) + button.add_theme_font_size_override("font_size", 19) + button.custom_minimum_size.y = SCENERY_EDIT_BUTTON_HEIGHT + _style_scenery_edit_button(button, button == _scenery_done_button, false) + _scenery_edit_prompt.add_theme_font_size_override("font_size", 27) + _scenery_edit_prompt.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA"))) + _scenery_edit_count.add_theme_font_size_override("font_size", 17) + _scenery_edit_count.add_theme_color_override("font_color", Color("#F5F7FA")) + var badge_style: StyleBoxFlat = StyleBoxFlat.new() + badge_style.bg_color = edit_accent.darkened(0.78) + badge_style.border_color = edit_accent + badge_style.set_border_width_all(1) + badge_style.set_corner_radius_all(14) + badge_style.content_margin_left = 14.0 + badge_style.content_margin_right = 14.0 + badge_style.content_margin_top = 6.0 + badge_style.content_margin_bottom = 6.0 + _scenery_edit_count_badge.add_theme_stylebox_override("panel", badge_style) + var group_style: StyleBoxFlat = StyleBoxFlat.new() + group_style.bg_color = _color("stage_panels.action.surface_color", Color("#171B24")) + group_style.border_color = Color("#667386") + group_style.set_border_width_all(1) + group_style.set_corner_radius_all(14) + group_style.content_margin_left = 2.0 + group_style.content_margin_right = 2.0 + group_style.content_margin_top = 2.0 + group_style.content_margin_bottom = 2.0 + _scenery_selection_group.add_theme_stylebox_override("panel", group_style) + _scenery_edit_layout.add_theme_constant_override("separation", 12) + _scenery_selection_actions.add_theme_constant_override("separation", 0) + _style_scenery_selection_segment(_scenery_select_several_button, true, false) + _style_scenery_selection_segment(_scenery_select_all_button, false, true) + _layout_capture_bar() + + +func _apply_scenery_capture_theme() -> void: + theme = _stage_theme.control_theme() if _stage_theme != null else null + var panel_style: StyleBoxFlat = StyleBoxFlat.new() + panel_style.bg_color = _color("stage_panels.shell.surface_color", Color("#202633FA")) + panel_style.border_color = Color("#667386") + panel_style.set_border_width_all(2) + panel_style.set_corner_radius_all(18) + panel_style.content_margin_left = 18.0 + panel_style.content_margin_right = 18.0 + panel_style.content_margin_top = 14.0 + panel_style.content_margin_bottom = 14.0 + panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.34) + panel_style.shadow_size = 8 + panel_style.shadow_offset = Vector2(0.0, 4.0) + add_theme_stylebox_override("panel", panel_style) + var font: Font = _stage_theme.font() if _stage_theme != null else ThemeDB.fallback_font + if font != null: + _prompt.add_theme_font_override("font", font) + _prompt.add_theme_font_size_override("font_size", 25) + _prompt.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA"))) + for button: Button in _custom_action_buttons: + if font != null: + button.add_theme_font_override("font", font) + button.add_theme_font_size_override("font_size", 19) + button.custom_minimum_size.y = SCENERY_EDIT_BUTTON_HEIGHT + if bool(button.get_meta("capture_choice", false)): + _style_scenery_depth_choice(button, bool(button.get_meta("capture_selected", false))) + var margin: MarginContainer = button.get_child(0) as MarginContainer if button.get_child_count() > 0 else null + var cue_stack: VBoxContainer = margin.get_child(0) as VBoxContainer if margin != null and margin.get_child_count() > 0 else null + if cue_stack != null and cue_stack.get_child_count() > 1: + var cue_label: Label = cue_stack.get_child(1) as Label + if font != null: + cue_label.add_theme_font_override("font", font) + cue_label.add_theme_font_size_override("font_size", 17) + cue_label.add_theme_color_override("font_color", Color("#F5F7FA")) + else: + _style_scenery_edit_button(button, bool(button.get_meta("capture_primary", false)), false) + _actions_row.add_theme_constant_override("separation", 12) + _layout_capture_bar() + + +func _style_scenery_depth_choice(button: Button, selected: bool) -> void: + var accent: Color = _color("mode_colors.edit", Color("#22C6FF")) + var background: Color = _color("stage_panels.action.primary_color", Color("#087DBA")) if selected else _color("stage_panels.action.surface_color", Color("#171B24")) + var hover: Color = _color("stage_panels.action.primary_hover_color", Color("#1596D3")) if selected else _color("stage_panels.action.hover_surface", Color("#303A4B")) + var border: Color = accent if selected else Color("#667386") + var border_width: int = 2 if selected else 1 + button.add_theme_stylebox_override("normal", _scenery_depth_choice_button_style(background, border, border_width)) + button.add_theme_stylebox_override("hover", _scenery_depth_choice_button_style(hover, accent, 2)) + button.add_theme_stylebox_override("pressed", _scenery_depth_choice_button_style(background, accent, 2)) + button.add_theme_stylebox_override("hover_pressed", _scenery_depth_choice_button_style(hover, accent, 2)) + button.add_theme_stylebox_override("focus", _scenery_depth_choice_button_style(background, accent, 2)) + button.add_theme_color_override("font_color", Color("#F5F7FA")) + button.add_theme_color_override("font_hover_color", Color("#F5F7FA")) + button.add_theme_color_override("font_pressed_color", Color("#F5F7FA")) + button.add_theme_color_override("font_focus_color", Color("#F5F7FA")) + + +func _scenery_depth_choice_button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat: + var style: StyleBoxFlat = _scenery_button_style(background, border, border_width) + style.content_margin_left = 6.0 + style.content_margin_right = 6.0 + style.content_margin_top = 4.0 + style.content_margin_bottom = 4.0 + return style + + +func _style_scenery_edit_button(button: Button, primary: bool, destructive: bool) -> void: + var accent: Color = _color("mode_colors.edit", Color("#22C6FF")) + var danger: Color = _color("stage_panels.action.destructive_color", Color("#FF626B")) + var border: Color = danger if destructive else (accent if primary else Color("#667386")) + var background: Color = _color("stage_panels.action.primary_color", Color("#087DBA")) if primary else _color("stage_panels.action.surface_color", Color("#171B24")) + var hover_background: Color = _color("stage_panels.action.primary_hover_color", Color("#1596D3")) if primary else _color("stage_panels.action.hover_surface", Color("#303A4B")) + var pressed_background: Color = _color("stage_panels.action.primary_pressed_color", Color("#066B9F")) if primary else _color("stage_panels.action.selected_surface", Color("#12394A")) + var text_color: Color = Color("#F5F7FA") + button.add_theme_stylebox_override("normal", _scenery_button_style(background, border, 2 if primary or destructive else 1)) + button.add_theme_stylebox_override("hover", _scenery_button_style(hover_background, border.lightened(0.12), 2)) + button.add_theme_stylebox_override("pressed", _scenery_button_style(pressed_background, border, 2)) + button.add_theme_stylebox_override("hover_pressed", _scenery_button_style(hover_background, border.lightened(0.12), 2)) + button.add_theme_stylebox_override("focus", _scenery_button_style(background, accent, 2)) + button.add_theme_color_override("font_color", text_color) + button.add_theme_color_override("font_hover_color", text_color) + button.add_theme_color_override("font_pressed_color", text_color) + button.add_theme_color_override("font_focus_color", text_color) + + +func _style_scenery_selection_segment(button: Button, is_first: bool, is_last: bool) -> void: + var base: Color = _color("stage_panels.action.surface_color", Color("#171B24")) + var hover: Color = _color("stage_panels.action.hover_surface", Color("#303A4B")) + var pressed: Color = _color("stage_panels.action.selected_surface", Color("#12394A")) + var divider: Color = Color("#667386") + var accent: Color = _color("mode_colors.edit", Color("#22C6FF")) + button.add_theme_stylebox_override("normal", _scenery_segment_style(base, divider, is_first, is_last)) + button.add_theme_stylebox_override("hover", _scenery_segment_style(hover, divider, is_first, is_last)) + button.add_theme_stylebox_override("pressed", _scenery_segment_style(pressed, divider, is_first, is_last)) + button.add_theme_stylebox_override("hover_pressed", _scenery_segment_style(hover, divider, is_first, is_last)) + button.add_theme_stylebox_override("focus", _scenery_segment_style(base, accent, is_first, is_last, true)) + + +func _scenery_segment_style(background: Color, border: Color, is_first: bool, is_last: bool, is_focused: bool = false) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = background + style.border_color = border + if is_focused: + style.set_border_width_all(2) + else: + style.border_width_left = 0 if is_first else 1 + style.border_width_right = 0 + style.border_width_top = 0 + style.border_width_bottom = 0 + style.corner_radius_top_left = 12 if is_first else 0 + style.corner_radius_bottom_left = 12 if is_first else 0 + style.corner_radius_top_right = 12 if is_last else 0 + style.corner_radius_bottom_right = 12 if is_last else 0 + style.content_margin_left = 12.0 + style.content_margin_right = 12.0 + style.content_margin_top = 6.0 + style.content_margin_bottom = 6.0 + return style + + +func _scenery_button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = background + style.border_color = border + style.set_border_width_all(border_width) + style.set_corner_radius_all(14) + style.content_margin_left = 12.0 + style.content_margin_right = 12.0 + style.content_margin_top = 6.0 + style.content_margin_bottom = 6.0 + return style + + func _build_controls() -> void: mouse_filter = Control.MOUSE_FILTER_STOP _layout_stash = Node.new() @@ -207,6 +496,100 @@ func _build_controls() -> void: _cancel_button.focus_mode = Control.FOCUS_ALL _cancel_button.pressed.connect(func() -> void: cancel_requested.emit()) _actions_row.add_child(_cancel_button) + _build_scenery_edit_controls() + + +func _build_scenery_edit_controls() -> void: + _scenery_edit_layout = VBoxContainer.new() + _scenery_edit_layout.name = "SceneryEditLayout" + _scenery_edit_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _scenery_edit_layout.visible = false + _layout_stash.add_child(_scenery_edit_layout) + var header_row: HBoxContainer = HBoxContainer.new() + header_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_row.add_theme_constant_override("separation", 12) + _scenery_edit_layout.add_child(header_row) + _scenery_edit_prompt = Label.new() + _scenery_edit_prompt.name = "SceneryPrompt" + _scenery_edit_prompt.text = "Tap scenery to select it" + _scenery_edit_prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _scenery_edit_prompt.max_lines_visible = 2 + _scenery_edit_prompt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _scenery_edit_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _scenery_edit_prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE + header_row.add_child(_scenery_edit_prompt) + _scenery_edit_count_badge = PanelContainer.new() + _scenery_edit_count_badge.name = "ScenerySelectionCountBadge" + header_row.add_child(_scenery_edit_count_badge) + _scenery_edit_count = Label.new() + _scenery_edit_count.name = "ScenerySelectionCount" + _scenery_edit_count.text = "0 selected" + _scenery_edit_count.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _scenery_edit_count_badge.add_child(_scenery_edit_count) + _scenery_clear_selection_button = _make_scenery_action_button("ClearSelection", "Clear selection", &"clear_selection") + _scenery_clear_selection_button.custom_minimum_size.x = 150.0 + _scenery_clear_selection_button.visible = false + header_row.add_child(_scenery_clear_selection_button) + _scenery_edit_actions = HBoxContainer.new() + _scenery_edit_actions.name = "SceneryEditActions" + _scenery_edit_actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _scenery_edit_actions.alignment = BoxContainer.ALIGNMENT_CENTER + _scenery_edit_actions.add_theme_constant_override("separation", 10) + _scenery_edit_layout.add_child(_scenery_edit_actions) + _scenery_selection_group = PanelContainer.new() + _scenery_selection_group.name = "SelectionActionsGroup" + _scenery_selection_group.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _scenery_edit_actions.add_child(_scenery_selection_group) + _scenery_selection_actions = HBoxContainer.new() + _scenery_selection_actions.add_theme_constant_override("separation", 6) + _scenery_selection_group.add_child(_scenery_selection_actions) + _scenery_select_several_button = _make_scenery_action_button("SelectSeveral", "Select several", &"select_several") + _scenery_select_several_button.custom_minimum_size.x = 158.0 + _scenery_selection_actions.add_child(_scenery_select_several_button) + _scenery_select_all_button = _make_scenery_action_button("SelectAll", "Select all", &"select_all") + _scenery_select_all_button.custom_minimum_size.x = 148.0 + _scenery_selection_actions.add_child(_scenery_select_all_button) + _scenery_move_group_button = _make_scenery_action_button("MoveGroup", "Move together", &"move_group") + _scenery_move_group_button.custom_minimum_size.x = 168.0 + _scenery_move_group_button.visible = false + _scenery_selection_actions.add_child(_scenery_move_group_button) + _scenery_resize_group_button = _make_scenery_action_button("ResizeGroup", "Resize together", &"resize_group") + _scenery_resize_group_button.custom_minimum_size.x = 182.0 + _scenery_resize_group_button.visible = false + _scenery_selection_actions.add_child(_scenery_resize_group_button) + _scenery_erase_area_button = _make_scenery_action_button("EraseArea", "Erase Area", &"erase_area") + _scenery_erase_area_button.custom_minimum_size.x = 152.0 + _scenery_edit_actions.add_child(_scenery_erase_area_button) + _scenery_done_button = _make_scenery_action_button("DoneEditing", "Done editing", &"done_edit") + _scenery_done_button.custom_minimum_size.x = 160.0 + _scenery_edit_actions.add_child(_scenery_done_button) + + +func _make_scenery_action_button(button_name: String, label: String, action_id: StringName) -> Button: + var button: Button = Button.new() + button.name = button_name + button.text = label + button.tooltip_text = label + button.focus_mode = Control.FOCUS_ALL + button.custom_minimum_size = Vector2(144.0, SCENERY_EDIT_BUTTON_HEIGHT) + button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + button.pressed.connect(func() -> void: capture_action_requested.emit(action_id)) + return button + + +func _show_standard_capture_layout() -> void: + _scenery_edit_active = false + _scenery_capture_active = false + if _scenery_edit_layout != null and _scenery_edit_layout.get_parent() == self: + _scenery_edit_layout.visible = false + remove_child(_scenery_edit_layout) + _layout_stash.add_child(_scenery_edit_layout) + if _active_layout != null and _active_layout.get_parent() == _layout_stash: + _layout_stash.remove_child(_active_layout) + add_child(_active_layout) + _active_layout.visible = true + if is_node_ready(): + apply_theme() func _clear_custom_actions() -> void: @@ -255,6 +638,12 @@ func _layout_capture_bar() -> void: if not is_node_ready() or _prompt == null: return var viewport_size: Vector2 = get_viewport_rect().size + if _scenery_edit_active: + _layout_scenery_edit_capture_bar(viewport_size) + return + if _scenery_capture_active: + _layout_scenery_capture_bar(viewport_size) + return var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48)) var safe_left: float = inset var safe_right: float = viewport_size.x - inset @@ -281,6 +670,123 @@ func _layout_capture_bar() -> void: call_deferred("_fit_height_to_content") +func _layout_scenery_edit_capture_bar(viewport_size: Vector2) -> void: + var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48)) + var safe_left: float = inset + var safe_right: float = viewport_size.x - inset + if _has_horizontal_safe_bounds: + safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset)) + safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset)) + var available_width: float = maxf(1.0, safe_right - safe_left) + var width: float = minf(SCENERY_EDIT_MAX_WIDTH, available_width) + var narrow: bool = width < 700.0 + custom_minimum_size.x = width + size.x = width + size_flags_horizontal = Control.SIZE_SHRINK_CENTER + clip_contents = false + position = Vector2(safe_left + (available_width - width) * 0.5, _top_bar_height + 56.0) + _scenery_edit_prompt.add_theme_font_size_override("font_size", 27 if width >= 700.0 else (21 if width >= 520.0 else 18)) + if narrow and _scenery_erase_area_button.get_parent() == _scenery_edit_actions: + _scenery_edit_actions.remove_child(_scenery_erase_area_button) + _scenery_edit_layout.get_child(0).add_child(_scenery_erase_area_button) + elif not narrow and _scenery_erase_area_button.get_parent() != _scenery_edit_actions: + var header_row: HBoxContainer = _scenery_edit_layout.get_child(0) as HBoxContainer + header_row.remove_child(_scenery_erase_area_button) + _scenery_edit_actions.add_child(_scenery_erase_area_button) + _scenery_edit_actions.move_child(_scenery_erase_area_button, 1) + _scenery_selection_actions.add_theme_constant_override("separation", 0) + _scenery_select_several_button.custom_minimum_size.x = 134.0 if narrow else 158.0 + _scenery_select_all_button.custom_minimum_size.x = 124.0 if narrow else 148.0 + _scenery_move_group_button.custom_minimum_size.x = 146.0 if narrow else 168.0 + _scenery_resize_group_button.custom_minimum_size.x = 158.0 if narrow else 182.0 + _scenery_done_button.custom_minimum_size.x = 142.0 if narrow else 160.0 + _scenery_erase_area_button.custom_minimum_size.x = 136.0 if narrow else 152.0 + _scenery_clear_selection_button.custom_minimum_size.x = 128.0 if narrow else 150.0 + for button: Button in [_scenery_select_several_button, _scenery_select_all_button, _scenery_move_group_button, _scenery_resize_group_button, _scenery_clear_selection_button, _scenery_erase_area_button, _scenery_done_button]: + button.add_theme_font_size_override("font_size", 19 if width >= 700.0 else 16) + button.custom_minimum_size.y = 52.0 if narrow else SCENERY_EDIT_BUTTON_HEIGHT + button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + _scenery_edit_actions.add_theme_constant_override("separation", 8 if narrow else 10) + call_deferred("_fit_scenery_edit_height") + + +func _layout_scenery_capture_bar(viewport_size: Vector2) -> void: + var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48)) + var available_width: float = maxf(1.0, viewport_size.x - inset * 2.0) + var width: float = minf(SCENERY_CAPTURE_MAX_WIDTH, available_width) + var button_count: int = maxi(1, _custom_action_buttons.size()) + var has_depth_choices: bool = button_count >= 5 + var horizontal_depth_cards: bool = has_depth_choices and width >= 700.0 + var compact_spacing: float = 8.0 if has_depth_choices and width < 760.0 else 12.0 + var minimum_row_width: float = 40.0 + 5.0 * 84.0 + float(button_count - 1) * compact_spacing + var stack_actions: bool = width < ACTION_ROW_MIN_WIDTH or (has_depth_choices and width < minimum_row_width) + custom_minimum_size.x = width + size.x = width + size_flags_horizontal = Control.SIZE_SHRINK_CENTER + clip_contents = false + position = Vector2((viewport_size.x - width) * 0.5, _top_bar_height + 56.0) + _prompt.add_theme_font_size_override("font_size", 25 if width >= 520.0 else 19) + _set_layout(true, stack_actions) + _actions_row.alignment = BoxContainer.ALIGNMENT_END + var available_button_width: float = maxf(1.0, width - 40.0 - compact_spacing * float(button_count - 1)) + var depth_button_width: float = available_button_width + var secondary_button_width: float = available_button_width + if stack_actions: + depth_button_width = maxf(120.0, width - 72.0) + secondary_button_width = depth_button_width + elif has_depth_choices: + secondary_button_width = clampf(available_button_width * 0.16, 84.0, 132.0) + depth_button_width = maxf(84.0, (available_button_width - secondary_button_width * 2.0) / 3.0) + else: + depth_button_width = minf(ACTION_BUTTON_WIDTH, maxf(120.0, available_button_width / float(button_count))) + secondary_button_width = depth_button_width + var cue_size := Vector2(56.0, 42.0) + var choice_button_height: float = 84.0 + if width >= 560.0: + cue_size = Vector2(64.0, 48.0) + choice_button_height = 88.0 + if width >= 700.0: + cue_size = Vector2(66.0, 50.0) + choice_button_height = 100.0 + if width >= 800.0: + cue_size = Vector2(96.0, 72.0) + choice_button_height = 112.0 + for button: Button in _custom_action_buttons: + var is_depth_choice: bool = bool(button.get_meta("capture_choice", false)) + button.custom_minimum_size = Vector2(depth_button_width if is_depth_choice else secondary_button_width, choice_button_height if has_depth_choices else SCENERY_CAPTURE_BUTTON_HEIGHT) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_SHRINK_CENTER + if is_depth_choice: + var margin: MarginContainer = button.get_child(0) as MarginContainer if button.get_child_count() > 0 else null + var cue_layout: BoxContainer = margin.get_child(0) as BoxContainer if margin != null and margin.get_child_count() > 0 else null + if cue_layout != null and cue_layout.get_child_count() > 1: + cue_layout.vertical = not horizontal_depth_cards + cue_layout.add_theme_constant_override("separation", 8 if horizontal_depth_cards else 2) + var cue: Control = cue_layout.get_child(0) as Control + if cue != null: + cue.custom_minimum_size = cue_size + var cue_label: Label = cue_layout.get_child(1) as Label + if cue_label != null: + cue_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT if horizontal_depth_cards else HORIZONTAL_ALIGNMENT_CENTER + cue_label.add_theme_font_size_override("font_size", 20 if width >= 800.0 else (18 if width >= 620.0 else 17)) + button.add_theme_font_size_override("font_size", 17 if width < 760.0 else 19) + _actions_row.add_theme_constant_override("separation", int(compact_spacing)) + call_deferred("_fit_scenery_capture_height_after_layout") + + +func _fit_scenery_capture_height_after_layout() -> void: + await get_tree().process_frame + _fit_height_to_content() + + +func _fit_scenery_edit_height() -> void: + if not is_inside_tree() or not visible or not _scenery_edit_active: + return + var panel_style: StyleBox = get_theme_stylebox("panel") + var panel_height: float = panel_style.get_minimum_size().y if panel_style != null else 0.0 + custom_minimum_size.y = _scenery_edit_layout.get_combined_minimum_size().y + panel_height + size.y = custom_minimum_size.y + + func _set_layout(compact: bool, stack_actions: bool, wrapped: bool = false) -> void: var target_layout: Container = _wrapped_layout if wrapped else (_vertical_layout if compact else _horizontal_layout) var target_actions: BoxContainer = _actions_column if stack_actions else _actions_row @@ -348,12 +854,13 @@ func _apply_compact_action_labels(compact: bool) -> void: "cancel_group": "Cancel", "done_tool": "Done", "cancel_tool": "Cancel", - "cancel_single": "Cancel", - "depth_far": "Far", - "depth_near": "Near", - "depth_floor": "Floor", + "depth_far": "Back", + "depth_near": "Middle", + "depth_floor": "Front", } for button: Button in _custom_action_buttons: + if bool(button.get_meta("capture_choice", false)): + continue var action_id: String = button.name.trim_prefix("Action_") var full_label: String = button.tooltip_text button.text = String(compact_labels.get(action_id, full_label)) if compact else full_label diff --git a/scripts/shared/stage_ui/stage_depth_choice_preview.gd b/scripts/shared/stage_ui/stage_depth_choice_preview.gd new file mode 100644 index 0000000..217cba2 --- /dev/null +++ b/scripts/shared/stage_ui/stage_depth_choice_preview.gd @@ -0,0 +1,97 @@ +class_name StageDepthChoicePreview +extends Control +## Small fixed-size diagram showing how scenery depth changes draw order. + +const DEPTH_FAR := &"far" +const DEPTH_NEAR := &"near" +const DEPTH_FLOOR := &"floor" + +var depth_id: StringName = DEPTH_NEAR +var artwork_texture: Texture2D + + +func configure(depth: StringName, texture: Texture2D, preview_size: Vector2) -> void: + depth_id = depth if depth in [DEPTH_FAR, DEPTH_NEAR, DEPTH_FLOOR] else DEPTH_NEAR + artwork_texture = texture + custom_minimum_size = preview_size + size_flags_horizontal = Control.SIZE_SHRINK_CENTER + size_flags_vertical = Control.SIZE_SHRINK_CENTER + mouse_filter = Control.MOUSE_FILTER_IGNORE + clip_contents = true + queue_redraw() + + +func set_artwork_texture(texture: Texture2D) -> void: + artwork_texture = texture + queue_redraw() + + +func _draw() -> void: + var bounds := Rect2(Vector2.ZERO, size) + draw_rect(bounds, Color("#B9D9D9"), true) + _draw_hills() + if depth_id != DEPTH_FAR: + _draw_near_scenery() + if depth_id == DEPTH_FLOOR: + _draw_ground_tiles() + _draw_artwork() + if depth_id == DEPTH_FAR: + _draw_near_scenery() + if depth_id != DEPTH_FLOOR: + _draw_ground_tiles() + draw_rect(bounds, Color("#566273"), false, 1.0) + + +func _draw_hills() -> void: + var points := PackedVector2Array([ + Vector2(0.0, size.y * 0.68), + Vector2(0.0, size.y * 0.52), + Vector2(size.x * 0.18, size.y * 0.39), + Vector2(size.x * 0.38, size.y * 0.54), + Vector2(size.x * 0.62, size.y * 0.36), + Vector2(size.x * 0.84, size.y * 0.49), + Vector2(size.x, size.y * 0.41), + Vector2(size.x, size.y * 0.68), + ]) + draw_colored_polygon(points, Color("#79AFA4")) + + +func _draw_near_scenery() -> void: + var canopy := Color("#277447") + var trunk := Color("#84502B") + var canopy_center := Vector2(size.x * 0.49, size.y * 0.56) + var radius := maxf(4.0, size.y * 0.25) + draw_rect(Rect2(Vector2(size.x * 0.49 - 2.0, size.y * 0.46), Vector2(4.0, size.y * 0.23)), trunk, true) + draw_circle(canopy_center, radius, canopy) + draw_circle(canopy_center + Vector2(-radius * 0.52, radius * 0.08), radius * 0.62, Color("#48A84E")) + draw_circle(canopy_center + Vector2(radius * 0.48, radius * 0.02), radius * 0.55, Color("#36934A")) + + +func _draw_ground_tiles() -> void: + var top: float = size.y * 0.64 + var tile_height: float = size.y - top + var tile_count: int = 5 + var tile_width: float = size.x / float(tile_count) + for index: int in tile_count: + var tile := Rect2(Vector2(float(index) * tile_width, top), Vector2(tile_width + 0.5, tile_height)) + draw_rect(tile, Color("#87512C"), true) + draw_rect(tile, Color("#A96C3B"), false, 1.0) + draw_line(Vector2(float(index) * tile_width + 2.0, top + tile_height * 0.58), Vector2(float(index + 1) * tile_width - 2.0, top + tile_height * 0.58), Color("#C9894B"), 1.0) + draw_rect(Rect2(Vector2(0.0, top - 3.0), Vector2(size.x, 6.0)), Color("#62D34D"), true) + draw_line(Vector2(0.0, top - 3.0), Vector2(size.x, top - 3.0), Color("#D5F27B"), 1.0) + + +func _draw_artwork() -> void: + if artwork_texture == null: + var center := Vector2(size.x * 0.49, size.y * 0.59) + draw_circle(center, maxf(3.0, minf(size.x, size.y) * 0.20), Color("#F4F1E8")) + draw_circle(center, maxf(3.0, minf(size.x, size.y) * 0.20), Color("#58616D"), false, 1.0) + return + var texture_size := Vector2(artwork_texture.get_size()) + if texture_size.x <= 0.0 or texture_size.y <= 0.0: + return + var available := Vector2(size.x * 0.84, size.y * 0.84) + var scale_factor: float = minf(available.x / texture_size.x, available.y / texture_size.y) + var target_size := texture_size * scale_factor + var target := Rect2(Vector2((size.x - target_size.x) * 0.5, size.y * 0.59 - target_size.y * 0.5), target_size) + draw_texture_rect(artwork_texture, target, false) diff --git a/scripts/shared/stage_ui/stage_depth_choice_preview.gd.uid b/scripts/shared/stage_ui/stage_depth_choice_preview.gd.uid new file mode 100644 index 0000000..7200c41 --- /dev/null +++ b/scripts/shared/stage_ui/stage_depth_choice_preview.gd.uid @@ -0,0 +1 @@ +uid://cc5hn0jge7758 diff --git a/scripts/shared/stage_ui/stage_inspector_panel.gd b/scripts/shared/stage_ui/stage_inspector_panel.gd index bd8c546..b01f5a4 100644 --- a/scripts/shared/stage_ui/stage_inspector_panel.gd +++ b/scripts/shared/stage_ui/stage_inspector_panel.gd @@ -116,6 +116,52 @@ func style_action_button(button: Button, prominent: bool = false) -> void: _style_action_button(button, prominent) +func style_primary_action_button(button: Button) -> void: + if button == null: + return + var primary := _color("stage_panels.action.primary_color", Color("#087DBA")) + var hover := _color("stage_panels.action.primary_hover_color", Color("#1596D3")) + var pressed := _color("stage_panels.action.primary_pressed_color", Color("#066B9F")) + var accent := _color("mode_colors.edit", Color("#22C6FF")) + button.add_theme_stylebox_override("normal", _make_style(primary, accent.lightened(0.15), 2, 10, 6)) + button.add_theme_stylebox_override("hover", _make_style(hover, accent, 2, 10, 6)) + button.add_theme_stylebox_override("pressed", _make_style(pressed, accent, 2, 10, 6)) + button.add_theme_stylebox_override("hover_pressed", _make_style(pressed.lightened(0.05), accent, 2, 10, 6)) + button.add_theme_stylebox_override("disabled", _make_style(_color("stage_panels.choice.disabled_surface", Color("#252B38")), _color("global.border_color", Color("#FFFFFF33")), 1, 10, 6)) + button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2, 10, 6)) + button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40)) + button.add_theme_color_override("font_color", Color.WHITE) + button.add_theme_color_override("font_hover_color", Color.WHITE) + button.add_theme_color_override("font_pressed_color", Color.WHITE) + if _stage_theme != null: + var ui_font := _stage_theme.font() + if ui_font != null: + button.add_theme_font_override("font", ui_font) + + +func style_destructive_button(button: Button) -> void: + if button == null: + return + var danger := _color("stage_panels.action.destructive_color", Color("#FF626B")) + var neutral := _color("stage_panels.action.surface_color", Color("#171B24")) + var hover := _color("stage_panels.action.destructive_hover_surface", Color("#3B252C")) + var pressed := _color("stage_panels.action.destructive_pressed_surface", Color("#4A252D")) + var border := _color("global.border_color", Color("#FFFFFF33")) + var accent := _color("mode_colors.edit", Color("#22C6FF")) + button.add_theme_stylebox_override("normal", _make_style(neutral, danger, 2, 10, 6)) + button.add_theme_stylebox_override("hover", _make_style(hover, danger, 2, 10, 6)) + button.add_theme_stylebox_override("pressed", _make_style(pressed, danger, 2, 10, 6)) + button.add_theme_stylebox_override("hover_pressed", _make_style(pressed.lightened(0.06), danger, 2, 10, 6)) + button.add_theme_stylebox_override("disabled", _make_style(_color("stage_panels.choice.disabled_surface", Color("#252B38")), border, 1, 10, 6)) + button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2, 10, 6)) + button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40)) + button.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA"))) + if _stage_theme != null: + var ui_font := _stage_theme.font() + if ui_font != null: + button.add_theme_font_override("font", ui_font) + + func _build_shell() -> void: if _body != null: return @@ -145,11 +191,12 @@ func _build_shell() -> void: _title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _header.add_child(_title) _close_button = Button.new() - _close_button.text = "✕ Close" + _close_button.text = "Close" _close_button.custom_minimum_size = Vector2( float(_integer("stage_panels.action.close_width", 88, 72, 180)), float(_integer("stage_panels.action.min_target_size", 48, 44, 96)) ) + _close_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN _close_button.focus_mode = Control.FOCUS_ALL _close_button.pressed.connect(func() -> void: close_requested.emit()) _header.add_child(_close_button) diff --git a/scripts/shared/stage_ui/stage_panel_choice_group.gd b/scripts/shared/stage_ui/stage_panel_choice_group.gd index 76950e4..815674a 100644 --- a/scripts/shared/stage_ui/stage_panel_choice_group.gd +++ b/scripts/shared/stage_ui/stage_panel_choice_group.gd @@ -1,5 +1,5 @@ class_name StagePanelChoiceGroup -extends VBoxContainer +extends BoxContainer ## Exclusive, full-width choices with optional decorative cues. signal choice_selected(id: StringName) @@ -8,20 +8,43 @@ var _stage_theme: SandboxTheme = null var _group := ButtonGroup.new() var _buttons: Dictionary = {} var _selected_id: StringName = &"" +var _vertical_layout: bool = true +var _minimum_height_override: float = -1.0 +var _font_size_override: int = -1 +var _corner_radius_override: int = -1 +var _border_width_override: int = -1 +var _selected_surface_override: Color = Color.TRANSPARENT +var _has_selected_surface_override: bool = false func _ready() -> void: + vertical = _vertical_layout _group.allow_unpress = true add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32)) -func add_choice(id: StringName, caption: String, cue: Control = null) -> Button: +func configure_layout(vertical_layout: bool, minimum_height: float = -1.0, font_size: int = -1, corner_radius: int = -1, border_width: int = -1, selected_surface: Color = Color.TRANSPARENT) -> void: + _vertical_layout = vertical_layout + vertical = vertical_layout + _minimum_height_override = minimum_height + _font_size_override = font_size + _corner_radius_override = corner_radius + _border_width_override = border_width + _selected_surface_override = selected_surface + _has_selected_surface_override = selected_surface.a > 0.0 + for entry: Dictionary in _buttons.values(): + _apply_choice_theme(entry["button"] as Button, entry["label"] as Label, entry.get("detail") as Label) + + +func add_choice(id: StringName, caption: String, cue: Control = null, detail: String = "") -> Button: var button := Button.new() button.name = "Choice_%s" % String(id) button.toggle_mode = true button.button_group = _group - button.custom_minimum_size.y = float(_integer("stage_panels.choice.minimum_height", 56, 44, 112)) + button.custom_minimum_size.y = _minimum_height_override if _minimum_height_override > 0.0 else float(_integer("stage_panels.choice.minimum_height", 56, 44, 112)) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + if not _vertical_layout: + button.size_flags_vertical = Control.SIZE_EXPAND_FILL button.focus_mode = Control.FOCUS_ALL button.text = "" var margin := MarginContainer.new() @@ -42,16 +65,31 @@ func add_choice(id: StringName, caption: String, cue: Control = null) -> Button: var label := Label.new() label.text = caption label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT if not detail.is_empty() else HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE - row.add_child(label) + var detail_label: Label + if detail.is_empty(): + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + row.add_child(label) + else: + var text_stack := VBoxContainer.new() + text_stack.add_theme_constant_override("separation", 0) + text_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL + text_stack.mouse_filter = Control.MOUSE_FILTER_IGNORE + text_stack.add_child(label) + detail_label = Label.new() + detail_label.text = detail + detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + detail_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + detail_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + text_stack.add_child(detail_label) + row.add_child(text_stack) button.pressed.connect(_on_choice_pressed.bind(id, button)) add_child(button) - _buttons[id] = {"button": button, "label": label} + _buttons[id] = {"button": button, "label": label, "detail": detail_label} if _stage_theme != null: - _apply_choice_theme(button, label) + _apply_choice_theme(button, label, detail_label) return button @@ -75,7 +113,7 @@ func apply_theme(stage_theme: SandboxTheme) -> void: _stage_theme = stage_theme add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32)) for entry: Dictionary in _buttons.values(): - _apply_choice_theme(entry["button"] as Button, entry["label"] as Label) + _apply_choice_theme(entry["button"] as Button, entry["label"] as Label, entry.get("detail") as Label) func _on_choice_pressed(id: StringName, button: Button) -> void: @@ -87,26 +125,40 @@ func _on_choice_pressed(id: StringName, button: Button) -> void: choice_selected.emit(id) -func _apply_choice_theme(button: Button, label: Label) -> void: +func _apply_choice_theme(button: Button, label: Label, detail: Label = null) -> void: if _stage_theme == null: return var font := _stage_theme.font() if font != null: - label.add_theme_font_override("font", font) - label.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40)) + if not _vertical_layout: + var strong_font := FontVariation.new() + strong_font.base_font = font + strong_font.variation_embolden = 0.25 + label.add_theme_font_override("font", strong_font) + else: + label.add_theme_font_override("font", font) + var font_size: int = _font_size_override if _font_size_override > 0 else _integer("stage_panels.type.body_size", 20, 14, 40) + label.add_theme_font_size_override("font_size", font_size) label.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA"))) + if detail != null: + if font != null: + detail.add_theme_font_override("font", font) + detail.add_theme_font_size_override("font_size", maxi(12, font_size - 4)) + detail.add_theme_color_override("font_color", _color("stage_panels.type.muted_text_color", Color("#AAB2C0"))) var neutral := _color("stage_panels.choice.neutral_surface", Color("#171B24")) var hover := _color("stage_panels.choice.hover_surface", Color("#303A4B")) - var selected := _color("stage_panels.choice.selected_surface", Color("#12394A")) + var selected := _selected_surface_override if _has_selected_surface_override else _color("stage_panels.choice.selected_surface", Color("#12394A")) var disabled := _color("stage_panels.choice.disabled_surface", Color("#252B38")) var border := _color("stage_panels.choice.border_color", Color("#FFFFFF33")) var accent := _color("mode_colors.edit", Color("#22C6FF")) - button.add_theme_stylebox_override("normal", _make_style(neutral, border, 1)) - button.add_theme_stylebox_override("hover", _make_style(hover, border, 1)) - button.add_theme_stylebox_override("pressed", _make_style(selected, accent, 2)) - button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 2)) - button.add_theme_stylebox_override("disabled", _make_style(disabled, border, 1)) - button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2)) + var border_width: int = _border_width_override if _border_width_override >= 0 else 1 + var selected_border_width: int = border_width + 1 if _border_width_override >= 0 else 2 + button.add_theme_stylebox_override("normal", _make_style(neutral, border, border_width)) + button.add_theme_stylebox_override("hover", _make_style(hover, border, border_width)) + button.add_theme_stylebox_override("pressed", _make_style(selected, accent, selected_border_width)) + button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, selected_border_width)) + button.add_theme_stylebox_override("disabled", _make_style(disabled, border, border_width)) + button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, selected_border_width)) func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat: @@ -114,7 +166,8 @@ func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat: style.bg_color = bg style.border_color = border style.set_border_width_all(width) - style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32)) + var radius: int = _corner_radius_override if _corner_radius_override >= 0 else _integer("stage_panels.shell.corner_radius", 10, 0, 32) + style.set_corner_radius_all(radius) return style diff --git a/scripts/shared/stage_ui/stage_surface_color_controls.gd b/scripts/shared/stage_ui/stage_surface_color_controls.gd index 67158bf..6368ca2 100644 --- a/scripts/shared/stage_ui/stage_surface_color_controls.gd +++ b/scripts/shared/stage_ui/stage_surface_color_controls.gd @@ -17,7 +17,6 @@ var _current_value: Label var _recent_empty_label: Label var _recent_swatches: ColorSwatchSelector var _preset_swatches: ColorSwatchSelector -var _more_button: Button var _compact_mode: bool = false @@ -54,60 +53,107 @@ func set_recent_colors(colors: Array[Color]) -> void: func focus_custom_button() -> void: - if _more_button != null: - _more_button.grab_focus() + if _current_chip != null: + _current_chip.grab_focus() func _build() -> void: _built = true _apply_compact_layout() - var current_heading: Label = _make_heading("Color") - add_child(current_heading) + var current_parent: Control = self + var recent_parent: Control = self + var presets_parent: Control = self + if _compact_mode: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", _integer("popups.asset_selector.color_group_gap", 8, 4, 32)) + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + add_child(row) + current_parent = _make_compact_section(132.0) + row.add_child(current_parent) + row.add_child(_make_compact_separator()) + recent_parent = _make_compact_section(92.0) + row.add_child(recent_parent) + row.add_child(_make_compact_separator()) + presets_parent = _make_compact_section(180.0) + presets_parent.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(presets_parent) + _build_current_color_section(current_parent) + _build_recent_color_section(recent_parent) + _build_palette_section(presets_parent) + if _stage_theme != null: + var font: Font = _stage_theme.font() + if font != null: + if _compact_mode: + var strong_font := FontVariation.new() + strong_font.base_font = font + strong_font.variation_embolden = 0.2 + _current_value.add_theme_font_override("font", strong_font) + else: + _current_value.add_theme_font_override("font", font) + _current_value.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40)) + + +func _build_current_color_section(parent: Control) -> void: + parent.add_child(_make_heading("Color")) var current_row := HBoxContainer.new() current_row.add_theme_constant_override("separation", _compact_gap()) - add_child(current_row) + parent.add_child(current_row) _current_chip = Button.new() var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48, 44, 96)) if _compact_mode: - swatch_size = minf(swatch_size, 44.0) + swatch_size = maxf(swatch_size, 52.0) _current_chip.custom_minimum_size = Vector2(swatch_size, swatch_size) - _current_chip.focus_mode = Control.FOCUS_NONE - _current_chip.mouse_filter = Control.MOUSE_FILTER_IGNORE + _current_chip.focus_mode = Control.FOCUS_ALL + _current_chip.tooltip_text = "Choose a custom color" + _current_chip.pressed.connect(func() -> void: custom_color_requested.emit()) current_row.add_child(_current_chip) _current_value = Label.new() _current_value.size_flags_horizontal = Control.SIZE_EXPAND_FILL _current_value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER current_row.add_child(_current_value) - add_child(_make_heading("Recently used")) + + +func _build_recent_color_section(parent: Control) -> void: + parent.add_child(_make_heading("Recently used")) _recent_empty_label = Label.new() - _recent_empty_label.text = "Colors you use will appear here" + _recent_empty_label.text = "No colors yet" if _compact_mode else "Colors you use will appear here" _recent_empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _recent_empty_label.add_theme_color_override("font_color", _color("global.muted_text_color", Color("#AAB2C0"))) - add_child(_recent_empty_label) + if _compact_mode: + _recent_empty_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.helper_size", 16, 12, 32)) + parent.add_child(_recent_empty_label) _recent_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector + _recent_swatches.size_flags_horizontal = Control.SIZE_EXPAND_FILL + if _compact_mode: + _recent_swatches.set_swatch_layout_override(_integer("popups.asset_selector.compact_swatch_size", 42, 28, 52), 8) _recent_swatches.color_selected.connect(_on_swatch_selected) - add_child(_recent_swatches) - add_child(_make_heading("Colors")) + parent.add_child(_recent_swatches) + + +func _build_palette_section(parent: Control) -> void: + parent.add_child(_make_heading("Colors")) _preset_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector + _preset_swatches.size_flags_horizontal = Control.SIZE_EXPAND_FILL + if _compact_mode: + _preset_swatches.set_swatch_layout_override(_integer("popups.asset_selector.compact_swatch_size", 42, 28, 52), 8) _preset_swatches.color_selected.connect(_on_swatch_selected) - add_child(_preset_swatches) - _more_button = Button.new() - _more_button.text = "More colors…" - var action_height: float = float(_integer("stage_panels.action.min_target_size", 48, 44, 96)) - _more_button.custom_minimum_size.y = minf(action_height, 44.0) if _compact_mode else action_height - _more_button.focus_mode = Control.FOCUS_ALL - _more_button.pressed.connect(func() -> void: custom_color_requested.emit()) - add_child(_more_button) - if _stage_theme != null: - var font: Font = _stage_theme.font() - if font != null: - _current_value.add_theme_font_override("font", font) - _more_button.add_theme_font_override("font", font) - _current_value.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40)) - _more_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40)) - _more_button.add_theme_color_override("font_color", _color("global.text_color", Color("#F5F7FA"))) - _more_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action")) - _more_button.add_theme_stylebox_override("hover", _stage_theme.stylebox("stage_panels.action").duplicate()) + parent.add_child(_preset_swatches) + + +func _make_compact_section(minimum_width: float) -> VBoxContainer: + var section := VBoxContainer.new() + section.custom_minimum_size.x = minimum_width + section.add_theme_constant_override("separation", 4) + return section + + +func _make_compact_separator() -> ColorRect: + var separator := ColorRect.new() + separator.color = _color("popups.asset_selector.separator_color", Color("#AAB2C0A0")) + separator.custom_minimum_size = Vector2(2.0, 64.0) + separator.size_flags_vertical = Control.SIZE_SHRINK_CENTER + separator.mouse_filter = Control.MOUSE_FILTER_IGNORE + return separator func _refresh_visuals() -> void: @@ -124,11 +170,16 @@ func _refresh_current_color() -> void: _current_value.text = "#%s" % _selected.to_html(false).to_upper() var chip_style := StyleBoxFlat.new() chip_style.bg_color = _selected - chip_style.border_color = _color("stage_panels.choice.border_color", Color("#FFFFFFAA")) - chip_style.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 2, 0, 8)) + chip_style.border_color = _color("mode_colors.edit", Color("#22C6FF")) + chip_style.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 3, 1, 8)) chip_style.set_corner_radius_all(_integer("stage_surface_colors.swatch_corner_radius", 8, 0, 24)) _current_chip.add_theme_stylebox_override("normal", chip_style) _current_chip.add_theme_stylebox_override("hover", chip_style.duplicate()) + _current_chip.add_theme_stylebox_override("pressed", chip_style.duplicate()) + var focus_style: StyleBoxFlat = chip_style.duplicate() as StyleBoxFlat + focus_style.border_color = Color.WHITE + focus_style.set_border_width_all(5) + _current_chip.add_theme_stylebox_override("focus", focus_style) func _on_swatch_selected(color: Color) -> void: @@ -143,18 +194,23 @@ func _make_heading(text_value: String) -> Label: var heading := Label.new() heading.text = text_value var heading_size: int = _integer("stage_panels.type.section_size", 20, 12, 40) - if _compact_mode: - heading_size = mini(heading_size, 18) heading.add_theme_font_size_override("font_size", heading_size) heading.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA"))) if _stage_theme != null and _stage_theme.font() != null: - heading.add_theme_font_override("font", _stage_theme.font()) + var font: Font = _stage_theme.font() + if _compact_mode: + var strong_font := FontVariation.new() + strong_font.base_font = font + strong_font.variation_embolden = 0.2 + heading.add_theme_font_override("font", strong_font) + else: + heading.add_theme_font_override("font", font) return heading func _apply_compact_layout() -> void: var section_gap: int = _integer("stage_surface_colors.section_gap", 8, 0, 28) - add_theme_constant_override("separation", mini(section_gap, 4) if _compact_mode else section_gap) + add_theme_constant_override("separation", section_gap) func _compact_gap() -> int: diff --git a/scripts/shared/stage_ui/stage_surface_color_dialog.gd b/scripts/shared/stage_ui/stage_surface_color_dialog.gd index 66948e2..0b70f08 100644 --- a/scripts/shared/stage_ui/stage_surface_color_dialog.gd +++ b/scripts/shared/stage_ui/stage_surface_color_dialog.gd @@ -1,22 +1,37 @@ class_name StageSurfaceColorDialog -extends Window -## Bounded custom-color dialog with a live Stage surface preview. +extends PopupPanel +## Rounded, reusable Stage color popup with the shared surface picker view. signal preview_color_changed(color: Color) signal color_confirmed(color: Color) signal cancelled -const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd") +const SURFACE_COLOR_PICKER_VIEW := preload("res://scripts/shared/stage_ui/stage_surface_color_picker_view.gd") +const COMPACT_LAYOUT_MAX_HEIGHT := 640 var _surface_kind: StringName = &"wall" var _finish_id: StringName = &"finish_wall_paint" var _initial_color: Color = Color.WHITE var _stage_theme: SandboxTheme -var _color_picker: ColorPicker -var _surface_preview: StageSurfacePreview +var _margin: MarginContainer +var _layout: VBoxContainer +var _heading: Label +var _view_scroll: ScrollContainer +var _picker_view: StageSurfaceColorPickerView +var _actions: HBoxContainer +var _cancel_button: Button +var _use_button: Button var _built: bool = false +func _ready() -> void: + exclusive = true + _build() + close_requested.connect(_cancel) + get_tree().root.size_changed.connect(_on_root_size_changed) + _apply_visual_style() + + func configure(surface_kind: StringName, finish: StringName, color: Color, stage_theme: SandboxTheme) -> void: _surface_kind = surface_kind _finish_id = finish @@ -24,26 +39,20 @@ func configure(surface_kind: StringName, finish: StringName, color: Color, stage _stage_theme = stage_theme if not _built: _build() - _color_picker.color = color - _update_preview(color) - title = "Choose a color" - exclusive = true - transient = true - theme = stage_theme.control_theme() if stage_theme != null else null + if _picker_view != null: + _picker_view.configure(_surface_kind, _finish_id, color, _stage_theme) + theme = _stage_theme.control_theme() if _stage_theme != null else null + _apply_visual_style() func open_dialog() -> void: - var max_size: Vector2i = _usable_window_size() - var preferred_size := Vector2i(560, 640) - var raw_size: Variant = _stage_theme.value("popups.dialog.surface_color_size", [560, 640]) if _stage_theme != null else [560, 640] - if raw_size is Array and (raw_size as Array).size() == 2: - preferred_size = Vector2i(int((raw_size as Array)[0]), int((raw_size as Array)[1])) - var dialog_size := Vector2i(mini(preferred_size.x, max_size.x), mini(preferred_size.y, max_size.y)) - dialog_size.x = maxi(1, dialog_size.x) - dialog_size.y = maxi(1, dialog_size.y) - size = dialog_size - popup_centered(dialog_size) - _color_picker.grab_focus() + if not _built or _picker_view == null: + return + var popup_size: Vector2i = _bounded_dialog_size() + _apply_responsive_layout(popup_size) + size = popup_size + popup_centered(popup_size) + call_deferred("_focus_picker") func _unhandled_key_input(event: InputEvent) -> void: @@ -53,98 +62,168 @@ func _unhandled_key_input(event: InputEvent) -> void: func _build() -> void: + if _built: + return _built = true - var margin := MarginContainer.new() - margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _margin = MarginContainer.new() + _margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(_margin) + _layout = VBoxContainer.new() + _margin.add_child(_layout) + _heading = Label.new() + _heading.name = "ChooseColorHeading" + _heading.text = "Choose a color" + _heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _heading.mouse_filter = Control.MOUSE_FILTER_IGNORE + _layout.add_child(_heading) + _view_scroll = ScrollContainer.new() + _view_scroll.name = "PickerScroll" + _view_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _view_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _view_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _layout.add_child(_view_scroll) + _picker_view = SURFACE_COLOR_PICKER_VIEW.new() as StageSurfaceColorPickerView + _picker_view.name = "SurfaceColorPickerView" + _picker_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _picker_view.preview_color_changed.connect(_on_preview_color_changed) + _view_scroll.add_child(_picker_view) + _actions = HBoxContainer.new() + _actions.name = "Actions" + _actions.alignment = BoxContainer.ALIGNMENT_END + _actions.add_theme_constant_override("separation", 8) + _layout.add_child(_actions) + _cancel_button = Button.new() + _cancel_button.name = "CancelButton" + _cancel_button.text = "Cancel" + _cancel_button.focus_mode = Control.FOCUS_ALL + _cancel_button.pressed.connect(_cancel) + _actions.add_child(_cancel_button) + _use_button = Button.new() + _use_button.name = "UseColorButton" + _use_button.text = "Use color" + _use_button.focus_mode = Control.FOCUS_ALL + _use_button.pressed.connect(_confirm_color) + _actions.add_child(_use_button) + + +func _apply_visual_style() -> void: + if _stage_theme == null or not _built: + return + theme = _stage_theme.control_theme() + var radius: int = _integer("popups.asset_selector.corner_radius", 16, 8, 32) + var shell: StyleBoxFlat = _stage_theme.stylebox("popups.asset_selector") + shell.set_corner_radius_all(radius) + shell.content_margin_left = 16.0 + shell.content_margin_right = 16.0 + shell.content_margin_top = 14.0 + shell.content_margin_bottom = 14.0 + add_theme_stylebox_override("panel", shell) + var text_color: Color = _color("stage_panels.type.text_color", Color("#F5F7FA")) + var font: Font = _stage_theme.font() + if font != null: + _heading.add_theme_font_override("font", font) + _cancel_button.add_theme_font_override("font", font) + var strong_font := FontVariation.new() + strong_font.base_font = font + strong_font.variation_embolden = 0.55 + _use_button.add_theme_font_override("font", strong_font) + _heading.add_theme_font_size_override("font_size", _integer("popups.asset_selector.setup_title_size", 30, 22, 48)) + _heading.add_theme_color_override("font_color", text_color) + _apply_action_button(_cancel_button, false) + _apply_action_button(_use_button, true) + + +func _apply_action_button(button: Button, primary: bool) -> void: + var accent: Color = _color("mode_colors.edit", Color("#22C6FF")) + var text_color: Color = _color("stage_panels.type.text_color", Color("#F5F7FA")) + var neutral: Color = _color("stage_panels.choice.neutral_surface", Color("#171B24")) + var hover: Color = _color("stage_panels.choice.hover_surface", Color("#303A4B")) + var selected: Color = _color("stage_panels.choice.selected_surface", Color("#12394A")) + var border: Color = _color("stage_panels.choice.border_color", Color("#FFFFFF33")) + var blue: Color = _color("popups.asset_selector.action_color", Color("#087DBA")) + var radius: int = _integer("popups.asset_selector.corner_radius", 16, 8, 32) + var border_width: int = _integer("popups.asset_selector.button_border_width", 2, 1, 5) + var normal_color: Color = blue if primary else neutral + var hover_color: Color = blue.lightened(0.12) if primary else hover + var pressed_color: Color = blue.darkened(0.12) if primary else selected + var edge: Color = accent.lightened(0.15) if primary else border + button.add_theme_stylebox_override("normal", _make_button_style(normal_color, edge, border_width, radius)) + button.add_theme_stylebox_override("hover", _make_button_style(hover_color, edge, border_width + 1, radius)) + button.add_theme_stylebox_override("pressed", _make_button_style(pressed_color, accent, border_width + 1, radius)) + button.add_theme_stylebox_override("hover_pressed", _make_button_style(pressed_color.lightened(0.05), accent, border_width + 1, radius)) + button.add_theme_stylebox_override("focus", _make_button_style(Color(0, 0, 0, 0), accent, border_width + 1, radius)) + button.add_theme_color_override("font_color", Color.WHITE if primary else text_color) + button.add_theme_color_override("font_hover_color", Color.WHITE if primary else text_color) + button.add_theme_color_override("font_pressed_color", Color.WHITE if primary else text_color) + button.add_theme_font_size_override("font_size", _integer("popups.asset_selector.setup_button_font_size", 20, 14, 40)) + button.custom_minimum_size.y = float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)) + + +func _apply_responsive_layout(popup_size: Vector2i) -> void: + if not _built: + return + var compact: bool = popup_size.y < COMPACT_LAYOUT_MAX_HEIGHT + var margin_size: int = 8 if compact else 12 for side: String in ["left", "right", "top", "bottom"]: - margin.add_theme_constant_override("margin_" + side, 12) - add_child(margin) - var layout := VBoxContainer.new() - layout.add_theme_constant_override("separation", 8) - margin.add_child(layout) - var preview_heading := Label.new() - preview_heading.text = "Preview" - layout.add_child(preview_heading) - _surface_preview = SURFACE_PREVIEW.new() as StageSurfacePreview - var preview_size := Vector2(420.0, 140.0) - var raw_preview: Variant = _stage_theme.value("popups.dialog.surface_color_preview_size", [420, 140]) if _stage_theme != null else [420, 140] - if raw_preview is Array and (raw_preview as Array).size() == 2: - preview_size = Vector2(float((raw_preview as Array)[0]), float((raw_preview as Array)[1])) - preview_size.x = minf(preview_size.x, maxf(1.0, float(_usable_window_size().x - 56))) - _surface_preview.custom_minimum_size = preview_size - _surface_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _surface_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE - layout.add_child(_surface_preview) - var picker_scroll := ScrollContainer.new() - picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - picker_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - picker_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL - layout.add_child(picker_scroll) - _color_picker = ColorPicker.new() - _color_picker.name = "ColorPickerControl" - var picker_min_size := Vector2(320.0, 340.0) - var raw_picker_size: Variant = _stage_theme.value("popups.dialog.surface_color_picker_min_size", [320, 340]) if _stage_theme != null else [320, 340] - if raw_picker_size is Array and (raw_picker_size as Array).size() == 2: - picker_min_size = Vector2(float((raw_picker_size as Array)[0]), float((raw_picker_size as Array)[1])) - picker_min_size.x = minf(picker_min_size.x, maxf(1.0, float(_usable_window_size().x - 56))) - _color_picker.custom_minimum_size = picker_min_size - _color_picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _color_picker.focus_mode = Control.FOCUS_ALL - _color_picker.color_changed.connect(_on_picker_color_changed) - picker_scroll.add_child(_color_picker) - var actions := HBoxContainer.new() - actions.alignment = BoxContainer.ALIGNMENT_END - actions.add_theme_constant_override("separation", 8) - layout.add_child(actions) - var use_button := Button.new() - use_button.text = "Use color" - var action_width: float = maxf(44.0, minf(120.0, (float(_usable_window_size().x) - 40.0) * 0.5)) - use_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96))) - use_button.focus_mode = Control.FOCUS_ALL - use_button.pressed.connect(_confirm_color) - actions.add_child(use_button) - var cancel_button := Button.new() - cancel_button.text = "✕ Cancel" - cancel_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96))) - cancel_button.focus_mode = Control.FOCUS_ALL - cancel_button.pressed.connect(_cancel) - actions.add_child(cancel_button) - close_requested.connect(_cancel) - if _stage_theme != null: - var font: Font = _stage_theme.font() - if font != null: - preview_heading.add_theme_font_override("font", font) - use_button.add_theme_font_override("font", font) - cancel_button.add_theme_font_override("font", font) - preview_heading.add_theme_font_size_override("font_size", _stage_theme.integer("stage_panels.type.section_size", 20, 12, 40)) - use_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action")) - cancel_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action")) - use_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA"))) - cancel_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA"))) + _margin.add_theme_constant_override("margin_" + side, margin_size) + _layout.add_theme_constant_override("separation", 4 if compact else 8) + _heading.add_theme_font_size_override("font_size", _integer("popups.asset_selector.setup_title_size", 30, 22, 48)) + var content_width: float = maxf(1.0, float(popup_size.x - margin_size * 2)) + _picker_view.set_available_size(Vector2(content_width, float(popup_size.y))) + _apply_action_layout(content_width) -func _on_picker_color_changed(color: Color) -> void: - _update_preview(color) +func _apply_action_layout(content_width: float) -> void: + if _actions == null or _cancel_button == null or _use_button == null: + return + var stack_buttons: bool = content_width < 344.0 + _actions.vertical = stack_buttons + _actions.alignment = BoxContainer.ALIGNMENT_END + var action_height: float = float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)) + var button_width: float = content_width if stack_buttons else maxf(112.0, (content_width - 8.0) * 0.5) + _cancel_button.custom_minimum_size = Vector2(button_width, action_height) + _use_button.custom_minimum_size = Vector2(button_width, action_height) + + +func _on_preview_color_changed(color: Color) -> void: preview_color_changed.emit(color) -func _update_preview(color: Color) -> void: - if _surface_preview != null: - _surface_preview.configure(_surface_kind, _finish_id, color) - - func _confirm_color() -> void: - var confirmed_color: Color = _color_picker.color + if _picker_view == null: + return + var confirmed_color: Color = _picker_view.get_color() hide() color_confirmed.emit(confirmed_color) func _cancel() -> void: - _update_preview(_initial_color) + if _picker_view != null: + _picker_view.set_color_no_signal(_initial_color) hide() cancelled.emit() +func _focus_picker() -> void: + if visible and _picker_view != null: + _picker_view.focus_picker() + + +func _on_root_size_changed() -> void: + if visible: + open_dialog() + + +func _bounded_dialog_size() -> Vector2i: + var max_size: Vector2i = _usable_window_size() + var preferred_size := Vector2i(560, 640) + var raw_size: Variant = _stage_theme.value("popups.dialog.surface_color_size", [560, 640]) if _stage_theme != null else [560, 640] + if raw_size is Array and (raw_size as Array).size() == 2: + preferred_size = Vector2i(int((raw_size as Array)[0]), int((raw_size as Array)[1])) + return Vector2i(maxi(1, mini(preferred_size.x, max_size.x)), maxi(1, mini(preferred_size.y, max_size.y))) + + func _usable_window_size() -> Vector2i: var available: Vector2i = Vector2i(get_tree().root.size) var usable: Rect2i = DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen()) @@ -156,5 +235,22 @@ func _usable_window_size() -> Vector2i: return available +func _make_button_style(background: Color, border: Color, border_width: int, radius: int) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = background + style.border_color = border + style.set_border_width_all(border_width) + style.set_corner_radius_all(radius) + style.content_margin_left = 12.0 + style.content_margin_right = 12.0 + style.content_margin_top = 8.0 + style.content_margin_bottom = 8.0 + return style + + func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int: return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum) + + +func _color(path: String, fallback: Color) -> Color: + return fallback if _stage_theme == null else _stage_theme.color(path, fallback) diff --git a/scripts/shared/stage_ui/stage_surface_color_picker_view.gd b/scripts/shared/stage_ui/stage_surface_color_picker_view.gd new file mode 100644 index 0000000..c111ef1 --- /dev/null +++ b/scripts/shared/stage_ui/stage_surface_color_picker_view.gd @@ -0,0 +1,130 @@ +class_name StageSurfaceColorPickerView +extends VBoxContainer +## Inline color picker content for the scenery chooser. + +const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd") +const COMPACT_LAYOUT_MAX_HEIGHT := 640.0 +const COMPACT_PREVIEW_HEIGHT := 64.0 +const COMPACT_PICKER_MIN_HEIGHT := 260.0 +const COMPACT_SV_HEIGHT := 144 +const REGULAR_SV_HEIGHT := 224 + +signal preview_color_changed(color: Color) + +var _surface_kind: StringName = &"wall" +var _finish_id: StringName = &"finish_wall_paint" +var _stage_theme: SandboxTheme +var _color_picker: ColorPicker +var _surface_preview: StageSurfacePreview +var _preview_heading: Label +var _preferred_preview_size: Vector2 = Vector2(420.0, 140.0) +var _preferred_preview_height: float = 140.0 +var _preferred_picker_min_size: Vector2 = Vector2(320.0, 340.0) +var _built: bool = false +var _suppress_color_signal: bool = false + + +func configure(surface_kind: StringName, finish: StringName, color: Color, stage_theme: SandboxTheme) -> void: + _surface_kind = surface_kind + _finish_id = finish + _stage_theme = stage_theme + if not _built: + _build() + set_color_no_signal(color) + theme = stage_theme.control_theme() if stage_theme != null else null + _apply_preview_heading_theme() + + +func get_color() -> Color: + return _color_picker.color if _color_picker != null else Color.WHITE + + +func set_color_no_signal(color: Color) -> void: + if _color_picker == null: + return + _suppress_color_signal = true + _color_picker.color = color + _suppress_color_signal = false + _update_preview(color) + + +func set_available_size(available_size: Vector2) -> void: + if not _built: + return + var compact: bool = available_size.y < COMPACT_LAYOUT_MAX_HEIGHT + var preview_size: Vector2 = _surface_preview.custom_minimum_size + preview_size.x = minf(_preferred_preview_size.x, maxf(1.0, available_size.x - 56.0)) + preview_size.y = minf(_preferred_preview_height, COMPACT_PREVIEW_HEIGHT) if compact else _preferred_preview_height + _surface_preview.custom_minimum_size = preview_size + var picker_size: Vector2 = _color_picker.custom_minimum_size + picker_size.x = minf(_preferred_picker_min_size.x, maxf(1.0, available_size.x - 56.0)) + picker_size.y = minf(_preferred_picker_min_size.y, COMPACT_PICKER_MIN_HEIGHT) if compact else _preferred_picker_min_size.y + _color_picker.custom_minimum_size = picker_size + _color_picker.add_theme_constant_override("sv_height", COMPACT_SV_HEIGHT if compact else REGULAR_SV_HEIGHT) + + +func focus_picker() -> void: + if _color_picker != null: + _color_picker.grab_focus() + + +func _build() -> void: + _built = true + add_theme_constant_override("separation", 8) + _surface_preview = SURFACE_PREVIEW.new() as StageSurfacePreview + _preview_heading = Label.new() + _preview_heading.name = "PreviewHeading" + _preview_heading.text = "Preview" + _preview_heading.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_preview_heading) + var preview_size := _preferred_preview_size + var raw_preview: Variant = _stage_theme.value("popups.dialog.surface_color_preview_size", [420, 140]) if _stage_theme != null else [420, 140] + if raw_preview is Array and (raw_preview as Array).size() == 2: + preview_size = Vector2(float((raw_preview as Array)[0]), float((raw_preview as Array)[1])) + _preferred_preview_size = preview_size + _preferred_preview_height = preview_size.y + _surface_preview.custom_minimum_size = preview_size + _surface_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _surface_preview.set_rounded_frame( + float(_stage_theme.integer("popups.asset_selector.surface_corner_radius", 14, 4, 32)) if _stage_theme != null else 14.0, + _stage_theme.color("popups.style_defaults.bg_color", Color("#202633FA")) if _stage_theme != null else Color("#202633FA") + ) + add_child(_surface_preview) + _color_picker = ColorPicker.new() + _color_picker.name = "ColorPickerControl" + var picker_size := _preferred_picker_min_size + var raw_picker_size: Variant = _stage_theme.value("popups.dialog.surface_color_picker_min_size", [320, 340]) if _stage_theme != null else [320, 340] + if raw_picker_size is Array and (raw_picker_size as Array).size() == 2: + picker_size = Vector2(float((raw_picker_size as Array)[0]), float((raw_picker_size as Array)[1])) + _preferred_picker_min_size = picker_size + _color_picker.custom_minimum_size = picker_size + _color_picker.presets_visible = false + _color_picker.sampler_visible = false + _color_picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _color_picker.focus_mode = Control.FOCUS_ALL + _color_picker.color_changed.connect(_on_picker_color_changed) + add_child(_color_picker) + _apply_preview_heading_theme() + + +func _apply_preview_heading_theme() -> void: + if _preview_heading == null: + return + if _stage_theme == null: + return + _preview_heading.add_theme_font_size_override("font_size", _stage_theme.integer("stage_panels.type.section_size", 20, 12, 40)) + _preview_heading.add_theme_color_override("font_color", _stage_theme.color("stage_panels.type.text_color", Color("#F5F7FA"))) + var font: Font = _stage_theme.font() + if font != null: + _preview_heading.add_theme_font_override("font", font) + + +func _on_picker_color_changed(color: Color) -> void: + _update_preview(color) + if not _suppress_color_signal: + preview_color_changed.emit(color) + + +func _update_preview(color: Color) -> void: + if _surface_preview != null: + _surface_preview.configure(_surface_kind, _finish_id, color) diff --git a/scripts/shared/stage_ui/stage_surface_color_picker_view.gd.uid b/scripts/shared/stage_ui/stage_surface_color_picker_view.gd.uid new file mode 100644 index 0000000..4f808bc --- /dev/null +++ b/scripts/shared/stage_ui/stage_surface_color_picker_view.gd.uid @@ -0,0 +1 @@ +uid://dbvatqk4a287y diff --git a/scripts/shared/ui/asset_selector.gd b/scripts/shared/ui/asset_selector.gd index 93f486d..7eef4b6 100644 --- a/scripts/shared/ui/asset_selector.gd +++ b/scripts/shared/ui/asset_selector.gd @@ -17,6 +17,8 @@ const GRID_V_SEPARATION: float = 8.0 const POPUP_SIDE_PADDING: float = 40.0 const POPUP_VERTICAL_PADDING: float = 32.0 const POPUP_SAFE_SCREEN_MARGIN: float = 24.0 +const ARTWORK_FILTER_IDS: Array[String] = ["all", "nature", "buildings", "walls", "roads", "objects"] +const ARTWORK_FILTER_LABELS: Array[String] = ["All artwork", "Nature", "Buildings", "Walls", "Roads & paths", "Objects"] const CELL_MIN_SIZE := Vector2(190.0, 220.0) const THUMB_SIZE := Vector2(156.0, 156.0) @@ -26,7 +28,7 @@ const STARTER_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_starter const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd") const SURFACE_COLOR_CONTROLS := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd") -const SURFACE_COLOR_DIALOG := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd") +const SURFACE_COLOR_PICKER_VIEW := preload("res://scripts/shared/stage_ui/stage_surface_color_picker_view.gd") const STAGE_PANEL_CHOICE_GROUP := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd") var kind: String = "" @@ -38,31 +40,41 @@ var _cell_texrects: Dictionary = {} var _placeholder_tex: ImageTexture = null var _ui_font: Font = null var _emoji_font: Font = null +var _strong_ui_font: Font = null +var _strong_action_font: Font = null var _sandbox_theme: Variant = null var _gallery_tab: String = "backgrounds" +var _gallery_tab_group: ButtonGroup +var _filter_chip_group: ButtonGroup +var _artwork_filter_buttons: Array[Button] = [] var _tag_id: String = "all" var _current_sky_id: String = "" var _sky_card_group: ButtonGroup var _active_columns: int = COLUMNS var _page_capacity: int = PAGE_SIZE var _effective_cell_size: Vector2 = CELL_MIN_SIZE +var _surface_cell_size: Vector2 = CELL_MIN_SIZE var _surface_color_history: Array[Color] = [] var _setup_view: VBoxContainer +var _color_picker_view: StageSurfaceColorPickerView var _setup_kind: StringName = &"" var _setup_finish: StringName = &"" var _setup_color: Color = Color.WHITE var _setup_original_color: Color = Color.WHITE var _setup_color_chosen: bool = false +var _color_picker_open: bool = false var _setup_preview: StageSurfacePreview var _setup_color_controls: StageSurfaceColorControls -var _setup_color_dialog: StageSurfaceColorDialog var _setup_cancel_button: Button var _setup_draw_button: Button +var _color_back_button: Button +var _color_use_button: Button @onready var _title_label: Label = %TitleLabel @onready var _grid: GridContainer = %GridContainer @onready var _content_stack: VBoxContainer = %ContentStack @onready var _grid_scroll: ScrollContainer = %GridScroll +@onready var _surface_cards_row: HBoxContainer = %SurfaceCardsRow @onready var _empty_label: Label = %EmptyLabel @onready var _page_label: Label = %PageLabel @onready var _prev_button: Button = %PrevButton @@ -70,10 +82,11 @@ var _setup_draw_button: Button @onready var _browse_button: Button = %BrowseButton @onready var _refresh_button: Button = %RefreshButton @onready var _close_button: Button = %CloseButton -@onready var _tabs: HBoxContainer = %GalleryTabs +@onready var _footer_spacer: Control = %FooterSpacer +@onready var _tabs: HFlowContainer = %GalleryTabs @onready var _backgrounds_tab: Button = %BackgroundsTab @onready var _artwork_tab: Button = %ArtworkTab -@onready var _tag_filter: OptionButton = %TagFilter +@onready var _filter_chips: HFlowContainer = %ArtworkFilters @onready var _make_artwork_button: Button = %MakeArtworkButton @onready var _edit_scenery_button: Button = %EditSceneryButton @onready var _title_bar: Control = $MarginContainer/VBoxContainer/TitleBar @@ -82,10 +95,15 @@ var _setup_draw_button: Button func _ready() -> void: exclusive = true + _gallery_tab_group = ButtonGroup.new() + _gallery_tab_group.allow_unpress = false + _filter_chip_group = ButtonGroup.new() + _filter_chip_group.allow_unpress = false + for tab: Button in [_backgrounds_tab, _artwork_tab]: + tab.toggle_mode = true + tab.button_group = _gallery_tab_group _build_surface_setup_view() - if _tag_filter.item_count == 0: - for label: String in ["All artwork", "Nature", "Buildings", "Walls", "Roads", "Objects"]: - _tag_filter.add_item(label) + _rebuild_artwork_filter_chips() _prev_button.pressed.connect(_on_prev) _next_button.pressed.connect(_on_next) _browse_button.pressed.connect(func() -> void: browse_requested.emit()) @@ -93,10 +111,10 @@ func _ready() -> void: _close_button.pressed.connect(func() -> void: cancelled.emit()) _backgrounds_tab.pressed.connect(func() -> void: _set_gallery_tab("backgrounds")) _artwork_tab.pressed.connect(func() -> void: _set_gallery_tab("artwork")) - _tag_filter.item_selected.connect(_on_tag_filter_selected) _make_artwork_button.pressed.connect(func() -> void: make_artwork_requested.emit()) _edit_scenery_button.pressed.connect(func() -> void: edit_scenery_requested.emit()) get_tree().root.size_changed.connect(_on_root_size_changed) + _apply_chooser_visual_style() func _on_root_size_changed() -> void: @@ -108,11 +126,15 @@ func _on_root_size_changed() -> void: func open(p_kind: String, entries: Array[Dictionary], current_sky_id: String = "") -> void: kind = p_kind _setup_kind = &"" + _color_picker_open = false _setup_view.visible = false + if _color_picker_view != null: + _color_picker_view.visible = false _current_sky_id = current_sky_id if kind == "scenery" else "" _title_label.text = "Choose Your Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop")) + _apply_chooser_visual_style() _tabs.visible = kind == "scenery" - _tag_filter.visible = false + _filter_chips.visible = false _make_artwork_button.visible = false _edit_scenery_button.visible = false _browse_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery" @@ -122,15 +144,16 @@ func open(p_kind: String, entries: Array[Dictionary], current_sky_id: String = " _tag_id = "all" _backgrounds_tab.set_pressed_no_signal(true) _artwork_tab.set_pressed_no_signal(false) - _tag_filter.select(0) set_entries(entries) if kind == "scenery": _set_gallery_tab("backgrounds") - popup_centered() + popup_centered(size) func set_entries(entries: Array[Dictionary]) -> void: _entries = entries + if kind == "scenery": + _rebuild_artwork_filter_chips() _thumbnails.clear() _cell_texrects.clear() _page = 0 @@ -167,12 +190,22 @@ func close() -> void: func apply_font(ui_font: Font, emoji_font: Font) -> void: _ui_font = ui_font _emoji_font = emoji_font + var base_font: Font = ui_font if ui_font != null else emoji_font + _strong_ui_font = _make_strong_font(base_font, 0.3) + _strong_action_font = _make_strong_font(base_font, 0.65) var controls: Array = [ _title_label, _empty_label, _page_label, _prev_button, _next_button, _browse_button, _refresh_button, _close_button, + _edit_scenery_button, _make_artwork_button, + _backgrounds_tab, _artwork_tab, + _setup_view.get_node_or_null("SetupHeading") if _setup_view != null else null, + get_meta("setup_helper", null) as Control, + _setup_cancel_button, _setup_draw_button, + _color_back_button, _color_use_button, ] for c: Control in controls: _apply_font_to(c) + _apply_chooser_visual_style() func apply_theme(theme: Variant) -> void: _sandbox_theme = theme @@ -193,49 +226,53 @@ func apply_theme(theme: Variant) -> void: ground_choices.apply_theme(theme as SandboxTheme) if _setup_color_controls != null: _setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), theme as SandboxTheme) + if _color_picker_view != null: + _color_picker_view.configure(_setup_kind, _setup_finish, _setup_color, theme as SandboxTheme) + _apply_chooser_visual_style() func _build_surface_setup_view() -> void: _setup_view = VBoxContainer.new() _setup_view.name = "SurfaceSetupView" _setup_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _setup_view.add_theme_constant_override("separation", mini(_theme_integer("popups.asset_selector.setup_gap", 10, 0, 32), 6)) + _setup_view.add_theme_constant_override("separation", _theme_integer("popups.asset_selector.setup_gap", 8, 4, 24)) _setup_view.visible = false _content_stack.add_child(_setup_view) - var heading_row := HBoxContainer.new() - heading_row.add_theme_constant_override("separation", 8) - _setup_view.add_child(heading_row) - var back_button := Button.new() - back_button.text = "‹ Back" - back_button.custom_minimum_size = Vector2(float(_theme_integer("stage_panels.action.close_width", 92, 72, 180)), float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96))) - back_button.focus_mode = Control.FOCUS_ALL - back_button.pressed.connect(_back_from_surface_setup) - heading_row.add_child(back_button) var heading := Label.new() heading.name = "SetupHeading" heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL heading.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.title_size", 24, 12, 48)) - heading_row.add_child(heading) - var look_heading := Label.new() - look_heading.text = "Look" - look_heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.section_size", 20, 12, 40)) - _setup_view.add_child(look_heading) + heading.add_theme_font_size_override("font_size", _theme_integer("popups.asset_selector.setup_heading_size", 26, 18, 48)) + _setup_view.add_child(heading) var wall_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup wall_choices.name = "WallLookChoices" + wall_choices.configure_layout( + false, + float(_theme_integer("popups.asset_selector.setup_choice_height", 68, 48, 104)), + _theme_integer("popups.asset_selector.setup_choice_font_size", 22, 16, 40), + _theme_integer("popups.asset_selector.setup_choice_corner_radius", 22, 8, 32), + _theme_integer("popups.asset_selector.setup_choice_border_width", 2, 1, 5) + ) wall_choices.add_choice(&"finish_wall_paint", "Paint", _make_look_cue(&"wall", &"finish_wall_paint")) wall_choices.add_choice(&"finish_wall_brick", "Brick", _make_look_cue(&"wall", &"finish_wall_brick")) wall_choices.add_choice(&"finish_wall_wood", "Wood", _make_look_cue(&"wall", &"finish_wall_wood")) wall_choices.choice_selected.connect(_on_setup_look_choice) - _compact_look_choices(wall_choices) + wall_choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL _setup_view.add_child(wall_choices) var ground_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup ground_choices.name = "BackgroundGroundLookChoices" + ground_choices.configure_layout( + false, + float(_theme_integer("popups.asset_selector.setup_choice_height", 68, 48, 104)), + _theme_integer("popups.asset_selector.setup_choice_font_size", 22, 16, 40), + _theme_integer("popups.asset_selector.setup_choice_corner_radius", 22, 8, 32), + _theme_integer("popups.asset_selector.setup_choice_border_width", 2, 1, 5) + ) ground_choices.add_choice(&"finish_background_grass", "Grass", _make_look_cue(&"background_ground", &"finish_background_grass")) ground_choices.add_choice(&"finish_background_concrete", "Concrete", _make_look_cue(&"background_ground", &"finish_background_concrete")) ground_choices.add_choice(&"finish_background_carpet", "Carpet", _make_look_cue(&"background_ground", &"finish_background_carpet")) ground_choices.choice_selected.connect(_on_setup_look_choice) - _compact_look_choices(ground_choices) + ground_choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL _setup_view.add_child(ground_choices) set_meta("wall_look_group", wall_choices) set_meta("ground_look_group", ground_choices) @@ -246,6 +283,10 @@ func _build_surface_setup_view() -> void: preview_frame.custom_minimum_size.y = preview_size.y + 10.0 preview_frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL _setup_preview = SURFACE_PREVIEW.new() as StageSurfacePreview + _setup_preview.set_rounded_frame( + float(_theme_integer("popups.asset_selector.surface_corner_radius", 14, 4, 32)), + _theme_color("popups.style_defaults.bg_color", Color("#202633FA")) + ) _setup_preview.custom_minimum_size = preview_size _setup_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL preview_frame.add_child(_setup_preview) @@ -254,42 +295,55 @@ func _build_surface_setup_view() -> void: _setup_color_controls.name = "SurfaceColorControls" _setup_color_controls.set_compact_mode(true) _setup_color_controls.color_selected.connect(_on_setup_color_selected) - _setup_color_controls.custom_color_requested.connect(_open_setup_color_dialog) + _setup_color_controls.custom_color_requested.connect(_open_setup_color_view) _setup_view.add_child(_setup_color_controls) var helper := Label.new() helper.name = "SurfaceRoleHelper" helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART helper.add_theme_color_override("font_color", _theme_color("global.muted_text_color", Color("#AAB2C0"))) _setup_view.add_child(helper) + _color_picker_view = SURFACE_COLOR_PICKER_VIEW.new() as StageSurfaceColorPickerView + _color_picker_view.name = "SurfaceColorPickerView" + _color_picker_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _color_picker_view.preview_color_changed.connect(_on_setup_color_preview) + _color_picker_view.visible = false + _content_stack.add_child(_color_picker_view) _setup_cancel_button = Button.new() _setup_cancel_button.name = "CancelSurfaceSetupButton" _setup_cancel_button.text = "Cancel" - _setup_cancel_button.custom_minimum_size = Vector2(104.0, float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96))) + _setup_cancel_button.custom_minimum_size = Vector2(116.0, float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96))) _setup_cancel_button.focus_mode = Control.FOCUS_ALL _setup_cancel_button.pressed.connect(_cancel_surface_setup) _footer.add_child(_setup_cancel_button) _setup_draw_button = Button.new() _setup_draw_button.name = "DrawSurfaceButton" - _setup_draw_button.custom_minimum_size = Vector2(220.0, float(_theme_integer("stage_panels.choice.minimum_height", 56, 44, 112))) + _setup_draw_button.custom_minimum_size = Vector2(244.0, float(_theme_integer("popups.asset_selector.tab_height", 60, 48, 96))) _setup_draw_button.focus_mode = Control.FOCUS_ALL _setup_draw_button.pressed.connect(_draw_selected_surface) _footer.add_child(_setup_draw_button) + _color_back_button = Button.new() + _color_back_button.name = "BackToSurfaceButton" + _color_back_button.text = "Back to wall panel" + _color_back_button.custom_minimum_size = Vector2(200.0, float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96))) + _color_back_button.focus_mode = Control.FOCUS_ALL + _color_back_button.pressed.connect(_back_from_setup_color_view) + _footer.add_child(_color_back_button) + _color_use_button = Button.new() + _color_use_button.name = "UseColorButton" + _color_use_button.text = "Use color" + _color_use_button.custom_minimum_size = Vector2(160.0, float(_theme_integer("stage_panels.choice.minimum_height", 56, 44, 112))) + _color_use_button.focus_mode = Control.FOCUS_ALL + _color_use_button.pressed.connect(_use_setup_color) + _footer.add_child(_color_use_button) set_meta("setup_heading", heading) set_meta("setup_helper", helper) - _apply_font_to(back_button) _apply_font_to(heading) - _apply_font_to(look_heading) _apply_font_to(helper) _apply_font_to(_setup_cancel_button) _apply_font_to(_setup_draw_button) - if _sandbox_theme != null: - _setup_cancel_button.add_theme_stylebox_override("normal", _sandbox_theme.stylebox("stage_panels.choice")) - _setup_cancel_button.add_theme_stylebox_override("hover", _sandbox_theme.stylebox("stage_panels.choice")) - _setup_cancel_button.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.body_size", 20, 14, 40)) - _setup_draw_button.add_theme_stylebox_override("normal", _sandbox_theme.stylebox("stage_panels.action")) - _setup_draw_button.add_theme_stylebox_override("hover", _sandbox_theme.stylebox("stage_panels.action")) - _setup_draw_button.add_theme_color_override("font_color", _theme_color("stage_panels.type.text_color", Color("#F5F7FA"))) - _setup_draw_button.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.body_size", 20, 14, 40)) + _apply_font_to(_color_back_button) + _apply_font_to(_color_use_button) + _apply_chooser_visual_style() for choice: StagePanelChoiceGroup in [wall_choices, ground_choices]: choice.apply_theme(_sandbox_theme as SandboxTheme) _apply_font_to(choice) @@ -298,20 +352,17 @@ func _build_surface_setup_view() -> void: func _make_look_cue(kind: StringName, finish: StringName) -> Control: var preview: StageSurfacePreview = SURFACE_PREVIEW.new() as StageSurfacePreview var cue_size: Vector2 = _theme_vector2("popups.asset_selector.look_cue_size", Vector2(80.0, 42.0)) - cue_size.x = minf(cue_size.x, 68.0) - cue_size.y = minf(cue_size.y, 34.0) + cue_size.x = minf(cue_size.x, 56.0) + cue_size.y = minf(cue_size.y, 36.0) preview.custom_minimum_size = cue_size + preview.set_rounded_frame( + float(_theme_integer("popups.asset_selector.look_corner_radius", 8, 2, 20)), + _theme_color("popups.style_defaults.bg_color", Color("#202633FA")) + ) preview.configure(kind, finish, SURFACE_RENDERER.default_tint(finish)) return preview -func _compact_look_choices(group: StagePanelChoiceGroup) -> void: - for child: Node in group.get_children(): - if child is Button: - var button := child as Button - button.custom_minimum_size.y = minf(button.custom_minimum_size.y, 50.0) - - func _open_surface_setup(kind: StringName) -> void: _setup_kind = kind _setup_color_chosen = false @@ -325,29 +376,22 @@ func _open_surface_setup(kind: StringName) -> void: wall_choices.select_no_signal(_setup_finish) ground_choices.select_no_signal(_setup_finish) (get_meta("setup_heading") as Label).text = "Wall panel" if kind == &"wall" else "Background ground" - (get_meta("setup_helper") as Label).text = "Looks like a wall · Stickmen can pass through." if kind == &"wall" else "Decorative only · Stickmen don’t walk here." + var setup_helper: Label = get_meta("setup_helper") as Label + setup_helper.text = "Looks like a wall · Stickmen can pass through." if kind == &"wall" else "" + setup_helper.visible = kind == &"wall" _setup_draw_button.text = "Draw wall panel" if kind == &"wall" else "Draw background ground" + _color_back_button.text = "Back to wall panel" if kind == &"wall" else "Back to background ground" _setup_color_controls.visible = _supports_setup_color() _setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), _sandbox_theme as SandboxTheme) _update_setup_preview() _page = 0 - _update_popup_layout() _rebuild() - back_button_focus() - - -func back_button_focus() -> void: - var back_button: Button = _setup_view.get_child(0).get_child(0) as Button - if back_button != null: - back_button.grab_focus() - - -func _back_from_surface_setup() -> void: - _setup_kind = &"" - _setup_view.visible = false + # The setup controls are first measured before the containers have recalculated + # their minimum sizes. Re-measure after the next layout pass so the popup can + # shrink to the setup content instead of keeping the gallery height. _update_popup_layout() - _rebuild() - _close_button.grab_focus() + call_deferred("_update_popup_layout_after_layout") + _setup_draw_button.grab_focus() func _on_setup_look_choice(id: StringName) -> void: @@ -368,7 +412,7 @@ func _supports_setup_color() -> bool: func _on_setup_color_selected(color: Color) -> void: - _setup_color = Color(color.r, color.g, color.b, 1.0) + _setup_color = _opaque_color(color) _setup_color_chosen = true _update_setup_preview() surface_color_confirmed.emit(_setup_color) @@ -387,38 +431,54 @@ func _preset_surface_colors() -> Array[Color]: return (_sandbox_theme as SandboxTheme).swatches() -func _open_setup_color_dialog() -> void: - if _setup_color_dialog == null or not is_instance_valid(_setup_color_dialog): - _setup_color_dialog = SURFACE_COLOR_DIALOG.new() as StageSurfaceColorDialog - add_child(_setup_color_dialog) - _setup_color_dialog.preview_color_changed.connect(_on_setup_color_preview) - _setup_color_dialog.color_confirmed.connect(_on_setup_color_confirmed) - _setup_color_dialog.cancelled.connect(_on_setup_color_cancelled) +func _open_setup_color_view() -> void: + if _setup_kind.is_empty() or not _supports_setup_color(): + return _setup_original_color = _setup_color - _setup_color_dialog.configure(_setup_kind, _setup_finish, _setup_original_color, _sandbox_theme as SandboxTheme) - _setup_color_dialog.open_dialog() + _color_picker_view.configure(_setup_kind, _setup_finish, _setup_original_color, _sandbox_theme as SandboxTheme) + _color_picker_open = true + _title_label.text = "Choose color" + _rebuild() + _update_popup_layout() + call_deferred("_update_popup_layout_after_layout") + _color_picker_view.focus_picker() func _on_setup_color_preview(color: Color) -> void: - _setup_color = Color(color.r, color.g, color.b, 1.0) + _setup_color = _opaque_color(color) _update_setup_preview() -func _on_setup_color_confirmed(color: Color) -> void: - _setup_color = Color(color.r, color.g, color.b, 1.0) +func _use_setup_color() -> void: + _setup_color = _opaque_color(_color_picker_view.get_color()) _setup_color_chosen = true _update_setup_preview() surface_color_confirmed.emit(_setup_color) + _close_setup_color_view() _setup_color_controls.focus_custom_button() -func _on_setup_color_cancelled() -> void: +func _back_from_setup_color_view() -> void: _setup_color = _setup_original_color _setup_color_controls.set_selected_no_signal(_setup_color) _update_setup_preview() + _close_setup_color_view() _setup_color_controls.focus_custom_button() +func _close_setup_color_view() -> void: + _color_picker_open = false + _title_label.text = "Choose Scenery" + _tabs.visible = kind == "scenery" + _rebuild() + _update_popup_layout() + call_deferred("_update_popup_layout_after_layout") + + +func _opaque_color(color: Color) -> Color: + return Color(color.r, color.g, color.b, 1.0) + + func _draw_selected_surface() -> void: if _setup_kind.is_empty(): return @@ -427,6 +487,8 @@ func _draw_selected_surface() -> void: func _cancel_surface_setup() -> void: + _color_picker_open = false + _color_picker_view.visible = false _setup_kind = &"" _setup_view.visible = false cancelled.emit() @@ -440,7 +502,10 @@ func _unhandled_input(event: InputEvent) -> void: var key := event as InputEventKey if key.pressed and not key.echo and key.keycode == KEY_ESCAPE: get_viewport().set_input_as_handled() - cancelled.emit() + if _color_picker_open: + _back_from_setup_color_view() + else: + cancelled.emit() static func page_bounds(total: int, page: int, page_size: int = PAGE_SIZE) -> Dictionary: @@ -458,28 +523,98 @@ func _entry_key(entry: Dictionary) -> String: return String(entry.get("id", "")) -func _on_tag_filter_selected(index: int) -> void: - var ids: Array[String] = ["all", "nature", "buildings", "walls", "roads", "objects"] - _tag_id = ids[clampi(index, 0, ids.size() - 1)] +func _rebuild_artwork_filter_chips() -> void: + if _filter_chips == null: + return + for child: Node in _filter_chips.get_children(): + _filter_chips.remove_child(child) + child.queue_free() + _artwork_filter_buttons.clear() + _filter_chip_group = ButtonGroup.new() + _filter_chip_group.allow_unpress = false + var filter_ids: Array[String] = ARTWORK_FILTER_IDS.duplicate() + var filter_labels: Array[String] = ARTWORK_FILTER_LABELS.duplicate() + if _has_unsorted_artwork(): + filter_ids.append("unsorted") + filter_labels.append("Unsorted") + if not filter_ids.has(_tag_id): + _tag_id = "all" + for index: int in range(filter_ids.size()): + var filter_id: String = filter_ids[index] + var chip: Button = Button.new() + chip.name = "ArtworkFilter_%s" % filter_id.capitalize() + chip.toggle_mode = true + chip.button_group = _filter_chip_group + chip.focus_mode = Control.FOCUS_ALL + chip.custom_minimum_size.y = float(_theme_integer("popups.asset_selector.filter_chip_height", 48, 44, 80)) + chip.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + chip.pressed.connect(func() -> void: _on_artwork_filter_selected(filter_id)) + _filter_chips.add_child(chip) + _artwork_filter_buttons.append(chip) + _sync_artwork_filter_chips(filter_ids, filter_labels) + _apply_chooser_visual_style() + + +func _sync_artwork_filter_chips(filter_ids: Array[String] = [], filter_labels: Array[String] = []) -> void: + var ids: Array[String] = filter_ids if not filter_ids.is_empty() else ARTWORK_FILTER_IDS.duplicate() + var labels: Array[String] = filter_labels if not filter_labels.is_empty() else ARTWORK_FILTER_LABELS.duplicate() + if ids.size() != _artwork_filter_buttons.size() or labels.size() != _artwork_filter_buttons.size(): + return + for index: int in range(_artwork_filter_buttons.size()): + var chip: Button = _artwork_filter_buttons[index] + var selected: bool = ids[index] == _tag_id + chip.text = ("✓ " if selected else "") + labels[index] + chip.set_pressed_no_signal(selected) + + +func _on_artwork_filter_selected(filter_id: String) -> void: + _tag_id = filter_id + _sync_artwork_filter_chips(_current_filter_ids(), _current_filter_labels()) _page = 0 - _update_popup_layout() + _update_popup_layout(true) _rebuild() +func _current_filter_ids() -> Array[String]: + var ids: Array[String] = ARTWORK_FILTER_IDS.duplicate() + if _has_unsorted_artwork(): + ids.append("unsorted") + return ids + + +func _current_filter_labels() -> Array[String]: + var labels: Array[String] = ARTWORK_FILTER_LABELS.duplicate() + if _has_unsorted_artwork(): + labels.append("Unsorted") + return labels + + +func _has_unsorted_artwork() -> bool: + for entry: Dictionary in _entries: + if String(entry.get("gallery_tab", "artwork")) != "artwork": + continue + var tags: Variant = entry.get("tags", []) + if not tags is Array or (tags as Array).is_empty(): + return true + return false + + func _set_gallery_tab(tab: String) -> void: + _color_picker_open = false _setup_kind = &"" _setup_view.visible = false + _color_picker_view.visible = false _gallery_tab = tab _backgrounds_tab.set_pressed_no_signal(tab == "backgrounds") _artwork_tab.set_pressed_no_signal(tab == "artwork") - _tag_filter.visible = kind == "scenery" and tab == "artwork" + _filter_chips.visible = kind == "scenery" and tab == "artwork" _make_artwork_button.visible = kind == "scenery" and tab == "artwork" _edit_scenery_button.visible = kind == "scenery" - _browse_button.visible = kind == "stickman" or kind == "prop" or (kind == "scenery" and tab == "artwork") - _refresh_button.visible = kind == "stickman" or kind == "prop" or (kind == "scenery" and tab == "artwork") - _empty_label.text = "No artwork in this group yet." if tab == "artwork" else "Choose a Sky look or wall finish." + _browse_button.visible = kind == "stickman" or kind == "prop" + _refresh_button.visible = kind == "stickman" or kind == "prop" + _empty_label.text = "No artwork here yet." if tab == "artwork" else "Choose a Sky look or wall finish." _page = 0 - _update_popup_layout() + _update_popup_layout(true) _rebuild() @@ -491,8 +626,15 @@ func _filtered_entries() -> Array[Dictionary]: if String(entry.get("gallery_tab", "artwork")) != _gallery_tab: continue if _gallery_tab == "artwork" and _tag_id != "all": - var tags: Array = entry.get("tags", []) as Array - if not tags.has(_tag_id): + var tags: Variant = entry.get("tags", []) + var tag_list: Array = tags as Array if tags is Array else [] + if _tag_id == "unsorted": + if not tag_list.is_empty(): + continue + elif _tag_id == "roads": + if not tag_list.has("roads") and not tag_list.has("paths"): + continue + elif not tag_list.has(_tag_id): continue filtered.append(entry) return filtered @@ -513,6 +655,8 @@ func _on_next() -> void: func _page_count() -> int: + if _is_scenery_background_gallery() or _is_scenery_artwork_gallery(): + return 1 var entries: Array[Dictionary] = _filtered_entries() if entries.is_empty(): return 1 @@ -530,66 +674,180 @@ func _usable_screen_size() -> Vector2: return available -func _update_popup_layout() -> void: - var in_setup: bool = not _setup_kind.is_empty() - _position_footer(in_setup) +func _update_popup_layout(keep_visible_position: bool = false) -> void: + var previous_position: Vector2i = position + var in_color_picker: bool = _color_picker_open + var in_setup: bool = not _setup_kind.is_empty() and not in_color_picker + var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_setup and not in_color_picker + var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_setup and not in_color_picker + var filtered: Array[Dictionary] = _filtered_entries() + _tabs.visible = kind == "scenery" and not in_color_picker + _filter_chips.visible = scenery_artwork_gallery + _position_footer() + _close_button.visible = not in_setup and not in_color_picker _setup_cancel_button.visible = in_setup _setup_draw_button.visible = in_setup - _edit_scenery_button.visible = kind == "scenery" and not in_setup - _footer.alignment = BoxContainer.ALIGNMENT_END if in_setup else BoxContainer.ALIGNMENT_CENTER + _setup_view.visible = in_setup + _color_picker_view.visible = in_color_picker + _surface_cards_row.visible = scenery_background_gallery and _has_surface_entries(filtered) + _color_back_button.visible = in_color_picker + _color_use_button.visible = in_color_picker + _edit_scenery_button.visible = kind == "scenery" and not in_setup and not in_color_picker + _footer_spacer.visible = scenery_artwork_gallery + _empty_label.visible = filtered.is_empty() and not in_setup and not in_color_picker + _grid.visible = not filtered.is_empty() and not in_setup and not in_color_picker + _grid_scroll.visible = not filtered.is_empty() or in_setup or in_color_picker + _footer.alignment = BoxContainer.ALIGNMENT_BEGIN if scenery_artwork_gallery else (BoxContainer.ALIGNMENT_END if in_setup or in_color_picker else BoxContainer.ALIGNMENT_CENTER) + _footer.add_theme_constant_override( + "separation", + _theme_integer("popups.asset_selector.footer_gap", 16, 8, 32) if in_setup or in_color_picker or kind == "scenery" else 8 + ) var available: Vector2 = _usable_screen_size() - var max_width: float = maxf(1.0, minf(available.x * 0.94, available.x - POPUP_SAFE_SCREEN_MARGIN)) + var width_ratio: float = 0.98 if in_setup else 0.94 + var max_width: float = maxf(1.0, minf(available.x * width_ratio, available.x - POPUP_SAFE_SCREEN_MARGIN)) var max_height: float = maxf(1.0, minf(available.y * 0.86, available.y - POPUP_SAFE_SCREEN_MARGIN * 2.0)) var preferred_width: float = CELL_MIN_SIZE.x * COLUMNS + GRID_H_SEPARATION * (COLUMNS - 1) + POPUP_SIDE_PADDING + var preferred_height: float = max_height + if in_color_picker: + var color_dialog_size: Vector2 = _theme_vector2("popups.dialog.surface_color_size", Vector2(560.0, 640.0)) + preferred_width = color_dialog_size.x + preferred_height = color_dialog_size.y var popup_width: float = minf(preferred_width, max_width) var grid_width: float = maxf(1.0, popup_width - POPUP_SIDE_PADDING) _active_columns = clampi(floori((grid_width + GRID_H_SEPARATION) / (CELL_MIN_SIZE.x + GRID_H_SEPARATION)), 1, COLUMNS) - _page_capacity = _active_columns * ROWS + _page_capacity = maxi(1, filtered.size()) if scenery_background_gallery or scenery_artwork_gallery else _active_columns * ROWS _grid.columns = _active_columns var card_width: float = minf(CELL_MIN_SIZE.x, (grid_width - GRID_H_SEPARATION * (_active_columns - 1)) / float(_active_columns)) var card_height_limit: float = 248.0 if kind == "starters" else CELL_MIN_SIZE.y var card_height: float = minf(card_height_limit, card_width + (58.0 if kind == "starters" else 30.0)) _effective_cell_size = Vector2(maxf(96.0, card_width), maxf(180.0, card_height)) - var filtered_count: int = _filtered_entries().size() + var surface_width: float = minf(_effective_cell_size.x, maxf(96.0, (grid_width - GRID_H_SEPARATION) * 0.5)) + var surface_height: float = maxf(180.0, minf(card_height_limit, surface_width + 30.0)) + _surface_cell_size = Vector2(surface_width, surface_height) + var filtered_count: int = filtered.size() var page_count: int = maxi(1, int(ceil(float(filtered_count) / float(_page_capacity)))) _page = clampi(_page, 0, page_count - 1) var page_start: int = _page * _page_capacity var page_items: int = clampi(filtered_count - page_start, 0, _page_capacity) - var grid_rows: int = int(ceil(float(page_items) / float(_active_columns))) if page_items > 0 else 0 + var grid_item_count: int = _count_sky_entries(filtered) if scenery_background_gallery else page_items + var grid_rows: int = int(ceil(float(grid_item_count) / float(_active_columns))) if grid_item_count > 0 else 0 var body_height: float - if not _setup_kind.is_empty(): - body_height = maxf(180.0, _setup_view.get_combined_minimum_size().y) + if in_color_picker: + _color_picker_view.set_available_size(Vector2(popup_width, minf(preferred_height, max_height))) + body_height = maxf(220.0, _color_picker_view.get_combined_minimum_size().y) + elif in_setup: + body_height = maxf(180.0, _visible_content_minimum_height(_setup_view)) else: body_height = maxf(120.0, float(grid_rows) * _effective_cell_size.y + float(maxi(0, grid_rows - 1)) * GRID_V_SEPARATION) + if scenery_background_gallery and _has_surface_entries(filtered): + body_height += _surface_cell_size.y + GRID_V_SEPARATION if page_items == 0: body_height = maxf(body_height, _empty_label.get_combined_minimum_size().y) + _tabs.custom_minimum_size.x = grid_width + _filter_chips.custom_minimum_size.x = grid_width if _filter_chips.visible else 0.0 var fixed_height: float = _title_bar.get_combined_minimum_size().y + _footer.get_combined_minimum_size().y + POPUP_VERTICAL_PADDING var visible_sections: int = 3 if _tabs.visible: - fixed_height += _tabs.get_combined_minimum_size().y + # HFlowContainer can report zero height before its first layout pass. Use + # the child button target as a floor so the first popup measurement reserves + # the tab row instead of letting the gallery scroll area consume that space. + fixed_height += maxf( + _tabs.get_combined_minimum_size().y, + float(_theme_integer("popups.asset_selector.tab_height", 60, 48, 96)) + ) + visible_sections += 1 + if _filter_chips.visible: + fixed_height += _filter_chips.get_combined_minimum_size().y visible_sections += 1 fixed_height += float(visible_sections - 1) * 8.0 - var popup_height: float = minf(fixed_height + body_height, max_height) + if scenery_background_gallery: + var has_surface_row: bool = _has_surface_entries(filtered) + var content_rows: int = grid_rows + (1 if has_surface_row else 0) + var row_gaps: float = float(maxi(0, grid_rows - 1)) * GRID_V_SEPARATION + if has_surface_row and grid_rows > 0: + row_gaps += GRID_V_SEPARATION + if content_rows > 0: + var available_body_height: float = maxf(1.0, max_height - fixed_height) + var max_row_height: float = (available_body_height - row_gaps) / float(content_rows) + if max_row_height < _effective_cell_size.y or (has_surface_row and max_row_height < _surface_cell_size.y): + var compact_row_height: float = maxf(180.0, max_row_height) + if grid_rows > 0: + _effective_cell_size.y = minf(_effective_cell_size.y, compact_row_height) + if has_surface_row: + _surface_cell_size.y = minf(_surface_cell_size.y, compact_row_height) + body_height = 0.0 + if grid_rows > 0: + body_height += float(grid_rows) * _effective_cell_size.y + float(maxi(0, grid_rows - 1)) * GRID_V_SEPARATION + if has_surface_row: + if grid_rows > 0: + body_height += GRID_V_SEPARATION + body_height += _surface_cell_size.y + if body_height <= 0.0: + body_height = maxf(120.0, _empty_label.get_combined_minimum_size().y) + var popup_height: float = minf(fixed_height + body_height, minf(preferred_height, max_height)) + var popup_position: Vector2i = previous_position + if visible and keep_visible_position: + var minimum_anchor_height: float = minf(fixed_height + 120.0, minf(preferred_height, max_height)) + var minimum_y: int = mini(roundi(POPUP_SAFE_SCREEN_MARGIN), maxi(0, floori(available.y - minimum_anchor_height))) + var maximum_y: int = maxi(minimum_y, floori(available.y - minimum_anchor_height - POPUP_SAFE_SCREEN_MARGIN)) + popup_position.y = clampi(previous_position.y, minimum_y, maximum_y) + var height_below_anchor: float = maxf(1.0, available.y - float(popup_position.y) - POPUP_SAFE_SCREEN_MARGIN) + popup_height = minf(popup_height, height_below_anchor) + if in_color_picker: + var action_width: float = maxf(100.0, (popup_width - POPUP_SIDE_PADDING - 8.0) * 0.5) + _color_back_button.custom_minimum_size.x = action_width + _color_use_button.custom_minimum_size.x = action_width size = Vector2i(maxi(1, roundi(popup_width)), maxi(1, roundi(popup_height))) if visible: - popup_centered() + if keep_visible_position: + var max_x: int = maxi(0, floori(available.x - float(size.x) - POPUP_SAFE_SCREEN_MARGIN)) + var max_y: int = maxi(0, floori(available.y - float(size.y) - POPUP_SAFE_SCREEN_MARGIN)) + var min_x: int = mini(roundi(POPUP_SAFE_SCREEN_MARGIN), max_x) + var min_y: int = mini(roundi(POPUP_SAFE_SCREEN_MARGIN), max_y) + position = Vector2i( + clampi(previous_position.x, min_x, max_x), + clampi(popup_position.y, min_y, max_y) + ) + else: + popup_centered(size) + + +func _update_popup_layout_after_layout() -> void: + await get_tree().process_frame + if is_inside_tree(): + _update_popup_layout() func _rebuild() -> void: _sky_card_group = ButtonGroup.new() - for c: Node in _grid.get_children(): - c.queue_free() + for container: Control in [_grid, _surface_cards_row]: + for child: Node in container.get_children(): + container.remove_child(child) + child.queue_free() _cell_texrects.clear() var filtered: Array[Dictionary] = _filtered_entries() - var in_setup: bool = not _setup_kind.is_empty() - _position_footer(in_setup) + var in_color_picker: bool = _color_picker_open + var in_setup: bool = not _setup_kind.is_empty() and not in_color_picker + var scenery_background_gallery: bool = _is_scenery_background_gallery() and not in_setup and not in_color_picker + var scenery_artwork_gallery: bool = _is_scenery_artwork_gallery() and not in_setup and not in_color_picker + _position_footer() + _close_button.visible = not in_setup and not in_color_picker _setup_view.visible = in_setup + _color_picker_view.visible = in_color_picker + _filter_chips.visible = scenery_artwork_gallery + _surface_cards_row.visible = scenery_background_gallery and _has_surface_entries(filtered) _setup_cancel_button.visible = in_setup _setup_draw_button.visible = in_setup - _edit_scenery_button.visible = kind == "scenery" and not in_setup - _empty_label.visible = filtered.is_empty() and not in_setup - _grid.visible = not filtered.is_empty() and not in_setup - _grid_scroll.visible = not filtered.is_empty() or in_setup + _color_back_button.visible = in_color_picker + _color_use_button.visible = in_color_picker + _edit_scenery_button.visible = kind == "scenery" and not in_setup and not in_color_picker + _make_artwork_button.visible = scenery_artwork_gallery + _browse_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker + _refresh_button.visible = (kind == "stickman" or kind == "prop") and not in_setup and not in_color_picker + _footer_spacer.visible = scenery_artwork_gallery + _empty_label.visible = filtered.is_empty() and not in_setup and not in_color_picker + _grid.visible = not filtered.is_empty() and not in_setup and not in_color_picker + _grid_scroll.visible = not filtered.is_empty() or in_setup or in_color_picker var total := _page_count() var single := total <= 1 _prev_button.visible = not single @@ -598,7 +856,7 @@ func _rebuild() -> void: _prev_button.disabled = _page <= 0 _next_button.disabled = (_page + 1) * _page_capacity >= filtered.size() _page_label.text = "Page %d/%d" % [_page + 1, total] - if in_setup: + if in_setup or in_color_picker: _prev_button.visible = false _next_button.visible = false _page_label.visible = false @@ -608,37 +866,83 @@ func _rebuild() -> void: return if filtered.is_empty(): return + if scenery_background_gallery: + for entry: Dictionary in filtered: + var background_kind: String = String(entry.get("background_kind", "")) + if background_kind == "sky": + _grid.add_child(_build_cell(entry)) + elif background_kind == "wall_setup" or background_kind == "background_ground_setup": + _surface_cards_row.add_child(_build_cell(entry, _surface_cell_size)) + return var bounds := page_bounds(filtered.size(), _page, _page_capacity) for i: int in range(int(bounds["start"]), int(bounds["end"])): _grid.add_child(_build_cell(filtered[i])) -func _position_footer(in_setup: bool) -> void: +func _position_footer() -> void: var parent := _footer.get_parent() as VBoxContainer if parent == null: return - var target_index: int = _tabs.get_index() + 1 if in_setup else parent.get_child_count() - 1 + var target_index: int = parent.get_child_count() - 1 if _footer.get_index() != target_index: parent.move_child(_footer, target_index) + if _close_button.get_parent() == _footer and _close_button.get_index() != 0: + _footer.move_child(_close_button, 0) -func _build_cell(entry: Dictionary) -> Control: +func _is_scenery_background_gallery() -> bool: + return kind == "scenery" and _gallery_tab == "backgrounds" + + +func _is_scenery_artwork_gallery() -> bool: + return kind == "scenery" and _gallery_tab == "artwork" + + +func _count_sky_entries(entries: Array[Dictionary]) -> int: + var count: int = 0 + for entry: Dictionary in entries: + if String(entry.get("background_kind", "")) == "sky": + count += 1 + return count + + +func _has_surface_entries(entries: Array[Dictionary]) -> bool: + for entry: Dictionary in entries: + var background_kind: String = String(entry.get("background_kind", "")) + if background_kind == "wall_setup" or background_kind == "background_ground_setup": + return true + return false + + +func _visible_content_minimum_height(container: VBoxContainer) -> float: + var height: float = 0.0 + var visible_children: int = 0 + for child: Node in container.get_children(): + if not child is Control or not (child as Control).visible: + continue + height += (child as Control).get_combined_minimum_size().y + visible_children += 1 + if visible_children > 1: + height += float(visible_children - 1) * float(container.get_theme_constant("separation")) + return height + + +func _build_cell(entry: Dictionary, requested_size: Vector2 = Vector2.ZERO) -> Control: var key := _entry_key(entry) var cell: Variant = ASSET_CARD.new() - # Selector cells inherit the popup theme. Avoid a per-card state override so - # opening a selector never implies that one asset is pre-selected. + # Give scenery cards rounded resting and interaction states. The selected sky + # state is applied separately below, so opening the gallery stays neutral. var categories: Array = entry.get("tags", []) as Array var category: String = ", ".join(PackedStringArray(categories)) if not categories.is_empty() else String(entry.get("category", entry.get("material_label", ""))) - var card_size: Vector2 = _effective_cell_size + var card_size: Vector2 = _effective_cell_size if requested_size == Vector2.ZERO else requested_size var is_background_ground_setup: bool = String(entry.get("background_kind", "")) == "background_ground_setup" var thumb_height_budget: float = card_size.y - (74.0 if is_background_ground_setup else 64.0) var thumb_edge: float = minf(_thumbnail_size.x, minf(card_size.x - 20.0, thumb_height_budget)) var thumb_size := Vector2.ONE * maxf(72.0, thumb_edge) if kind == "starters": thumb_size = Vector2.ONE * minf(128.0, maxf(72.0, thumb_edge)) - var card_payload: Dictionary = {"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": String(entry.get("icon", "")), "category": category, "description": String(entry.get("description", "")), "thumbnail_size": thumb_size} - if is_background_ground_setup: - card_payload["description_min_height"] = 42.0 + var description: String = "" if is_background_ground_setup else String(entry.get("description", "")) + var card_payload: Dictionary = {"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": String(entry.get("icon", "")), "category": category, "description": description, "thumbnail_size": thumb_size} if entry.has("background_kind"): card_payload["background_kind"] = entry["background_kind"] card_payload["finish_id"] = entry.get("finish_id", "") @@ -649,6 +953,33 @@ func _build_cell(entry: Dictionary) -> Control: card_payload["status"] = "Current Sky" card_payload["status_color"] = _theme_color("mode_colors.edit", Color("#22C6FF")) cell.configure(card_payload, _thumbnails.get(key, null), null) + if kind == "scenery": + var card_button: Button = cell as Button + var card_border_width: int = _theme_integer("popups.asset_selector.button_border_width", 2, 1, 5) + card_button.add_theme_stylebox_override("normal", _make_rounded_style( + _theme_color("stage_panels.choice.neutral_surface", Color("#171B24")), + _theme_color("stage_panels.choice.border_color", Color("#FFFFFF33")), + card_border_width, + _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32) + )) + card_button.add_theme_stylebox_override("hover", _make_rounded_style( + _theme_color("stage_panels.choice.hover_surface", Color("#303A4B")), + _theme_color("mode_colors.edit", Color("#22C6FF")), + card_border_width + 1, + _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32) + )) + card_button.add_theme_stylebox_override("pressed", _make_rounded_style( + _theme_color("stage_panels.choice.selected_surface", Color("#12394A")), + _theme_color("mode_colors.edit", Color("#22C6FF")), + card_border_width + 1, + _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32) + )) + card_button.add_theme_stylebox_override("focus", _make_rounded_style( + Color(0, 0, 0, 0), + _theme_color("mode_colors.edit", Color("#22C6FF")), + card_border_width + 1, + _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32) + )) cell.custom_minimum_size = card_size cell.focus_mode = Control.FOCUS_ALL cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry)) @@ -690,6 +1021,173 @@ func _theme_vector2(path: String, fallback: Vector2) -> Vector2: return Vector2(float((raw as Array)[0]), float((raw as Array)[1])) +func _apply_chooser_visual_style() -> void: + var radius: int = _theme_integer("popups.asset_selector.corner_radius", 16, 8, 32) + var button_border_width: int = _theme_integer("popups.asset_selector.button_border_width", 2, 1, 5) + var accent: Color = _theme_color("mode_colors.edit", Color("#22C6FF")) + var text_color: Color = _theme_color("stage_panels.type.text_color", Color("#F5F7FA")) + var neutral: Color = _theme_color("stage_panels.choice.neutral_surface", Color("#171B24")) + var hover: Color = _theme_color("stage_panels.choice.hover_surface", Color("#303A4B")) + var selected: Color = _theme_color("stage_panels.choice.selected_surface", Color("#12394A")) + var border: Color = _theme_color("stage_panels.choice.border_color", Color("#FFFFFF33")) + if _sandbox_theme != null: + self.theme = _sandbox_theme.control_theme() + var shell: StyleBoxFlat = _sandbox_theme.stylebox("popups.asset_selector") + shell.set_corner_radius_all(radius) + shell.content_margin_left = 16.0 + shell.content_margin_right = 16.0 + shell.content_margin_top = 14.0 + shell.content_margin_bottom = 14.0 + add_theme_stylebox_override("panel", shell) + var preview_background: Color = _theme_color("popups.style_defaults.bg_color", Color("#202633FA")) + if _setup_preview != null: + _setup_preview.set_rounded_frame( + float(_theme_integer("popups.asset_selector.surface_corner_radius", 14, 4, 32)), + preview_background + ) + var preview_frame: PanelContainer = null + if _setup_view != null: + preview_frame = _setup_view.get_node_or_null("SurfacePreviewFrame") as PanelContainer + if preview_frame != null: + var frame_style := StyleBoxFlat.new() + frame_style.bg_color = preview_background + frame_style.set_corner_radius_all(_theme_integer("popups.asset_selector.surface_corner_radius", 14, 4, 32)) + preview_frame.add_theme_stylebox_override("panel", frame_style) + if _title_label != null: + _title_label.add_theme_font_size_override("font_size", _theme_integer("popups.asset_selector.setup_title_size", 30, 22, 48) if kind == "scenery" else 24) + _title_label.add_theme_color_override("font_color", text_color) + _apply_emphasis_font(_title_label) + for tab: Button in [_backgrounds_tab, _artwork_tab]: + if tab == null: + continue + tab.custom_minimum_size.y = float(_theme_integer("popups.asset_selector.tab_height", 60, 48, 96)) + tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL + tab.focus_mode = Control.FOCUS_ALL + tab.add_theme_font_size_override("font_size", _theme_integer("popups.asset_selector.tab_font_size", 22, 16, 40)) + tab.add_theme_color_override("font_color", text_color) + tab.add_theme_color_override("font_hover_color", text_color) + tab.add_theme_color_override("font_pressed_color", text_color) + tab.add_theme_stylebox_override("normal", _make_rounded_style(neutral, border, button_border_width, radius)) + tab.add_theme_stylebox_override("hover", _make_rounded_style(hover, accent, button_border_width + 1, radius)) + tab.add_theme_stylebox_override("pressed", _make_rounded_style(selected, accent, button_border_width + 1, radius)) + tab.add_theme_stylebox_override("hover_pressed", _make_rounded_style(selected.lightened(0.06), accent, button_border_width + 1, radius)) + tab.add_theme_stylebox_override("focus", _make_rounded_style(Color(0, 0, 0, 0), accent, button_border_width + 1, radius)) + _apply_emphasis_font(tab) + if _tabs != null: + _tabs.add_theme_constant_override("h_separation", 8) + _tabs.add_theme_constant_override("v_separation", 8) + if _filter_chips != null: + _filter_chips.add_theme_constant_override("h_separation", _theme_integer("popups.asset_selector.filter_chip_gap", 8, 4, 24)) + _filter_chips.add_theme_constant_override("v_separation", _theme_integer("popups.asset_selector.filter_chip_gap", 8, 4, 24)) + for chip: Button in _artwork_filter_buttons: + chip.custom_minimum_size.y = float(_theme_integer("popups.asset_selector.filter_chip_height", 48, 44, 80)) + chip.add_theme_font_size_override("font_size", _theme_integer("popups.asset_selector.filter_chip_font_size", 20, 16, 32)) + chip.add_theme_color_override("font_color", text_color) + chip.add_theme_color_override("font_hover_color", text_color) + chip.add_theme_color_override("font_pressed_color", text_color) + chip.add_theme_stylebox_override("normal", _make_rounded_style(neutral, border, button_border_width, radius)) + chip.add_theme_stylebox_override("hover", _make_rounded_style(hover, accent, button_border_width + 1, radius)) + chip.add_theme_stylebox_override("pressed", _make_rounded_style(selected, accent, button_border_width + 1, radius)) + chip.add_theme_stylebox_override("hover_pressed", _make_rounded_style(selected.lightened(0.06), accent, button_border_width + 1, radius)) + chip.add_theme_stylebox_override("focus", _make_rounded_style(Color(0, 0, 0, 0), accent, button_border_width + 1, radius)) + _apply_emphasis_font(chip) + var heading: Label = get_meta("setup_heading", null) as Label + if heading != null: + heading.add_theme_font_size_override("font_size", _theme_integer("popups.asset_selector.setup_heading_size", 26, 18, 48)) + heading.add_theme_color_override("font_color", text_color) + _apply_emphasis_font(heading) + _apply_rounded_button(_setup_cancel_button, false, accent, text_color, neutral, hover, border, radius) + _apply_rounded_button(_setup_draw_button, true, accent, text_color, neutral, hover, border, radius) + _apply_emphasis_font(_setup_draw_button, true) + _apply_rounded_button(_color_back_button, false, accent, text_color, neutral, hover, border, radius) + _apply_rounded_button(_color_use_button, true, accent, text_color, neutral, hover, border, radius) + if _close_button != null: + _close_button.custom_minimum_size = Vector2(136.0, float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96))) + _apply_rounded_button(_close_button, false, accent, text_color, neutral, hover, border, radius) + if _make_artwork_button != null: + _make_artwork_button.custom_minimum_size = Vector2(224.0, float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96))) + _apply_rounded_button(_make_artwork_button, false, accent, text_color, neutral, hover, border, radius) + if _edit_scenery_button != null: + _edit_scenery_button.text = "✋ Edit placed scenery" + _edit_scenery_button.custom_minimum_size = Vector2(292.0, float(_theme_integer("popups.asset_selector.tab_height", 60, 48, 96))) + _edit_scenery_button.focus_mode = Control.FOCUS_ALL + _edit_scenery_button.add_theme_font_size_override("font_size", _theme_integer("popups.asset_selector.tab_font_size", 22, 16, 40)) + _edit_scenery_button.add_theme_color_override("font_color", text_color) + _edit_scenery_button.add_theme_color_override("font_hover_color", text_color) + _edit_scenery_button.add_theme_color_override("font_pressed_color", text_color) + _edit_scenery_button.add_theme_stylebox_override("normal", _make_rounded_style(neutral, border, button_border_width, radius)) + _edit_scenery_button.add_theme_stylebox_override("hover", _make_rounded_style(hover, accent, button_border_width + 1, radius)) + _edit_scenery_button.add_theme_stylebox_override("pressed", _make_rounded_style(selected, accent, button_border_width + 1, radius)) + _edit_scenery_button.add_theme_stylebox_override("hover_pressed", _make_rounded_style(selected.lightened(0.06), accent, button_border_width + 1, radius)) + _edit_scenery_button.add_theme_stylebox_override("focus", _make_rounded_style(Color(0, 0, 0, 0), accent, button_border_width + 1, radius)) + _apply_emphasis_font(_edit_scenery_button) + + +func _apply_rounded_button( + button: Button, + primary: bool, + accent: Color, + text_color: Color, + neutral: Color, + hover: Color, + border: Color, + radius: int +) -> void: + if button == null: + return + var primary_blue: Color = _theme_color("popups.asset_selector.action_color", Color("#087DBA")) + var base: Color = primary_blue if primary else neutral + var over: Color = primary_blue.lightened(0.12) if primary else hover + var down: Color = primary_blue.darkened(0.12) if primary else _selected_button_color() + var edge: Color = accent.lightened(0.15) if primary else border + var border_width: int = _theme_integer("popups.asset_selector.button_border_width", 2, 1, 5) + button.add_theme_stylebox_override("normal", _make_rounded_style(base, edge, border_width, radius)) + button.add_theme_stylebox_override("hover", _make_rounded_style(over, edge, border_width + 1, radius)) + button.add_theme_stylebox_override("pressed", _make_rounded_style(down, accent, border_width + 1, radius)) + button.add_theme_stylebox_override("hover_pressed", _make_rounded_style(down.lightened(0.05), accent, border_width + 1, radius)) + button.add_theme_stylebox_override("focus", _make_rounded_style(Color(0, 0, 0, 0), accent, border_width + 1, radius)) + var label_color: Color = Color.WHITE if primary else text_color + button.add_theme_color_override("font_color", label_color) + button.add_theme_color_override("font_hover_color", label_color) + button.add_theme_color_override("font_pressed_color", label_color) + button.add_theme_font_size_override("font_size", _theme_integer("popups.asset_selector.setup_button_font_size", 20, 14, 40)) + _apply_emphasis_font(button) + + +func _selected_button_color() -> Color: + return _theme_color("stage_panels.choice.selected_surface", Color("#12394A")) + + +func _make_rounded_style(background: Color, edge: Color, edge_width: int, radius: int) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = background + style.border_color = edge + style.set_border_width_all(edge_width) + style.set_corner_radius_all(radius) + style.content_margin_left = 12.0 + style.content_margin_right = 12.0 + style.content_margin_top = 8.0 + style.content_margin_bottom = 8.0 + return style + + +func _make_strong_font(font: Font, embolden: float) -> Font: + if font == null: + return null + var variation := FontVariation.new() + variation.base_font = font + variation.variation_embolden = embolden + return variation + + +func _apply_emphasis_font(control: Control, extra_bold: bool = false) -> void: + if control == null: + return + var font: Font = _strong_action_font if extra_bold else _strong_ui_font + if font != null: + control.add_theme_font_override("font", font) + + func _on_cell_pressed(entry: Dictionary) -> void: var background_kind: String = String(entry.get("background_kind", "")) if background_kind == "wall_setup": diff --git a/scripts/shared/ui/color_swatch_selector.gd b/scripts/shared/ui/color_swatch_selector.gd index 02658e5..6ce389c 100644 --- a/scripts/shared/ui/color_swatch_selector.gd +++ b/scripts/shared/ui/color_swatch_selector.gd @@ -8,6 +8,15 @@ var _group: ButtonGroup = ButtonGroup.new() var _buttons: Array[Button] = [] var _stage_theme: SandboxTheme var _colors: Array[Color] = [] +var _swatch_size_override: float = -1.0 +var _swatch_gap_override: int = -1 + + +func set_swatch_layout_override(size: float, gap: int) -> void: + _swatch_size_override = size + _swatch_gap_override = gap + if is_inside_tree() and not _colors.is_empty(): + configure(_colors, _selected, _stage_theme) func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void: @@ -25,8 +34,9 @@ func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> for child: Node in get_children(): remove_child(child) child.queue_free() - add_theme_constant_override("h_separation", _integer("stage_surface_colors.swatch_gap", 8)) - add_theme_constant_override("v_separation", _integer("stage_surface_colors.swatch_gap", 8)) + var gap: int = _swatch_gap_override if _swatch_gap_override >= 0 else _integer("stage_surface_colors.swatch_gap", 8) + add_theme_constant_override("h_separation", gap) + add_theme_constant_override("v_separation", gap) var seen: Dictionary = {} for color: Color in _colors: var color_key: String = color.to_html(true).to_upper() @@ -34,7 +44,7 @@ func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> continue seen[color_key] = true var button := Button.new() - var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48)) + var swatch_size: float = _swatch_size_override if _swatch_size_override > 0.0 else float(_integer("stage_surface_colors.swatch_size", 48)) button.custom_minimum_size = Vector2(swatch_size, swatch_size) button.toggle_mode = true button.button_group = _group diff --git a/scripts/stage/sandbox_theme.gd b/scripts/stage/sandbox_theme.gd index 1e8df5f..2b471de 100644 --- a/scripts/stage/sandbox_theme.gd +++ b/scripts/stage/sandbox_theme.gd @@ -4,6 +4,29 @@ extends RefCounted const SCHEMA_VERSION: String = "2.0" const DEFAULT_PATH: String = "res://sandbox_theme.json" +const CONFIRMATION_DEFAULTS: Dictionary = { + "scenery_erase_size": [520, 208], + "padding": 18, + "body_gap": 10, + "footer_gap": 12, + "corner_radius": 16, + "border_width": 2, + "title_font_size": 26, + "message_font_size": 20, + "button_font_size": 20, + "button_height": 48, + "button_radius": 12, + "button_border_width": 2, + "remove_button_width": 170, + "keep_button_width": 132, + "surface_color": "#202633FA", + "border_color": "#22C6FF", + "accent_color": "#22C6FF", + "action_color": "#087DBA", + "danger_color": "#FF626B", + "danger_hover_surface": "#3B252C", + "danger_pressed_surface": "#4A252D" +} const DEFAULT_CONFIG: Dictionary = { "version": SCHEMA_VERSION, @@ -13,14 +36,14 @@ const DEFAULT_CONFIG: Dictionary = { "effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}}, "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}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "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": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":10}}, + "popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":8, "setup_title_size":30, "setup_heading_size":26, "setup_choice_font_size":22, "setup_choice_height":68, "setup_choice_corner_radius":22, "setup_choice_border_width":2, "tab_font_size":22, "tab_height":60, "filter_chip_font_size":20, "filter_chip_height":48, "filter_chip_gap":8, "setup_button_font_size":20, "button_border_width":2, "footer_gap":16, "surface_corner_radius":14, "look_corner_radius":8, "corner_radius":16, "compact_swatch_size":42, "color_group_gap":8, "action_color":"#087DBA", "separator_color":"#AAB2C0A0"}}, "palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3}, "stage_panels": { "shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"}, "type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"}, "choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"}, "toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"}, - "action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"}, + "action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "primary_color": "#087DBA", "primary_hover_color": "#1596D3", "primary_pressed_color": "#066B9F", "destructive_color": "#FF626B", "destructive_hover_surface": "#3B252C", "destructive_pressed_surface": "#4A252D"}, "prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"}, "spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "completion_duration": 0.6} }, @@ -36,9 +59,13 @@ var _style_cache: Dictionary = {} var _control_theme: Theme = null var _warned: Dictionary = {} +func _init() -> void: + _apply_confirmation_defaults() + func load_from(path: String = DEFAULT_PATH) -> bool: invalidate_caches() _config = DEFAULT_CONFIG.duplicate(true) + _apply_confirmation_defaults() if not FileAccess.file_exists(path): _warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path) return false @@ -55,6 +82,11 @@ func load_from(path: String = DEFAULT_PATH) -> bool: _config = _deep_merge(_config, parsed as Dictionary) return true +func _apply_confirmation_defaults() -> void: + var popups: Dictionary = _config.get("popups", {}).duplicate(true) + popups["confirmation"] = CONFIRMATION_DEFAULTS.duplicate(true) + _config["popups"] = popups + func raw_config() -> Dictionary: return _config.duplicate(true) diff --git a/scripts/stage/scenery/stage_background_ground.gd b/scripts/stage/scenery/stage_background_ground.gd index cc5ef30..0717c52 100644 --- a/scripts/stage/scenery/stage_background_ground.gd +++ b/scripts/stage/scenery/stage_background_ground.gd @@ -2,14 +2,20 @@ class_name StageBackgroundGround extends "res://scripts/stage/scenery/stage_rect_surface.gd" ## Non-walkable rectangular scenery surface behind the set. -const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd") +## Keeps procedural ground finishes aligned when this surface is split by the area eraser. +var motif_origin_offset: Vector2 = Vector2.ZERO + func configure(new_size: Vector2, finish: StringName, color: Color) -> void: super.configure(new_size, finish if SURFACE_RENDERER.is_background_finish(finish) else &"finish_background_grass", color) z_index = DRAW_ORDER.BACKGROUND_GROUND +func get_surface_draw_origin_world() -> Vector2: + return global_position + motif_origin_offset + + func _draw() -> void: - SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world()) + _draw_surface(false) diff --git a/scripts/stage/scenery/stage_rect_surface.gd b/scripts/stage/scenery/stage_rect_surface.gd index ce91991..71d1186 100644 --- a/scripts/stage/scenery/stage_rect_surface.gd +++ b/scripts/stage/scenery/stage_rect_surface.gd @@ -2,17 +2,47 @@ class_name StageRectSurface extends Node2D ## Shared rectangular geometry and transient tint preview for inert Stage scenery. +const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") +const SURFACE_CACHE := preload("res://scripts/stage/scenery/stage_surface_cache.gd") +const PREVIEW_DRAW_BUDGET_USEC: int = 6000 +## Each rectangle needs one handle; the image itself is shared across rectangles. +const MAX_RESIDENT_TILES: int = 1 + var panel_size: Vector2 = Vector2(320.0, 400.0) var finish_id: StringName = &"" var tint: Color = Color.WHITE var _preview_tint: Color = Color.WHITE var _has_preview_tint: bool = false +var _preview_over_budget: bool = false +var _surface_tiles: Dictionary = {} +var _cache_style: Array = [] +var _cache_dirty: bool = true +var _last_view_transform: Transform2D +var _last_view_rect: Rect2 +var _was_covered: bool = false +var _cover_regions_frame: int = -1 +var _cover_regions: Array[Dictionary] = [] +var _last_cover_regions: Array[Dictionary] = [] +## Placement uses adaptive vector detail; committed surfaces share material textures. +var interactive_preview: bool = false: + set(value): + if interactive_preview == value: + return + interactive_preview = value + _preview_over_budget = false + queue_redraw() func configure(new_size: Vector2, finish: StringName, color: Color) -> void: - panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y)) + var next_size := Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y)) + if panel_size == next_size and finish_id == finish and tint == color: + return + if finish_id != finish: + _preview_over_budget = false + panel_size = next_size finish_id = finish tint = color + _cache_dirty = true queue_redraw() @@ -37,3 +67,154 @@ func get_render_tint() -> Color: func get_surface_draw_origin_world() -> Vector2: return global_position + + +func _exit_tree() -> void: + # Erase/Cancel retains detached geometry, never native drawing resources. + for tile: SURFACE_CACHE in _surface_tiles.values(): + tile.dispose() + _surface_tiles.clear() + _cache_dirty = true + _cover_regions_frame = -1 + _last_cover_regions.clear() + RenderingServer.canvas_item_clear(get_canvas_item()) + + +func _process(_delta: float) -> void: + if not is_visible_in_tree(): + for tile: SURFACE_CACHE in _surface_tiles.values(): + tile.wanted = false + SURFACE_CACHE.build_next() + return + var covered: bool = not interactive_preview and _covered_by_surface() + var coverage_changed: bool = _get_cover_regions() != _last_cover_regions + if coverage_changed: + _last_cover_regions = _cover_regions.duplicate() + if covered: + for tile: SURFACE_CACHE in _surface_tiles.values(): + tile.wanted = false + SURFACE_CACHE.build_next() + # Camera pans, zooms, and window resizing can reveal previously culled motifs. + if coverage_changed or covered != _was_covered or get_global_transform_with_canvas() != _last_view_transform or get_viewport_rect() != _last_view_rect: + queue_redraw() + _was_covered = covered + + +func _draw_surface(wall: bool) -> void: + _last_view_transform = get_global_transform_with_canvas() + _last_view_rect = get_viewport_rect() + if not interactive_preview and DisplayServer.get_name() != "headless" and finish_id not in [&"finish_wall_paint", &"finish_ground_paint"]: + if _covered_by_surface(): + for tile: SURFACE_CACHE in _surface_tiles.values(): + tile.wanted = false + return + _draw_cached_surface(wall) + return + var started_usec: int = Time.get_ticks_usec() if interactive_preview else 0 + if wall: + SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world(), Vector2.ONE, true, interactive_preview, _preview_over_budget) + else: + SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world(), Vector2.ONE, true, interactive_preview, _preview_over_budget) + # Latch for this placement session to avoid oscillating between detail levels. + # The complexity estimate handles large first draws before timing is available. + if interactive_preview and Time.get_ticks_usec() - started_usec > PREVIEW_DRAW_BUDGET_USEC: + _preview_over_budget = true + + +func _draw_cached_surface(wall: bool) -> void: + var visible_rect: Rect2 = get_local_bounds().intersection(SURFACE_RENDERER._visible_local_rect(self)) + var color: Color = get_render_tint() + var style: Array = [wall, finish_id, color] + if _cache_style != style: + for previous: SURFACE_CACHE in _surface_tiles.values(): + previous.dispose() + _surface_tiles.clear() + _cache_style = style + _cache_dirty = false + for previous: SURFACE_CACHE in _surface_tiles.values(): + previous.wanted = false + if visible_rect.size.x <= 0.0 or visible_rect.size.y <= 0.0: + return + var period: Vector2 = SURFACE_RENDERER.pattern_size_for(finish_id) + if period.x <= 0.0 or period.y <= 0.0: + draw_rect(visible_rect, SURFACE_RENDERER.surface_base_color(finish_id, color, wall)) + return + var pattern: SURFACE_CACHE = _surface_tiles.get(&"pattern") as SURFACE_CACHE + if pattern == null: + pattern = SURFACE_CACHE.new() as SURFACE_CACHE + pattern.attach_to(self) + pattern.request_pattern(wall, finish_id, color, period) + pattern.updated.connect(queue_redraw) + _surface_tiles[&"pattern"] = pattern + pattern.wanted = true + pattern.last_used_frame = Engine.get_process_frames() + pattern.ensure_queued() + var texture: Texture2D = pattern.get_texture() + if texture == null: + draw_rect(visible_rect, SURFACE_RENDERER.surface_base_color(finish_id, color, wall)) + return + texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED + texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + # One quad samples a repeating material. Camera motion, panel resizing and + # erase fragments change only these UVs, never the image or its mip levels. + var sample_origin: Vector2 = visible_rect.position + get_surface_draw_origin_world() + sample_origin = Vector2(fposmod(sample_origin.x, period.x), fposmod(sample_origin.y, period.y)) + var pixels_per_unit: Vector2 = Vector2(pattern.size) / period + var source := Rect2(sample_origin * pixels_per_unit, visible_rect.size * pixels_per_unit) + draw_texture_rect_region(texture, visible_rect, source, Color.WHITE, false, false) + + +func _covered_by_surface(local_visible: Rect2 = Rect2()) -> bool: + var visible: Rect2 = local_visible + if visible.size.x <= 0.0 or visible.size.y <= 0.0: + visible = get_local_bounds().intersection(SURFACE_RENDERER._visible_local_rect(self)) + if visible.size.x <= 0.0 or visible.size.y <= 0.0: + return true + var corners := PackedVector2Array([visible.position, Vector2(visible.end.x, visible.position.y), visible.end, Vector2(visible.position.x, visible.end.y)]) + for region: Dictionary in _get_cover_regions(): + var fully_covered: bool = true + var cover_bounds: Rect2 = region["bounds"] + var to_cover: Transform2D = region["transform"] + for corner: Vector2 in corners: + if not cover_bounds.has_point(to_cover * corner): + fully_covered = false + break + if fully_covered: + return true + return false + + +func _get_cover_regions() -> Array[Dictionary]: + var frame: int = Engine.get_process_frames() + if _cover_regions_frame == frame: + return _cover_regions + _cover_regions_frame = frame + _cover_regions.clear() + var parent: Node = get_parent() + if parent == null or (parent is Node2D and (parent as Node2D).y_sort_enabled): + return _cover_regions + # Transparent ancestors and custom materials can reveal underlying layers. + var ancestor: Node = self + while ancestor is CanvasItem: + if (ancestor as CanvasItem).modulate.a < 0.999 or (ancestor as CanvasItem).material != null: + return _cover_regions + ancestor = ancestor.get_parent() + for sibling: Node in parent.get_children(): + if sibling == self or not (sibling is StageRectSurface): + continue + var cover: StageRectSurface = sibling as StageRectSurface + if cover.z_as_relative != z_as_relative: + continue + if not cover.is_visible_in_tree() or cover.interactive_preview or cover.material != null or cover.modulate.a < 0.999 or cover.self_modulate.a < 0.999: + continue + if cover.z_index < z_index or (cover.z_index == z_index and cover.get_index() < get_index()): + continue + if SURFACE_RENDERER.surface_base_color(cover.finish_id, cover.get_render_tint(), SURFACE_RENDERER.is_wall_finish(cover.finish_id)).a < 0.999: + continue + if absf(cover.global_transform.determinant()) < 0.000001: + continue + _cover_regions.append({ + "bounds": cover.get_local_bounds().grow(-0.01), + "transform": cover.global_transform.affine_inverse() * global_transform, + }) + return _cover_regions diff --git a/scripts/stage/scenery/stage_surface_cache.gd b/scripts/stage/scenery/stage_surface_cache.gd new file mode 100644 index 0000000..147fbb4 --- /dev/null +++ b/scripts/stage/scenery/stage_surface_cache.gd @@ -0,0 +1,929 @@ +class_name StageSurfaceCache +extends RefCounted +## Lightweight per-surface handle into a scene-scoped immutable bitmap cache. + +const MAX_TEXTURE_EDGE: int = 4096 +const MAX_TEXTURE_PIXELS: float = 4194304.0 +const MAX_CACHE_BYTES: int = 64 * 1024 * 1024 +const MAX_CACHE_ENTRIES: int = 512 +const MAX_QUEUED_BUILDS: int = 512 +const MAX_QUEUE_SCAN_PER_FRAME: int = 8 +const RECENT_USE_PROTECTION_FRAMES: int = 3 +const MIN_DETAILED_SCALE: float = 0.5 +const PATTERN_CACHE_VERSION: int = 1 +const PATTERN_SOURCE_OVERSCAN: float = 4.0 +const PATTERN_PIXEL_SCALE: float = 2.0 + +static var _pool_refs: Dictionary = {} +static var _last_service_frame: int = -1 +static var _active_pool_ref: WeakRef + +signal updated + + +class TileEntry extends RefCounted: + var key: String + var canonical_rect: Rect2 + var source_scale: Vector2 + var wall: bool + var finish: StringName + var tint: Color + var detail_view_size: Vector2 + var size: Vector2i + var byte_count: int = 0 + var texture: Texture2D + var is_pattern: bool = false + var period: Vector2 = Vector2.ZERO + var watchers: Array[WeakRef] = [] + var canvas_owners: Array[WeakRef] = [] + var last_used_frame: int = 0 + var build_usec: int = 0 + var queued: bool = false + var building: bool = false + + +class SurfaceDrawing extends Node2D: + const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") + var source_rect: Rect2 + var wall: bool + var finish: StringName + var tint: Color + var origin: Vector2 = Vector2.ZERO + var detail_view_size: Vector2 + var low_detail: bool = false + var active: bool = false + var pool_ref: WeakRef + + func _draw() -> void: + var pool: Variant = pool_ref.get_ref() if pool_ref != null else null + if not active or pool == null: + return + active = false + var started_usec: int = Time.get_ticks_usec() + if wall: + RENDERER.draw_wall(self, source_rect, finish, tint, origin, Vector2.ONE, false, low_detail, low_detail, detail_view_size) + else: + RENDERER.draw_background(self, source_rect, finish, tint, origin, Vector2.ONE, false, low_detail, low_detail) + pool._finish_vector_submission(Time.get_ticks_usec() - started_usec) + + +class BakePool extends Node: + const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") + const BAKE_GUTTER_PIXELS: int = 4 + var scope_id: int = 0 + var entries: Dictionary = {} + var queue: Array[String] = [] + var _waiting_handles: Array[WeakRef] = [] + var resident_bytes: int = 0 + var builds: int = 0 + var cache_hits: int = 0 + var evictions: int = 0 + var refusals: int = 0 + var readbacks: int = 0 + var pattern_builds: int = 0 + var pattern_readbacks: int = 0 + var pattern_resident_bytes: int = 0 + var pattern_retired_bytes: int = 0 + var last_build_usec: int = 0 + var vector_submission_usec: int = 0 + var retired_bytes: int = 0 + var budget_blocked_requests: int = 0 + var budget_pressure: bool = false + var budget_pressure_frame: int = -1 + var _viewport: SubViewport + var _drawing: SurfaceDrawing + var _building_entry: TileEntry + var _building_started_usec: int = 0 + var _reserved_bytes: int = 0 + var _retired_textures: Array[Texture2D] = [] + var _retire_callback_pending: bool = false + var _capture_pending: bool = false + var _disposed: bool = false + + func _ready() -> void: + set_process(true) + _ensure_baker() + + func _process(_delta: float) -> void: + _prune_queue() + StageSurfaceCache._service_pools() + + func _ensure_baker() -> void: + if is_instance_valid(_viewport): + return + _viewport = SubViewport.new() + _viewport.name = "SurfaceBitmapBaker" + _viewport.disable_3d = true + _viewport.transparent_bg = true + _viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS + _viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED + _viewport.world_2d = World2D.new() + add_child(_viewport) + _drawing = SurfaceDrawing.new() + _drawing.name = "SurfaceVectorSource" + _drawing.pool_ref = weakref(self) + _viewport.add_child(_drawing) + + func queue_handle(handle: StageSurfaceCache) -> void: + if _disposed or handle._disposed: + return + var entry: TileEntry = _find_or_create_entry(handle) + if entry == null: + refusals += 1 + _add_waiter(handle) + return + _bind_handle(handle, entry) + if not _handle_is_interested(handle): + return + handle.last_used_frame = Engine.get_process_frames() + entry.last_used_frame = Engine.get_process_frames() + if entry.texture != null or entry.queued or entry.building: + return + if queue.size() >= MAX_QUEUED_BUILDS: + refusals += 1 + _add_waiter(handle) + return + entry.queued = true + queue.append(entry.key) + handle._enqueued = true + + func _find_or_create_entry(handle: StageSurfaceCache) -> TileEntry: + var existing: TileEntry = entries.get(handle._cache_key) as TileEntry + if existing != null: + cache_hits += 1 + return existing + while entries.size() + _retired_textures.size() >= MAX_CACHE_ENTRIES: + if not _retired_textures.is_empty(): + return null + if not _evict_one(null): + return null + if not _retired_textures.is_empty(): + return null + var arguments: Array = handle._pending_arguments + if arguments.size() < 6: + return null + var entry := TileEntry.new() + entry.key = handle._cache_key + entry.canonical_rect = arguments[0] as Rect2 + entry.source_scale = arguments[1] as Vector2 + entry.wall = bool(arguments[2]) + entry.finish = arguments[3] as StringName + entry.tint = arguments[4] as Color + entry.detail_view_size = arguments[5] as Vector2 + if arguments.size() >= 8: + entry.is_pattern = bool(arguments[6]) + entry.period = arguments[7] as Vector2 + entries[entry.key] = entry + return entry + + func _bind_handle(handle: StageSurfaceCache, entry: TileEntry) -> void: + _register_canvas_owner(entry, handle) + var current: TileEntry = handle._entry_ref.get_ref() as TileEntry if handle._entry_ref != null else null + if current == entry: + handle.ready_for_display = entry.texture != null + handle._building = entry.building + handle._enqueued = entry.queued + handle.build_usec = entry.build_usec + return + if current != null: + _unwatch_handle(current, handle) + handle._entry_ref = weakref(entry) + handle.ready_for_display = entry.texture != null + handle._building = entry.building + handle._enqueued = entry.queued + handle.build_usec = entry.build_usec + var found: bool = false + for watcher_ref: WeakRef in entry.watchers: + if watcher_ref.get_ref() == handle: + found = true + break + if not found: + entry.watchers.append(weakref(handle)) + + func _unwatch_handle(entry: TileEntry, handle: StageSurfaceCache) -> void: + for index: int in range(entry.watchers.size() - 1, -1, -1): + var watcher: Variant = entry.watchers[index].get_ref() + if watcher == null or watcher == handle: + entry.watchers.remove_at(index) + + func _register_canvas_owner(entry: TileEntry, handle: StageSurfaceCache) -> void: + var owner: Variant = handle._owner_ref.get_ref() if handle._owner_ref != null else null + if owner == null or not is_instance_valid(owner): + return + for index: int in range(entry.canvas_owners.size() - 1, -1, -1): + var registered_owner: Variant = entry.canvas_owners[index].get_ref() + if registered_owner == null or not is_instance_valid(registered_owner): + entry.canvas_owners.remove_at(index) + elif registered_owner == owner: + return + entry.canvas_owners.append(weakref(owner)) + + func _start_next_build() -> bool: + if _disposed or _capture_pending: + return false + _prune_queue() + var attempts: int = mini(queue.size(), MAX_QUEUE_SCAN_PER_FRAME) + while attempts > 0 and not queue.is_empty(): + attempts -= 1 + var key: String = _pop_next_build_key() + var entry: TileEntry = entries.get(key) as TileEntry + if entry == null or not entry.queued or entry.texture != null: + continue + entry.queued = false + if not _entry_has_live_watchers(entry): + _evict_entry(entry) + continue + var size: Vector2i = _baker_size_for(entry) + var required_bytes: int = _estimated_texture_bytes(entry) + if not _make_room(required_bytes, entry): + refusals += 1 + if queue.size() < MAX_QUEUED_BUILDS: + entry.queued = true + queue.append(entry.key) + budget_blocked_requests += 1 + budget_pressure = true + budget_pressure_frame = Engine.get_process_frames() + continue + _begin_build(entry, size) + return true + return false + + func _baker_rect_for(entry: TileEntry) -> Rect2: + return entry.canonical_rect.grow(PATTERN_SOURCE_OVERSCAN) if entry.is_pattern else entry.canonical_rect + + func _baker_size_for(entry: TileEntry) -> Vector2i: + var draw_rect: Rect2 = _baker_rect_for(entry) + return StageSurfaceCache.texture_size_for(draw_rect.size, entry.source_scale) + + func _pattern_output_size(entry: TileEntry) -> Vector2i: + var draw_rect: Rect2 = _baker_rect_for(entry) + var baker_size: Vector2i = StageSurfaceCache.texture_size_for(draw_rect.size, entry.source_scale) + return Vector2i( + maxi(1, roundi(float(baker_size.x) * entry.period.x / draw_rect.size.x)), + maxi(1, roundi(float(baker_size.y) * entry.period.y / draw_rect.size.y)) + ) + + func _estimated_texture_bytes(entry: TileEntry) -> int: + var base_size: Vector2i = _pattern_output_size(entry) if entry.is_pattern else _baker_size_for(entry) + return _mip_chain_byte_count(base_size) if entry.is_pattern else base_size.x * base_size.y * 4 + + func _crop_pattern_image(image: Image, entry: TileEntry) -> Image: + var draw_rect: Rect2 = _baker_rect_for(entry) + var output_size: Vector2i = _pattern_output_size(entry) + var scale: Vector2 = Vector2(image.get_size()) / draw_rect.size + var crop_position := Vector2i( + roundi((entry.canonical_rect.position.x - draw_rect.position.x) * scale.x), + roundi((entry.canonical_rect.position.y - draw_rect.position.y) * scale.y) + ) + crop_position.x = clampi(crop_position.x, 0, maxi(0, image.get_width() - output_size.x)) + crop_position.y = clampi(crop_position.y, 0, maxi(0, image.get_height() - output_size.y)) + var safe_size := Vector2i( + mini(output_size.x, image.get_width() - crop_position.x), + mini(output_size.y, image.get_height() - crop_position.y) + ) + if safe_size.x <= 0 or safe_size.y <= 0: + return Image.new() + return image.get_region(Rect2i(crop_position, safe_size)) + + func _mip_chain_byte_count(size: Vector2i) -> int: + var width: int = maxi(1, size.x) + var height: int = maxi(1, size.y) + var total: int = 0 + while true: + total += width * height * 4 + if width == 1 and height == 1: + break + width = maxi(1, floori(float(width) * 0.5)) + height = maxi(1, floori(float(height) * 0.5)) + return total + + func _pop_next_build_key() -> String: + var selected_index: int = 0 + var selected_scale: float = INF + for index: int in range(queue.size()): + var entry: TileEntry = entries.get(queue[index]) as TileEntry + if entry == null: + continue + var scale_rank: float = maxf(entry.source_scale.x, entry.source_scale.y) + if scale_rank < selected_scale: + selected_scale = scale_rank + selected_index = index + return queue.pop_at(selected_index) + + func _begin_build(entry: TileEntry, texture_size: Vector2i) -> void: + _ensure_baker() + RenderingServer.canvas_item_clear(_drawing.get_canvas_item()) + _viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED + _viewport.size = texture_size + var draw_rect: Rect2 = _baker_rect_for(entry) + _drawing.source_rect = draw_rect + _drawing.wall = entry.wall + _drawing.finish = entry.finish + _drawing.tint = entry.tint + _drawing.origin = Vector2.ZERO + _drawing.detail_view_size = entry.period if entry.is_pattern else entry.detail_view_size + _drawing.low_detail = not entry.is_pattern and minf(entry.source_scale.x, entry.source_scale.y) < MIN_DETAILED_SCALE + var draw_scale := Vector2(texture_size) / draw_rect.size + _drawing.scale = draw_scale + _drawing.position = -draw_rect.position * draw_scale + _drawing.active = true + _building_entry = entry + _building_started_usec = Time.get_ticks_usec() + _reserved_bytes = _estimated_texture_bytes(entry) + entry.building = true + _capture_pending = true + _drawing.queue_redraw() + _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + if not RenderingServer.frame_post_draw.is_connected(_on_frame_post_draw): + RenderingServer.frame_post_draw.connect(_on_frame_post_draw, CONNECT_ONE_SHOT) + StageSurfaceCache._active_pool_ref = weakref(self) + + func _finish_vector_submission(elapsed_usec: int) -> void: + vector_submission_usec = elapsed_usec + + func _on_frame_post_draw() -> void: + if not _capture_pending or _building_entry == null: + return + var entry: TileEntry = _building_entry + var image: Image + if _entry_has_live_watchers(entry) and is_instance_valid(_viewport): + image = _viewport.get_texture().get_image() + if image != null and not image.is_empty(): + if entry.is_pattern: + image = _crop_pattern_image(image, entry) + if image != null and not image.is_empty(): + image.generate_mipmaps() + if image != null and not image.is_empty(): + var texture := ImageTexture.create_from_image(image) + entry.texture = texture + entry.size = image.get_size() + entry.byte_count = image.get_data_size() + resident_bytes += entry.byte_count + if entry.is_pattern: + pattern_builds += 1 + pattern_readbacks += 1 + pattern_resident_bytes += entry.byte_count + _reserved_bytes = 0 + builds += 1 + readbacks += 1 + budget_pressure = false + else: + _reserved_bytes = 0 + refusals += 1 + _notify_entry_failed(entry) + last_build_usec = Time.get_ticks_usec() - _building_started_usec + if entry.texture != null: + entry.build_usec = last_build_usec + _notify_entry_ready(entry) + entry.building = false + _building_entry = null + _capture_pending = false + if is_instance_valid(_viewport): + _viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED + if is_instance_valid(_drawing): + _drawing.active = false + RenderingServer.canvas_item_clear(_drawing.get_canvas_item()) + if StageSurfaceCache._active_pool_ref != null and StageSurfaceCache._active_pool_ref.get_ref() == self: + StageSurfaceCache._active_pool_ref = null + _wake_waiters() + StageSurfaceCache._service_pools() + + func _notify_entry_ready(entry: TileEntry) -> void: + for index: int in range(entry.watchers.size() - 1, -1, -1): + var handle: Variant = entry.watchers[index].get_ref() + if handle == null: + entry.watchers.remove_at(index) + continue + handle._on_entry_ready(entry) + + func _notify_entry_failed(entry: TileEntry) -> void: + for index: int in range(entry.watchers.size() - 1, -1, -1): + var handle: Variant = entry.watchers[index].get_ref() + if handle == null: + entry.watchers.remove_at(index) + continue + handle._on_entry_failed(entry) + + func _entry_has_live_watchers(entry: TileEntry) -> bool: + var has_watcher: bool = false + for index: int in range(entry.watchers.size() - 1, -1, -1): + var handle: Variant = entry.watchers[index].get_ref() + if handle == null or handle._disposed: + entry.watchers.remove_at(index) + continue + if _handle_is_interested(handle): + has_watcher = true + return has_watcher + + func _make_room(required_bytes: int, protected_entry: TileEntry) -> bool: + if resident_bytes + retired_bytes + _reserved_bytes + required_bytes <= MAX_CACHE_BYTES: + return true + if retired_bytes > 0: + return false + if not _evict_one(protected_entry): + return false + # Keep the old texture alive until the frame that releases its CanvasItem RIDs. + # The request will retry after the post-draw retirement callback. + return resident_bytes + retired_bytes + _reserved_bytes + required_bytes <= MAX_CACHE_BYTES + + func _evict_one(protected_entry: TileEntry) -> bool: + var candidate: TileEntry + for value: Variant in entries.values(): + var entry: TileEntry = value as TileEntry + if entry == null or entry == protected_entry or entry.building: + continue + if _entry_is_protected(entry): + continue + if candidate == null or entry.last_used_frame < candidate.last_used_frame: + candidate = entry + if candidate == null: + return false + _evict_entry(candidate) + return true + + func _entry_is_protected(entry: TileEntry) -> bool: + if entry.texture == null: + return false + var current_frame: int = Engine.get_process_frames() + for index: int in range(entry.watchers.size() - 1, -1, -1): + var handle: Variant = entry.watchers[index].get_ref() + if handle == null or handle._disposed: + entry.watchers.remove_at(index) + continue + if _handle_is_visible(handle) and (handle.wanted or current_frame - handle.last_used_frame <= RECENT_USE_PROTECTION_FRAMES): + return true + return false + + func _evict_entry(entry: TileEntry) -> void: + if entry == null or entry.building: + return + if entry.queued: + queue.erase(entry.key) + entry.queued = false + _clear_entry_canvas_owners(entry) + for index: int in range(entry.watchers.size() - 1, -1, -1): + var handle: Variant = entry.watchers[index].get_ref() + if handle == null: + entry.watchers.remove_at(index) + continue + handle._on_entry_evicted(entry) + var retired_texture: Texture2D = entry.texture + var retired_size_bytes: int = entry.byte_count + entry.texture = null + resident_bytes = maxi(0, resident_bytes - entry.byte_count) + if entry.is_pattern: + pattern_resident_bytes = maxi(0, pattern_resident_bytes - entry.byte_count) + entry.byte_count = 0 + if retired_texture != null: + _retired_textures.append(retired_texture) + retired_bytes += retired_size_bytes + if entry.is_pattern: + pattern_retired_bytes += retired_size_bytes + if not _retire_callback_pending: + _retire_callback_pending = true + RenderingServer.frame_post_draw.connect(_on_retired_frame_post_draw, CONNECT_ONE_SHOT) + if entries.get(entry.key) == entry: + entries.erase(entry.key) + evictions += 1 + + func _clear_owner_canvas(owner: Variant) -> void: + if not (owner is CanvasItem) or not is_instance_valid(owner): + return + var canvas_item: CanvasItem = owner as CanvasItem + RenderingServer.canvas_item_clear(canvas_item.get_canvas_item()) + canvas_item.queue_redraw() + + func _clear_entry_canvas_owners(entry: TileEntry) -> void: + var cleared_owners: Dictionary = {} + for owner_ref: WeakRef in entry.canvas_owners: + var owner: Variant = owner_ref.get_ref() + if owner == null or not is_instance_valid(owner): + continue + var owner_id: int = owner.get_instance_id() + if cleared_owners.has(owner_id): + continue + _clear_owner_canvas(owner) + cleared_owners[owner_id] = true + entry.canvas_owners.clear() + + func _stats() -> Dictionary: + var queued_count: int = 0 + for value: Variant in entries.values(): + var entry: TileEntry = value as TileEntry + if entry != null and entry.queued: + queued_count += 1 + return { + "bytes": resident_bytes, + "resident_bytes": resident_bytes, + "retired_bytes": retired_bytes, + "entries": entries.size(), + "queued": queued_count, + "bakers": 1 if is_instance_valid(_viewport) else 0, + "live_vector_bakers": 1 if _capture_pending else 0, + "live_vector_items": 1 if _capture_pending else 0, + "builds": builds, + "cache_hits": cache_hits, + "evictions": evictions, + "refusals": refusals, + "budget_pressure": budget_pressure, + "budget_pressure_frame": budget_pressure_frame, + "budget_blocked_requests": budget_blocked_requests, + "waiting_handles": _waiting_handles.size(), + "readbacks": readbacks, + "pattern_builds": pattern_builds, + "pattern_readbacks": pattern_readbacks, + "pattern_resident_bytes": pattern_resident_bytes, + "pattern_retired_bytes": pattern_retired_bytes, + "last_build_usec": last_build_usec, + "vector_submission_usec": vector_submission_usec, + "active_build": _building_entry.key if _building_entry != null else "" + } + + func _exit_tree() -> void: + _disposed = true + if RenderingServer.frame_post_draw.is_connected(_on_frame_post_draw): + RenderingServer.frame_post_draw.disconnect(_on_frame_post_draw) + if _capture_pending and _building_entry != null: + _building_entry.building = false + _notify_entry_failed(_building_entry) + _building_entry = null + _capture_pending = false + if StageSurfaceCache._active_pool_ref != null and StageSurfaceCache._active_pool_ref.get_ref() == self: + StageSurfaceCache._active_pool_ref = null + for value: Variant in entries.values(): + var entry: TileEntry = value as TileEntry + if entry == null: + continue + _clear_entry_canvas_owners(entry) + for watcher_ref: WeakRef in entry.watchers: + var handle: Variant = watcher_ref.get_ref() + if handle != null: + handle._on_entry_evicted(entry) + entry.texture = null + entries.clear() + queue.clear() + _waiting_handles.clear() + _retired_textures.clear() + resident_bytes = 0 + retired_bytes = 0 + pattern_resident_bytes = 0 + pattern_retired_bytes = 0 + if is_instance_valid(_viewport): + _viewport.queue_free() + _viewport = null + _drawing = null + StageSurfaceCache._forget_pool(scope_id, self) + + func _prune_queue() -> void: + if queue.is_empty(): + return + var kept: Array[String] = [] + for key: String in queue: + var entry: TileEntry = entries.get(key) as TileEntry + if entry == null or not entry.queued or entry.texture != null or not _entry_has_live_watchers(entry): + if entry != null: + entry.queued = false + _set_entry_handle_queue_state(entry, false) + if entry.texture == null and not entry.building and not _entry_has_live_watchers(entry): + _evict_entry(entry) + continue + kept.append(key) + queue = kept + if queue.size() < MAX_QUEUED_BUILDS and not _waiting_handles.is_empty(): + _wake_waiters() + + func _entry_has_any_handles(entry: TileEntry) -> bool: + for index: int in range(entry.watchers.size() - 1, -1, -1): + var handle: Variant = entry.watchers[index].get_ref() + if handle == null or handle._disposed: + entry.watchers.remove_at(index) + else: + return true + return false + + func _set_entry_handle_queue_state(entry: TileEntry, is_queued: bool) -> void: + for watcher_ref: WeakRef in entry.watchers: + var handle: Variant = watcher_ref.get_ref() + if handle != null: + handle._enqueued = is_queued + + func _handle_is_interested(handle: StageSurfaceCache) -> bool: + return handle != null and not handle._disposed and handle.wanted and _handle_is_visible(handle) + + func _handle_is_visible(handle: StageSurfaceCache) -> bool: + if handle == null or handle._disposed: + return false + var owner: Variant = handle._owner_ref.get_ref() if handle._owner_ref != null else null + if owner == null or not is_instance_valid(owner) or not (owner is Node2D): + return false + if not owner.is_inside_tree() or not owner.is_visible_in_tree(): + return false + var visible_rect: Rect2 = RENDERER._visible_local_rect(owner) + if handle.is_pattern: + if not owner.has_method("get_local_bounds"): + return false + var local_bounds: Variant = owner.call("get_local_bounds") + if not (local_bounds is Rect2): + return false + var bounds: Rect2 = local_bounds + return bounds.intersects(visible_rect) + return handle.source_rect.intersects(visible_rect) or handle.core_rect.intersects(visible_rect) + + func _add_waiter(handle: StageSurfaceCache) -> void: + for waiter_ref: WeakRef in _waiting_handles: + if waiter_ref.get_ref() == handle: + return + _waiting_handles.append(weakref(handle)) + + func _wake_waiters() -> void: + if _waiting_handles.is_empty() or _disposed: + return + var pending: Array[WeakRef] = _waiting_handles + _waiting_handles = [] + for waiter_ref: WeakRef in pending: + var handle: Variant = waiter_ref.get_ref() + if handle == null or handle._disposed or not _handle_is_interested(handle): + continue + queue_handle(handle) + + func _on_retired_frame_post_draw() -> void: + _retired_textures.clear() + retired_bytes = 0 + pattern_retired_bytes = 0 + _retire_callback_pending = false + _wake_waiters() + StageSurfaceCache._service_pools() + + +var source_rect: Rect2 +var source_scale: Vector2 +var core_rect: Rect2 +var ready_for_display: bool = false +var wanted: bool = false +var last_used_frame: int = 0 +var build_usec: int = 0 +var _enqueued: bool = false +var _building: bool = false +var _pending_arguments: Array = [] +var _owner_ref: WeakRef +var _pool_ref: WeakRef +var _entry_ref: WeakRef +var _cache_key: String = "" +var _disposed: bool = false +var _finish: StringName +var _tint: Color +var _origin: Vector2 +var is_pattern: bool = false + +var size: Vector2i: + get: + var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null + return entry.size if entry != null else Vector2i.ZERO + +var render_target_update_mode: int: + get: + return SubViewport.UPDATE_DISABLED + + +func attach_to(owner_node: Node) -> void: + _owner_ref = weakref(owner_node) + var pool: BakePool = _get_or_create_pool(owner_node) + _pool_ref = weakref(pool) if pool != null else null + + +func dispose() -> void: + wanted = false + _disposed = true + var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null + var pool: BakePool = _pool_ref.get_ref() as BakePool if _pool_ref != null else null + if entry != null and pool != null: + pool._unwatch_handle(entry, self) + _entry_ref = null + ready_for_display = false + _building = false + _enqueued = false + + +func get_texture() -> Texture2D: + var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null + return entry.texture if entry != null else null + + +func request_build(core: Rect2, rect: Rect2, pixel_scale: Vector2, wall: bool, finish: StringName, tint: Color, origin: Vector2, detail_view_size: Vector2) -> void: + if _disposed: + return + is_pattern = false + core_rect = core + source_rect = rect + source_scale = pixel_scale + wanted = true + _finish = finish + _tint = tint + _origin = origin + var canonical_position: Vector2 = _snap_canonical_position(rect.position + origin, pixel_scale) + var canonical_rect := Rect2(canonical_position, rect.size) + _pending_arguments = [canonical_rect, pixel_scale, wall, finish, tint, detail_view_size] + _cache_key = _make_cache_key(canonical_rect, pixel_scale, wall, finish, tint, detail_view_size) + queue_if_needed() + + +func request_pattern(wall: bool, finish: StringName, tint: Color, period: Vector2) -> void: + if _disposed: + return + var safe_period := Vector2(maxf(1.0, period.x), maxf(1.0, period.y)) + is_pattern = true + core_rect = Rect2(Vector2.ZERO, safe_period) + source_rect = core_rect + source_scale = Vector2.ONE * PATTERN_PIXEL_SCALE + wanted = true + _finish = finish + _tint = tint + _origin = Vector2.ZERO + _pending_arguments = [core_rect, source_scale, wall, finish, tint, safe_period, true, safe_period] + _cache_key = _make_pattern_cache_key(wall, finish, tint, safe_period) + queue_if_needed() + + +func ensure_queued() -> void: + if _disposed: + return + queue_if_needed() + + +func queue_if_needed() -> void: + var pool: BakePool = _pool_ref.get_ref() as BakePool if _pool_ref != null else null + if pool == null: + var owner: Variant = _owner_ref.get_ref() if _owner_ref != null else null + if owner != null and is_instance_valid(owner): + pool = _get_or_create_pool(owner) + _pool_ref = weakref(pool) if pool != null else null + if pool != null: + pool.queue_handle(self) + + +func _on_entry_ready(entry: TileEntry) -> void: + if _disposed: + return + ready_for_display = true + _building = false + _enqueued = false + build_usec = entry.build_usec + updated.emit() + + +func _on_entry_failed(entry: TileEntry) -> void: + if _disposed: + return + if _entry_ref != null and _entry_ref.get_ref() == entry: + ready_for_display = false + _building = false + _enqueued = false + updated.emit() + + +func _on_entry_evicted(entry: TileEntry) -> void: + if _entry_ref != null and _entry_ref.get_ref() == entry: + _entry_ref = null + ready_for_display = false + _building = false + _enqueued = false + updated.emit() + + +static func build_next() -> void: + _service_pools() + + +static func _service_pools() -> void: + var frame: int = Engine.get_process_frames() + if frame == _last_service_frame: + return + _last_service_frame = frame + var active_pool: Variant = _active_pool_ref.get_ref() if _active_pool_ref != null else null + if active_pool != null and is_instance_valid(active_pool): + if not active_pool._capture_pending: + active_pool._start_next_build() + return + _active_pool_ref = null + for scope_id: Variant in _pool_refs.keys(): + var pool: Variant = _pool_refs[scope_id].get_ref() + if pool == null or not is_instance_valid(pool): + _pool_refs.erase(scope_id) + continue + if pool._start_next_build(): + return + + +static func _get_or_create_pool(owner_node: Node) -> BakePool: + if owner_node == null or not is_instance_valid(owner_node) or owner_node.get_tree() == null: + return null + var scope: Node = _get_scope_node(owner_node) + if scope == null or not is_instance_valid(scope): + return null + var scope_id: int = scope.get_instance_id() + var pool_ref: WeakRef = _pool_refs.get(scope_id) as WeakRef + var existing: BakePool = pool_ref.get_ref() as BakePool if pool_ref != null else null + if existing != null and is_instance_valid(existing): + return existing + var pool := BakePool.new() + pool.name = "StageSurfaceBakePool" + pool.scope_id = scope_id + scope.add_child(pool) + _pool_refs[scope_id] = weakref(pool) + return pool + + +static func _get_scope_node(owner_node: Node) -> Node: + var tree: SceneTree = owner_node.get_tree() + if tree == null: + return null + var current_scene: Node = tree.current_scene + if current_scene != null and (current_scene == owner_node or current_scene.is_ancestor_of(owner_node)): + return current_scene + var root: Window = tree.root + var scope: Node = owner_node + while scope.get_parent() != null and scope.get_parent() != root: + scope = scope.get_parent() + return scope + + +static func _forget_pool(scope_id: int, pool: BakePool) -> void: + var pool_ref: WeakRef = _pool_refs.get(scope_id) as WeakRef + if pool_ref != null and pool_ref.get_ref() == pool: + _pool_refs.erase(scope_id) + + +static func _make_cache_key(rect: Rect2, pixel_scale: Vector2, wall: bool, finish: StringName, tint: Color, detail_view_size: Vector2) -> String: + return var_to_str([wall, finish, tint, rect.position.x, rect.position.y, rect.size.x, rect.size.y, pixel_scale.x, pixel_scale.y, detail_view_size.x, detail_view_size.y]) + + +static func _make_pattern_cache_key(wall: bool, finish: StringName, tint: Color, period: Vector2) -> String: + return var_to_str([&"periodic_style", PATTERN_CACHE_VERSION, wall, finish, tint, period.x, period.y, PATTERN_PIXEL_SCALE, &"mipmaps"]) + + +static func _snap_canonical_position(position: Vector2, pixel_scale: Vector2) -> Vector2: + var scale_x: float = maxf(0.001, pixel_scale.x) + var scale_y: float = maxf(0.001, pixel_scale.y) + return Vector2(roundf(position.x * scale_x) / scale_x, roundf(position.y * scale_y) / scale_y) + + +static func get_stats(owner_node: Node = null) -> Dictionary: + if owner_node != null and is_instance_valid(owner_node): + var scope: Node = _get_scope_node(owner_node) + if scope != null: + var pool_ref: WeakRef = _pool_refs.get(scope.get_instance_id()) as WeakRef + var pool: BakePool = pool_ref.get_ref() as BakePool if pool_ref != null else null + return pool._stats() if pool != null else _empty_stats() + var totals: Dictionary = _empty_stats() + for scope_id: Variant in _pool_refs.keys(): + var pool: BakePool = _pool_refs[scope_id].get_ref() as BakePool + if pool == null or not is_instance_valid(pool): + continue + var stats: Dictionary = pool._stats() + for key: String in totals.keys(): + if key == "active_build": + if not str(stats[key]).is_empty(): + totals[key] = stats[key] + elif key == "last_build_usec" or key == "vector_submission_usec" or key == "budget_pressure_frame": + totals[key] = maxi(int(totals[key]), int(stats[key])) + else: + totals[key] = int(totals[key]) + int(stats[key]) + return totals + + +static func _empty_stats() -> Dictionary: + return { + "bytes": 0, + "resident_bytes": 0, + "retired_bytes": 0, + "entries": 0, + "queued": 0, + "bakers": 0, + "live_vector_bakers": 0, + "live_vector_items": 0, + "builds": 0, + "cache_hits": 0, + "evictions": 0, + "refusals": 0, + "budget_pressure": 0, + "budget_pressure_frame": -1, + "budget_blocked_requests": 0, + "waiting_handles": 0, + "readbacks": 0, + "pattern_builds": 0, + "pattern_readbacks": 0, + "pattern_resident_bytes": 0, + "pattern_retired_bytes": 0, + "last_build_usec": 0, + "vector_submission_usec": 0, + "active_build": "" + } + + +static func texture_size_for(local_size: Vector2, pixel_scale: Vector2) -> Vector2i: + var safe_size := Vector2(maxf(1.0, local_size.x), maxf(1.0, local_size.y)) + var safe_scale := Vector2(maxf(0.001, pixel_scale.x), maxf(0.001, pixel_scale.y)) + var pixels: Vector2 = safe_size * safe_scale + var reduction: float = maxf(1.0, maxf(pixels.x, pixels.y) / float(MAX_TEXTURE_EDGE)) + reduction = maxf(reduction, sqrt(pixels.x * pixels.y / MAX_TEXTURE_PIXELS)) + return Vector2i(maxi(1, floori(pixels.x / reduction)), maxi(1, floori(pixels.y / reduction))) diff --git a/scripts/stage/scenery/stage_surface_cache.gd.uid b/scripts/stage/scenery/stage_surface_cache.gd.uid new file mode 100644 index 0000000..3ae3420 --- /dev/null +++ b/scripts/stage/scenery/stage_surface_cache.gd.uid @@ -0,0 +1 @@ +uid://cwcf7h5yf3xi0 diff --git a/scripts/stage/scenery/stage_surface_preview.gd b/scripts/stage/scenery/stage_surface_preview.gd index e44b2c2..34e2cc1 100644 --- a/scripts/stage/scenery/stage_surface_preview.gd +++ b/scripts/stage/scenery/stage_surface_preview.gd @@ -7,6 +7,8 @@ const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_ren var surface_kind: StringName = &"wall" var finish_id: StringName = &"finish_wall_paint" var tint: Color = Color("#5A9D70") +var corner_radius: float = 0.0 +var outside_color: Color = Color("#202633FA") func configure(kind: StringName, finish: StringName, color: Color) -> void: @@ -17,6 +19,12 @@ func configure(kind: StringName, finish: StringName, color: Color) -> void: queue_redraw() +func set_rounded_frame(radius: float, background: Color) -> void: + corner_radius = maxf(0.0, radius) + outside_color = background + queue_redraw() + + func _draw() -> void: var rect: Rect2 = Rect2(Vector2(3.0, 3.0), size - Vector2(6.0, 6.0)) if rect.size.x <= 0.0 or rect.size.y <= 0.0: @@ -25,4 +33,49 @@ func _draw() -> void: SURFACE_RENDERER.draw_background(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false) else: SURFACE_RENDERER.draw_wall(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false) - draw_rect(rect, Color("#F5F7FA"), false, 2.0) + var radius: float = minf(corner_radius, minf(rect.size.x, rect.size.y) * 0.5) + if radius > 0.0: + var wall: bool = surface_kind != &"background_ground" + var base_color: Color = SURFACE_RENDERER.surface_base_color(finish_id, tint, wall) + _draw_rounded_corner(rect, radius, base_color, outside_color, 0) + _draw_rounded_corner(rect, radius, base_color, outside_color, 1) + _draw_rounded_corner(rect, radius, base_color, outside_color, 2) + _draw_rounded_corner(rect, radius, base_color, outside_color, 3) + var outline := StyleBoxFlat.new() + outline.bg_color = Color.TRANSPARENT + outline.draw_center = false + outline.border_color = Color("#F5F7FA") + outline.set_border_width_all(2) + outline.set_corner_radius_all(roundi(radius)) + draw_style_box(outline, rect) + else: + draw_rect(rect, Color("#F5F7FA"), false, 2.0) + + +func _draw_rounded_corner(rect: Rect2, radius: float, base_color: Color, background: Color, corner: int) -> void: + var square := Rect2() + var center := Vector2.ZERO + var start_angle: float = 0.0 + match corner: + 0: + square = Rect2(rect.position, Vector2.ONE * radius) + center = rect.position + Vector2.ONE * radius + start_angle = PI + 1: + square = Rect2(Vector2(rect.end.x - radius, rect.position.y), Vector2.ONE * radius) + center = Vector2(rect.end.x - radius, rect.position.y + radius) + start_angle = -PI * 0.5 + 2: + square = Rect2(rect.end - Vector2.ONE * radius, Vector2.ONE * radius) + center = rect.end - Vector2.ONE * radius + start_angle = 0.0 + _: + square = Rect2(Vector2(rect.position.x, rect.end.y - radius), Vector2.ONE * radius) + center = Vector2(rect.position.x + radius, rect.end.y - radius) + start_angle = PI * 0.5 + draw_rect(square, background, true) + var arc := PackedVector2Array([center]) + for point_index: int in 13: + var angle: float = start_angle + PI * 0.5 * float(point_index) / 12.0 + arc.append(center + Vector2(cos(angle), sin(angle)) * radius) + draw_colored_polygon(arc, base_color) diff --git a/scripts/stage/scenery/stage_surface_renderer.gd b/scripts/stage/scenery/stage_surface_renderer.gd index 0f2efd7..6fccb31 100644 --- a/scripts/stage/scenery/stage_surface_renderer.gd +++ b/scripts/stage/scenery/stage_surface_renderer.gd @@ -13,12 +13,37 @@ const WALL_BRICK_WIDTH: float = 100.0 const WALL_BRICK_HEIGHT: float = 38.0 const MAX_WALL_BRICK_CELLS: int = 1200 const MAX_BACKGROUND_CELLS: int = 12000 +## Finite material variations repeat independently of wall size and camera travel. +## Even row periods preserve staggered brick/plank courses across the seam. +const BRICK_PATTERN_CELLS := Vector2i(8, 8) +const WOOD_PATTERN_CELLS := Vector2i(3, 6) +const GRASS_PATTERN_CELLS := Vector2i(16, 16) +const CONCRETE_PATTERN_CELLS := Vector2i(4, 4) +const CARPET_PATTERN_CELLS := Vector2i(16, 16) +## Conservative work estimate to avoid an expensive first placement redraw. +## Runtime timing in StageRectSurface also catches slower machines. +const PREVIEW_WORK_BUDGET: float = 6000.0 +const MAX_PREVIEW_TEXTURE_CELLS: int = 128 static func is_wall_finish(id: StringName) -> bool: return WALL_FINISHES.has(id) +static func pattern_size_for(finish: StringName) -> Vector2: + match finish: + &"finish_wall_brick": return Vector2(BRICK_PATTERN_CELLS) * Vector2(WALL_BRICK_WIDTH, WALL_BRICK_HEIGHT) + &"finish_wall_wood": return Vector2(WOOD_PATTERN_CELLS) * Vector2(320.0, 64.0) + &"finish_background_grass": return Vector2(GRASS_PATTERN_CELLS) * Vector2(44.0, 36.0) + &"finish_background_concrete": return Vector2(CONCRETE_PATTERN_CELLS) * Vector2(280.0, 190.0) + &"finish_background_carpet": return Vector2(CARPET_PATTERN_CELLS) * Vector2(34.0, 26.0) + _: return Vector2.ZERO + + +static func _pattern_seed(row: int, column: int, cells: Vector2i, row_factor: int, column_factor: int, offset: int = 0) -> int: + return posmod(row, cells.y) * row_factor + posmod(column, cells.x) * column_factor + offset + + static func is_background_finish(id: StringName) -> bool: return BACKGROUND_FINISHES.has(id) @@ -38,6 +63,28 @@ static func is_ground_finish(id: StringName) -> bool: return GROUND_FINISHES.has(id) or id == &"finish_ground_legacy" +static func surface_base_color(finish: StringName, tint: Color, wall: bool) -> Color: + if finish == &"finish_ground_legacy": + return Color("#468B42") + if finish.ends_with("brick"): + return Color("#D2CBAF") if wall else Color("#994D35") + if finish.ends_with("wood"): + return Color("#9A673C") if wall else Color("#B88951") + if finish == &"finish_ground_grass" or finish == &"finish_background_grass": + return Color("#72A84C") + if finish == &"finish_background_concrete": + return Color("#A6AAA3") + if finish == &"finish_background_carpet": + return tint + if finish == &"finish_ground_dirt": + return Color("#8E633E") + if finish == &"finish_ground_sidewalk": + return Color("#B7B9B2") + if finish == &"finish_ground_asphalt": + return Color("#505A62") + return tint + + static func draw_wall( canvas: CanvasItem, rect: Rect2, @@ -45,9 +92,12 @@ static func draw_wall( tint: Color, world_origin: Vector2, drawing_scale: Vector2 = Vector2.ONE, - cull_to_viewport: bool = true + cull_to_viewport: bool = true, + interactive_preview: bool = false, + force_simple_preview: bool = false, + detail_view_size: Vector2 = Vector2.ZERO ) -> void: - _draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport) + _draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport, interactive_preview, force_simple_preview, detail_view_size) static func draw_ground( @@ -68,9 +118,11 @@ static func draw_background( tint: Color, world_origin: Vector2, drawing_scale: Vector2 = Vector2.ONE, - cull_to_viewport: bool = true + cull_to_viewport: bool = true, + interactive_preview: bool = false, + force_simple_preview: bool = false ) -> void: - _draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport) + _draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport, interactive_preview, force_simple_preview) static func _draw_surface( @@ -82,66 +134,47 @@ static func _draw_surface( drawing_scale: Vector2, wall: bool, background: bool, - cull_to_viewport: bool + cull_to_viewport: bool, + interactive_preview: bool = false, + force_simple_preview: bool = false, + detail_view_size: Vector2 = Vector2.ZERO ) -> void: var normalized := rect.abs() if normalized.size.x <= 0.0 or normalized.size.y <= 0.0: return var motif_scale: Vector2 = _normalized_scale(drawing_scale) - var base: Color = tint - if finish == &"finish_ground_legacy": - base = Color("#468B42") - elif finish.ends_with("brick"): - base = Color("#D2CBAF") if wall else Color("#994D35") - elif finish.ends_with("wood"): - base = Color("#9A673C") if wall else Color("#B88951") - elif finish == &"finish_ground_grass" or finish == &"finish_background_grass": - base = Color("#72A84C") - elif finish == &"finish_background_concrete": - base = Color("#A6AAA3") - elif finish == &"finish_background_carpet": - base = tint - elif finish == &"finish_ground_dirt": - base = Color("#8E633E") - elif finish == &"finish_ground_sidewalk": - base = Color("#B7B9B2") - elif finish == &"finish_ground_asphalt": - base = Color("#505A62") + var base: Color = surface_base_color(finish, tint, wall) canvas.draw_rect(normalized, base, true) + var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized + var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized + var simplified: bool = interactive_preview and (force_simple_preview or needs_light_preview(finish, pattern_rect.size, motif_scale)) if finish.ends_with("brick"): - var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized - var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0: var detail_size: Vector2 = view_rect.size if not view_rect.size.is_zero_approx() else normalized.size + if detail_view_size.x > 0.0 and detail_view_size.y > 0.0: + detail_size = detail_view_size var detail_scale: float = brick_detail_scale_for_view(detail_size, motif_scale, wall) if wall: - _draw_wall_bricks(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint) + _draw_wall_bricks(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint, simplified) else: _draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale) elif finish.ends_with("wood"): if wall: - var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized - var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0: - var detail_scale: float = wood_detail_scale_for_view(pattern_rect.size, motif_scale) - _draw_wall_wood(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint) + var detail_size: Vector2 = detail_view_size if detail_view_size.x > 0.0 and detail_view_size.y > 0.0 else pattern_rect.size + var detail_scale: float = wood_detail_scale_for_view(detail_size, motif_scale) + _draw_wall_wood(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint, simplified) else: _draw_wood(canvas, normalized, world_origin, base, motif_scale) elif finish == &"finish_background_grass": - var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized - var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0: - _draw_background_grass(canvas, pattern_rect, world_origin, motif_scale) + _draw_background_grass(canvas, pattern_rect, world_origin, motif_scale, simplified) elif finish == &"finish_background_concrete": - var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized - var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0: - _draw_background_concrete(canvas, pattern_rect, world_origin, motif_scale) + _draw_background_concrete(canvas, pattern_rect, world_origin, motif_scale, simplified) elif finish == &"finish_background_carpet": - var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized - var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0: - _draw_background_carpet(canvas, pattern_rect, world_origin, motif_scale, tint) + _draw_background_carpet(canvas, pattern_rect, world_origin, motif_scale, tint, simplified) elif finish == &"finish_ground_grass": _draw_grass(canvas, normalized, world_origin, motif_scale) elif finish == &"finish_ground_dirt": @@ -154,6 +187,39 @@ static func _draw_surface( push_warning("StageSurfaceRenderer: unknown finish '%s'; drawing paint." % String(finish)) +static func needs_light_preview(finish: StringName, visible_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> bool: + if visible_size.x <= 0.0 or visible_size.y <= 0.0: + return false + var cell_size: Vector2 + var work_per_cell: float + match finish: + &"finish_wall_brick": + cell_size = Vector2(WALL_BRICK_WIDTH, WALL_BRICK_HEIGHT) + work_per_cell = 240.0 + &"finish_wall_wood": + cell_size = Vector2(320.0, 64.0) + work_per_cell = 750.0 + &"finish_background_grass": + cell_size = Vector2(44.0, 36.0) + work_per_cell = 70.0 + &"finish_background_concrete": + cell_size = Vector2(280.0, 190.0) + work_per_cell = 20.0 + &"finish_background_carpet": + cell_size = Vector2(34.0, 26.0) + work_per_cell = 35.0 + _: + return false + var world_size: Vector2 = visible_size * _normalized_scale(motif_scale) + var cells: float = (ceil(world_size.x / cell_size.x) + 1.0) * (ceil(world_size.y / cell_size.y) + 1.0) + return cells * work_per_cell > PREVIEW_WORK_BUDGET + + +static func _preview_texture_stride(size: Vector2, motif_scale: Vector2, cell_size: Vector2) -> int: + var cells: Vector2 = size * motif_scale / cell_size + Vector2(3.0, 3.0) + return maxi(1, ceili(sqrt(cells.x * cells.y / float(MAX_PREVIEW_TEXTURE_CELLS)))) + + static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE, wall: bool = false) -> float: var scale: float = 1.0 var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y)) @@ -185,7 +251,7 @@ static func wood_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 return scale -static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void: +static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color, simplified: bool = false) -> void: const BOARD_HEIGHT: float = 64.0 const PLANK_WIDTH: float = 320.0 var safe_scale: Vector2 = _normalized_scale(motif_scale) @@ -234,14 +300,15 @@ static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, mo Vector2(plank_local_left, board_local_top + board_world_height / safe_scale.y), _color_with_alpha(seam_color, 0.88), 2.0 / safe_scale.x ) - _draw_wall_wood_grain( - canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height, - origin, safe_scale, row, column, grain_color, highlight_color, detail_scale, tint, natural - ) - _draw_wall_wood_knot( - canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height, - origin, safe_scale, row, column, detail_scale, tint, natural - ) + if not simplified: + _draw_wall_wood_grain( + canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height, + origin, safe_scale, row, column, grain_color, highlight_color, detail_scale, tint, natural + ) + _draw_wall_wood_knot( + canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height, + origin, safe_scale, row, column, detail_scale, tint, natural + ) _draw_clipped_line( canvas, rect, Vector2(rect.position.x, board_local_top), Vector2(rect.end.x, board_local_top), @@ -274,7 +341,7 @@ static func _draw_wall_wood_grain( ) -> void: const TRACK_COUNT: int = 5 const SAMPLES: int = 32 - var seed: int = row * 73856093 + column * 19349663 + var seed: int = _pattern_seed(row, column, WOOD_PATTERN_CELLS, 73856093, 19349663) for track: int in TRACK_COUNT: var track_seed: int = seed + track * 83492791 var lane: float = 0.16 + _wood_hash(track_seed) * 0.68 @@ -312,7 +379,7 @@ static func _draw_wall_wood_knot( tint: Color, natural: Color ) -> void: - var seed: int = row * 92821 + column * 68917 + 31 + var seed: int = _pattern_seed(row, column, WOOD_PATTERN_CELLS, 92821, 68917, 31) if _wood_hash(seed) > 0.24: return var center_world := Vector2( @@ -376,7 +443,7 @@ static func _material_color(source: Color, selected: Color, natural: Color) -> C return Color.from_hsv(selected.h, saturation, value, source.a) -static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void: +static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, simplified: bool = false) -> void: const CELL_WIDTH: float = 44.0 const CELL_HEIGHT: float = 36.0 var world_left: float = rect.position.x * motif_scale.x + origin.x @@ -388,12 +455,16 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect var first_row: int = floori(world_top / CELL_HEIGHT) - 1 var last_row: int = floori(world_bottom / CELL_HEIGHT) + 1 var remaining: int = MAX_BACKGROUND_CELLS - for row: int in range(first_row, last_row + 1): - for column: int in range(first_column, last_column + 1): + var stride: int = _preview_texture_stride(rect.size, motif_scale, Vector2(CELL_WIDTH, CELL_HEIGHT)) if simplified else 1 + # Sample the same world cells at a lower density, including in negative coordinates. + first_row = floori(float(first_row) / float(stride)) * stride + first_column = floori(float(first_column) / float(stride)) * stride + for row: int in range(first_row, last_row + 1, stride): + for column: int in range(first_column, last_column + 1, stride): if remaining <= 0: return remaining -= 1 - var seed: int = row * 73856093 + column * 19349663 + 197 + var seed: int = _pattern_seed(row, column, GRASS_PATTERN_CELLS, 73856093, 19349663, 197) var center_world := Vector2((float(column) + 0.5) * CELL_WIDTH, (float(row) + 0.5) * CELL_HEIGHT) center_world += Vector2((_wood_hash(seed) - 0.5) * 34.0, (_wood_hash(seed + 1) - 0.5) * 26.0) var center := Vector2((center_world.x - origin.x) / motif_scale.x, (center_world.y - origin.y) / motif_scale.y) @@ -403,7 +474,7 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect var highlight: Color = Color("#C3DA74").darkened(_wood_hash(seed + 3) * 0.24) var radius: float = 1.8 + _wood_hash(seed + 4) * 1.6 canvas.draw_circle(center, radius, _color_with_alpha(tuft_color, 0.56)) - for blade: int in 4: + for blade: int in (1 if simplified else 4): var blade_seed: int = seed + blade * 47 + 11 var x_offset: float = (_wood_hash(blade_seed) - 0.5) * 9.0 / motif_scale.x var y_offset: float = (_wood_hash(blade_seed + 1) - 0.5) * 7.0 / motif_scale.y @@ -413,7 +484,7 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect _draw_clipped_line(canvas, rect, start, tip, _color_with_alpha(highlight, 0.78), 1.25 / motif_scale.y) -static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void: +static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, simplified: bool = false) -> void: const SLAB_WIDTH: float = 280.0 const SLAB_HEIGHT: float = 190.0 var world_left: float = rect.position.x * motif_scale.x + origin.x @@ -431,11 +502,13 @@ static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: V var x: float = float(column) * SLAB_WIDTH var local_x: float = (x - origin.x) / motif_scale.x _draw_clipped_line(canvas, rect, Vector2(local_x, rect.position.y), Vector2(local_x, rect.end.y), Color("#68757A", 0.32), column_gap) + if simplified: + continue for row: int in range(first_row, mini(last_row + 1, first_row + MAX_BACKGROUND_CELLS)): if remaining <= 0: return remaining -= 1 - var seed: int = column * 92821 + row * 68917 + 51 + var seed: int = _pattern_seed(row, column, CONCRETE_PATTERN_CELLS, 68917, 92821, 51) var world_point := Vector2(x + 16.0 + _wood_hash(seed) * (SLAB_WIDTH - 32.0), float(row) * SLAB_HEIGHT + 14.0 + _wood_hash(seed + 1) * (SLAB_HEIGHT - 28.0)) var point := Vector2((world_point.x - origin.x) / motif_scale.x, (world_point.y - origin.y) / motif_scale.y) if rect.grow(3.0).has_point(point): @@ -447,25 +520,28 @@ static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: V _draw_clipped_line(canvas, rect, Vector2(rect.position.x, local_y), Vector2(rect.end.x, local_y), Color("#68757A", 0.32), row_gap) -static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, tint: Color) -> void: +static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, tint: Color, simplified: bool = false) -> void: const CELL_WIDTH: float = 34.0 const CELL_HEIGHT: float = 26.0 var world_left: float = rect.position.x * motif_scale.x + origin.x var world_right: float = rect.end.x * motif_scale.x + origin.x var world_top: float = rect.position.y * motif_scale.y + origin.y var world_bottom: float = rect.end.y * motif_scale.y + origin.y - var first_column: int = floori(world_left / CELL_WIDTH) + var first_column: int = floori(world_left / CELL_WIDTH) - 1 var last_column: int = floori(world_right / CELL_WIDTH) var first_row: int = floori(world_top / CELL_HEIGHT) var last_row: int = floori(world_bottom / CELL_HEIGHT) var remaining: int = MAX_BACKGROUND_CELLS var natural: Color = default_tint(&"finish_background_carpet") - for row: int in range(first_row, last_row + 1): - for column: int in range(first_column, last_column + 1): + var stride: int = _preview_texture_stride(rect.size, motif_scale, Vector2(CELL_WIDTH, CELL_HEIGHT)) if simplified else 1 + first_row = floori(float(first_row) / float(stride)) * stride + first_column = floori(float(first_column) / float(stride)) * stride + for row: int in range(first_row, last_row + 1, stride): + for column: int in range(first_column, last_column + 1, stride): if remaining <= 0: return remaining -= 1 - var seed: int = row * 92821 + column * 68917 + 71 + var seed: int = _pattern_seed(row, column, CARPET_PATTERN_CELLS, 92821, 68917, 71) var cell_left: float = float(column) * CELL_WIDTH var cell_top: float = float(row) * CELL_HEIGHT for fiber: int in 2: @@ -479,7 +555,7 @@ static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vec _draw_clipped_line(canvas, rect, start, finish, _color_with_alpha(fiber_color, 0.42), 0.85 / motif_scale.y) -static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void: +static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color, interactive_preview: bool = false) -> void: var brick_world_w: float = WALL_BRICK_WIDTH * detail_scale var brick_world_h: float = WALL_BRICK_HEIGHT * detail_scale var brick_w: float = brick_world_w / motif_scale.x @@ -508,7 +584,7 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, var column_count: int = int(ceil(rect.size.x * motif_scale.x / brick_world_w)) + 2 for column_offset: int in column_count: var column: int = first_column + column_offset - var seed: int = row * 92821 + column * 68917 + 31 + var seed: int = _pattern_seed(row, column, BRICK_PATTERN_CELLS, 92821, 68917, 31) var brick_world_left: float = float(column) * brick_world_w + stagger_world var brick_local_left: float = (brick_world_left - origin.x) / motif_scale.x var width_variation: float = 0.92 + _wood_hash(seed + 1) * 0.14 @@ -526,6 +602,15 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, continue if face_rect.end.y < rect.position.y or face_rect.position.y > rect.end.y: continue + var color_index: int = int(_wood_hash(seed + 5) * float(palette.size())) + var face_color: Color = palette[color_index] + if interactive_preview: + # Keep the committed pattern's world phase, dimensions, jitter, and palette. + # Fine outlines and texture are expensive to regenerate on every drag frame. + var visible_face: Rect2 = face_rect.intersection(rect) + if visible_face.size.x > 0.0 and visible_face.size.y > 0.0: + canvas.draw_rect(visible_face, face_color) + continue var roughness := Vector2(1.7 * detail_scale / motif_scale.x, 1.2 * detail_scale / motif_scale.y) var outline: PackedVector2Array = _rough_brick_outline(face_rect, seed, roughness) var shadow_offset := Vector2(1.3 * detail_scale / motif_scale.x, 1.8 * detail_scale / motif_scale.y) @@ -535,8 +620,6 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, var face_points: PackedVector2Array = _clip_polygon_to_rect(outline, rect) if face_points.size() < 3: continue - var color_index: int = int(_wood_hash(seed + 5) * float(palette.size())) - var face_color: Color = palette[color_index] canvas.draw_colored_polygon(face_points, face_color) var closed_outline := face_points.duplicate() closed_outline.append(face_points[0]) @@ -628,12 +711,34 @@ static func _offset_polygon(points: PackedVector2Array, offset: Vector2) -> Pack static func _clip_polygon_to_rect(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array: + var fully_inside: bool = true + for point: Vector2 in points: + if not rect.has_point(point): + fully_inside = false + break + if fully_inside: + return points var clipped := points for edge: int in 4: clipped = _clip_polygon_to_edge(clipped, rect, edge) if clipped.is_empty(): return clipped - return clipped + # Clipping at a corner can produce repeated vertices or a zero-area sliver. + # These cannot be triangulated, even when the array still has three entries. + var cleaned := PackedVector2Array() + for point: Vector2 in clipped: + if cleaned.is_empty() or cleaned[cleaned.size() - 1].distance_squared_to(point) > 0.000001: + cleaned.append(point) + if cleaned.size() > 1 and cleaned[0].distance_squared_to(cleaned[cleaned.size() - 1]) <= 0.000001: + cleaned.remove_at(cleaned.size() - 1) + if cleaned.size() < 3: + return PackedVector2Array() + var area: float = 0.0 + for index: int in range(1, cleaned.size() - 1): + area += (cleaned[index] - cleaned[0]).cross(cleaned[index + 1] - cleaned[0]) + if absf(area) <= 0.001 or Geometry2D.triangulate_polygon(cleaned).is_empty(): + return PackedVector2Array() + return cleaned static func _clip_polygon_to_edge(points: PackedVector2Array, rect: Rect2, edge: int) -> PackedVector2Array: diff --git a/scripts/stage/scenery/stage_wall_panel.gd b/scripts/stage/scenery/stage_wall_panel.gd index f8a9cf2..ff1448a 100644 --- a/scripts/stage/scenery/stage_wall_panel.gd +++ b/scripts/stage/scenery/stage_wall_panel.gd @@ -2,7 +2,6 @@ class_name StageWallPanel extends "res://scripts/stage/scenery/stage_rect_surface.gd" ## Finite decorative wall panel with world-scale finish motifs. -const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd") ## Saved source-wall anchor offset, retained to preserve erased-fragment metadata. ## Motif drawing uses the fragment's current world origin so its brick courses stay aligned. @@ -23,4 +22,4 @@ func get_surface_draw_origin_world() -> Vector2: func _draw() -> void: - SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world()) + _draw_surface(true) diff --git a/scripts/stage/stage_scene_io.gd b/scripts/stage/stage_scene_io.gd index a312983..8e08715 100644 --- a/scripts/stage/stage_scene_io.gd +++ b/scripts/stage/stage_scene_io.gd @@ -307,6 +307,8 @@ static func _validate_root(data: Dictionary) -> Dictionary: return {"ok": false, "error": "Background ground size or finish is invalid."} if not _valid_color(entry.get("color", "")): return {"ok": false, "error": "Background ground color is invalid."} + if entry.has("motif_origin_offset") and not is_vector_data(entry.get("motif_origin_offset")): + return {"ok": false, "error": "Background ground motif origin is invalid."} elif String(entry.get("type", "")) == "scenery_artwork": var asset_id: Variant = entry.get("asset_id", "") if not asset_id is String or String(asset_id).strip_edges().is_empty(): diff --git a/tests/test_phase19_surface_followup.gd b/tests/test_phase19_surface_followup.gd index 1722b27..1e8a1d1 100644 --- a/tests/test_phase19_surface_followup.gd +++ b/tests/test_phase19_surface_followup.gd @@ -447,7 +447,7 @@ func _test_selector_setup_routes(controller: StageSceneryController) -> void: await process_frame await create_timer(0.2).timeout var ground_ghost: StageRectSurface = controller.get("_ghost") as StageRectSurface - _expect(ground_ghost != null and ground_ghost.panel_size.is_equal_approx(Vector2(800.0, 320.0)), "background-ground placement displays its patterned cursor ghost after production mouse motion") + _expect(ground_ghost != null and ground_ghost.panel_size.is_equal_approx(Vector2(240.0, 280.0)), "background-ground placement starts with a cursor ghost the same size as a wall panel") _capture_root_viewport("phase19_ground_placement.png") controller.exit_tool() selector.hide() diff --git a/tests/test_scenery_brick_preview.gd b/tests/test_scenery_brick_preview.gd new file mode 100644 index 0000000..ed340fb --- /dev/null +++ b/tests/test_scenery_brick_preview.gd @@ -0,0 +1,97 @@ +extends SceneTree +## Focused placement regression and CPU draw-cost comparison for large brick walls. + +const WALL := preload("res://scripts/stage/scenery/stage_wall_panel.gd") +const CONTROLLER := preload("res://scripts/editors/stage/stage_scenery_controller.gd") + +class TimedWall extends StageWallPanel: + var draw_times_usec: Array[int] = [] + + func _draw() -> void: + var started: int = Time.get_ticks_usec() + super._draw() + draw_times_usec.append(Time.get_ticks_usec() - started) + +var _failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(2560, 1440) + var world := Node2D.new() + var ghosts := Node2D.new() + var controller := CONTROLLER.new() as StageSceneryController + root.add_child(world) + root.add_child(ghosts) + root.add_child(controller) + controller.configure(world, ghosts, null) + controller.set_grid_snap(true, 15.0) + var tint := Color("#995544") + controller.begin_wall(&"finish_wall_brick", Vector2(-300.0, -150.0), tint) + var ghost: StageWallPanel = ghosts.get_child(0) as StageWallPanel + _expect(ghost.interactive_preview, "placement uses the inexpensive brick preview") + var press := InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + controller.handle_mouse_button(press, Vector2(-300.0, -150.0), false) + controller.handle_mouse_motion(Vector2(2100.0, 1150.0)) + var preview_bounds: Rect2 = ghost.global_transform * ghost.get_local_bounds() + var release := InputEventMouseButton.new() + release.button_index = MOUSE_BUTTON_LEFT + controller.handle_mouse_button(release, Vector2(2100.0, 1150.0), false) + _expect(world.get_child_count() == 1, "release commits one large wall") + var committed: StageWallPanel = world.get_child(0) as StageWallPanel + _expect(not committed.interactive_preview, "committed wall restores the full brick artwork") + _expect(committed.finish_id == &"finish_wall_brick" and committed.tint == tint, "commit preserves the chosen finish and tint") + _expect(preview_bounds.is_equal_approx(committed.global_transform * committed.get_local_bounds()), "preview and commit use the same snapped bounds across negative world coordinates") + controller.exit_tool() + world.free() + ghosts.free() + controller.free() + + var sample := TimedWall.new() + sample.position = Vector2(1280.0, 720.0) + sample.interactive_preview = true + sample.configure(Vector2(2400.0, 1320.0), &"finish_wall_brick", tint) + root.add_child(sample) + await process_frame + await process_frame + var draw_count: int = sample.draw_times_usec.size() + _expect(draw_count > 0, "large wall executes its drawing callback") + sample.configure(sample.panel_size, sample.finish_id, sample.tint) + await process_frame + await process_frame + _expect(sample.draw_times_usec.size() == draw_count, "unchanged snapped geometry does not rebuild the drawing") + var preview_usec: float = await _measure_redraws(sample) + sample.interactive_preview = false + await process_frame + await process_frame + var detailed_usec: float = await _measure_redraws(sample) + print("Brick draw CPU time (2400 x 1320): preview=%.2f ms, detailed=%.2f ms, speedup=%.1fx" % [preview_usec / 1000.0, detailed_usec / 1000.0, detailed_usec / maxf(1.0, preview_usec)]) + sample.free() + if _failures.is_empty(): + print("PASS: scenery brick placement preview") + else: + for failure: String in _failures: + push_error("FAIL: " + failure) + quit(0 if _failures.is_empty() else 1) + + +func _measure_redraws(sample: TimedWall) -> float: + sample.draw_times_usec.clear() + for _iteration: int in 5: + sample.queue_redraw() + await process_frame + await process_frame + var total_usec: float = 0.0 + for elapsed: int in sample.draw_times_usec: + total_usec += float(elapsed) + return total_usec / maxf(1.0, float(sample.draw_times_usec.size())) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/test_scenery_brick_preview.gd.uid b/tests/test_scenery_brick_preview.gd.uid new file mode 100644 index 0000000..d4da13a --- /dev/null +++ b/tests/test_scenery_brick_preview.gd.uid @@ -0,0 +1 @@ +uid://du36kyv22uneg diff --git a/tests/test_scenery_surface_cache.gd b/tests/test_scenery_surface_cache.gd new file mode 100644 index 0000000..1b42248 --- /dev/null +++ b/tests/test_scenery_surface_cache.gd @@ -0,0 +1,284 @@ +extends SceneTree +## Texture budget, full-detail visual equivalence, reuse, and cache invalidation. + +const CACHE := preload("res://scripts/stage/scenery/stage_surface_cache.gd") +const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") + +class ReferenceSurface extends Node2D: + var finish: StringName + var panel_size := Vector2(320.0, 240.0) + var tint: Color + var simplified: bool = false + + func _draw() -> void: + var rect := Rect2(-panel_size * 0.5, panel_size) + if RENDERER.is_wall_finish(finish): + RENDERER.draw_wall(self, rect, finish, tint, global_position, Vector2.ONE, true, simplified, simplified) + else: + RENDERER.draw_background(self, rect, finish, tint, global_position, Vector2.ONE, true, simplified, simplified) + +var _failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_texture_budget() + _test_clipped_degenerate_polygon() + if DisplayServer.get_name() == "headless": + print("SKIP: texture rendering and invalidation checks require a GUI renderer") + else: + for finish: StringName in [&"finish_wall_brick", &"finish_wall_wood", &"finish_background_grass", &"finish_background_concrete", &"finish_background_carpet"]: + await _test_visual_equivalence(finish) + await _test_visual_equivalence(&"finish_wall_brick", 0.4) + await _test_visual_equivalence(&"finish_wall_wood", 0.4) + await _test_visual_equivalence(&"finish_wall_brick", 0.4, 0.2) + await _test_invalidation() + await _test_camera_tile_budget() + await _test_covered_surfaces() + if _failures.is_empty(): + print("PASS: full-detail scenery surface texture cache") + else: + for failure: String in _failures: + push_error("FAIL: " + failure) + quit(0 if _failures.is_empty() else 1) + + +func _test_texture_budget() -> void: + for extent: Vector2 in [Vector2(320.0, 240.0), Vector2(100000.0, 80000.0), Vector2(100000.0, 12.0), Vector2(1.0, 1.0)]: + var pixels: Vector2i = CACHE.texture_size_for(extent, Vector2(3.0, 3.0)) + _expect(pixels.x > 0 and pixels.y > 0 and pixels.x <= CACHE.MAX_TEXTURE_EDGE and pixels.y <= CACHE.MAX_TEXTURE_EDGE, "cache dimensions stay positive and bounded") + _expect(float(pixels.x) * float(pixels.y) <= CACHE.MAX_TEXTURE_PIXELS, "cache pixel allocation stays bounded") + _expect(CACHE.texture_size_for(Vector2(320.0, 240.0), Vector2(2.0, 2.0)) == Vector2i(640, 480), "ordinary surfaces rasterize at their current screen resolution") + + +func _test_clipped_degenerate_polygon() -> void: + var rect := Rect2(Vector2.ZERO, Vector2(100.0, 100.0)) + var touching := PackedVector2Array([Vector2(-10.0, -10.0), Vector2(0.0, 0.0), Vector2(-10.0, 10.0)]) + _expect(RENDERER._clip_polygon_to_rect(touching, rect).is_empty(), "a polygon touching only the clip edge is discarded") + var crossing := PackedVector2Array([Vector2(-10.0, 20.0), Vector2(0.0, 20.0), Vector2(30.0, 70.0), Vector2(-10.0, 70.0)]) + var clipped: PackedVector2Array = RENDERER._clip_polygon_to_rect(crossing, rect) + _expect(clipped.size() >= 3 and not Geometry2D.triangulate_polygon(clipped).is_empty(), "clipped polygons with vertices on the boundary remain triangulatable") + + +func _make_viewport() -> SubViewport: + var viewport := SubViewport.new() + viewport.size = Vector2i(384, 256) + viewport.transparent_bg = true + viewport.world_2d = World2D.new() + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + return viewport + + +func _test_visual_equivalence(finish: StringName, zoom: float = 1.0, starting_zoom: float = 1.0) -> void: + var cached_view := _make_viewport() + var direct_view := _make_viewport() + var surface: StageRectSurface = StageWallPanel.new() if RENDERER.is_wall_finish(finish) else StageBackgroundGround.new() + var reference := ReferenceSurface.new() + var tint: Color = RENDERER.default_tint(finish) + var view_transform := Transform2D(Vector2(zoom, 0.0), Vector2(0.0, zoom), Vector2(192.0, 128.0) * (1.0 - zoom)) + cached_view.canvas_transform = Transform2D(Vector2(starting_zoom, 0.0), Vector2(0.0, starting_zoom), Vector2(192.0, 128.0) * (1.0 - starting_zoom)) + direct_view.canvas_transform = view_transform + surface.position = Vector2(192.0, 128.0) + surface.configure(Vector2(320.0, 240.0) / zoom, finish, tint) + cached_view.add_child(surface) + reference.position = surface.position + reference.finish = finish + reference.tint = tint + reference.panel_size = surface.panel_size + direct_view.add_child(reference) + await _settle() + if not is_equal_approx(starting_zoom, zoom): + cached_view.canvas_transform = view_transform + await _settle() + var cached_image: Image = cached_view.get_texture().get_image() + var direct_image: Image = direct_view.get_texture().get_image() + var difference: float = 0.0 + var samples: int = 0 + for y: int in range(16, 240, 2): + for x: int in range(40, 344, 2): + var cached_pixel: Color = cached_image.get_pixel(x, y) + var direct_pixel: Color = direct_image.get_pixel(x, y) + difference += absf(cached_pixel.r - direct_pixel.r) + absf(cached_pixel.g - direct_pixel.g) + absf(cached_pixel.b - direct_pixel.b) + samples += 3 + var mean_error: float = difference / float(samples) + print("%s zoom=%.2f->%.2f cached/reference mean color difference=%.5f" % [finish, starting_zoom, zoom, mean_error]) + # Fractional zoom resamples the cached artwork; compare it with both detailed + # and simplified references so a flat preview cannot pass as a settled texture. + var tolerance: float = 0.015 if is_equal_approx(zoom, 1.0) else 0.02 + _expect(mean_error < tolerance, "%s zoom=%.2f->%.2f cache restores full-detail appearance" % [finish, starting_zoom, zoom]) + if zoom < 1.0: + # Shared immutable bitmaps cannot be changed through a tile's private baker. + # Draw the simplified reference separately, keeping the full image above. + reference.simplified = true + reference.queue_redraw() + await _settle() + var simplified_image: Image = direct_view.get_texture().get_image() + var simplified_difference: float = 0.0 + for y: int in range(16, 240, 2): + for x: int in range(40, 344, 2): + var simple_pixel: Color = simplified_image.get_pixel(x, y) + var full_pixel: Color = direct_image.get_pixel(x, y) + simplified_difference += absf(simple_pixel.r - full_pixel.r) + absf(simple_pixel.g - full_pixel.g) + absf(simple_pixel.b - full_pixel.b) + var simple_error: float = simplified_difference / float(samples) + _expect(mean_error < simple_error * 0.95, "%s normal zoom retains more artwork detail than the flat preview" % finish) + cached_image.save_png("res://tmp/surface_detail_%s.png" % finish) + _expect(cached_image.get_pixel(192, 128).a > 0.95, "%s comparison includes visible surface artwork" % finish) + var tiles: Dictionary = surface.get("_surface_tiles") + _expect(not tiles.is_empty(), "%s has cached texture tiles" % finish) + # UPDATE_ONCE is consumed by the rendering server; the node may retain the enum. + for cache: CACHE in tiles.values(): + _expect(cache.ready_for_display and cache.render_target_update_mode in [SubViewport.UPDATE_ONCE, SubViewport.UPDATE_DISABLED], "%s tile requests a single rasterization" % finish) + cached_view.queue_free() + direct_view.queue_free() + await process_frame + + +func _test_invalidation() -> void: + var viewport := _make_viewport() + var surface := StageWallPanel.new() + surface.position = Vector2(192.0, 128.0) + surface.configure(Vector2(1600.0, 1200.0), &"finish_wall_brick", Color("#B95F3D")) + viewport.add_child(surface) + await _settle() + var initial_tiles: Dictionary = (surface.get("_surface_tiles") as Dictionary).duplicate() + var initial_builds: int = int(CACHE.get_stats(surface).get("builds", 0)) + surface.queue_redraw() + await _settle() + _expect(int(CACHE.get_stats(surface).get("builds", 0)) == initial_builds, "unchanged surfaces reuse the rasterized texture") + viewport.canvas_transform.origin = Vector2(-16.0, 0.0) + await _settle() + _expect((surface.get("_surface_tiles") as Dictionary).size() == initial_tiles.size() and int(CACHE.get_stats(surface).get("builds", 0)) == initial_builds, "small camera pans reuse cached tiles") + viewport.canvas_transform.origin = Vector2(-300.0, 0.0) + await _settle() + var tiles: Dictionary = surface.get("_surface_tiles") + var reused: int = 0 + for key: Variant in initial_tiles: + if tiles.get(key) == initial_tiles[key]: + reused += 1 + _expect(reused > 0, "large pans retain the same repeating material") + viewport.canvas_transform = Transform2D(Vector2(2.0, 0.0), Vector2(0.0, 2.0), Vector2.ZERO) + await _settle() + tiles = surface.get("_surface_tiles") + for cache: CACHE in tiles.values(): + if cache.wanted: + _expect(cache.ready_for_display and cache.source_scale.is_equal_approx(Vector2(2.0, 2.0)), "zoom keeps the same full-resolution mipmapped material") + viewport.size = Vector2i(512, 384) + await _settle() + _expect(_pending_tiles(root) == 0, "window resizing completes newly exposed tiles") + surface.set_preview_tint(Color("#22C6FF")) + await _settle() + for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values(): + _expect(cache.get("_tint") == Color("#22C6FF"), "temporary color edits refresh the tiles") + surface.clear_preview_tint() + await _settle() + for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values(): + _expect(cache.get("_tint") == surface.tint, "canceling a color preview restores tile colors") + viewport.canvas_transform = Transform2D.IDENTITY + var retained_material: CACHE = (surface.get("_surface_tiles") as Dictionary).values()[0] as CACHE + surface.configure(Vector2(48.0, 64.0), surface.finish_id, surface.tint) + await _settle() + for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values(): + _expect(cache == retained_material, "shrinking a panel keeps the shared material and changes only clipping") + surface.position += Vector2(30.0, 20.0) + await _settle() + for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values(): + _expect(cache == retained_material, "moving a panel keeps the material and changes only pattern coordinates") + surface.configure(surface.panel_size, &"finish_wall_wood", Color("#C87137")) + await _settle() + for cache: CACHE in (surface.get("_surface_tiles") as Dictionary).values(): + _expect(cache.get("_finish") == &"finish_wall_wood", "changing finish replaces cached artwork") + viewport.queue_free() + await process_frame + + +func _settle() -> void: + for frame: int in 1000: + await process_frame + await RenderingServer.frame_post_draw + if frame >= 3 and _pending_tiles(root) == 0: + return + _expect(false, "progressive tile builds complete") + + +func _test_camera_tile_budget() -> void: + var viewport := _make_viewport() + var surface := StageWallPanel.new() + surface.position = Vector2(192.0, 128.0) + surface.configure(Vector2(12000.0, 6000.0), &"finish_wall_brick", Color("#B95F3D")) + viewport.add_child(surface) + await _settle() + var original: Dictionary = (surface.get("_surface_tiles") as Dictionary).duplicate() + # Camera zoom uses mipmaps from the same material image. + for scale: float in [0.95, 0.9, 0.85]: + viewport.canvas_transform = Transform2D(Vector2(scale, 0.0), Vector2(0.0, scale), Vector2.ZERO) + await _settle() + for key: Variant in original: + _expect((surface.get("_surface_tiles") as Dictionary).get(key) == original[key], "wheel steps reuse the same material image") + var builds_by_frame: Dictionary = {} + for index: int in 28: + var builds_before: int = int(CACHE.get_stats(surface).get("builds", 0)) + viewport.canvas_transform.origin = Vector2(-float(index) * 220.0, -float(index % 5) * 70.0) + await process_frame + await RenderingServer.frame_post_draw + var frame: int = Engine.get_process_frames() + var tiles: Dictionary = surface.get("_surface_tiles") + _expect(tiles.size() <= surface.MAX_RESIDENT_TILES, "camera traversal keeps the tile cache bounded") + # Eviction also emits updated; count actual shared bitmap builds instead. + builds_by_frame[frame] = int(CACHE.get_stats(surface).get("builds", 0)) - builds_before + # Queued work is metadata only; all tiles share a single GPU viewport. + for cache: CACHE in tiles.values(): + if not cache.ready_for_display and not cache.get("_building"): + _expect(cache.get_texture() == null and int(CACHE.get_stats(surface).get("bakers", 0)) <= 1, "unbuilt tiles share one render target during camera input") + _expect(frame >= 0, "camera traversal advances frames") + await _settle() + for count: int in builds_by_frame.values(): + _expect(count <= 1, "the shared queue admits at most one tile build per frame") + viewport.queue_free() + await process_frame + + +func _test_covered_surfaces() -> void: + var viewport := _make_viewport() + var back := StageWallPanel.new() + back.position = Vector2(192.0, 128.0) + back.configure(Vector2(1600.0, 1200.0), &"finish_wall_wood", Color("#C87137")) + viewport.add_child(back) + var front := StageWallPanel.new() + front.position = back.position + front.configure(Vector2(2000.0, 1600.0), &"finish_wall_brick", Color("#B95F3D")) + viewport.add_child(front) + await _settle() + _expect((back.get("_surface_tiles") as Dictionary).is_empty(), "fully covered walls do not prepare hidden texture tiles") + front.hide() + await _settle() + _expect(not (back.get("_surface_tiles") as Dictionary).is_empty(), "revealing a wall resumes its tile preparation") + front.show() + front.modulate.a = 0.5 + await _settle() + _expect(not back.call("_covered_by_surface"), "transparent walls keep underlying artwork visible") + front.modulate.a = 1.0 + await _settle() + _expect(back.call("_covered_by_surface"), "opaque coverage is detected again after transparency is removed") + viewport.queue_free() + await process_frame + + +func _pending_tiles(node: Node) -> int: + var count: int = 0 + if node is StageRectSurface: + for cache: CACHE in (node.get("_surface_tiles") as Dictionary).values(): + if cache.wanted and not cache.ready_for_display: + count += 1 + return count + for child: Node in node.get_children(): + count += _pending_tiles(child) + return count + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/test_scenery_surface_cache.gd.uid b/tests/test_scenery_surface_cache.gd.uid new file mode 100644 index 0000000..d478616 --- /dev/null +++ b/tests/test_scenery_surface_cache.gd.uid @@ -0,0 +1 @@ +uid://cuhlecbuev43k diff --git a/tests/test_scenery_surface_preview.gd b/tests/test_scenery_surface_preview.gd new file mode 100644 index 0000000..9a47a34 --- /dev/null +++ b/tests/test_scenery_surface_preview.gd @@ -0,0 +1,149 @@ +extends SceneTree +## Adaptive preview selection, placement/commit behavior, and per-finish CPU costs. + +const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") +const CONTROLLER := preload("res://scripts/editors/stage/stage_scenery_controller.gd") + +class TimedWall extends StageWallPanel: + var draw_times_usec: Array[int] = [] + + func _draw() -> void: + var started: int = Time.get_ticks_usec() + super._draw() + draw_times_usec.append(Time.get_ticks_usec() - started) + +class TimedGround extends StageBackgroundGround: + var draw_times_usec: Array[int] = [] + + func _draw() -> void: + var started: int = Time.get_ticks_usec() + super._draw() + draw_times_usec.append(Time.get_ticks_usec() - started) + +class SlowWall extends StageWallPanel: + var inject_delay: bool = true + + func get_surface_draw_origin_world() -> Vector2: + if inject_delay: + OS.delay_usec(PREVIEW_DRAW_BUDGET_USEC + 1000) + return super.get_surface_draw_origin_world() + +var _failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(2560, 1440) + for finish: StringName in RENDERER.WALL_FINISHES + RENDERER.BACKGROUND_FINISHES: + _expect(not RENDERER.needs_light_preview(finish, Vector2(16.0, 16.0)), "tiny %s keeps its detail" % finish) + _expect(not RENDERER.needs_light_preview(finish, Vector2.ZERO), "invisible %s has no preview work" % finish) + _test_placement(finish) + await _benchmark_finish(finish) + for finish: StringName in [&"finish_wall_brick", &"finish_wall_wood", &"finish_background_grass", &"finish_background_carpet"]: + _expect(RENDERER.needs_light_preview(finish, Vector2(2400.0, 1320.0)), "large %s reduces detail automatically" % finish) + _expect(not RENDERER.needs_light_preview(&"finish_wall_paint", Vector2(100000.0, 100000.0)), "paint stays detailed at any size") + _expect(not RENDERER.needs_light_preview(&"finish_background_concrete", Vector2(2400.0, 1320.0)), "ordinary concrete slab patterns stay detailed") + _expect(RENDERER.needs_light_preview(&"finish_background_concrete", Vector2(24000.0, 13200.0)), "very large concrete patterns can also simplify") + _expect(RENDERER.needs_light_preview(&"finish_wall_wood", Vector2(240.0, 132.0), Vector2(10.0, 10.0)), "work estimate accounts for motif scale") + await _test_runtime_fallback() + if _failures.is_empty(): + print("PASS: adaptive scenery surface previews") + else: + for failure: String in _failures: + push_error("FAIL: " + failure) + quit(0 if _failures.is_empty() else 1) + + +func _test_placement(finish: StringName) -> void: + var world := Node2D.new() + var ghosts := Node2D.new() + var controller := CONTROLLER.new() as StageSceneryController + root.add_child(world) + root.add_child(ghosts) + root.add_child(controller) + controller.configure(world, ghosts, null) + var wall: bool = RENDERER.is_wall_finish(finish) + var tint: Color = RENDERER.default_tint(finish) + var start := Vector2(-300.0, -150.0) + if wall: + controller.begin_wall(finish, start, tint) + else: + controller.begin_background_ground(finish, start, tint) + var ghost: StageRectSurface = ghosts.get_child(0) as StageRectSurface + _expect(ghost.interactive_preview, "%s placement opts into adaptive rendering" % finish) + var press := InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + controller.handle_mouse_button(press, start, false) + controller.handle_mouse_motion(Vector2(2100.0, 1170.0)) + var bounds: Rect2 = ghost.global_transform * ghost.get_local_bounds() + var release := InputEventMouseButton.new() + release.button_index = MOUSE_BUTTON_LEFT + controller.handle_mouse_button(release, Vector2(2100.0, 1170.0), false) + _expect(world.get_child_count() == 1, "%s commits one surface" % finish) + var placed: StageRectSurface = world.get_child(0) as StageRectSurface + _expect(not placed.interactive_preview, "%s commit uses full detail" % finish) + _expect(placed.finish_id == finish and placed.tint == tint, "%s preserves finish and tint" % finish) + _expect(bounds.is_equal_approx(placed.global_transform * placed.get_local_bounds()), "%s preserves preview bounds" % finish) + controller.exit_tool() + world.free() + ghosts.free() + controller.free() + + +func _benchmark_finish(finish: StringName) -> void: + var sample: StageRectSurface = TimedWall.new() if RENDERER.is_wall_finish(finish) else TimedGround.new() + sample.position = Vector2(1280.0, 720.0) + sample.interactive_preview = true + sample.configure(Vector2(2400.0, 1320.0), finish, RENDERER.default_tint(finish)) + root.add_child(sample) + await process_frame + await process_frame + var preview_usec: float = await _measure_redraws(sample) + sample.interactive_preview = false + await process_frame + await process_frame + var full_usec: float = await _measure_redraws(sample) + print("%s CPU redraw: adaptive=%.2f ms, full=%.2f ms" % [finish, preview_usec / 1000.0, full_usec / 1000.0]) + sample.free() + + +func _measure_redraws(sample: StageRectSurface) -> float: + var timings: Array[int] = sample.get("draw_times_usec") + timings.clear() + for _iteration: int in 3: + sample.queue_redraw() + await process_frame + await process_frame + _expect(not timings.is_empty(), "surface draws during benchmark") + var total: float = 0.0 + for elapsed: int in timings: + total += float(elapsed) + return total / maxf(1.0, float(timings.size())) + + +func _test_runtime_fallback() -> void: + var sample := SlowWall.new() + sample.position = Vector2(100.0, 100.0) + sample.interactive_preview = true + sample.configure(Vector2(16.0, 16.0), &"finish_wall_wood", Color.WHITE) + root.add_child(sample) + await process_frame + await process_frame + _expect(sample.get("_preview_over_budget"), "measured slow redraw enables the fallback even for a small panel") + sample.inject_delay = false + sample.configure(Vector2(20.0, 20.0), sample.finish_id, sample.tint) + await process_frame + await process_frame + _expect(sample.get("_preview_over_budget"), "fallback stays stable during the current placement session") + sample.interactive_preview = false + _expect(not sample.get("_preview_over_budget"), "leaving preview resets the timing fallback") + sample.free() + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/test_scenery_surface_preview.gd.uid b/tests/test_scenery_surface_preview.gd.uid new file mode 100644 index 0000000..7c46181 --- /dev/null +++ b/tests/test_scenery_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://du5qxtgupm60f