Add tests for scenery surface caching and preview behavior

- Introduced `test_scenery_brick_preview.gd` to validate focused placement and CPU draw-cost for large brick walls.
- Added `test_scenery_surface_cache.gd` to assess texture budget, visual equivalence, and cache invalidation.
- Created `test_scenery_surface_preview.gd` to evaluate adaptive preview selection and placement behavior.
- Implemented various helper functions to measure drawing performance and validate expected behaviors during tests.
- Added unique identifiers for new test files to ensure proper tracking and management.
This commit is contained in:
2026-10-21 11:06:57 -04:00
parent 80ba83b703
commit c1758efd3b
54 changed files with 5941 additions and 526 deletions
+19 -4
View File
@@ -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:
+1 -2
View File
@@ -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.
+2
View File
@@ -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/
+19
View File
@@ -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/`
+372
View File
@@ -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": ""
}
}
+140
View File
@@ -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": ""
}
}
+417
View File
@@ -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": ""
}
}
@@ -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.
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="640" viewBox="0 0 1200 640" role="img" aria-labelledby="title desc">
<title id="title">Erasing changes rectangles while sharing the same background texture</title>
<desc id="desc">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.</desc>
<defs>
<pattern id="brick" width="80" height="48" patternUnits="userSpaceOnUse"><rect width="80" height="48" fill="#d2cbaf"/><rect x="2" y="2" width="36" height="19" fill="#25b8df"/><rect x="42" y="2" width="36" height="19" fill="#39c7ee"/><rect x="-18" y="26" width="36" height="19" fill="#32c2e8"/><rect x="22" y="26" width="36" height="19" fill="#21b4da"/><rect x="62" y="26" width="36" height="19" fill="#32c2e8"/></pattern>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#475569"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#475569;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="640" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Erase the wall geometry. Keep the shared image.</text>
<text x="40" y="86" class="body">Fragments crop their own rectangles and use the existing pattern coordinates.</text>
<text x="50" y="136" class="heading">Before: one wall rectangle</text>
<g transform="translate(50 160)">
<rect width="460" height="300" fill="url(#brick)" stroke="#94a3b8" stroke-width="2"/>
<rect x="155" y="90" width="150" height="120" fill="#fff" fill-opacity=".3" stroke="#b45309" stroke-width="4" stroke-dasharray="10 6"/>
<rect x="174" y="131" width="112" height="36" rx="6" fill="#fff7ed"/>
<text x="230" y="155" class="body" text-anchor="middle">Cut here</text>
</g>
<path d="M532 310H648" class="arrow"/>
<text x="590" y="283" class="small" text-anchor="middle">Erase area</text>
<text x="680" y="136" class="heading">After: up to four fragments</text>
<g transform="translate(680 160)">
<rect width="460" height="300" fill="#dbeafe"/>
<g fill="url(#brick)" stroke="#0f766e" stroke-width="3">
<rect width="460" height="90"/>
<rect x="0" y="210" width="460" height="90"/>
<rect x="0" y="90" width="155" height="120"/>
<rect x="305" y="90" width="155" height="120"/>
</g>
<g fill="#fff"><rect x="195" y="26" width="70" height="36" rx="6"/><rect x="182" y="242" width="96" height="36" rx="6"/><rect x="43" y="131" width="70" height="36" rx="6"/><rect x="342" y="131" width="78" height="36" rx="6"/></g>
<text x="230" y="51" class="body" text-anchor="middle">Top</text>
<text x="230" y="267" class="body" text-anchor="middle">Bottom</text>
<text x="78" y="155" class="body" text-anchor="middle">Left</text>
<text x="381" y="155" class="body" text-anchor="middle">Right</text>
<text x="230" y="143" class="body" text-anchor="middle">Empty area</text>
<text x="230" y="173" class="small" text-anchor="middle">Scene behind</text>
</g>
<rect x="40" y="510" width="1120" height="95" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="62" y="546" class="heading">All remaining pieces still use the same cached texture.</text>
<text x="62" y="580" class="body">Cancel restores the original geometry. Commit keeps the fragments. Neither paints into the image.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
<title id="title">Zoom uses smaller copies already stored in the material texture</title>
<desc id="desc">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.</desc>
<defs>
<pattern id="brick" width="100" height="38" patternUnits="userSpaceOnUse"><rect width="100" height="38" fill="#d2cbaf"/><rect x="2" y="2" width="46" height="15" fill="#26b8df"/><rect x="52" y="2" width="46" height="15" fill="#39c7ee"/><rect x="-23" y="21" width="46" height="15" fill="#32c2e8"/><rect x="27" y="21" width="46" height="15" fill="#21b4da"/><rect x="77" y="21" width="46" height="15" fill="#32c2e8"/></pattern>
<pattern id="half" xlink:href="#brick" patternTransform="scale(.5)"/>
<pattern id="quarter" xlink:href="#brick" patternTransform="scale(.25)"/>
<pattern id="eighth" xlink:href="#brick" patternTransform="scale(.125)"/>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#7c3aed"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#7c3aed;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="630" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Zoom samples ready-made sizes of the same image.</text>
<text x="40" y="86" class="body">Mipmaps are generated once with the material. The GPU selects and blends them during rendering.</text>
<rect x="60" y="150" width="400" height="152" fill="url(#brick)" stroke="#94a3b8"/>
<path d="M471 228H508" class="arrow"/>
<rect x="530" y="188" width="200" height="76" fill="url(#half)" stroke="#94a3b8"/>
<path d="M741 228H778" class="arrow"/>
<rect x="800" y="207" width="100" height="38" fill="url(#quarter)" stroke="#94a3b8"/>
<path d="M911 228H948" class="arrow"/>
<rect x="970" y="217" width="50" height="19" fill="url(#eighth)" stroke="#94a3b8"/>
<text x="1043" y="234" class="heading">…</text>
<text x="260" y="342" class="heading" text-anchor="middle">1600 × 608</text>
<text x="260" y="373" class="small" text-anchor="middle">Base image: 2 pixels / world unit</text>
<text x="630" y="342" class="heading" text-anchor="middle">800 × 304</text>
<text x="630" y="373" class="small" text-anchor="middle">Half size</text>
<text x="850" y="342" class="heading" text-anchor="middle">400 × 152</text>
<text x="850" y="373" class="small" text-anchor="middle">Quarter size</text>
<text x="1000" y="342" class="heading" text-anchor="middle">200 × 76</text>
<text x="1000" y="373" class="small" text-anchor="middle">Eighth size</text>
<text x="60" y="414" class="small">The actual chain continues down to 1 × 1. Diagram sizes are scaled for illustration.</text>
<rect x="40" y="450" width="1120" height="143" rx="12" fill="#ecfdf5" stroke="#0f766e" stroke-width="2"/>
<text x="62" y="486" class="heading">World size, image size and screen size are different things.</text>
<text x="62" y="521" class="body">A brick stays 100 world units wide. At 40% zoom, it covers about 40 screen pixels.</text>
<text x="62" y="556" class="body">Camera zoom changes sampling detail; it does not bake another image or resize the brick geometry.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="640" viewBox="0 0 1200 640" role="img" aria-labelledby="title desc">
<title id="title">Background rendering: prepare a style once, reuse it while moving the camera</title>
<desc id="desc">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.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#475569"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#475569;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="640" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Prepare a material once. Reuse it as the camera moves.</text>
<text x="40" y="86" class="body">Only visible, committed patterned scenery requests a cached material.</text>
<rect x="40" y="140" width="250" height="126" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="60" y="177" class="heading">Visible surface</text>
<text x="60" y="210" class="body">Finish + tint</text>
<text x="60" y="239" class="small">Example: blue brick</text>
<path d="M292 203H370" class="arrow"/>
<rect x="390" y="140" width="260" height="126" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="410" y="177" class="heading">Scene style cache</text>
<text x="410" y="210" class="body">Already have this image?</text>
<text x="410" y="239" class="small">Shared by all matching surfaces</text>
<path d="M652 203H740" class="arrow"/>
<text x="674" y="182" class="small">Hit</text>
<rect x="760" y="140" width="400" height="126" rx="12" fill="#ecfdf5" stroke="#0f766e" stroke-width="2"/>
<text x="782" y="177" class="heading">GPU draws the visible rectangle</text>
<text x="782" y="210" class="body">Repeat texture + sample its mipmaps</text>
<text x="782" y="239" class="small">Pan / zoom updates sampling coordinates</text>
<path d="M520 268V324" class="arrow"/>
<text x="540" y="304" class="small">Miss: prepare this style</text>
<rect x="390" y="344" width="260" height="158" rx="12" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2"/>
<text x="410" y="379" class="heading">One temporary baker</text>
<text x="410" y="412" class="body">Draw one pattern period</text>
<text x="410" y="441" class="body">Wait for GPU; capture image</text>
<text x="410" y="476" class="small">Then clear vector commands</text>
<path d="M652 423H740" class="arrow"/>
<rect x="760" y="344" width="400" height="158" rx="12" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2"/>
<text x="782" y="379" class="heading">Store the reusable texture</text>
<text x="782" y="412" class="body">Crop overscan; generate mipmaps</text>
<text x="782" y="441" class="body">Keep the image, not the vector artwork</text>
<text x="782" y="476" class="small">Ready styles stay warm within the cache budget</text>
<path d="M960 342V282" class="arrow"/>
<text x="980" y="309" class="small">Ready</text>
<rect x="40" y="548" width="540" height="56" rx="10" fill="#dbeafe"/>
<text x="59" y="582" class="body">Blue route: reuse during ordinary pan and zoom</text>
<rect x="604" y="548" width="556" height="56" rx="10" fill="#ede9fe"/>
<text x="623" y="582" class="body">Purple route: bake only when a style is missing</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="650" viewBox="0 0 1200 650" role="img" aria-labelledby="title desc">
<title id="title">Many wall rectangles share one material image</title>
<desc id="desc">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.</desc>
<defs>
<pattern id="brick" width="64" height="40" patternUnits="userSpaceOnUse">
<rect width="64" height="40" fill="#d2cbaf"/>
<g><rect x="2" y="2" width="28" height="15" fill="#25b8df"/><rect x="34" y="2" width="28" height="15" fill="#39c7ee"/><rect x="-14" y="22" width="28" height="15" fill="#32c2e8"/><rect x="18" y="22" width="28" height="15" fill="#21b4da"/><rect x="50" y="22" width="28" height="15" fill="#32c2e8"/></g>
</pattern>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#2563eb"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#2563eb;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="650" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Wall area can grow. The number of style images stays the same.</text>
<text x="40" y="86" class="body">Position, wall size, camera position and zoom are not part of the style key.</text>
<rect x="400" y="117" width="400" height="163" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<rect x="420" y="140" width="160" height="112" fill="url(#brick)" stroke="#94a3b8"/>
<text x="602" y="162" class="heading">One shared image</text>
<text x="602" y="194" class="body">Blue brick</text>
<text x="602" y="224" class="small">Varied brick faces</text>
<text x="602" y="250" class="small">Repeats both ways</text>
<path d="M600 282V311H210V341" class="arrow"/>
<path d="M600 311V341" class="arrow"/>
<path d="M600 311H990V341" class="arrow"/>
<rect x="40" y="360" width="340" height="192" rx="12" fill="#fff" stroke="#cbd5e1" stroke-width="2"/>
<text x="62" y="396" class="heading">Wall A</text>
<rect x="62" y="416" width="296" height="103" fill="url(#brick)"/>
<rect x="430" y="360" width="340" height="192" rx="12" fill="#fff" stroke="#cbd5e1" stroke-width="2"/>
<text x="452" y="396" class="heading">Wall B</text>
<rect x="452" y="416" width="190" height="103" fill="url(#brick)"/>
<text x="659" y="455" class="small">Same</text>
<text x="659" y="480" class="small">style</text>
<rect x="820" y="360" width="340" height="192" rx="12" fill="#fff" stroke="#cbd5e1" stroke-width="2"/>
<text x="842" y="396" class="heading">Erased fragment</text>
<rect x="842" y="416" width="90" height="103" fill="url(#brick)"/>
<text x="952" y="455" class="small">Only the visible</text>
<text x="952" y="480" class="small">rectangle changes</text>
<text x="40" y="591" class="body">A different finish or tint is a separate style. A matching warm style reuses the existing image.</text>
<text x="40" y="621" class="small" fill="#64748b">Patterns shown here are schematic; the actual brick image contains 8 columns × 8 rows of varied artwork.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -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`.
@@ -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_
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+32
View File
@@ -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.
@@ -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. |
+78
View File
@@ -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.
+1
View File
@@ -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
+52 -2
View File
@@ -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",
+33 -16
View File
@@ -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…"
+506 -95
View File
@@ -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):
+1 -1
View File
@@ -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": "🌿"}
]
@@ -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":
+248 -37
View File
@@ -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)))
@@ -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
@@ -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)
@@ -0,0 +1 @@
uid://cc5hn0jge7758
@@ -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)
@@ -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
@@ -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:
@@ -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)
@@ -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)
@@ -0,0 +1 @@
uid://dbvatqk4a287y
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -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
+34 -2
View File
@@ -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)
@@ -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)
+182 -1
View File
@@ -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
@@ -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)))
@@ -0,0 +1 @@
uid://cwcf7h5yf3xi0
+54 -1
View File
@@ -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)
+173 -68
View File
@@ -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:
+1 -2
View File
@@ -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)
+2
View File
@@ -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():
+1 -1
View File
@@ -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()
+97
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://du36kyv22uneg
+284
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cuhlecbuev43k
+149
View File
@@ -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)
@@ -0,0 +1 @@
uid://du5qxtgupm60f