diff --git a/.github/workflows/opencode-pr.yml b/.github/workflows/opencode-pr.yml index 7f9b5944..f0df7790 100644 --- a/.github/workflows/opencode-pr.yml +++ b/.github/workflows/opencode-pr.yml @@ -79,9 +79,9 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }} - KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} with: - model: kimi-for-coding/k2p7 + model: zhipuai-coding-plan/glm-5.2 use_github_token: true prompt: ${{ env.PROMPT }} diff --git a/assets/shaders/vulkan/ui_rml.frag b/assets/shaders/vulkan/ui_rml.frag new file mode 100644 index 00000000..6062964c --- /dev/null +++ b/assets/shaders/vulkan/ui_rml.frag @@ -0,0 +1,8 @@ +#version 450 + +layout(location = 0) in vec4 v_color; +layout(location = 0) out vec4 frag_color; + +void main() { + frag_color = v_color; +} diff --git a/assets/shaders/vulkan/ui_rml.frag.spv b/assets/shaders/vulkan/ui_rml.frag.spv new file mode 100644 index 00000000..211ebbf3 Binary files /dev/null and b/assets/shaders/vulkan/ui_rml.frag.spv differ diff --git a/assets/shaders/vulkan/ui_rml.vert b/assets/shaders/vulkan/ui_rml.vert new file mode 100644 index 00000000..638e6775 --- /dev/null +++ b/assets/shaders/vulkan/ui_rml.vert @@ -0,0 +1,20 @@ +#version 450 + +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec4 a_color; +layout(location = 2) in vec2 a_uv; + +layout(location = 0) out vec4 v_color; +layout(location = 1) out vec2 v_uv; + +layout(push_constant) uniform PushConstants { + mat4 projection; + layout(offset = 64) vec2 translation; +} pc; + +void main() { + gl_Position = pc.projection * vec4(a_position + pc.translation, 0.0, 1.0); + gl_Position.y = -gl_Position.y; + v_color = a_color; + v_uv = a_uv; +} diff --git a/assets/shaders/vulkan/ui_rml.vert.spv b/assets/shaders/vulkan/ui_rml.vert.spv new file mode 100644 index 00000000..f83a283d Binary files /dev/null and b/assets/shaders/vulkan/ui_rml.vert.spv differ diff --git a/assets/shaders/vulkan/ui_rml_tex.frag b/assets/shaders/vulkan/ui_rml_tex.frag new file mode 100644 index 00000000..28a22e72 --- /dev/null +++ b/assets/shaders/vulkan/ui_rml_tex.frag @@ -0,0 +1,12 @@ +#version 450 + +layout(location = 0) in vec4 v_color; +layout(location = 1) in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +layout(set = 0, binding = 0) uniform sampler2D ui_texture; + +void main() { + frag_color = texture(ui_texture, v_uv) * v_color; +} diff --git a/assets/shaders/vulkan/ui_rml_tex.frag.spv b/assets/shaders/vulkan/ui_rml_tex.frag.spv new file mode 100644 index 00000000..50526bd1 Binary files /dev/null and b/assets/shaders/vulkan/ui_rml_tex.frag.spv differ diff --git a/assets/ui/rmlui/create_world.rml b/assets/ui/rmlui/create_world.rml new file mode 100644 index 00000000..e57cc39a --- /dev/null +++ b/assets/ui/rmlui/create_world.rml @@ -0,0 +1,52 @@ + + + ZigCraft Create World + + + +
+
+
+
NEW WORLD
+

CREATE WORLD

+

Name the world and choose how its seed is generated.

+
+ +
+
+
01 DETAILS
+
02 TERRAIN
+
03 REVIEW
+
+ +
+
+ + + +
+
+ +
+ A blank seed creates a new random world. +
+ +
+

Pick the generator that will shape this world.

+
+
+ +
+
+
+
+
+ + +
+
+ +
diff --git a/assets/ui/rmlui/environment.rml b/assets/ui/rmlui/environment.rml new file mode 100644 index 00000000..8b8cb1a2 --- /dev/null +++ b/assets/ui/rmlui/environment.rml @@ -0,0 +1,28 @@ + + + ZigCraft Environment + + + +
+
+
+
LIGHT PROBE
+

Environment

+

Choose the sky probe that lights the scene. Changes apply immediately.

+
READY
+
+
+
+
+
+
+
+ +
+
+ +
diff --git a/assets/ui/rmlui/home.rcss b/assets/ui/rmlui/home.rcss new file mode 100644 index 00000000..64f001ae --- /dev/null +++ b/assets/ui/rmlui/home.rcss @@ -0,0 +1,340 @@ +body { + position: absolute; + display: block; + left: 0; + top: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + margin: 0; + color: #F4F8FC; + font-family: Inter Variable; + background-color: #000000BF; + overflow: hidden; +} + +#page { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 1360dp; + height: 100%; + min-height: 100%; + box-sizing: border-box; + margin: 0 auto; + padding: 52dp 64dp; +} + +#hero { + display: block; + width: 500dp; + min-width: 0; + margin-right: 56dp; +} + +#brand { + display: block; + color: #F6FAFD; + font-size: 18dp; + font-weight: 600; + letter-spacing: 4dp; +} + +#brand span { + color: #7DD8F0; + font-size: 12dp; + font-weight: normal; + letter-spacing: 1dp; +} + +.eyebrow, +.panel-kicker, +.section-label { + display: block; + color: #83D9EF; + font-size: 12dp; + font-weight: 600; + letter-spacing: 2dp; + line-height: 1.25; +} + +.eyebrow { + margin-top: 34dp; +} + +h1 { + display: block; + margin: 10dp 0 16dp; + color: #F7FAFC; + font-size: 48dp; + font-weight: 600; + line-height: 1.08; + letter-spacing: -1dp; +} + +h1 span { + display: block; +} + +.hero-compact { + display: none; +} + +.lede { + display: block; + max-width: 460dp; + margin: 0; + color: #C7D4DD; + font-size: 16dp; + line-height: 1.45; +} + +#navigation-panel { + display: block; + width: 460dp; + max-width: 100%; + box-sizing: border-box; + padding: 26dp; + background-color: #07141FE0; + border: 1dp #BDE5F05C; + border-top: 1dp #E1F7FC8A; + border-radius: 10dp; +} + +h2 { + display: block; + margin: 7dp 0 6dp; + color: #F5F8FB; + font-size: 26dp; + font-weight: 600; + line-height: 1.18; +} + +.panel-copy { + display: block; + margin: 0 0 18dp; + color: #B9C8D1; + font-size: 13dp; + line-height: 1.4; +} + +button { + display: block; + width: 100%; + min-height: 50dp; + box-sizing: border-box; + margin: 0; + padding: 9dp 12dp; + color: #F3F7FA; + background-color: transparent; + border: 0dp transparent; + border-bottom: 1dp #A9DDEA26; + border-radius: 0dp; + font-family: Inter Variable; + text-align: left; + cursor: pointer; +} + +button strong { + display: block; + color: #F3F8FB; + font-size: 14dp; + font-weight: 600; + letter-spacing: 1dp; + pointer-events: none; +} + +button span { + display: block; + margin-top: 3dp; + color: #A9BBC6; + font-size: 12dp; + line-height: 1.25; + letter-spacing: 0dp; + pointer-events: none; +} + +button:hover, +button:focus { + background-color: #8BE4FF1C; + border: 0dp transparent; + border-left: 2dp #81D8F2; + border-bottom: 1dp #A9DDEA3D; +} + +button:active { + background-color: #55CDEB3D; +} + +button.primary { + min-height: 76dp; + margin-bottom: 16dp; + padding: 14dp 16dp; + background-color: #087F99D4; + border: 1dp #CFF8FFB8; + border-left: 3dp #E8FDFF; + border-radius: 8dp; +} + +button.primary:hover, +button.primary:focus { + background-color: #0A9AB6E8; + border: 1dp #F0FDFFE8; + border-left: 3dp #FFFFFF; +} + +button.primary strong { + font-size: 18dp; + letter-spacing: 1dp; +} + +button.primary span { + color: #D4F2F8; +} + +.section-label { + margin: 0 0 5dp; + padding-bottom: 7dp; + border-bottom: 1dp #9CDDEF29; +} + +#secondary-actions { + display: block; + background-color: transparent; + border: 0dp transparent; +} + +#secondary-actions button { + display: block; +} + +#secondary-actions button:hover, +#secondary-actions button:focus { + background-color: #99E7FF1C; + border: 0dp transparent; + border-left: 2dp #74D3EE; + border-bottom: 1dp #9CDDEF3D; +} + +button.quiet:hover, +button.quiet:focus { + background-color: #EF7D931C; + border: 0dp transparent; + border-left: 2dp #D47C87; + border-bottom: 1dp #69454D; +} + +@media (max-width: 1080dp) { + #page { + display: block; + padding: 30dp 36dp; + } + + #hero { + display: block; + width: 460dp; + max-width: 100%; + margin: 0 auto 18dp; + } + + .eyebrow { + margin-top: 18dp; + } + + #navigation-panel { + display: block; + margin: 0 auto; + } +} + +@media (max-width: 620dp) { + #page { + display: block; + padding: 20dp; + } + + #hero { + display: block; + width: 100%; + margin: 0 0 14dp; + } + + #brand { + font-size: 16dp; + letter-spacing: 3dp; + } + + #brand span { + font-size: 12dp; + } + + .eyebrow { + display: none; + } + + h1 { + margin: 8dp 0 6dp; + font-size: 28dp; + line-height: 1.12; + } + + .hero-full { + display: none; + } + + .hero-compact { + display: block; + } + + .lede { + max-width: 420dp; + font-size: 12dp; + line-height: 1.35; + } + + #navigation-panel { + display: block; + width: 100%; + padding: 20dp; + } + + h2 { + margin: 5dp 0 4dp; + font-size: 22dp; + } + + .panel-copy { + margin-bottom: 12dp; + font-size: 12dp; + } + + button.primary { + min-height: 68dp; + margin-bottom: 12dp; + padding: 12dp 14dp; + } + + button.primary strong { + font-size: 16dp; + } + + button { + min-height: 45dp; + padding: 7dp 10dp; + } + + button strong { + font-size: 13dp; + } + + button span { + font-size: 12dp; + } + + .section-label { + margin-bottom: 3dp; + padding-bottom: 5dp; + } +} diff --git a/assets/ui/rmlui/home.rml b/assets/ui/rmlui/home.rml new file mode 100644 index 00000000..049cd0eb --- /dev/null +++ b/assets/ui/rmlui/home.rml @@ -0,0 +1,38 @@ + + + ZigCraft Home + + + +
+
+
ZIGCRAFT 0.1
+
PROCEDURAL VOXEL SANDBOX
+

+ Build your next world. + Build a world. +

+

Create a place to explore, shape it block by block, and return whenever you are ready.

+
+ + +
+ +
diff --git a/assets/ui/rmlui/menu.rcss b/assets/ui/rmlui/menu.rcss new file mode 100644 index 00000000..dad0973f --- /dev/null +++ b/assets/ui/rmlui/menu.rcss @@ -0,0 +1,190 @@ +body { + position: absolute; + display: block; + left: 0; + top: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + margin: 0; + color: #F4F8FC; + font-family: Inter Variable; + background-color: #000000BF; + overflow: hidden; +} + +.page { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 34dp; +} + +.shell { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + max-width: 1040dp; + height: 100%; + max-height: 720dp; + box-sizing: border-box; + padding: 28dp; + background-color: #07141FE8; + border: 1dp #BDE5F061; + border-top: 1dp #ECFBFF99; + border-radius: 10dp; +} + +.shell.compact { max-width: 520dp; max-height: 560dp; } +.shell.medium { max-width: 900dp; max-height: 680dp; } +.shell.wide { max-width: 1160dp; max-height: 760dp; } + +.header { display: block; flex: 0 0 auto; padding-bottom: 18dp; border-bottom: 1dp #9CDDEF33; } +.kicker, .section-label { display: block; color: #83D9EF; font-size: 12dp; font-weight: 600; letter-spacing: 2dp; } +h1 { display: block; margin: 7dp 0 5dp; color: #F7FAFC; font-size: 30dp; line-height: 1.14; } +h2 { display: block; margin: 0 0 6dp; color: #F5F8FB; font-size: 22dp; } +.copy { display: block; margin: 0; color: #B9C8D1; font-size: 13dp; line-height: 1.4; } +.header-meta { position: absolute; right: 28dp; top: 35dp; color: #83D9EF; font-size: 12dp; letter-spacing: 1dp; } + +.content { display: block; flex: 1 1 auto; min-height: 0; padding: 20dp 0; overflow: auto; } +.footer { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; padding-top: 16dp; border-top: 1dp #9CDDEF2E; } +.footer-right { display: flex; justify-content: flex-end; } +.footer-right button { margin-left: 10dp; } + +button { + display: block; + min-width: 132dp; + min-height: 46dp; + box-sizing: border-box; + padding: 11dp 16dp; + color: #F3F7FA; + background-color: #99E7FF0A; + border: 1dp #A8DDEB3D; + border-radius: 7dp; + font-family: Inter Variable; + font-size: 13dp; + font-weight: 600; + letter-spacing: 1dp; + cursor: pointer; +} +button:hover, button:focus { background-color: #99E7FF24; border: 1dp #A8E7F57A; border-left: 3dp #74D3EE; } +button.primary { color: #FFFFFF; background-color: #087F99D4; border: 1dp #CFF8FFB8; border-left: 3dp #E8FDFF; } +button.primary:hover, button.primary:focus { background-color: #0A9AB6E8; border: 1dp #F0FDFFE8; border-left: 3dp #FFFFFF; } +button.danger { color: #FFDDE2; background-color: #EF7D9317; border-color: #D47C8766; } +button.danger:hover, button.danger:focus { background-color: #EF7D9333; border-left-color: #E58C98; } +button:disabled, button.disabled { color: #667680; background-color: #00000020; border-color: #71838D26; cursor: default; } + +.split { display: flex; width: 100%; height: 100%; min-height: 0; } +.pane { display: block; box-sizing: border-box; min-width: 0; background-color: #03101A70; border: 1dp #9CDDEF2B; } +.pane-left { width: 58%; margin-right: 14dp; } +.pane-right { flex: 1; } +.pane-header { display: flex; align-items: center; justify-content: space-between; padding: 16dp 18dp; border-bottom: 1dp #9CDDEF2B; } +.pane-body { display: block; padding: 12dp 16dp; } + +.row { + position: relative; + display: block; + width: 100%; + min-height: 66dp; + box-sizing: border-box; + padding: 12dp 108dp 11dp 16dp; + color: #F3F7FA; + background-color: transparent; + border: 0dp transparent; + border-bottom: 1dp #9CDDEF29; + border-radius: 0dp; + text-align: left; +} +.row:hover, .row:focus, .row.selected { background-color: #99E7FF1C; border: 0dp transparent; border-left: 2dp #74D3EE; border-bottom: 1dp #9CDDEF42; } +.row strong { display: block; color: #F5F9FC; font-size: 14dp; letter-spacing: 0dp; pointer-events: none; } +.row span { display: block; margin-top: 5dp; color: #A9BBC6; font-size: 12dp; font-weight: normal; letter-spacing: 0dp; pointer-events: none; } +.row em { position: absolute; right: 16dp; top: 24dp; color: #83D9EF; font-size: 11dp; font-style: normal; pointer-events: none; } + +.empty { display: block; padding: 90dp 20dp; color: #B9C8D1; font-size: 14dp; text-align: center; } +.detail { display: block; padding: 20dp; } +.detail-label { display: block; margin-top: 18dp; color: #83D9EF; font-size: 11dp; letter-spacing: 1dp; } +.detail-value { display: block; margin-top: 5dp; color: #F5F8FB; font-size: 16dp; } +.detail-actions { display: block; margin-top: 28dp; } +.detail-actions button { width: 100%; margin-top: 10dp; } +.action-pair { display: flex; } +.action-pair button { width: 50%; } +.action-pair button:first-child { margin-right: 10dp; } + +.steps { display: flex; margin-bottom: 20dp; } +.step { display: block; flex: 1; box-sizing: border-box; padding: 13dp; color: #8496A1; border-bottom: 2dp #9CDDEF24; font-size: 12dp; letter-spacing: 1dp; text-align: center; } +.step.active { color: #FFFFFF; background-color: #99E7FF14; border-bottom-color: #67C9EA; } +.step.complete { color: #83D9EF; border-bottom-color: #67C9EA80; } +.wizard-body { display: block; width: 100%; max-width: 720dp; margin: 0 auto; } +.wizard-page { display: none; } +.wizard-page.active { display: block; } + +label { display: block; margin: 18dp 0 8dp; color: #83D9EF; font-size: 12dp; font-weight: 600; letter-spacing: 1dp; } +input, select { + display: block; + width: 100%; + min-height: 52dp; + box-sizing: border-box; + padding: 12dp 14dp; + color: #F7FAFC; + background-color: #03101ACF; + border: 1dp #A8DDEB59; + border-radius: 6dp; + font-family: Inter Variable; + font-size: 14dp; +} +input:focus, select:focus { border: 2dp #67C9EA; } +.input-row { display: flex; align-items: flex-end; } +.input-grow { flex: 1; min-width: 0; } +.input-row button { margin-left: 10dp; } +.help { display: block; margin-top: 12dp; color: #91A6B2; font-size: 12dp; } +.cards { display: flex; flex-wrap: wrap; margin: -5dp; } +.cards .row { width: 50%; margin: 0; } + +.summary { display: block; padding: 20dp; background-color: #03101A70; border: 1dp #9CDDEF33; } +.summary-item { display: block; padding: 13dp 0; border-bottom: 1dp #9CDDEF24; } +.summary-item:last-child { border-bottom: 0dp transparent; } +.summary-item strong { display: block; color: #83D9EF; font-size: 11dp; letter-spacing: 1dp; } +.summary-item span { display: block; margin-top: 6dp; color: #F5F8FB; font-size: 16dp; } + +.tabs { display: flex; margin-bottom: 16dp; } +.tabs button { flex: 1; min-width: 0; margin-right: 8dp; } +.tabs button:last-child { margin-right: 0; } +.tabs button.active { color: #FFFFFF; background-color: #087F996E; border-color: #67C9EA; } +.settings-list { display: block; max-width: 820dp; margin: 0 auto; } +.setting-row { display: flex; align-items: center; min-height: 74dp; padding: 10dp 0; border-bottom: 1dp #9CDDEF24; } +.setting-copy { display: block; flex: 1; min-width: 0; padding-right: 18dp; } +.setting-copy strong { display: block; font-size: 14dp; } +.setting-copy span { display: block; margin-top: 5dp; color: #A9BBC6; font-size: 12dp; } +.setting-control { display: flex; align-items: center; } +.setting-control button { min-width: 44dp; padding: 8dp; } +.setting-value { display: block; min-width: 150dp; padding: 0 12dp; color: #F5F8FB; font-size: 13dp; text-align: center; } + +.status { color: #83D9EF; font-size: 12dp; } +.modal-layer { position: absolute; display: none; left: 0; top: 0; width: 100%; height: 100%; background-color: #000000D1; } +.modal-layer.visible { display: flex; align-items: center; justify-content: center; } +.modal { display: block; width: 460dp; max-width: 86%; box-sizing: border-box; padding: 24dp; background-color: #07141FF5; border: 1dp #BDE5F080; border-top: 1dp #ECFBFFB8; border-radius: 9dp; } +.modal p { color: #B9C8D1; font-size: 13dp; } +.modal-actions { display: flex; justify-content: flex-end; margin-top: 20dp; } +.modal-actions button { margin-left: 10dp; } + +@media (max-width: 760dp) { + .page { padding: 18dp; } + .shell { padding: 20dp; max-height: none; } + .header-meta { position: relative; right: auto; top: auto; display: block; margin-top: 8dp; } + .split { display: block; overflow: auto; } + .pane-left { width: 100%; height: auto; margin: 0 0 14dp; } + .pane-right { width: 100%; } + .cards { display: block; margin: 0; } + .cards .row { width: 100%; } + .footer { display: block; } + .footer-right { margin-top: 10dp; } + .footer-right button { flex: 1; } + .tabs { flex-wrap: wrap; } + .tabs button { min-width: 46%; margin-bottom: 8dp; } + .setting-row { display: block; } + .setting-control { margin-top: 12dp; } +} diff --git a/assets/ui/rmlui/paused.rml b/assets/ui/rmlui/paused.rml new file mode 100644 index 00000000..817d9996 --- /dev/null +++ b/assets/ui/rmlui/paused.rml @@ -0,0 +1,25 @@ + + + ZigCraft Paused + + + +
+
+
+
SESSION
+

Paused

+

Resume or leave the current world.

+
+
+
+ + + +
+
+ +
+
+ +
diff --git a/assets/ui/rmlui/resource_packs.rml b/assets/ui/rmlui/resource_packs.rml new file mode 100644 index 00000000..6a06b6c2 --- /dev/null +++ b/assets/ui/rmlui/resource_packs.rml @@ -0,0 +1,28 @@ + + + ZigCraft Resource Packs + + + +
+
+
+
ASSETS
+

Resource Packs

+

Choose the block texture pack used by your worlds. Changes apply immediately.

+
READY
+
+
+
+
+
+
+
+ +
+
+ +
diff --git a/assets/ui/rmlui/settings.rml b/assets/ui/rmlui/settings.rml new file mode 100644 index 00000000..1c8190c1 --- /dev/null +++ b/assets/ui/rmlui/settings.rml @@ -0,0 +1,31 @@ + + + ZigCraft Settings + + + +
+
+
+
PREFERENCES
+

Settings

+

Changes apply immediately and are saved when you leave.

+
LIVE SETTINGS
+
+
+
+ + + + +
+
+
+ +
+
+ +
diff --git a/assets/ui/rmlui/world_library.rml b/assets/ui/rmlui/world_library.rml new file mode 100644 index 00000000..26809f7a --- /dev/null +++ b/assets/ui/rmlui/world_library.rml @@ -0,0 +1,51 @@ + + + ZigCraft World Library + + + +
+
+
+
PLAY
+

WORLD LIBRARY

+

Choose an existing world or create something new.

+
0 WORLDS
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + +
diff --git a/build.zig b/build.zig index 874bb9a9..a46b485e 100644 --- a/build.zig +++ b/build.zig @@ -8,6 +8,7 @@ const BuildOptions = struct { engine_graphics_options: *std.Build.Step.Options, enable_debug_shadows: bool, enable_imgui: bool, + enable_rmlui: bool, chunk_debug_mode: bool, chunk_debug_enable: []const u8, startup_diagnostic_seconds: u32, @@ -78,6 +79,7 @@ fn defineModules( ) BuildModules { const options = opts.options; const enable_imgui = opts.enable_imgui; + const enable_rmlui = opts.enable_rmlui; const engine_ui_options = opts.engine_ui_options; const world_lod_options = opts.world_lod_options; const world_runtime_options = opts.world_runtime_options; @@ -316,6 +318,10 @@ fn defineModules( engine_ui.linkSystemLibrary("cimgui", .{ .use_pkg_config = .force }); engine_ui.link_libcpp = true; } + if (enable_rmlui) { + engine_ui.linkSystemLibrary("zigcraft-rmlui-bridge", .{ .use_pkg_config = .force }); + engine_ui.link_libcpp = true; + } addSharedImports(world_core, zig_math, zig_noise, fs_module, sync_module, c_module, options); world_core.addImport("engine-core", engine_core); world_core.addImport("engine-math", engine_math); @@ -413,6 +419,7 @@ fn defineBuildSteps( const options = opts.options; const enable_debug_shadows = opts.enable_debug_shadows; const enable_imgui = opts.enable_imgui; + const enable_rmlui = opts.enable_rmlui; const monitor_index = opts.monitor_index; const monitor_name = opts.monitor_name; const window_video_driver = opts.window_video_driver; @@ -474,6 +481,7 @@ fn defineBuildSteps( exe.root_module.linkSystemLibrary("sdl3", .{}); exe.root_module.linkSystemLibrary("vulkan", .{}); if (enable_imgui) addCimgui(b, exe); + if (enable_rmlui) addRmlUi(exe); b.installArtifact(exe); @@ -494,6 +502,7 @@ fn defineBuildSteps( const benchmark_options = b.addOptions(); benchmark_options.addOption(bool, "debug_shadows", enable_debug_shadows); benchmark_options.addOption(bool, "imgui", enable_imgui); + benchmark_options.addOption(bool, "rmlui", enable_rmlui); benchmark_options.addOption(bool, "smoke_test", false); benchmark_options.addOption(bool, "chunk_debug_mode", false); benchmark_options.addOption([]const u8, "chunk_debug_enable", ""); @@ -551,6 +560,7 @@ fn defineBuildSteps( benchmark_exe.root_module.linkSystemLibrary("sdl3", .{}); benchmark_exe.root_module.linkSystemLibrary("vulkan", .{}); if (enable_imgui) addCimgui(b, benchmark_exe); + if (enable_rmlui) addRmlUi(benchmark_exe); b.installArtifact(benchmark_exe); @@ -575,6 +585,7 @@ fn defineBuildSteps( .name = "worldgen-report", .root_module = worldgen_report_root_module, }); + if (enable_rmlui) addRmlUi(worldgen_report_exe); const worldgen_report_run_cmd = b.addRunArtifact(worldgen_report_exe); const worldgen_report_step = b.step("worldgen-report", "Print deterministic worldgen baseline report"); @@ -594,6 +605,7 @@ fn defineBuildSteps( .name = "lod-bench", .root_module = lod_bench_root_module, }); + if (enable_rmlui) addRmlUi(lod_bench_exe); const lod_bench_run_cmd = b.addRunArtifact(lod_bench_exe); const lod_bench_step = b.step("lod-bench", "Benchmark LOD heightmap generation (CPU-only, no graphics)"); @@ -613,6 +625,7 @@ fn defineBuildSteps( .name = "worldgen-climate-snapshot", .root_module = worldgen_climate_snapshot_root_module, }); + if (enable_rmlui) addRmlUi(worldgen_climate_snapshot_exe); const worldgen_climate_snapshot_run_cmd = b.addRunArtifact(worldgen_climate_snapshot_exe); if (b.args) |args| { @@ -656,6 +669,7 @@ fn defineBuildSteps( exe_tests.root_module.linkSystemLibrary("vulkan", .{}); exe_tests.root_module.addIncludePath(b.path("libs/stb")); if (enable_imgui) addCimgui(b, exe_tests); + if (enable_rmlui) addRmlUi(exe_tests); const test_step = b.step("test", "Run unit tests"); const run_exe_tests = b.addRunArtifact(exe_tests); @@ -666,10 +680,12 @@ fn defineBuildSteps( const engine_math_fuzz_root = b.createModule(.{ .root_source_file = b.path("modules/engine-math/src/ray_fuzz_tests.zig"), .target = target, .optimize = optimize, .sanitize_c = sanitize_c }); engine_math_fuzz_root.addImport("zig-math", zig_math); const engine_math_fuzz_tests = b.addTest(.{ .root_module = engine_math_fuzz_root, .filters = test_filters }); + if (enable_rmlui) addRmlUi(engine_math_fuzz_tests); test_step.dependOn(&b.addRunArtifact(engine_math_fuzz_tests).step); const world_core_fuzz_root = b.createModule(.{ .root_source_file = b.path("modules/world-core/src/light_fuzz_tests.zig"), .target = target, .optimize = optimize, .sanitize_c = sanitize_c }); const world_core_fuzz_tests = b.addTest(.{ .root_module = world_core_fuzz_root, .filters = test_filters }); + if (enable_rmlui) addRmlUi(world_core_fuzz_tests); test_step.dependOn(&b.addRunArtifact(world_core_fuzz_tests).step); const world_persistence_fuzz_root = b.createModule(.{ .root_source_file = b.path("modules/world-persistence/src/fuzz_tests.zig"), .target = target, .optimize = optimize, .sanitize_c = sanitize_c }); @@ -677,6 +693,7 @@ fn defineBuildSteps( world_persistence_fuzz_root.addImport("fs", fs_module); world_persistence_fuzz_root.addImport("world-core", world_core); const world_persistence_fuzz_tests = b.addTest(.{ .root_module = world_persistence_fuzz_root, .filters = test_filters }); + if (enable_rmlui) addRmlUi(world_persistence_fuzz_tests); test_step.dependOn(&b.addRunArtifact(world_persistence_fuzz_tests).step); const world_worldgen_fuzz_root = b.createModule(.{ .root_source_file = b.path("modules/world-worldgen/src/fuzz_tests.zig"), .target = target, .optimize = optimize, .sanitize_c = sanitize_c }); @@ -690,6 +707,7 @@ fn defineBuildSteps( world_worldgen_fuzz_root.addImport("worldgen-test", worldgen_test); const world_worldgen_fuzz_tests = b.addTest(.{ .root_module = world_worldgen_fuzz_root, .filters = test_filters }); world_worldgen_fuzz_tests.root_module.link_libc = true; + if (enable_rmlui) addRmlUi(world_worldgen_fuzz_tests); test_step.dependOn(&b.addRunArtifact(world_worldgen_fuzz_tests).step); const integration_root_module = b.createModule(.{ @@ -724,6 +742,7 @@ fn defineBuildSteps( exe_integration_tests.root_module.linkSystemLibrary("sdl3", .{}); exe_integration_tests.root_module.linkSystemLibrary("vulkan", .{}); if (enable_imgui) addCimgui(b, exe_integration_tests); + if (enable_rmlui) addRmlUi(exe_integration_tests); const test_integration_step = b.step("test-integration", "Run integration smoke test"); const run_integration_tests = b.addRunArtifact(exe_integration_tests); @@ -752,6 +771,7 @@ fn defineBuildSteps( robust_demo.root_module.linkSystemLibrary("sdl3", .{}); robust_demo.root_module.linkSystemLibrary("vulkan", .{}); robust_demo.root_module.addIncludePath(b.path("libs/stb")); + if (enable_rmlui) addRmlUi(robust_demo); b.installArtifact(robust_demo); @@ -771,6 +791,7 @@ fn defineBuildSteps( integration_robustness.root_module.addImport("engine-core", engine_core); integration_robustness.root_module.link_libc = true; integration_robustness.root_module.linkSystemLibrary("sdl3", .{}); // Needed for C imports if any + if (enable_rmlui) addRmlUi(integration_robustness); const test_robustness_run = b.addRunArtifact(integration_robustness); // Ensure robust-demo is built first @@ -795,8 +816,13 @@ fn defineBuildOptions(b: *std.Build) BuildOptions { const enable_imgui = b.option(bool, "imgui", "Enable Dear ImGui debug UI integration") orelse true; options.addOption(bool, "imgui", enable_imgui); + + const enable_rmlui = b.option(bool, "rmlui", "Enable the RmlUi 6.2 C ABI bridge") orelse false; + options.addOption(bool, "rmlui", enable_rmlui); + const engine_ui_options = b.addOptions(); engine_ui_options.addOption(bool, "imgui", enable_imgui); + engine_ui_options.addOption(bool, "rmlui", enable_rmlui); const smoke_test = b.option(bool, "smoke-test", "Enable automated smoke test mode (auto-loads world and exits)") orelse false; options.addOption(bool, "smoke_test", smoke_test); @@ -841,6 +867,7 @@ fn defineBuildOptions(b: *std.Build) BuildOptions { engine_graphics_options.addOption([]const u8, "chunk_debug_enable", chunk_debug_enable); engine_graphics_options.addOption(bool, "skip_present", skip_present); engine_graphics_options.addOption(bool, "imgui", enable_imgui); + engine_graphics_options.addOption(bool, "rmlui", enable_rmlui); const screenshot_path = b.option([]const u8, "screenshot-path", "Capture a PNG screenshot after N frames and exit") orelse ""; options.addOption([]const u8, "screenshot_path", screenshot_path); @@ -883,6 +910,7 @@ fn defineBuildOptions(b: *std.Build) BuildOptions { .engine_graphics_options = engine_graphics_options, .enable_debug_shadows = enable_debug_shadows, .enable_imgui = enable_imgui, + .enable_rmlui = enable_rmlui, .chunk_debug_mode = chunk_debug_mode, .chunk_debug_enable = chunk_debug_enable, .startup_diagnostic_seconds = startup_diagnostic_seconds, @@ -931,6 +959,11 @@ fn addCimgui(_: *std.Build, compile: *std.Build.Step.Compile) void { compile.root_module.link_libcpp = true; } +fn addRmlUi(compile: *std.Build.Step.Compile) void { + compile.root_module.linkSystemLibrary("zigcraft-rmlui-bridge", .{ .use_pkg_config = .force }); + compile.root_module.link_libcpp = true; +} + fn addSharedImports(module: *std.Build.Module, zig_math: *std.Build.Module, zig_noise: *std.Build.Module, fs_module: *std.Build.Module, sync_module: *std.Build.Module, c_module: *std.Build.Module, options: *std.Build.Step.Options) void { addSharedImportsNoOptions(module, zig_math, zig_noise, fs_module, sync_module, c_module); module.addOptions("build_options", options); diff --git a/docs/shaders/spirv-sizes.json b/docs/shaders/spirv-sizes.json index 05a7c6c7..b4918411 100644 --- a/docs/shaders/spirv-sizes.json +++ b/docs/shaders/spirv-sizes.json @@ -21,18 +21,21 @@ "assets/shaders/vulkan/shadow.vert": 5236, "assets/shaders/vulkan/sky.frag": 18756, "assets/shaders/vulkan/sky.vert": 2696, - "assets/shaders/vulkan/ssao_blur.frag": 1760, "assets/shaders/vulkan/ssao.frag": 6588, "assets/shaders/vulkan/ssao.vert": 1160, + "assets/shaders/vulkan/ssao_blur.frag": 1760, "assets/shaders/vulkan/taa.frag": 9760, "assets/shaders/vulkan/taa.vert": 1160, - "assets/shaders/vulkan/terrain_debug.frag": 1336, "assets/shaders/vulkan/terrain.frag": 63336, "assets/shaders/vulkan/terrain.vert": 10500, + "assets/shaders/vulkan/terrain_debug.frag": 1336, "assets/shaders/vulkan/ui.frag": 744, + "assets/shaders/vulkan/ui.vert": 1336, + "assets/shaders/vulkan/ui_rml.frag": 376, + "assets/shaders/vulkan/ui_rml.vert": 1624, + "assets/shaders/vulkan/ui_rml_tex.frag": 664, "assets/shaders/vulkan/ui_tex.frag": 1408, "assets/shaders/vulkan/ui_tex.vert": 1344, - "assets/shaders/vulkan/ui.vert": 1336, "assets/shaders/vulkan/water.frag": 22236, "assets/shaders/vulkan/water.vert": 8796 } diff --git a/docs/ui-architecture.md b/docs/ui-architecture.md new file mode 100644 index 00000000..b510c2f5 --- /dev/null +++ b/docs/ui-architecture.md @@ -0,0 +1,65 @@ +# Player UI Architecture + +ZigCraft uses three deliberately separate UI layers: + +- **RmlUi** is the opt-in player-facing layout and widget system. It is enabled + with `-Drmlui=true` and owns the complete player menu flow. +- **`engine-ui` / `UIRenderer`** remains the render abstraction. RmlUi submits + indexed geometry, generated textures, and scissors through the existing RHI; + it does not own a Vulkan instance, device, swapchain, or command buffer. +- **Dear ImGui** remains developer-only UI for diagnostics and tooling. + +## Dependency boundary + +RmlUi 6.2 is pinned in `flake.nix`. `libs/rmlui_bridge` contains the only C++ +integration code and exposes a narrow C ABI to Zig. Feature-off builds do not +link the bridge or allocate the retained-geometry Vulkan buffers and pipelines. + +## Lifetime and input + +`UISystemManager` owns a heap-stable `RmlUi` backend. Screens own documents and +event listeners, while the backend owns compiled geometry and generated texture +handles until RmlUi releases them. Raw SDL events carry an explicit callback +context; when an active Rml document consumes an event, game input does not see +the same event. + +## Player menu flow + +With `-Drmlui=true`, normal menu startup and menu screenshot startup use +`RmlHomeScreen`. It retains the deterministic world preview and provides World +Library, Resource Packs, Sky & Lighting, Settings, and Exit actions. World +Library is the entry point for loading saves and opening the three-step Details, +Terrain, and Review creation wizard. If RmlUi fails to initialize, startup falls +back to the immediate-mode home screen with the same navigation model. + +World Library, Create World, Settings, Resource Packs, Sky & Lighting, Pause, +and their modals use documents in `assets/ui/rmlui/` and the shared +`menu.rcss` design system. `rml_page.zig` owns common document and action +lifecycle, while `rml_markup.zig` safely builds escaped dynamic document +fragments. The immediate-mode implementations remain as the `-Drmlui=false` +fallback. + +`ScreenManager.drawBackgroundFor` finds the nearest background provider below a +menu without redrawing intermediate pages. Home provides the deterministic +world preview and Pause provides the active world, keeping nested pages visually +continuous beneath the shared 75% black tint. + +## Migration sequence + +1. Validate every migrated menu at supported window sizes on windowed Vulkan. +2. Repair deterministic offscreen capture and replace the invalid menu golden. +3. Move inventory and HUD components that benefit from responsive layout. +4. Remove superseded fallback presentation code only after RmlUi becomes the + default build mode. +5. Keep specialized low-level overlays and ImGui diagnostics separate. + +Each migrated screen must support mouse, keyboard/controller focus, resize and +HiDPI, clean document teardown, and visual regression captures at 1280x720, +1920x1080, and a high-DPI resolution. + +## Known limitation + +The tracked menu golden is invalid because it is black. The comparison script +now rejects black inputs. A new golden must not be promoted until the headless +capture visibly contains the final player UI and is deterministic under the +`ci-graphics` Nix shell. diff --git a/docs/visual-test/README.md b/docs/visual-test/README.md index 20b0557f..5afcf0a6 100644 --- a/docs/visual-test/README.md +++ b/docs/visual-test/README.md @@ -1,13 +1,20 @@ # Visual Regression Tests -`visual-test.yml` captures the menu in Lavapipe headless mode and compares it against `docs/visual-test/golden/menu.png` with ImageMagick RMSE. The golden diff is the primary gate; LLM verification remains a non-blocking diagnostic to explain failures. +`visual-test.yml` captures the menu in Lavapipe headless mode and compares it against `docs/visual-test/golden/menu.png` with ImageMagick RMSE. The capture is recorded after UI composition and before frame submission/presentation, so the copied image is the same final color target in both headless and windowed paths. The comparison rejects effectively black actuals and baselines before calculating RMSE; a black image is not visual-regression evidence. + +## Current Baseline Status + +The tracked `golden/menu.png` is known to be black and is therefore intentionally **not a valid baseline**. It has not been replaced by this change. Until a reviewed candidate is promoted, CI should fail loudly rather than treating an all-black image as a valid visual result. ## Regenerating The Golden -Run the same capture path used by CI, then replace the golden image after reviewing the result: +Capture a candidate through the same path used by CI. Inspect it visually and confirm it is non-black before promoting it: ```bash -nix develop .#ci-graphics --command zig build run -Dskip-present=true -Dscreenshot-path=docs/visual-test/golden/menu.png +nix develop .#ci-graphics --command zig build run -Dskip-present=true -Dscreenshot-path=screenshots/menu-candidate.png +magick screenshots/menu-candidate.png -colorspace RGB -format '%[fx:mean]\n' info: ``` +Promotion requires a deterministic Lavapipe run, visible menu controls/text, a non-black mean, and reviewer approval. Only then replace `docs/visual-test/golden/menu.png` with the reviewed candidate and rerun the capture/compare command. + CI uses `VISUAL_DIFF_RMSE_TOLERANCE=0.015` to allow small Lavapipe version differences while still catching deterministic layout or rendering regressions. diff --git a/flake.nix b/flake.nix index 0e88ae7f..084f459f 100644 --- a/flake.nix +++ b/flake.nix @@ -281,6 +281,77 @@ runHook postInstall ''; }; + + rmlui = pkgs.llvmPackages.libcxxStdenv.mkDerivation rec { + pname = "rmlui-core"; + version = "6.2"; + + src = pkgs.fetchFromGitHub { + owner = "mikke89"; + repo = "RmlUi"; + rev = "2230d1a6e8e0848ed87a5761e2a5160b2a175ba4"; + hash = "sha256-K/znksrli3/FQ+lHgZgMgefFrWAGbxKNvFIIqtybOMc="; + }; + + nativeBuildInputs = [ pkgs.cmake pkgs.pkg-config ]; + buildInputs = [ pkgs.freetype ]; + + # The upstream top-level target normally pulls the optional debugger + # library too. ZigCraft consumes only Core through the C ABI bridge. + postPatch = '' + substituteInPlace Source/CMakeLists.txt --replace-fail 'add_subdirectory("Debugger")' "" + substituteInPlace CMakeLists.txt --replace-fail 'target_link_libraries(rmlui INTERFACE rmlui_core rmlui_debugger)' 'target_link_libraries(rmlui INTERFACE rmlui_core)' + ''; + + cmakeFlags = [ + "-DBUILD_SHARED_LIBS=OFF" + "-DBUILD_TESTING=OFF" + "-DRMLUI_SAMPLES=OFF" + "-DRMLUI_FONT_ENGINE=freetype" + "-DRMLUI_LUA_BINDINGS=OFF" + "-DRMLUI_LOTTIE_PLUGIN=OFF" + "-DRMLUI_SVG_PLUGIN=OFF" + "-DRMLUI_PRECOMPILED_HEADERS=OFF" + ]; + }; + + rmluiBridge = pkgs.llvmPackages.libcxxStdenv.mkDerivation { + pname = "zigcraft-rmlui-bridge"; + version = "6.2"; + src = ./.; + + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ pkgs.sdl3 pkgs.freetype rmlui ]; + dontConfigure = true; + + buildPhase = '' + runHook preBuild + $CXX -std=c++17 -DRMLUI_STATIC_LIB -O2 -fPIC -I$src/libs/rmlui_bridge -I${rmlui}/include $(pkg-config --cflags sdl3) \ + -c $src/libs/rmlui_bridge/zigcraft_rmlui.cpp -o zigcraft_rmlui.o + ar rcs libzigcraft_rmlui_bridge.a zigcraft_rmlui.o + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/include/zigcraft $out/lib/pkgconfig + cp $src/libs/rmlui_bridge/zigcraft_rmlui.h $out/include/zigcraft/ + cp libzigcraft_rmlui_bridge.a $out/lib/ + cat > $out/lib/pkgconfig/zigcraft-rmlui-bridge.pc < +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +static_assert(sizeof(ZigCraftRmlUiVertex) == sizeof(Rml::Vertex)); +static_assert(alignof(ZigCraftRmlUiVertex) == alignof(Rml::Vertex)); +static_assert(sizeof(int) == sizeof(int32_t)); +static_assert(offsetof(ZigCraftRmlUiVertex, x) == offsetof(Rml::Vertex, position)); +static_assert(offsetof(ZigCraftRmlUiVertex, color) == offsetof(Rml::Vertex, colour)); +static_assert(offsetof(ZigCraftRmlUiVertex, u) == offsetof(Rml::Vertex, tex_coord)); + +int key_modifiers(SDL_Keymod modifiers) { + int result = 0; + if (modifiers & SDL_KMOD_CTRL) result |= Rml::Input::KM_CTRL; + if (modifiers & SDL_KMOD_SHIFT) result |= Rml::Input::KM_SHIFT; + if (modifiers & SDL_KMOD_ALT) result |= Rml::Input::KM_ALT; + if (modifiers & SDL_KMOD_GUI) result |= Rml::Input::KM_META; + if (modifiers & SDL_KMOD_CAPS) result |= Rml::Input::KM_CAPSLOCK; + if (modifiers & SDL_KMOD_NUM) result |= Rml::Input::KM_NUMLOCK; + if (modifiers & SDL_KMOD_SCROLL) result |= Rml::Input::KM_SCROLLLOCK; + return result; +} + +Rml::Input::KeyIdentifier convert_key(SDL_Keycode key) { + if (key >= SDLK_0 && key <= SDLK_9) + return static_cast(Rml::Input::KI_0 + key - SDLK_0); + if (key >= SDLK_A && key <= SDLK_Z) + return static_cast(Rml::Input::KI_A + key - SDLK_A); + if (key >= SDLK_F1 && key <= SDLK_F24) + return static_cast(Rml::Input::KI_F1 + key - SDLK_F1); + + switch (key) { + case SDLK_UNKNOWN: return Rml::Input::KI_UNKNOWN; + case SDLK_ESCAPE: return Rml::Input::KI_ESCAPE; + case SDLK_SPACE: return Rml::Input::KI_SPACE; + case SDLK_SEMICOLON: return Rml::Input::KI_OEM_1; + case SDLK_PLUS: return Rml::Input::KI_OEM_PLUS; + case SDLK_COMMA: return Rml::Input::KI_OEM_COMMA; + case SDLK_MINUS: return Rml::Input::KI_OEM_MINUS; + case SDLK_PERIOD: return Rml::Input::KI_OEM_PERIOD; + case SDLK_SLASH: return Rml::Input::KI_OEM_2; + case SDLK_GRAVE: return Rml::Input::KI_OEM_3; + case SDLK_LEFTBRACKET: return Rml::Input::KI_OEM_4; + case SDLK_BACKSLASH: return Rml::Input::KI_OEM_5; + case SDLK_RIGHTBRACKET: return Rml::Input::KI_OEM_6; + case SDLK_DBLAPOSTROPHE: return Rml::Input::KI_OEM_7; + case SDLK_KP_0: return Rml::Input::KI_NUMPAD0; + case SDLK_KP_1: return Rml::Input::KI_NUMPAD1; + case SDLK_KP_2: return Rml::Input::KI_NUMPAD2; + case SDLK_KP_3: return Rml::Input::KI_NUMPAD3; + case SDLK_KP_4: return Rml::Input::KI_NUMPAD4; + case SDLK_KP_5: return Rml::Input::KI_NUMPAD5; + case SDLK_KP_6: return Rml::Input::KI_NUMPAD6; + case SDLK_KP_7: return Rml::Input::KI_NUMPAD7; + case SDLK_KP_8: return Rml::Input::KI_NUMPAD8; + case SDLK_KP_9: return Rml::Input::KI_NUMPAD9; + case SDLK_KP_ENTER: return Rml::Input::KI_NUMPADENTER; + case SDLK_KP_MULTIPLY: return Rml::Input::KI_MULTIPLY; + case SDLK_KP_PLUS: return Rml::Input::KI_ADD; + case SDLK_KP_MINUS: return Rml::Input::KI_SUBTRACT; + case SDLK_KP_PERIOD: return Rml::Input::KI_DECIMAL; + case SDLK_KP_DIVIDE: return Rml::Input::KI_DIVIDE; + case SDLK_KP_EQUALS: return Rml::Input::KI_OEM_NEC_EQUAL; + case SDLK_BACKSPACE: return Rml::Input::KI_BACK; + case SDLK_TAB: return Rml::Input::KI_TAB; + case SDLK_CLEAR: return Rml::Input::KI_CLEAR; + case SDLK_RETURN: return Rml::Input::KI_RETURN; + case SDLK_PAUSE: return Rml::Input::KI_PAUSE; + case SDLK_CAPSLOCK: return Rml::Input::KI_CAPITAL; + case SDLK_PAGEUP: return Rml::Input::KI_PRIOR; + case SDLK_PAGEDOWN: return Rml::Input::KI_NEXT; + case SDLK_END: return Rml::Input::KI_END; + case SDLK_HOME: return Rml::Input::KI_HOME; + case SDLK_LEFT: return Rml::Input::KI_LEFT; + case SDLK_UP: return Rml::Input::KI_UP; + case SDLK_RIGHT: return Rml::Input::KI_RIGHT; + case SDLK_DOWN: return Rml::Input::KI_DOWN; + case SDLK_INSERT: return Rml::Input::KI_INSERT; + case SDLK_DELETE: return Rml::Input::KI_DELETE; + case SDLK_HELP: return Rml::Input::KI_HELP; + case SDLK_NUMLOCKCLEAR: return Rml::Input::KI_NUMLOCK; + case SDLK_SCROLLLOCK: return Rml::Input::KI_SCROLL; + case SDLK_LSHIFT: return Rml::Input::KI_LSHIFT; + case SDLK_RSHIFT: return Rml::Input::KI_RSHIFT; + case SDLK_LCTRL: return Rml::Input::KI_LCONTROL; + case SDLK_RCTRL: return Rml::Input::KI_RCONTROL; + case SDLK_LALT: return Rml::Input::KI_LMENU; + case SDLK_RALT: return Rml::Input::KI_RMENU; + case SDLK_LGUI: return Rml::Input::KI_LMETA; + case SDLK_RGUI: return Rml::Input::KI_RMETA; + default: return Rml::Input::KI_UNKNOWN; + } +} + +int mouse_button(Uint8 button) { + switch (button) { + case SDL_BUTTON_LEFT: return 0; + case SDL_BUTTON_RIGHT: return 1; + case SDL_BUTTON_MIDDLE: return 2; + default: return 3; + } +} + +class BridgeSystemInterface final : public Rml::SystemInterface { +public: + void set_window(SDL_Window *new_window) { window = new_window; } + SDL_Window *get_window() const { return window; } + + double GetElapsedTime() override { + const auto elapsed = std::chrono::steady_clock::now() - started_at; + return std::chrono::duration(elapsed).count(); + } + + void SetClipboardText(const Rml::String &text) override { SDL_SetClipboardText(text.c_str()); } + + void GetClipboardText(Rml::String &text) override { + char *clipboard_text = SDL_GetClipboardText(); + text = clipboard_text ? clipboard_text : ""; + SDL_free(clipboard_text); + } + + void ActivateKeyboard(Rml::Vector2f caret_position, float line_height) override { + if (!window) return; + const SDL_Rect rect = {int(caret_position.x), int(caret_position.y), 1, int(line_height)}; + SDL_SetTextInputArea(window, &rect, 0); + SDL_StartTextInput(window); + } + + void DeactivateKeyboard() override { + if (window) SDL_StopTextInput(window); + } + +private: + SDL_Window *window = nullptr; + std::chrono::steady_clock::time_point started_at = std::chrono::steady_clock::now(); +}; + +class BridgeRenderInterface final : public Rml::RenderInterface { +public: + explicit BridgeRenderInterface(ZigCraftRmlUiRenderCallbacks callbacks) : callbacks(callbacks) {} + + Rml::CompiledGeometryHandle CompileGeometry(Rml::Span vertices, Rml::Span indices) override { + if (!callbacks.compile_geometry) return 0; + return callbacks.compile_geometry( + callbacks.user_data, + reinterpret_cast(vertices.data()), + vertices.size(), + reinterpret_cast(indices.data()), + indices.size()); + } + + void RenderGeometry(Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation, Rml::TextureHandle texture) override { + if (callbacks.render_geometry) + callbacks.render_geometry(callbacks.user_data, geometry, translation.x, translation.y, texture); + } + + void ReleaseGeometry(Rml::CompiledGeometryHandle geometry) override { + if (callbacks.release_geometry) callbacks.release_geometry(callbacks.user_data, geometry); + } + + Rml::TextureHandle LoadTexture(Rml::Vector2i &dimensions, const Rml::String &source) override { + if (!callbacks.load_texture) return 0; + return callbacks.load_texture(callbacks.user_data, source.c_str(), &dimensions.x, &dimensions.y); + } + + Rml::TextureHandle GenerateTexture(Rml::Span source, Rml::Vector2i dimensions) override { + if (!callbacks.generate_texture) return 0; + return callbacks.generate_texture(callbacks.user_data, source.data(), source.size(), dimensions.x, dimensions.y); + } + + void ReleaseTexture(Rml::TextureHandle texture) override { + if (callbacks.release_texture) callbacks.release_texture(callbacks.user_data, texture); + } + + void EnableScissorRegion(bool enable) override { + if (callbacks.enable_scissor) callbacks.enable_scissor(callbacks.user_data, enable); + } + + void SetScissorRegion(Rml::Rectanglei region) override { + if (callbacks.set_scissor) + callbacks.set_scissor(callbacks.user_data, region.Left(), region.Top(), region.Width(), region.Height()); + } + +private: + ZigCraftRmlUiRenderCallbacks callbacks; +}; + +} // namespace + +struct ZigCraftRmlUiAction : Rml::EventListener { + ZigCraftRmlUiDocument *document = nullptr; + std::string event_type; + ZigCraftRmlUiActionCallback callback = nullptr; + void *user_data = nullptr; + bool attached = false; + + void ProcessEvent(Rml::Event &event) override { + if (!attached || !callback) return; + Rml::Element *target = event.GetTargetElement(); + while (target && target->GetId().empty()) target = target->GetParentNode(); + callback(user_data, event.GetType().c_str(), target ? target->GetId().c_str() : ""); + } + + void OnDetach(Rml::Element *) override { attached = false; } +}; + +struct ZigCraftRmlUi { + explicit ZigCraftRmlUi(const ZigCraftRmlUiRenderCallbacks &callbacks) : render_interface(callbacks) {} + + BridgeSystemInterface system_interface; + BridgeRenderInterface render_interface; + std::vector> actions; +}; + +namespace { + +bool runtime_exists = false; + +Rml::Context *as_context(ZigCraftRmlUiContext *context) { return reinterpret_cast(context); } +Rml::ElementDocument *as_document(ZigCraftRmlUiDocument *document) { + return reinterpret_cast(document); +} + +void detach_action(ZigCraftRmlUiAction *action) { + if (action && action->attached) { + as_document(action->document)->RemoveEventListener(action->event_type, action); + action->attached = false; + } +} + +void remove_actions_for_document(ZigCraftRmlUi *rmlui, ZigCraftRmlUiDocument *document) { + auto &actions = rmlui->actions; + for (const auto &action : actions) + if (action->document == document) detach_action(action.get()); + actions.erase(std::remove_if(actions.begin(), actions.end(), [document](const auto &action) { + return action->document == document; + }), + actions.end()); +} + +void remove_actions_for_context(ZigCraftRmlUi *rmlui, Rml::Context *context) { + auto &actions = rmlui->actions; + for (const auto &action : actions) + if (as_document(action->document)->GetContext() == context) detach_action(action.get()); + actions.erase(std::remove_if(actions.begin(), actions.end(), [context](const auto &action) { + return as_document(action->document)->GetContext() == context; + }), + actions.end()); +} + +} // namespace + +extern "C" { + +ZigCraftRmlUi *zigcraft_rmlui_init(const ZigCraftRmlUiRenderCallbacks *callbacks) { + if (!callbacks || runtime_exists) return nullptr; + + auto *rmlui = new ZigCraftRmlUi(*callbacks); + Rml::SetSystemInterface(&rmlui->system_interface); + Rml::SetRenderInterface(&rmlui->render_interface); + if (!Rml::Initialise()) { + delete rmlui; + return nullptr; + } + runtime_exists = true; + return rmlui; +} + +void zigcraft_rmlui_destroy(ZigCraftRmlUi *rmlui) { + if (!rmlui) return; + for (const auto &action : rmlui->actions) detach_action(action.get()); + rmlui->actions.clear(); + Rml::Shutdown(); + runtime_exists = false; + delete rmlui; +} + +void zigcraft_rmlui_set_sdl_window(ZigCraftRmlUi *rmlui, SDL_Window *window) { + if (rmlui) rmlui->system_interface.set_window(window); +} + +ZigCraftRmlUiContext *zigcraft_rmlui_context_create(ZigCraftRmlUi *rmlui, const char *name, int width, int height) { + if (!rmlui || !name) return nullptr; + Rml::Context *context = Rml::CreateContext(name, {width, height}); + if (!context) return nullptr; + + if (SDL_Window *window = rmlui->system_interface.get_window()) { + const float display_scale = SDL_GetWindowDisplayScale(window); + if (display_scale > 0.0f) context->SetDensityIndependentPixelRatio(display_scale); + } + return reinterpret_cast(context); +} + +void zigcraft_rmlui_context_destroy(ZigCraftRmlUi *rmlui, ZigCraftRmlUiContext *context) { + if (!rmlui || !context) return; + Rml::Context *native_context = as_context(context); + remove_actions_for_context(rmlui, native_context); + Rml::RemoveContext(native_context->GetName()); +} + +void zigcraft_rmlui_context_resize(ZigCraftRmlUiContext *context, int width, int height) { + if (context) as_context(context)->SetDimensions({width, height}); +} + +bool zigcraft_rmlui_context_update(ZigCraftRmlUiContext *context) { + return context && as_context(context)->Update(); +} + +bool zigcraft_rmlui_context_render(ZigCraftRmlUiContext *context) { + return context && as_context(context)->Render(); +} + +bool zigcraft_rmlui_load_font_face(const char *path, bool fallback_face) { + return path && Rml::LoadFontFace(path, fallback_face); +} + +ZigCraftRmlUiDocument *zigcraft_rmlui_context_load_document(ZigCraftRmlUiContext *context, const char *path) { + if (!context || !path) return nullptr; + return reinterpret_cast(as_context(context)->LoadDocument(path)); +} + +ZigCraftRmlUiDocument *zigcraft_rmlui_context_load_document_memory( + ZigCraftRmlUiContext *context, const char *rml, const char *source_url) { + if (!context || !rml) return nullptr; + return reinterpret_cast( + as_context(context)->LoadDocumentFromMemory(rml, source_url ? source_url : "[ZigCraft RML document]")); +} + +void zigcraft_rmlui_document_show(ZigCraftRmlUiDocument *document) { + if (document) as_document(document)->Show(); +} + +void zigcraft_rmlui_document_hide(ZigCraftRmlUiDocument *document) { + if (document) as_document(document)->Hide(); +} + +void zigcraft_rmlui_document_close(ZigCraftRmlUi *rmlui, ZigCraftRmlUiDocument *document) { + if (!rmlui || !document) return; + remove_actions_for_document(rmlui, document); + as_document(document)->Close(); +} + +bool zigcraft_rmlui_document_set_inner_rml(ZigCraftRmlUiDocument *document, const char *element_id, const char *rml) { + if (!document || !element_id || !rml) return false; + Rml::Element *element = as_document(document)->GetElementById(element_id); + if (!element) return false; + element->SetInnerRML(rml); + return true; +} + +bool zigcraft_rmlui_document_set_class(ZigCraftRmlUiDocument *document, const char *element_id, const char *class_name, bool active) { + if (!document || !element_id || !class_name) return false; + Rml::Element *element = as_document(document)->GetElementById(element_id); + if (!element) return false; + element->SetClass(class_name, active); + return true; +} + +bool zigcraft_rmlui_document_set_property(ZigCraftRmlUiDocument *document, const char *element_id, const char *property_name, const char *value) { + if (!document || !element_id || !property_name || !value) return false; + Rml::Element *element = as_document(document)->GetElementById(element_id); + return element && element->SetProperty(property_name, value); +} + +size_t zigcraft_rmlui_document_get_value(ZigCraftRmlUiDocument *document, const char *element_id, char *buffer, size_t buffer_size) { + if (!document || !element_id) return 0; + Rml::Element *element = as_document(document)->GetElementById(element_id); + if (!element) return 0; + auto *control = rmlui_dynamic_cast(element); + if (!control) return 0; + const Rml::String value = control->GetValue(); + if (buffer && buffer_size > 0) { + const size_t copy_size = std::min(value.size(), buffer_size - 1); + std::copy_n(value.data(), copy_size, buffer); + buffer[copy_size] = '\0'; + } + return value.size(); +} + +bool zigcraft_rmlui_document_set_value(ZigCraftRmlUiDocument *document, const char *element_id, const char *value) { + if (!document || !element_id || !value) return false; + Rml::Element *element = as_document(document)->GetElementById(element_id); + if (!element) return false; + auto *control = rmlui_dynamic_cast(element); + if (!control) return false; + control->SetValue(value); + return true; +} + +bool zigcraft_rmlui_document_set_disabled(ZigCraftRmlUiDocument *document, const char *element_id, bool disabled) { + if (!document || !element_id) return false; + Rml::Element *element = as_document(document)->GetElementById(element_id); + if (!element) return false; + if (auto *control = rmlui_dynamic_cast(element)) { + control->SetDisabled(disabled); + } else { + element->SetClass("disabled", disabled); + } + return true; +} + +bool zigcraft_rmlui_document_focus(ZigCraftRmlUiDocument *document, const char *element_id, bool focus_visible) { + if (!document || !element_id) return false; + Rml::Element *element = as_document(document)->GetElementById(element_id); + return element && element->Focus(focus_visible); +} + +bool zigcraft_rmlui_process_sdl_event( + ZigCraftRmlUi *rmlui, ZigCraftRmlUiContext *context, SDL_Window *window, const SDL_Event *event) { + if (!rmlui || !context || !event) return true; + rmlui->system_interface.set_window(window); + Rml::Context *native_context = as_context(context); + + switch (event->type) { + case SDL_EVENT_MOUSE_MOTION: { + const float pixel_density = window ? SDL_GetWindowPixelDensity(window) : 1.0f; + return native_context->ProcessMouseMove( + int(event->motion.x * pixel_density), int(event->motion.y * pixel_density), key_modifiers(SDL_GetModState())); + } + case SDL_EVENT_MOUSE_BUTTON_DOWN: + SDL_CaptureMouse(true); + return native_context->ProcessMouseButtonDown(mouse_button(event->button.button), key_modifiers(SDL_GetModState())); + case SDL_EVENT_MOUSE_BUTTON_UP: + SDL_CaptureMouse(false); + return native_context->ProcessMouseButtonUp(mouse_button(event->button.button), key_modifiers(SDL_GetModState())); + case SDL_EVENT_MOUSE_WHEEL: + return native_context->ProcessMouseWheel({-event->wheel.x, -event->wheel.y}, key_modifiers(SDL_GetModState())); + case SDL_EVENT_KEY_DOWN: { + bool result = native_context->ProcessKeyDown(convert_key(event->key.key), key_modifiers(event->key.mod)); + if (event->key.key == SDLK_RETURN || event->key.key == SDLK_KP_ENTER) + result = native_context->ProcessTextInput('\n') && result; + return result; + } + case SDL_EVENT_KEY_UP: + return native_context->ProcessKeyUp(convert_key(event->key.key), key_modifiers(event->key.mod)); + case SDL_EVENT_TEXT_INPUT: + return event->text.text && native_context->ProcessTextInput(event->text.text); + case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + native_context->SetDimensions({event->window.data1, event->window.data2}); + return true; + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + if (window) native_context->SetDensityIndependentPixelRatio(SDL_GetWindowDisplayScale(window)); + return true; + case SDL_EVENT_WINDOW_MOUSE_LEAVE: + return native_context->ProcessMouseLeave(); + default: + return true; + } +} + +ZigCraftRmlUiAction *zigcraft_rmlui_document_add_action( + ZigCraftRmlUi *rmlui, + ZigCraftRmlUiDocument *document, + const char *event_type, + ZigCraftRmlUiActionCallback callback, + void *user_data) { + if (!rmlui || !document || !event_type || !callback) return nullptr; + auto action = std::make_unique(); + action->document = document; + action->event_type = event_type; + action->callback = callback; + action->user_data = user_data; + action->attached = true; + as_document(document)->AddEventListener(action->event_type, action.get()); + ZigCraftRmlUiAction *result = action.get(); + rmlui->actions.push_back(std::move(action)); + return result; +} + +void zigcraft_rmlui_action_remove(ZigCraftRmlUi *rmlui, ZigCraftRmlUiAction *action) { + if (!rmlui || !action) return; + auto &actions = rmlui->actions; + const auto found = std::find_if(actions.begin(), actions.end(), [action](const auto &entry) { return entry.get() == action; }); + if (found == actions.end()) return; + detach_action(found->get()); + actions.erase(found); +} + +} // extern "C" diff --git a/libs/rmlui_bridge/zigcraft_rmlui.h b/libs/rmlui_bridge/zigcraft_rmlui.h new file mode 100644 index 00000000..b598dab0 --- /dev/null +++ b/libs/rmlui_bridge/zigcraft_rmlui.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// Stable C ABI for the RmlUi 6.2 integration. This header intentionally does +// not expose RmlUi C++ types so it can be imported directly from Zig. +#ifndef ZIGCRAFT_RMLUI_H +#define ZIGCRAFT_RMLUI_H + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct ZigCraftRmlUi ZigCraftRmlUi; +typedef struct ZigCraftRmlUiContext ZigCraftRmlUiContext; +typedef struct ZigCraftRmlUiDocument ZigCraftRmlUiDocument; +typedef struct ZigCraftRmlUiAction ZigCraftRmlUiAction; + +typedef struct ZigCraftRmlUiVertex { + float x; + float y; + uint8_t color[4]; + float u; + float v; +} ZigCraftRmlUiVertex; + +typedef uintptr_t (*ZigCraftRmlUiCompileGeometryFn)( + void *user_data, + const ZigCraftRmlUiVertex *vertices, + size_t vertex_count, + const int32_t *indices, + size_t index_count); +typedef void (*ZigCraftRmlUiRenderGeometryFn)( + void *user_data, uintptr_t geometry, float translation_x, float translation_y, uintptr_t texture); +typedef void (*ZigCraftRmlUiReleaseGeometryFn)(void *user_data, uintptr_t geometry); +typedef uintptr_t (*ZigCraftRmlUiLoadTextureFn)( + void *user_data, const char *source, int *out_width, int *out_height); +typedef uintptr_t (*ZigCraftRmlUiGenerateTextureFn)( + void *user_data, const uint8_t *pixels_rgba_premultiplied, size_t size, int width, int height); +typedef void (*ZigCraftRmlUiReleaseTextureFn)(void *user_data, uintptr_t texture); +typedef void (*ZigCraftRmlUiEnableScissorFn)(void *user_data, bool enable); +typedef void (*ZigCraftRmlUiSetScissorFn)(void *user_data, int x, int y, int width, int height); + +typedef struct ZigCraftRmlUiRenderCallbacks { + void *user_data; + ZigCraftRmlUiCompileGeometryFn compile_geometry; + ZigCraftRmlUiRenderGeometryFn render_geometry; + ZigCraftRmlUiReleaseGeometryFn release_geometry; + ZigCraftRmlUiLoadTextureFn load_texture; + ZigCraftRmlUiGenerateTextureFn generate_texture; + ZigCraftRmlUiReleaseTextureFn release_texture; + ZigCraftRmlUiEnableScissorFn enable_scissor; + ZigCraftRmlUiSetScissorFn set_scissor; +} ZigCraftRmlUiRenderCallbacks; + +// Creates the process-global RmlUi runtime. Only one instance may exist at a +// time. Call zigcraft_rmlui_destroy() before creating another instance. +ZigCraftRmlUi *zigcraft_rmlui_init(const ZigCraftRmlUiRenderCallbacks *callbacks); +void zigcraft_rmlui_destroy(ZigCraftRmlUi *rmlui); + +// Supplies the SDL window used for text input and clipboard integration. +void zigcraft_rmlui_set_sdl_window(ZigCraftRmlUi *rmlui, SDL_Window *window); + +ZigCraftRmlUiContext *zigcraft_rmlui_context_create(ZigCraftRmlUi *rmlui, const char *name, int width, int height); +void zigcraft_rmlui_context_destroy(ZigCraftRmlUi *rmlui, ZigCraftRmlUiContext *context); +void zigcraft_rmlui_context_resize(ZigCraftRmlUiContext *context, int width, int height); +bool zigcraft_rmlui_context_update(ZigCraftRmlUiContext *context); +bool zigcraft_rmlui_context_render(ZigCraftRmlUiContext *context); + +bool zigcraft_rmlui_load_font_face(const char *path, bool fallback_face); +ZigCraftRmlUiDocument *zigcraft_rmlui_context_load_document(ZigCraftRmlUiContext *context, const char *path); +ZigCraftRmlUiDocument *zigcraft_rmlui_context_load_document_memory( + ZigCraftRmlUiContext *context, const char *rml, const char *source_url); +void zigcraft_rmlui_document_show(ZigCraftRmlUiDocument *document); +void zigcraft_rmlui_document_hide(ZigCraftRmlUiDocument *document); +void zigcraft_rmlui_document_close(ZigCraftRmlUi *rmlui, ZigCraftRmlUiDocument *document); +bool zigcraft_rmlui_document_set_inner_rml(ZigCraftRmlUiDocument *document, const char *element_id, const char *rml); +bool zigcraft_rmlui_document_set_class(ZigCraftRmlUiDocument *document, const char *element_id, const char *class_name, bool active); +bool zigcraft_rmlui_document_set_property(ZigCraftRmlUiDocument *document, const char *element_id, const char *property_name, const char *value); +size_t zigcraft_rmlui_document_get_value(ZigCraftRmlUiDocument *document, const char *element_id, char *buffer, size_t buffer_size); +bool zigcraft_rmlui_document_set_value(ZigCraftRmlUiDocument *document, const char *element_id, const char *value); +bool zigcraft_rmlui_document_set_disabled(ZigCraftRmlUiDocument *document, const char *element_id, bool disabled); +bool zigcraft_rmlui_document_focus(ZigCraftRmlUiDocument *document, const char *element_id, bool focus_visible); + +// Returns true when RmlUi did not consume the event. The event can still be +// dispatched to the rest of the game when this returns true. +bool zigcraft_rmlui_process_sdl_event( + ZigCraftRmlUi *rmlui, ZigCraftRmlUiContext *context, SDL_Window *window, const SDL_Event *event); + +typedef void (*ZigCraftRmlUiActionCallback)( + void *user_data, const char *event_type, const char *target_id); + +// Attach a bubbling document-level action callback (for example, "click"). +// The event and target strings are only valid for the duration of the callback. +ZigCraftRmlUiAction *zigcraft_rmlui_document_add_action( + ZigCraftRmlUi *rmlui, + ZigCraftRmlUiDocument *document, + const char *event_type, + ZigCraftRmlUiActionCallback callback, + void *user_data); +void zigcraft_rmlui_action_remove(ZigCraftRmlUi *rmlui, ZigCraftRmlUiAction *action); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/modules/engine-graphics/src/rhi_tests.zig b/modules/engine-graphics/src/rhi_tests.zig index a40f0958..9c1fc3ba 100644 --- a/modules/engine-graphics/src/rhi_tests.zig +++ b/modules/engine-graphics/src/rhi_tests.zig @@ -496,6 +496,8 @@ const MockContext = struct { .drawTextureRegion = undefined, .drawDepthTexture = drawDepthTexture, .bindPipeline = undefined, + .drawIndexedGeometry = undefined, + .setScissorRegion = undefined, }; const MOCK_VULKAN_RHI_VTABLE = rhi.RHI.VTable{ diff --git a/modules/engine-graphics/src/rhi_vulkan.zig b/modules/engine-graphics/src/rhi_vulkan.zig index 6b174559..1948229f 100644 --- a/modules/engine-graphics/src/rhi_vulkan.zig +++ b/modules/engine-graphics/src/rhi_vulkan.zig @@ -408,7 +408,9 @@ fn createCullingSystem(ctx_ptr: *anyopaque, allocator: std.mem.Allocator, max_ch fn captureFrame(ctx_ptr: *anyopaque, path: []const u8) bool { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); - return screenshot.captureScreenshot(ctx, path); + ctx.mutex.lock(); + defer ctx.mutex.unlock(); + return screenshot.requestCapture(ctx, path); } fn endFrame(ctx_ptr: *anyopaque) void { @@ -734,6 +736,16 @@ fn drawRect2D(ctx_ptr: *anyopaque, rect: rhi.Rect, color: rhi.Color) void { ui_submission.drawRect2D(ctx, rect, color); } +fn drawIndexedUIGeometry(ctx_ptr: *anyopaque, vertices: []const rhi.UiVertex, indices: []const u32, texture: rhi.TextureHandle, translation: [2]f32) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ui_submission.drawIndexedGeometry(ctx, vertices, indices, texture, translation); +} + +fn setUIScissorRegion(ctx_ptr: *anyopaque, region: rhi.UiScissor) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ui_submission.setScissorRegion(ctx, region); +} + const VULKAN_SHADOW_CONTEXT_VTABLE = rhi.IShadowContext.VTable{ .beginPass = beginShadowPass, .endPass = endShadowPass, @@ -982,6 +994,8 @@ const VULKAN_UI_CONTEXT_VTABLE = rhi.IUIContext.VTable{ .drawTextureRegion = drawTextureRegion2D, .drawDepthTexture = drawDepthTexture, .bindPipeline = bindUIPipeline, + .drawIndexedGeometry = drawIndexedUIGeometry, + .setScissorRegion = setUIScissorRegion, }; fn initImGuiBackend(ctx_ptr: *anyopaque, window: *anyopaque) bool { diff --git a/modules/engine-graphics/src/root.zig b/modules/engine-graphics/src/root.zig index 7fc3ff34..12b27961 100644 --- a/modules/engine-graphics/src/root.zig +++ b/modules/engine-graphics/src/root.zig @@ -35,6 +35,7 @@ pub const descriptor_bindings_tests = @import("vulkan/descriptor_bindings_tests. pub const descriptor_manager_error_tests = @import("vulkan/descriptor_manager_error_tests.zig"); pub const descriptor_manager_tests = @import("vulkan/descriptor_manager_tests.zig"); pub const frame_manager_tests = @import("vulkan/frame_manager_tests.zig"); +pub const final_composition = @import("vulkan/final_composition.zig"); pub const pipeline_manager_edge_tests = @import("vulkan/pipeline_manager_edge_tests.zig"); pub const pipeline_manager_tests = @import("vulkan/pipeline_manager_tests.zig"); pub const pipeline_specialized_edge_tests = @import("vulkan/pipeline_specialized_edge_tests.zig"); diff --git a/modules/engine-graphics/src/vulkan/final_composition.zig b/modules/engine-graphics/src/vulkan/final_composition.zig new file mode 100644 index 00000000..00c8b838 --- /dev/null +++ b/modules/engine-graphics/src/vulkan/final_composition.zig @@ -0,0 +1,78 @@ +//! Pure contracts for the image that is ready for display and screenshot +//! readback. Vulkan calls live in the pass orchestration layer. + +const c = @import("c").c; + +pub const AttachmentUse = enum { + /// A full-screen shader replaces every pixel. Previous color must not be + /// loaded, even when the display image has a valid prior layout. + full_screen_replace, + /// UI draws are alpha-blended over a completed display image. + overlay, +}; + +pub const AttachmentContract = struct { + load_op: c.VkAttachmentLoadOp, + store_op: c.VkAttachmentStoreOp = c.VK_ATTACHMENT_STORE_OP_STORE, + initial_layout: c.VkImageLayout, + final_layout: c.VkImageLayout, +}; + +pub fn displayLayout(skip_present: bool) c.VkImageLayout { + return if (skip_present) c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL else c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; +} + +pub fn attachmentContract(use: AttachmentUse, layout: c.VkImageLayout) AttachmentContract { + return .{ + .load_op = switch (use) { + .full_screen_replace => c.VK_ATTACHMENT_LOAD_OP_DONT_CARE, + .overlay => c.VK_ATTACHMENT_LOAD_OP_LOAD, + }, + .initial_layout = layout, + .final_layout = layout, + }; +} + +/// Actual state, updated only after a final-color render pass ends. A screenshot +/// must consume this state instead of reconstructing it from presentation mode. +pub const FinalComposedImage = struct { + image: c.VkImage = null, + image_index: u32 = 0, + layout: c.VkImageLayout = c.VK_IMAGE_LAYOUT_UNDEFINED, + + pub fn clear(self: *FinalComposedImage) void { + self.* = .{}; + } + + pub fn set(self: *FinalComposedImage, image: c.VkImage, image_index: u32, layout: c.VkImageLayout) void { + self.* = .{ .image = image, .image_index = image_index, .layout = layout }; + } + + pub fn isCurrentImage(self: FinalComposedImage, image_index: u32) bool { + return self.image != null and self.image_index == image_index and self.layout != c.VK_IMAGE_LAYOUT_UNDEFINED; + } +}; + +test "full-screen replacement and overlay have distinct load contracts" { + const layout = displayLayout(true); + const replacement = attachmentContract(.full_screen_replace, layout); + const overlay = attachmentContract(.overlay, layout); + + try @import("std").testing.expectEqual(c.VK_ATTACHMENT_LOAD_OP_DONT_CARE, replacement.load_op); + try @import("std").testing.expectEqual(c.VK_ATTACHMENT_LOAD_OP_LOAD, overlay.load_op); + try @import("std").testing.expectEqual(layout, replacement.final_layout); + try @import("std").testing.expectEqual(layout, overlay.initial_layout); +} + +test "final composition records the actual image and layout" { + var final_image = FinalComposedImage{}; + try @import("std").testing.expect(!final_image.isCurrentImage(2)); + + final_image.set(@ptrFromInt(1), 2, c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + try @import("std").testing.expect(final_image.isCurrentImage(2)); + try @import("std").testing.expect(!final_image.isCurrentImage(1)); + try @import("std").testing.expectEqual(c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, final_image.layout); + + final_image.clear(); + try @import("std").testing.expect(!final_image.isCurrentImage(2)); +} diff --git a/modules/engine-graphics/src/vulkan/fxaa_system.zig b/modules/engine-graphics/src/vulkan/fxaa_system.zig index 691beb89..1e8c91bd 100644 --- a/modules/engine-graphics/src/vulkan/fxaa_system.zig +++ b/modules/engine-graphics/src/vulkan/fxaa_system.zig @@ -32,7 +32,7 @@ pub const FXAASystem = struct { post_process_to_fxaa_render_pass: c.VkRenderPass = null, post_process_to_fxaa_framebuffer: c.VkFramebuffer = null, - pub fn init(self: *FXAASystem, device: *const VulkanDevice, allocator: Allocator, descriptor_pool: c.VkDescriptorPool, extent: c.VkExtent2D, format: c.VkFormat, sampler: c.VkSampler, swapchain_views: []const c.VkImageView) !void { + pub fn init(self: *FXAASystem, device: *const VulkanDevice, allocator: Allocator, descriptor_pool: c.VkDescriptorPool, extent: c.VkExtent2D, format: c.VkFormat, sampler: c.VkSampler, swapchain_views: []const c.VkImageView, final_layout: c.VkImageLayout) !void { self.deinit(device.vk_device, allocator, descriptor_pool); const vk = device.vk_device; @@ -69,10 +69,12 @@ pub const FXAASystem = struct { var color_attachment = std.mem.zeroes(c.VkAttachmentDescription); color_attachment.format = format; color_attachment.samples = c.VK_SAMPLE_COUNT_1_BIT; + // FXAA is a full-screen replacement pass, never an overlay. It must + // not load stale menu pixels from a prior composition. color_attachment.loadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE; color_attachment.storeOp = c.VK_ATTACHMENT_STORE_OP_STORE; - color_attachment.initialLayout = c.VK_IMAGE_LAYOUT_UNDEFINED; - color_attachment.finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + color_attachment.initialLayout = final_layout; + color_attachment.finalLayout = final_layout; var color_ref = c.VkAttachmentReference{ .attachment = 0, .layout = c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; var subpass = std.mem.zeroes(c.VkSubpassDescription); diff --git a/modules/engine-graphics/src/vulkan/pipeline_manager.zig b/modules/engine-graphics/src/vulkan/pipeline_manager.zig index d410eb78..883bdc8d 100644 --- a/modules/engine-graphics/src/vulkan/pipeline_manager.zig +++ b/modules/engine-graphics/src/vulkan/pipeline_manager.zig @@ -38,11 +38,15 @@ pub const PipelineManager = struct { sky_pipeline: c.VkPipeline = null, ui_pipeline: c.VkPipeline = null, ui_tex_pipeline: c.VkPipeline = null, + rml_ui_pipeline: c.VkPipeline = null, + rml_ui_tex_pipeline: c.VkPipeline = null, water_pipeline: c.VkPipeline = null, // Swapchain UI pipelines ui_swapchain_pipeline: c.VkPipeline = null, ui_swapchain_tex_pipeline: c.VkPipeline = null, + rml_ui_swapchain_pipeline: c.VkPipeline = null, + rml_ui_swapchain_tex_pipeline: c.VkPipeline = null, // Pipeline layouts pipeline_layout: c.VkPipelineLayout = null, @@ -213,9 +217,13 @@ pub const PipelineManager = struct { if (self.sky_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.rml_ui_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.rml_ui_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.water_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_swapchain_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_swapchain_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.rml_ui_swapchain_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.rml_ui_swapchain_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (comptime build_options.debug_shadows) { if (self.debug_shadow_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); @@ -229,9 +237,13 @@ pub const PipelineManager = struct { self.sky_pipeline = null; self.ui_pipeline = null; self.ui_tex_pipeline = null; + self.rml_ui_pipeline = null; + self.rml_ui_tex_pipeline = null; self.water_pipeline = null; self.ui_swapchain_pipeline = null; self.ui_swapchain_tex_pipeline = null; + self.rml_ui_swapchain_pipeline = null; + self.rml_ui_swapchain_tex_pipeline = null; self.debug_shadow_pipeline = null; } @@ -307,6 +319,17 @@ pub const PipelineManager = struct { ui_color_blending.attachmentCount = 1; ui_color_blending.pAttachments = &ui_color_blend_attachment; + // RmlUi 6.2 emits premultiplied vertex and texture color. Its blend + // equation is deliberately separate from the legacy straight-alpha + // immediate UI pipeline above. + var rml_color_blend_attachment = ui_color_blend_attachment; + rml_color_blend_attachment.srcColorBlendFactor = c.VK_BLEND_FACTOR_ONE; + rml_color_blend_attachment.dstColorBlendFactor = c.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + rml_color_blend_attachment.srcAlphaBlendFactor = c.VK_BLEND_FACTOR_ONE; + rml_color_blend_attachment.dstAlphaBlendFactor = c.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + var rml_color_blending = ui_color_blending; + rml_color_blending.pAttachments = &rml_color_blend_attachment; + var terrain_color_blending = std.mem.zeroes(c.VkPipelineColorBlendStateCreateInfo); terrain_color_blending.sType = c.VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; terrain_color_blending.attachmentCount = 1; @@ -320,6 +343,9 @@ pub const PipelineManager = struct { // UI Pipelines try self.createUIPipelines(allocator, vk_device, hdr_render_pass, &viewport_state, &dynamic_state, &input_assembly, &rasterizer, &multisampling, &depth_stencil, &ui_color_blending); + if (comptime build_options.rmlui) { + try self.createRmlUIPipelines(allocator, vk_device, hdr_render_pass, &viewport_state, &dynamic_state, &input_assembly, &rasterizer, &multisampling, &depth_stencil, &rml_color_blending); + } // Debug Shadow Pipeline if (comptime build_options.debug_shadows) { @@ -474,6 +500,76 @@ pub const PipelineManager = struct { try Utils.checkVk(c.vkCreateGraphicsPipelines(vk_device, null, 1, &pipeline_info, null, &self.ui_tex_pipeline)); } + /// Creates retained-geometry UI pipelines with RmlUi's RGBA8 vertex color + /// and UV layout. They intentionally remain separate from the legacy UI + /// pipelines, whose two six-float vertex layouts are incompatible. + fn createRmlUIPipelines( + self: *PipelineManager, + allocator: std.mem.Allocator, + vk_device: c.VkDevice, + hdr_render_pass: c.VkRenderPass, + viewport_state: *const c.VkPipelineViewportStateCreateInfo, + dynamic_state: *const c.VkPipelineDynamicStateCreateInfo, + input_assembly: *const c.VkPipelineInputAssemblyStateCreateInfo, + rasterizer: *const c.VkPipelineRasterizationStateCreateInfo, + multisampling: *const c.VkPipelineMultisampleStateCreateInfo, + depth_stencil: *const c.VkPipelineDepthStencilStateCreateInfo, + color_blending: *const c.VkPipelineColorBlendStateCreateInfo, + ) !void { + const binding_description = c.VkVertexInputBindingDescription{ .binding = 0, .stride = @sizeOf(rhi.UiVertex), .inputRate = c.VK_VERTEX_INPUT_RATE_VERTEX }; + const attribute_descriptions = [_]c.VkVertexInputAttributeDescription{ + .{ .binding = 0, .location = 0, .format = c.VK_FORMAT_R32G32_SFLOAT, .offset = @offsetOf(rhi.UiVertex, "position") }, + .{ .binding = 0, .location = 1, .format = c.VK_FORMAT_R8G8B8A8_UNORM, .offset = @offsetOf(rhi.UiVertex, "color") }, + .{ .binding = 0, .location = 2, .format = c.VK_FORMAT_R32G32_SFLOAT, .offset = @offsetOf(rhi.UiVertex, "uv") }, + }; + + var vertex_input_info = std.mem.zeroes(c.VkPipelineVertexInputStateCreateInfo); + vertex_input_info.sType = c.VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vertex_input_info.vertexBindingDescriptionCount = 1; + vertex_input_info.pVertexBindingDescriptions = &binding_description; + vertex_input_info.vertexAttributeDescriptionCount = attribute_descriptions.len; + vertex_input_info.pVertexAttributeDescriptions = &attribute_descriptions[0]; + + var ui_depth_stencil = depth_stencil.*; + ui_depth_stencil.depthTestEnable = c.VK_FALSE; + ui_depth_stencil.depthWriteEnable = c.VK_FALSE; + + const shaders = try loadShaderPair(allocator, vk_device, shader_registry.UI_RML_VERT, shader_registry.UI_RML_FRAG); + defer c.vkDestroyShaderModule(vk_device, shaders.vert, null); + defer c.vkDestroyShaderModule(vk_device, shaders.frag, null); + var stages = [_]c.VkPipelineShaderStageCreateInfo{ + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_VERTEX_BIT, .module = shaders.vert, .pName = "main" }, + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_FRAGMENT_BIT, .module = shaders.frag, .pName = "main" }, + }; + + var pipeline_info = std.mem.zeroes(c.VkGraphicsPipelineCreateInfo); + pipeline_info.sType = c.VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pipeline_info.stageCount = 2; + pipeline_info.pStages = &stages[0]; + pipeline_info.pVertexInputState = &vertex_input_info; + pipeline_info.pInputAssemblyState = input_assembly; + pipeline_info.pViewportState = viewport_state; + pipeline_info.pRasterizationState = rasterizer; + pipeline_info.pMultisampleState = multisampling; + pipeline_info.pDepthStencilState = &ui_depth_stencil; + pipeline_info.pColorBlendState = color_blending; + pipeline_info.pDynamicState = dynamic_state; + pipeline_info.layout = self.ui_pipeline_layout; + pipeline_info.renderPass = hdr_render_pass; + try Utils.checkVk(c.vkCreateGraphicsPipelines(vk_device, null, 1, &pipeline_info, null, &self.rml_ui_pipeline)); + + const textured_shaders = try loadShaderPair(allocator, vk_device, shader_registry.UI_RML_VERT, shader_registry.UI_RML_TEX_FRAG); + defer c.vkDestroyShaderModule(vk_device, textured_shaders.vert, null); + defer c.vkDestroyShaderModule(vk_device, textured_shaders.frag, null); + const textured_stages = [_]c.VkPipelineShaderStageCreateInfo{ + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_VERTEX_BIT, .module = textured_shaders.vert, .pName = "main" }, + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_FRAGMENT_BIT, .module = textured_shaders.frag, .pName = "main" }, + }; + pipeline_info.pStages = &textured_stages[0]; + pipeline_info.layout = self.ui_tex_pipeline_layout; + try Utils.checkVk(c.vkCreateGraphicsPipelines(vk_device, null, 1, &pipeline_info, null, &self.rml_ui_tex_pipeline)); + } + /// Create swapchain UI pipelines pub fn createSwapchainUIPipelines( self: *PipelineManager, diff --git a/modules/engine-graphics/src/vulkan/pipeline_specialized.zig b/modules/engine-graphics/src/vulkan/pipeline_specialized.zig index 48cd6d4d..2bef9d91 100644 --- a/modules/engine-graphics/src/vulkan/pipeline_specialized.zig +++ b/modules/engine-graphics/src/vulkan/pipeline_specialized.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const build_options = @import("engine_graphics_options"); const fs = @import("fs"); const c = @import("c").c; const rhi = @import("engine-rhi").rhi; @@ -152,8 +153,12 @@ pub fn createSwapchainUIPipelines( if (self.ui_swapchain_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_swapchain_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.rml_ui_swapchain_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.rml_ui_swapchain_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); self.ui_swapchain_pipeline = null; self.ui_swapchain_tex_pipeline = null; + self.rml_ui_swapchain_pipeline = null; + self.rml_ui_swapchain_tex_pipeline = null; var viewport_state = std.mem.zeroes(c.VkPipelineViewportStateCreateInfo); viewport_state.sType = c.VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; @@ -253,6 +258,56 @@ pub fn createSwapchainUIPipelines( pipeline_info.layout = self.ui_tex_pipeline_layout; pipeline_info.renderPass = ui_swapchain_render_pass; try Utils.checkVk(c.vkCreateGraphicsPipelines(vk_device, null, 1, &pipeline_info, null, &self.ui_swapchain_tex_pipeline)); + + if (comptime !build_options.rmlui) return; + + // RmlUi 6.2 produces premultiplied vertex/texture colors. Keep this + // equation distinct from legacy immediate UI's straight-alpha blending. + var rml_blend_attachment = ui_color_blend_attachment; + rml_blend_attachment.srcColorBlendFactor = c.VK_BLEND_FACTOR_ONE; + rml_blend_attachment.dstColorBlendFactor = c.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + rml_blend_attachment.srcAlphaBlendFactor = c.VK_BLEND_FACTOR_ONE; + rml_blend_attachment.dstAlphaBlendFactor = c.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + var rml_color_blending = ui_color_blending; + rml_color_blending.pAttachments = &rml_blend_attachment; + + const rml_binding = c.VkVertexInputBindingDescription{ .binding = 0, .stride = @sizeOf(rhi.UiVertex), .inputRate = c.VK_VERTEX_INPUT_RATE_VERTEX }; + const rml_attributes = [_]c.VkVertexInputAttributeDescription{ + .{ .binding = 0, .location = 0, .format = c.VK_FORMAT_R32G32_SFLOAT, .offset = @offsetOf(rhi.UiVertex, "position") }, + .{ .binding = 0, .location = 1, .format = c.VK_FORMAT_R8G8B8A8_UNORM, .offset = @offsetOf(rhi.UiVertex, "color") }, + .{ .binding = 0, .location = 2, .format = c.VK_FORMAT_R32G32_SFLOAT, .offset = @offsetOf(rhi.UiVertex, "uv") }, + }; + var rml_vertex_input = std.mem.zeroes(c.VkPipelineVertexInputStateCreateInfo); + rml_vertex_input.sType = c.VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + rml_vertex_input.vertexBindingDescriptionCount = 1; + rml_vertex_input.pVertexBindingDescriptions = &rml_binding; + rml_vertex_input.vertexAttributeDescriptionCount = rml_attributes.len; + rml_vertex_input.pVertexAttributeDescriptions = &rml_attributes[0]; + + const rml_shaders = try loadShaderPair(allocator, vk_device, shader_registry.UI_RML_VERT, shader_registry.UI_RML_FRAG); + defer c.vkDestroyShaderModule(vk_device, rml_shaders.vert, null); + defer c.vkDestroyShaderModule(vk_device, rml_shaders.frag, null); + const rml_stages = [_]c.VkPipelineShaderStageCreateInfo{ + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_VERTEX_BIT, .module = rml_shaders.vert, .pName = "main" }, + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_FRAGMENT_BIT, .module = rml_shaders.frag, .pName = "main" }, + }; + + pipeline_info.pStages = &rml_stages[0]; + pipeline_info.pVertexInputState = &rml_vertex_input; + pipeline_info.pColorBlendState = &rml_color_blending; + pipeline_info.layout = self.ui_pipeline_layout; + try Utils.checkVk(c.vkCreateGraphicsPipelines(vk_device, null, 1, &pipeline_info, null, &self.rml_ui_swapchain_pipeline)); + + const rml_textured_shaders = try loadShaderPair(allocator, vk_device, shader_registry.UI_RML_VERT, shader_registry.UI_RML_TEX_FRAG); + defer c.vkDestroyShaderModule(vk_device, rml_textured_shaders.vert, null); + defer c.vkDestroyShaderModule(vk_device, rml_textured_shaders.frag, null); + const rml_textured_stages = [_]c.VkPipelineShaderStageCreateInfo{ + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_VERTEX_BIT, .module = rml_textured_shaders.vert, .pName = "main" }, + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_FRAGMENT_BIT, .module = rml_textured_shaders.frag, .pName = "main" }, + }; + pipeline_info.pStages = &rml_textured_stages[0]; + pipeline_info.layout = self.ui_tex_pipeline_layout; + try Utils.checkVk(c.vkCreateGraphicsPipelines(vk_device, null, 1, &pipeline_info, null, &self.rml_ui_swapchain_tex_pipeline)); } pub fn createDebugShadowPipeline( diff --git a/modules/engine-graphics/src/vulkan/pipeline_specialized_tests.zig b/modules/engine-graphics/src/vulkan/pipeline_specialized_tests.zig index 5ba85ca9..43790564 100644 --- a/modules/engine-graphics/src/vulkan/pipeline_specialized_tests.zig +++ b/modules/engine-graphics/src/vulkan/pipeline_specialized_tests.zig @@ -118,4 +118,6 @@ test "pipeline_specialized functions use anytype for self parameter" { // ui_swapchain_pipeline and ui_swapchain_tex_pipeline are modified by the function try testing.expect(@TypeOf(manager.ui_swapchain_pipeline) == c.VkPipeline); try testing.expect(@TypeOf(manager.ui_swapchain_tex_pipeline) == c.VkPipeline); + try testing.expect(@TypeOf(manager.rml_ui_swapchain_pipeline) == c.VkPipeline); + try testing.expect(@TypeOf(manager.rml_ui_swapchain_tex_pipeline) == c.VkPipeline); } diff --git a/modules/engine-graphics/src/vulkan/render_pass_manager.zig b/modules/engine-graphics/src/vulkan/render_pass_manager.zig index 1a03e604..b51eb5fa 100644 --- a/modules/engine-graphics/src/vulkan/render_pass_manager.zig +++ b/modules/engine-graphics/src/vulkan/render_pass_manager.zig @@ -12,6 +12,7 @@ const rhi = @import("engine-rhi").rhi; const log = @import("engine-core").log; const VulkanDevice = @import("../vulkan_device.zig").VulkanDevice; const Utils = @import("utils.zig"); +const final_composition = @import("final_composition.zig"); /// Depth format used throughout the renderer pub const DEPTH_FORMAT = c.VK_FORMAT_D32_SFLOAT; @@ -358,7 +359,7 @@ pub const RenderPassManager = struct { } /// Create post-process render pass - pub fn createPostProcessRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat) !void { + pub fn createPostProcessRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat, final_layout: c.VkImageLayout) !void { if (self.post_process_render_pass) |rp| { c.vkDestroyRenderPass(vk_device, rp, null); self.post_process_render_pass = null; @@ -367,12 +368,13 @@ pub const RenderPassManager = struct { var color_attachment = std.mem.zeroes(c.VkAttachmentDescription); color_attachment.format = swapchain_format; color_attachment.samples = c.VK_SAMPLE_COUNT_1_BIT; - color_attachment.loadOp = c.VK_ATTACHMENT_LOAD_OP_LOAD; - color_attachment.storeOp = c.VK_ATTACHMENT_STORE_OP_STORE; + const contract = final_composition.attachmentContract(.full_screen_replace, final_layout); + color_attachment.loadOp = contract.load_op; + color_attachment.storeOp = contract.store_op; color_attachment.stencilLoadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE; color_attachment.stencilStoreOp = c.VK_ATTACHMENT_STORE_OP_DONT_CARE; - color_attachment.initialLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; - color_attachment.finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + color_attachment.initialLayout = contract.initial_layout; + color_attachment.finalLayout = contract.final_layout; var color_ref = c.VkAttachmentReference{ .attachment = 0, .layout = c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; @@ -385,7 +387,7 @@ pub const RenderPassManager = struct { dependency.srcSubpass = c.VK_SUBPASS_EXTERNAL; dependency.dstSubpass = 0; dependency.srcStageMask = c.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; - dependency.srcAccessMask = 0; + dependency.srcAccessMask = c.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; dependency.dstStageMask = c.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.dstAccessMask = c.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; @@ -402,7 +404,7 @@ pub const RenderPassManager = struct { } /// Create UI swapchain render pass - pub fn createUISwapchainRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat) !void { + pub fn createUISwapchainRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat, final_layout: c.VkImageLayout) !void { if (self.ui_swapchain_render_pass) |rp| { c.vkDestroyRenderPass(vk_device, rp, null); self.ui_swapchain_render_pass = null; @@ -411,12 +413,13 @@ pub const RenderPassManager = struct { var color_attachment = std.mem.zeroes(c.VkAttachmentDescription); color_attachment.format = swapchain_format; color_attachment.samples = c.VK_SAMPLE_COUNT_1_BIT; - color_attachment.loadOp = c.VK_ATTACHMENT_LOAD_OP_LOAD; - color_attachment.storeOp = c.VK_ATTACHMENT_STORE_OP_STORE; + const contract = final_composition.attachmentContract(.overlay, final_layout); + color_attachment.loadOp = contract.load_op; + color_attachment.storeOp = contract.store_op; color_attachment.stencilLoadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE; color_attachment.stencilStoreOp = c.VK_ATTACHMENT_STORE_OP_DONT_CARE; - color_attachment.initialLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; - color_attachment.finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + color_attachment.initialLayout = contract.initial_layout; + color_attachment.finalLayout = contract.final_layout; var color_ref = c.VkAttachmentReference{ .attachment = 0, .layout = c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; diff --git a/modules/engine-graphics/src/vulkan/rhi_context_factory.zig b/modules/engine-graphics/src/vulkan/rhi_context_factory.zig index 4df5a2c6..4cf38ccc 100644 --- a/modules/engine-graphics/src/vulkan/rhi_context_factory.zig +++ b/modules/engine-graphics/src/vulkan/rhi_context_factory.zig @@ -124,8 +124,12 @@ pub fn createRHI( ctx.pipeline_manager.ui_pipeline_layout = null; ctx.pipeline_manager.ui_tex_pipeline = null; ctx.pipeline_manager.ui_tex_pipeline_layout = null; + ctx.pipeline_manager.rml_ui_pipeline = null; + ctx.pipeline_manager.rml_ui_tex_pipeline = null; ctx.pipeline_manager.ui_swapchain_pipeline = null; ctx.pipeline_manager.ui_swapchain_tex_pipeline = null; + ctx.pipeline_manager.rml_ui_swapchain_pipeline = null; + ctx.pipeline_manager.rml_ui_swapchain_tex_pipeline = null; ctx.render_pass_manager.ui_swapchain_framebuffers = .empty; if (comptime build_options.debug_shadows) { ctx.debug_shadow.pipeline = null; @@ -160,6 +164,8 @@ pub fn createRHI( ctx.descriptors.shadow_ubos[i] = .{ .buffer = null, .memory = null, .size = 0, .is_host_visible = false }; ctx.descriptors.shadow_ubos_mapped[i] = null; ctx.ui.ui_vbos[i] = .{ .buffer = null, .memory = null, .size = 0, .is_host_visible = false }; + ctx.ui.rml_vbos[i] = .{ .buffer = null, .memory = null, .size = 0, .is_host_visible = false }; + ctx.ui.rml_ibos[i] = .{ .buffer = null, .memory = null, .size = 0, .is_host_visible = false }; ctx.descriptors.descriptor_sets[i] = null; ctx.descriptors.lod_descriptor_sets[i] = null; ctx.ui.ui_tex_descriptor_sets[i] = null; diff --git a/modules/engine-graphics/src/vulkan/rhi_context_types.zig b/modules/engine-graphics/src/vulkan/rhi_context_types.zig index 85307c14..398ad7ff 100644 --- a/modules/engine-graphics/src/vulkan/rhi_context_types.zig +++ b/modules/engine-graphics/src/vulkan/rhi_context_types.zig @@ -25,6 +25,8 @@ const WaterSystem = @import("water_system.zig").WaterSystem; const VulkanDevice = @import("device.zig").VulkanDevice; const DynamicResolutionState = @import("dynamic_resolution.zig").DynamicResolutionState; const rhi_timing = @import("rhi_timing.zig"); +const screenshot = @import("screenshot.zig"); +const final_composition = @import("final_composition.zig"); const MAX_FRAMES_IN_FLIGHT = rhi.MAX_FRAMES_IN_FLIGHT; @@ -74,9 +76,17 @@ const UIState = struct { ui_tex_descriptor_pool: [MAX_FRAMES_IN_FLIGHT][64]c.VkDescriptorSet = .{.{null} ** 64} ** MAX_FRAMES_IN_FLIGHT, ui_tex_descriptor_next: [MAX_FRAMES_IN_FLIGHT]u32 = .{0} ** MAX_FRAMES_IN_FLIGHT, ui_vbos: [MAX_FRAMES_IN_FLIGHT]VulkanBuffer = .{VulkanBuffer{}} ** MAX_FRAMES_IN_FLIGHT, + rml_vbos: [MAX_FRAMES_IN_FLIGHT]VulkanBuffer = .{VulkanBuffer{}} ** MAX_FRAMES_IN_FLIGHT, + rml_ibos: [MAX_FRAMES_IN_FLIGHT]VulkanBuffer = .{VulkanBuffer{}} ** MAX_FRAMES_IN_FLIGHT, ui_screen_width: f32 = 0.0, ui_screen_height: f32 = 0.0, ui_using_swapchain: bool = false, + /// This is deliberately independent from FXAA: UI overlays may use the + /// final display target after FXAA has already ended. + ui_swapchain_pass_active: bool = false, + /// The first UI pass of an otherwise empty frame explicitly clears the + /// display target; subsequent UI passes load and overlay it. + ui_swapchain_clears_output: bool = false, ui_in_progress: bool = false, ui_vertex_offset: u64 = 0, ui_active_textured: bool = false, @@ -85,6 +95,9 @@ const UIState = struct { selection_mode: bool = false, ui_flushed_vertex_count: u32 = 0, ui_mapped_ptr: ?*anyopaque = null, + rml_vertex_offset: u64 = 0, + rml_index_offset: u64 = 0, + legacy_pipeline_bound: bool = false, }; const LegacyResources = struct { @@ -175,6 +188,7 @@ const RuntimeState = struct { ssao_pass_active: bool = false, post_process_ran_this_frame: bool = false, fxaa_ran_this_frame: bool = false, + direct_ui_composed_this_frame: bool = false, transfer_barrier_needed: bool = false, pipeline_rebuild_needed: bool = false, swapchain_recreate_failed: bool = false, @@ -182,6 +196,7 @@ const RuntimeState = struct { image_index: u32, clear_color: [4]f32 = .{ 0.07, 0.08, 0.1, 1.0 }, first_main_pass_draw_logged: bool = false, + final_composed: final_composition.FinalComposedImage = .{}, }; const TimingState = struct { @@ -252,4 +267,5 @@ pub const VulkanContext = struct { timing: TimingState = .{}, dynamic_resolution: DynamicResolutionState = .{}, compute_resources: ComputeResources = .{}, + screenshot_capture: screenshot.PendingCapture = .{}, }; diff --git a/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig b/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig index 5b0f522b..af808c09 100644 --- a/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig +++ b/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig @@ -5,6 +5,7 @@ const log = @import("engine-core").log; const build_options = @import("engine_graphics_options"); const bindings = @import("descriptor_bindings.zig"); const lifecycle = @import("rhi_resource_lifecycle.zig"); +const final_composition = @import("final_composition.zig"); const setup = @import("rhi_resource_setup.zig"); pub fn recreatePendingShadowResources(ctx: anytype) void { @@ -82,6 +83,10 @@ pub fn recreateSwapchainInternal(ctx: anytype) void { lifecycle.transitionImagesToPresent(ctx, ctx.swapchain.swapchain.images.items) catch |err| { log.log.warn("Failed to transition swapchain images to PRESENT: {}", .{err}); }; + } else { + lifecycle.transitionImagesToColorAttachment(ctx, ctx.swapchain.swapchain.images.items) catch |err| { + log.log.warn("Failed to transition headless image to COLOR_ATTACHMENT: {}", .{err}); + }; } lifecycle.createHDRResources(ctx) catch |err| { @@ -142,7 +147,7 @@ pub fn recreateSwapchainInternal(ctx: anytype) void { _ = markSwapchainRecreateFailed(ctx, "swapchain UI resources", err); return; }; - ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews()) catch |err| { + ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews(), final_composition.displayLayout(ctx.swapchain.skip_present)) catch |err| { _ = markSwapchainRecreateFailed(ctx, "FXAA resources", err); return; }; @@ -225,8 +230,12 @@ pub fn prepareFrameState(ctx: anytype) void { ctx.shadow_system.pass_active = false; ctx.runtime.post_process_ran_this_frame = false; ctx.runtime.fxaa_ran_this_frame = false; + ctx.runtime.direct_ui_composed_this_frame = false; + ctx.runtime.final_composed.clear(); ctx.taa.ran_this_frame = false; ctx.ui.ui_using_swapchain = false; + ctx.ui.ui_swapchain_pass_active = false; + ctx.ui.ui_swapchain_clears_output = false; ctx.draw.terrain_pipeline_bound = false; ctx.shadow_system.pipeline_bound = false; @@ -260,6 +269,9 @@ pub fn prepareFrameState(ctx: anytype) void { ctx.ui.ui_active_textured = false; ctx.ui.ui_active_texture = 0; ctx.ui.ui_active_tint = .{ 0.0, 0.0, 0.0, 0.0 }; + ctx.ui.rml_vertex_offset = 0; + ctx.ui.rml_index_offset = 0; + ctx.ui.legacy_pipeline_bound = false; ctx.ui.ui_tex_descriptor_next[ctx.frames.current_frame] = 0; if (comptime build_options.debug_shadows) { ctx.debug_shadow.descriptor_next[ctx.frames.current_frame] = 0; diff --git a/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig b/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig index 4cc4d9f4..acb90e0b 100644 --- a/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig +++ b/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig @@ -15,7 +15,10 @@ const Utils = @import("utils.zig"); const lifecycle = @import("rhi_resource_lifecycle.zig"); const setup = @import("rhi_resource_setup.zig"); const rhi_timing = @import("rhi_timing.zig"); +const screenshot = @import("screenshot.zig"); +const final_composition = @import("final_composition.zig"); const runtime_env = @import("engine-core").runtime_env; +const build_options = @import("engine_graphics_options"); const MAX_FRAMES_IN_FLIGHT = rhi.MAX_FRAMES_IN_FLIGHT; const TOTAL_QUERY_COUNT = rhi_timing.QUERY_COUNT_PER_FRAME * MAX_FRAMES_IN_FLIGHT; @@ -33,6 +36,8 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* if (!ctx.swapchain.skip_present) { try lifecycle.transitionImagesToPresent(ctx, ctx.swapchain.swapchain.images.items); + } else { + try lifecycle.transitionImagesToColorAttachment(ctx, ctx.swapchain.swapchain.images.items); } ctx.pipeline_manager = try PipelineManager.init(&ctx.vulkan_device, &ctx.descriptors, null); @@ -102,7 +107,7 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* try setup.createPostProcessResources(ctx); try setup.createSwapchainUIResources(ctx); - try ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews()); + try ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews(), final_composition.displayLayout(ctx.swapchain.skip_present)); try ctx.pipeline_manager.createSwapchainUIPipelines(ctx.allocator, ctx.vulkan_device.vk_device, ctx.render_pass_manager.ui_swapchain_render_pass); try ctx.bloom.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.hdr.hdr_view, ctx.swapchain.getExtent().width, ctx.swapchain.getExtent().height, c.VK_FORMAT_R16G16B16A16_SFLOAT); @@ -125,6 +130,10 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* for (0..MAX_FRAMES_IN_FLIGHT) |i| { ctx.ui.ui_vbos[i] = try Utils.createVulkanBuffer(&ctx.vulkan_device, 1024 * 1024, c.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (comptime build_options.rmlui) { + ctx.ui.rml_vbos[i] = try Utils.createVulkanBuffer(&ctx.vulkan_device, 1024 * 1024, c.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + ctx.ui.rml_ibos[i] = try Utils.createVulkanBuffer(&ctx.vulkan_device, 1024 * 1024, c.VK_BUFFER_USAGE_INDEX_BUFFER_BIT, c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + } } for (0..MAX_FRAMES_IN_FLIGHT) |i| { @@ -186,6 +195,7 @@ pub fn deinit(ctx: anytype) void { if (vk_device != null) { _ = c.vkDeviceWaitIdle(vk_device); + screenshot.discardCapture(ctx); var compute_pipeline_iter = ctx.compute_resources.pipelines.iterator(); while (compute_pipeline_iter.next()) |entry| { @@ -237,9 +247,22 @@ pub fn deinit(ctx: anytype) void { if (ctx.legacy.dummy_instance_buffer.memory != null) c.vkFreeMemory(device, ctx.legacy.dummy_instance_buffer.memory, null); for (ctx.ui.ui_vbos) |buf| { + if (buf.mapped_ptr != null and buf.memory != null) c.vkUnmapMemory(device, buf.memory); if (buf.buffer != null) c.vkDestroyBuffer(device, buf.buffer, null); if (buf.memory != null) c.vkFreeMemory(device, buf.memory, null); } + if (comptime build_options.rmlui) { + for (ctx.ui.rml_vbos) |buf| { + if (buf.mapped_ptr != null and buf.memory != null) c.vkUnmapMemory(device, buf.memory); + if (buf.buffer != null) c.vkDestroyBuffer(device, buf.buffer, null); + if (buf.memory != null) c.vkFreeMemory(device, buf.memory, null); + } + for (ctx.ui.rml_ibos) |buf| { + if (buf.mapped_ptr != null and buf.memory != null) c.vkUnmapMemory(device, buf.memory); + if (buf.buffer != null) c.vkDestroyBuffer(device, buf.buffer, null); + if (buf.memory != null) c.vkFreeMemory(device, buf.memory, null); + } + } } if (comptime @import("engine_graphics_options").debug_shadows) { diff --git a/modules/engine-graphics/src/vulkan/rhi_pass_orchestration.zig b/modules/engine-graphics/src/vulkan/rhi_pass_orchestration.zig index 27084c47..de05bffc 100644 --- a/modules/engine-graphics/src/vulkan/rhi_pass_orchestration.zig +++ b/modules/engine-graphics/src/vulkan/rhi_pass_orchestration.zig @@ -6,6 +6,26 @@ const PostProcessPushConstants = post_process_system_pkg.PostProcessPushConstant const fxaa_system_pkg = @import("fxaa_system.zig"); const FXAAPushConstants = fxaa_system_pkg.FXAAPushConstants; const setup = @import("rhi_resource_setup.zig"); +const screenshot = @import("screenshot.zig"); +const final_composition = @import("final_composition.zig"); + +fn recordFinalComposedImage(ctx: anytype) void { + const image_index = ctx.frames.current_image_index; + if (image_index >= ctx.swapchain.swapchain.images.items.len) { + log.log.err("final composition: image index {} is out of range", .{image_index}); + ctx.runtime.final_composed.clear(); + return; + } + ctx.runtime.final_composed.set( + ctx.swapchain.swapchain.images.items[image_index], + image_index, + final_composition.displayLayout(ctx.swapchain.skip_present), + ); +} + +fn postProcessTargetsFXAA(ctx: anytype) bool { + return ctx.fxaa.enabled and ctx.fxaa.post_process_to_fxaa_render_pass != null and ctx.fxaa.post_process_to_fxaa_framebuffer != null; +} pub fn beginGPassInternal(ctx: anytype) void { if (!ctx.frames.frame_in_progress or ctx.runtime.g_pass_active) return; @@ -91,6 +111,8 @@ pub fn beginFXAAPassInternal(ctx: anytype) void { return; } + ensureNoRenderPassActiveInternal(ctx); + const image_index = ctx.frames.current_image_index; if (image_index >= ctx.fxaa.framebuffers.items.len) { return; @@ -99,16 +121,12 @@ pub fn beginFXAAPassInternal(ctx: anytype) void { const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; const extent = ctx.swapchain.getExtent(); - var clear_value = std.mem.zeroes(c.VkClearValue); - clear_value.color.float32 = .{ 0.0, 0.0, 0.0, 1.0 }; - var rp_begin = std.mem.zeroes(c.VkRenderPassBeginInfo); rp_begin.sType = c.VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; rp_begin.renderPass = ctx.fxaa.render_pass; rp_begin.framebuffer = ctx.fxaa.framebuffers.items[image_index]; rp_begin.renderArea = .{ .offset = .{ .x = 0, .y = 0 }, .extent = extent }; - rp_begin.clearValueCount = 1; - rp_begin.pClearValues = &clear_value; + rp_begin.clearValueCount = 0; c.vkCmdBeginRenderPass(command_buffer, &rp_begin, c.VK_SUBPASS_CONTENTS_INLINE); @@ -144,11 +162,14 @@ pub fn beginFXAAPassInternal(ctx: anytype) void { ctx.fxaa.pass_active = true; } -pub fn beginFXAAPassForUI(ctx: anytype) void { +/// Begins an overlay pass on the already-composed display image. This state is +/// intentionally not FXAA state: UI may be submitted after the FXAA pass has +/// ended, and several UI begin/end pairs are valid in one frame. +pub fn beginUISwapchainPassInternal(ctx: anytype, clear_output: bool) void { if (!ctx.frames.frame_in_progress) { return; } - if (ctx.fxaa.pass_active) return; + if (ctx.ui.ui_swapchain_pass_active) return; if (ctx.render_pass_manager.ui_swapchain_render_pass == null) { return; } @@ -165,22 +186,16 @@ pub fn beginFXAAPassForUI(ctx: anytype) void { const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; const extent = ctx.swapchain.getExtent(); - const ui_only_frame = ctx.runtime.draw_call_count == 0; - - var clear_value = std.mem.zeroes(c.VkClearValue); - clear_value.color.float32 = .{ 0.0, 0.0, 0.0, 1.0 }; - var rp_begin = std.mem.zeroes(c.VkRenderPassBeginInfo); rp_begin.sType = c.VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; rp_begin.renderPass = ctx.render_pass_manager.ui_swapchain_render_pass.?; rp_begin.framebuffer = ctx.render_pass_manager.ui_swapchain_framebuffers.items[image_index]; rp_begin.renderArea = .{ .offset = .{ .x = 0, .y = 0 }, .extent = extent }; - rp_begin.clearValueCount = 1; - rp_begin.pClearValues = &clear_value; + rp_begin.clearValueCount = 0; c.vkCmdBeginRenderPass(command_buffer, &rp_begin, c.VK_SUBPASS_CONTENTS_INLINE); - if (ui_only_frame) { + if (clear_output) { var clear_attachment = std.mem.zeroes(c.VkClearAttachment); clear_attachment.aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT; clear_attachment.colorAttachment = 0; @@ -208,7 +223,8 @@ pub fn beginFXAAPassForUI(ctx: anytype) void { const scissor = c.VkRect2D{ .offset = .{ .x = 0, .y = 0 }, .extent = extent }; c.vkCmdSetScissor(command_buffer, 0, 1, &scissor); - ctx.fxaa.pass_active = true; + ctx.ui.ui_swapchain_pass_active = true; + ctx.ui.ui_swapchain_clears_output = clear_output; } pub fn endFXAAPassInternal(ctx: anytype) void { @@ -218,6 +234,18 @@ pub fn endFXAAPassInternal(ctx: anytype) void { c.vkCmdEndRenderPass(command_buffer); ctx.fxaa.pass_active = false; + recordFinalComposedImage(ctx); +} + +pub fn endUISwapchainPassInternal(ctx: anytype) void { + if (!ctx.ui.ui_swapchain_pass_active) return; + + const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; + c.vkCmdEndRenderPass(command_buffer); + ctx.ui.ui_swapchain_pass_active = false; + if (ctx.ui.ui_swapchain_clears_output) ctx.runtime.direct_ui_composed_this_frame = true; + ctx.ui.ui_swapchain_clears_output = false; + recordFinalComposedImage(ctx); } pub fn beginMainPassInternal(ctx: anytype) void { @@ -328,7 +356,7 @@ pub fn beginPostProcessPassInternal(ctx: anytype) void { if (!ctx.post_process.pass_active) { ensureNoRenderPassActiveInternal(ctx); - const use_fxaa_output = ctx.fxaa.enabled and ctx.fxaa.post_process_to_fxaa_render_pass != null and ctx.fxaa.post_process_to_fxaa_framebuffer != null; + const use_fxaa_output = postProcessTargetsFXAA(ctx); var render_pass_info = std.mem.zeroes(c.VkRenderPassBeginInfo); render_pass_info.sType = c.VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; @@ -400,6 +428,7 @@ pub fn endPostProcessPassInternal(ctx: anytype) void { const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; c.vkCmdEndRenderPass(command_buffer); ctx.post_process.pass_active = false; + if (!postProcessTargetsFXAA(ctx)) recordFinalComposedImage(ctx); } pub fn ensureNoRenderPassActiveInternal(ctx: anytype) void { @@ -410,6 +439,7 @@ pub fn ensureNoRenderPassActiveInternal(ctx: anytype) void { } if (ctx.runtime.g_pass_active) endGPassInternal(ctx); if (ctx.post_process.pass_active) endPostProcessPassInternal(ctx); + if (ctx.ui.ui_swapchain_pass_active) endUISwapchainPassInternal(ctx); } pub fn endFrame(ctx: anytype) void { @@ -420,8 +450,11 @@ pub fn endFrame(ctx: anytype) void { const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; ctx.shadow_system.endPass(command_buffer); } + // Defensively close a caller-owned overlay before opening a full-screen + // replacement pass. Normal UI submission closes it in end2DPass. + if (ctx.ui.ui_swapchain_pass_active) endUISwapchainPassInternal(ctx); - if (ctx.runtime.draw_call_count > 0 and !ctx.runtime.post_process_ran_this_frame and ctx.render_pass_manager.post_process_framebuffers.items.len > 0 and ctx.frames.current_image_index < ctx.render_pass_manager.post_process_framebuffers.items.len) { + if (ctx.runtime.draw_call_count > 0 and !ctx.runtime.direct_ui_composed_this_frame and !ctx.runtime.post_process_ran_this_frame and ctx.render_pass_manager.post_process_framebuffers.items.len > 0 and ctx.frames.current_image_index < ctx.render_pass_manager.post_process_framebuffers.items.len) { beginPostProcessPassInternal(ctx); if (ctx.post_process.pass_active) { const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; @@ -436,6 +469,10 @@ pub fn endFrame(ctx: anytype) void { } if (ctx.fxaa.pass_active) endFXAAPassInternal(ctx); + // The UI path can trigger post-processing at endFrame, so append the + // readback only after every final-color pass has completed. + screenshot.recordCapture(ctx); + const transfer_cb = ctx.resources.getTransferCommandBuffer(); if (!ctx.resources.transfer.is_dedicated) { @@ -470,12 +507,25 @@ pub fn endFrame(ctx: anytype) void { const transfer_sem = ctx.resources.getTransferSemaphore(); - ctx.frames.endFrame(&ctx.swapchain, transfer_cb, transfer_sem) catch |err| { + var submitted = false; + if (ctx.frames.endFrame(&ctx.swapchain, transfer_cb, transfer_sem)) |_| { + submitted = true; + } else |err| { log.log.errWithTrace("endFrame failed: {}", .{err}); if (err == error.GpuLost) { ctx.runtime.gpu_fault_detected = true; } - }; + } + + if (ctx.screenshot_capture.staging != null) { + if (submitted) { + if (!screenshot.completeCapture(ctx)) { + log.log.err("SCREENSHOT: Failed to encode final composed frame", .{}); + } + } else { + screenshot.discardCapture(ctx); + } + } if (transfer_cb != null) { ctx.resources.resetTransferState(); diff --git a/modules/engine-graphics/src/vulkan/rhi_resource_lifecycle.zig b/modules/engine-graphics/src/vulkan/rhi_resource_lifecycle.zig index 4bbf6b38..a4aa031b 100644 --- a/modules/engine-graphics/src/vulkan/rhi_resource_lifecycle.zig +++ b/modules/engine-graphics/src/vulkan/rhi_resource_lifecycle.zig @@ -132,6 +132,14 @@ pub fn destroySwapchainUIPipelines(ctx: anytype) void { c.vkDestroyPipeline(vk, ctx.pipeline_manager.ui_swapchain_tex_pipeline, null); ctx.pipeline_manager.ui_swapchain_tex_pipeline = null; } + if (ctx.pipeline_manager.rml_ui_swapchain_pipeline != null) { + c.vkDestroyPipeline(vk, ctx.pipeline_manager.rml_ui_swapchain_pipeline, null); + ctx.pipeline_manager.rml_ui_swapchain_pipeline = null; + } + if (ctx.pipeline_manager.rml_ui_swapchain_tex_pipeline != null) { + c.vkDestroyPipeline(vk, ctx.pipeline_manager.rml_ui_swapchain_tex_pipeline, null); + ctx.pipeline_manager.rml_ui_swapchain_tex_pipeline = null; + } } pub fn destroySwapchainUIResources(ctx: anytype) void { @@ -230,6 +238,22 @@ pub fn transitionImagesToShaderRead(ctx: anytype, images: []const c.VkImage, is_ } pub fn transitionImagesToPresent(ctx: anytype, images: []const c.VkImage) !void { + return transitionImagesFromUndefined(ctx, images, c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, c.VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0); +} + +/// Headless final-composition passes load and retain this layout between +/// frames, so the offscreen image needs an explicit first-use transition. +pub fn transitionImagesToColorAttachment(ctx: anytype, images: []const c.VkImage) !void { + return transitionImagesFromUndefined( + ctx, + images, + c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + c.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + c.VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | c.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, + ); +} + +fn transitionImagesFromUndefined(ctx: anytype, images: []const c.VkImage, layout: c.VkImageLayout, dst_stage: c.VkPipelineStageFlags, dst_access: c.VkAccessFlags) !void { if (ctx.runtime.recovering) return; var alloc_info = std.mem.zeroes(c.VkCommandBufferAllocateInfo); @@ -252,16 +276,16 @@ pub fn transitionImagesToPresent(ctx: anytype, images: []const c.VkImage) !void barriers[i] = std.mem.zeroes(c.VkImageMemoryBarrier); barriers[i].sType = c.VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; barriers[i].oldLayout = c.VK_IMAGE_LAYOUT_UNDEFINED; - barriers[i].newLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + barriers[i].newLayout = layout; barriers[i].srcQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; barriers[i].dstQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; barriers[i].image = images[i]; barriers[i].subresourceRange = .{ .aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = 1, .baseArrayLayer = 0, .layerCount = 1 }; barriers[i].srcAccessMask = 0; - barriers[i].dstAccessMask = 0; + barriers[i].dstAccessMask = dst_access; } - c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, c.VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, null, 0, null, @intCast(count), &barriers[0]); + c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, dst_stage, 0, 0, null, 0, null, @intCast(count), &barriers[0]); try Utils.checkVk(c.vkEndCommandBuffer(cmd)); diff --git a/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig b/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig index 32e7b6eb..a39af076 100644 --- a/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig +++ b/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig @@ -8,6 +8,7 @@ const shader_registry = @import("shader_registry.zig"); const build_options = @import("engine_graphics_options"); const bindings = @import("descriptor_bindings.zig"); const lifecycle = @import("rhi_resource_lifecycle.zig"); +const final_composition = @import("final_composition.zig"); const DEPTH_FORMAT = c.VK_FORMAT_D32_SFLOAT; const MAX_FRAMES_IN_FLIGHT = rhi.MAX_FRAMES_IN_FLIGHT; @@ -18,7 +19,7 @@ pub fn createSwapchainUIResources(ctx: anytype) !void { lifecycle.destroySwapchainUIResources(ctx); errdefer lifecycle.destroySwapchainUIResources(ctx); - try ctx.render_pass_manager.createUISwapchainRenderPass(vk, ctx.swapchain.getImageFormat()); + try ctx.render_pass_manager.createUISwapchainRenderPass(vk, ctx.swapchain.getImageFormat(), final_composition.displayLayout(ctx.swapchain.skip_present)); try ctx.render_pass_manager.createUISwapchainFramebuffers(vk, ctx.allocator, ctx.swapchain.getExtent(), ctx.swapchain.getImageViews()); } @@ -445,7 +446,7 @@ pub fn createWaterResources(ctx: anytype) !void { pub fn createPostProcessResources(ctx: anytype) !void { const vk = ctx.vulkan_device.vk_device; - try ctx.render_pass_manager.createPostProcessRenderPass(vk, ctx.swapchain.getImageFormat()); + try ctx.render_pass_manager.createPostProcessRenderPass(vk, ctx.swapchain.getImageFormat(), final_composition.displayLayout(ctx.swapchain.skip_present)); const global_uniform_size: usize = @intCast(ctx.descriptors.global_ubos[0].size); try ctx.post_process.init( diff --git a/modules/engine-graphics/src/vulkan/rhi_state_control.zig b/modules/engine-graphics/src/vulkan/rhi_state_control.zig index 5b3fe8e2..0b87f4cd 100644 --- a/modules/engine-graphics/src/vulkan/rhi_state_control.zig +++ b/modules/engine-graphics/src/vulkan/rhi_state_control.zig @@ -75,6 +75,9 @@ pub fn recover(ctx: anytype) !void { ctx.shadow_system.pass_active = false; ctx.runtime.g_pass_active = false; ctx.runtime.ssao_pass_active = false; + ctx.runtime.final_composed.clear(); + ctx.ui.ui_swapchain_pass_active = false; + ctx.ui.ui_swapchain_clears_output = false; ctx.runtime.recovering = true; defer ctx.runtime.recovering = false; ctx.draw.descriptors_updated = false; diff --git a/modules/engine-graphics/src/vulkan/rhi_ui_submission.zig b/modules/engine-graphics/src/vulkan/rhi_ui_submission.zig index 58247027..d82775c0 100644 --- a/modules/engine-graphics/src/vulkan/rhi_ui_submission.zig +++ b/modules/engine-graphics/src/vulkan/rhi_ui_submission.zig @@ -15,6 +15,13 @@ fn getUIPipeline(ctx: anytype, textured: bool) c.VkPipeline { return if (textured) ctx.pipeline_manager.ui_tex_pipeline else ctx.pipeline_manager.ui_pipeline; } +fn getRmlUIPipeline(ctx: anytype, textured: bool) c.VkPipeline { + if (ctx.ui.ui_using_swapchain) { + return if (textured) ctx.pipeline_manager.rml_ui_swapchain_tex_pipeline else ctx.pipeline_manager.rml_ui_swapchain_pipeline; + } + return if (textured) ctx.pipeline_manager.rml_ui_tex_pipeline else ctx.pipeline_manager.rml_ui_pipeline; +} + fn sampledImageLayout(format: rhi.TextureFormat) c.VkImageLayout { return if (format == .depth) c.VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL @@ -26,8 +33,42 @@ fn sameTint(a: [4]f32, b: [4]f32) bool { return a[0] == b[0] and a[1] == b[1] and a[2] == b[2] and a[3] == b[3]; } +fn setFullUIScissor(ctx: anytype, command_buffer: c.VkCommandBuffer) void { + const scissor = c.VkRect2D{ + .offset = .{ .x = 0, .y = 0 }, + .extent = .{ + .width = @intFromFloat(@max(ctx.ui.ui_screen_width, 0.0)), + .height = @intFromFloat(@max(ctx.ui.ui_screen_height, 0.0)), + }, + }; + c.vkCmdSetScissor(command_buffer, 0, 1, &scissor); +} + +fn bindLegacyPipeline(ctx: anytype, textured: bool) bool { + if (ctx.ui.legacy_pipeline_bound and ctx.ui.ui_active_textured == textured) return true; + + flushUI(ctx); + const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; + const pipeline = getUIPipeline(ctx, textured); + if (pipeline == null) return false; + + c.vkCmdBindPipeline(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + const legacy_vbo = ctx.ui.ui_vbos[ctx.frames.current_frame]; + const legacy_offset: c.VkDeviceSize = 0; + c.vkCmdBindVertexBuffers(command_buffer, 0, 1, &legacy_vbo.buffer, &legacy_offset); + const proj = Mat4.orthographic(0, ctx.ui.ui_screen_width, ctx.ui.ui_screen_height, 0, -1, 1); + const layout = if (textured) ctx.pipeline_manager.ui_tex_pipeline_layout else ctx.pipeline_manager.ui_pipeline_layout; + c.vkCmdPushConstants(command_buffer, layout, UI_PUSH_STAGES, 0, @sizeOf(Mat4), &proj.data); + setFullUIScissor(ctx, command_buffer); + ctx.ui.legacy_pipeline_bound = true; + ctx.ui.ui_active_textured = textured; + if (!textured) ctx.ui.ui_active_texture = 0; + ctx.draw.terrain_pipeline_bound = false; + return true; +} + pub fn flushUI(ctx: anytype) void { - if (!ctx.runtime.main_pass_active and !ctx.fxaa.pass_active) { + if (!ctx.runtime.main_pass_active and !ctx.ui.ui_swapchain_pass_active) { return; } if (ctx.ui.ui_vertex_offset / (6 * @sizeOf(f32)) > ctx.ui.ui_flushed_vertex_count) { @@ -38,6 +79,10 @@ pub fn flushUI(ctx: anytype) void { c.vkCmdDraw(command_buffer, count, 1, ctx.ui.ui_flushed_vertex_count, 0); ctx.ui.ui_flushed_vertex_count = total_vertices; + // UI rendered into the HDR main pass still requires final composition. + // Count this submission so endFrame runs post-processing even when the + // world preview has not produced geometry yet. + ctx.runtime.draw_call_count += 1; } } @@ -46,18 +91,21 @@ pub fn begin2DPass(ctx: anytype, screen_width: f32, screen_height: f32) void { return; } - const direct_to_present = !ctx.runtime.post_process_ran_this_frame and !ctx.runtime.main_pass_active and ctx.runtime.draw_call_count == 0; - const use_swapchain = ctx.runtime.post_process_ran_this_frame or direct_to_present; + // UI overlays a completed scene. On a genuinely UI-only frame, it is also + // allowed to establish the final target itself by explicitly clearing it. + // Do not use FXAA pass state as a proxy: FXAA has already ended when UI is + // layered over its output. + const has_final_output = ctx.runtime.final_composed.isCurrentImage(ctx.frames.current_image_index); + const ui_only_frame = !has_final_output and !ctx.runtime.main_pass_active and ctx.runtime.draw_call_count == 0; + const use_swapchain = has_final_output or ui_only_frame; const ui_pipeline = if (use_swapchain) ctx.pipeline_manager.ui_swapchain_pipeline else ctx.pipeline_manager.ui_pipeline; if (ui_pipeline == null) { return; } if (use_swapchain) { - if (!ctx.fxaa.pass_active) { - pass_orchestration.beginFXAAPassForUI(ctx); - } - if (!ctx.fxaa.pass_active) { + pass_orchestration.beginUISwapchainPassInternal(ctx, ui_only_frame); + if (!ctx.ui.ui_swapchain_pass_active) { return; } } else { @@ -69,17 +117,17 @@ pub fn begin2DPass(ctx: anytype, screen_width: f32, screen_height: f32) void { ctx.ui.ui_using_swapchain = use_swapchain; - if (direct_to_present) { - ctx.runtime.post_process_ran_this_frame = true; - ctx.runtime.fxaa_ran_this_frame = true; - } - - ctx.ui.ui_screen_width = screen_width; - ctx.ui.ui_screen_height = screen_height; + // Headless SDL paths can report a transient zero drawable size while the + // offscreen swapchain already has a real extent. Never turn that into a + // zero UI scissor/viewport. + const swapchain_extent = ctx.swapchain.getExtent(); + ctx.ui.ui_screen_width = if (screen_width > 0.0) screen_width else @floatFromInt(swapchain_extent.width); + ctx.ui.ui_screen_height = if (screen_height > 0.0) screen_height else @floatFromInt(swapchain_extent.height); ctx.ui.ui_in_progress = true; ctx.ui.ui_active_textured = false; ctx.ui.ui_active_texture = 0; ctx.ui.ui_active_tint = .{ 0.0, 0.0, 0.0, 0.0 }; + ctx.ui.legacy_pipeline_bound = true; const ui_vbo = ctx.ui.ui_vbos[ctx.frames.current_frame]; if (ui_vbo.mapped_ptr) |ptr| { @@ -100,8 +148,7 @@ pub fn begin2DPass(ctx: anytype, screen_width: f32, screen_height: f32) void { const viewport = c.VkViewport{ .x = 0, .y = 0, .width = ctx.ui.ui_screen_width, .height = ctx.ui.ui_screen_height, .minDepth = 0, .maxDepth = 1 }; c.vkCmdSetViewport(command_buffer, 0, 1, &viewport); - const scissor = c.VkRect2D{ .offset = .{ .x = 0, .y = 0 }, .extent = .{ .width = @intFromFloat(ctx.ui.ui_screen_width), .height = @intFromFloat(ctx.ui.ui_screen_height) } }; - c.vkCmdSetScissor(command_buffer, 0, 1, &scissor); + setFullUIScissor(ctx, command_buffer); } pub fn end2DPass(ctx: anytype) void { @@ -111,25 +158,14 @@ pub fn end2DPass(ctx: anytype) void { flushUI(ctx); if (ctx.ui.ui_using_swapchain) { - pass_orchestration.endFXAAPassInternal(ctx); + pass_orchestration.endUISwapchainPassInternal(ctx); ctx.ui.ui_using_swapchain = false; } ctx.ui.ui_in_progress = false; } pub fn drawRect2D(ctx: anytype, rect: rhi.Rect, color: rhi.Color) void { - if (ctx.ui.ui_active_textured) { - flushUI(ctx); - ctx.ui.ui_active_textured = false; - ctx.ui.ui_active_texture = 0; - const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; - const pipeline = getUIPipeline(ctx, false); - if (pipeline != null) { - c.vkCmdBindPipeline(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); - const proj = Mat4.orthographic(0, ctx.ui.ui_screen_width, ctx.ui.ui_screen_height, 0, -1, 1); - c.vkCmdPushConstants(command_buffer, ctx.pipeline_manager.ui_pipeline_layout, UI_PUSH_STAGES, 0, @sizeOf(Mat4), &proj.data); - } - } + if (!ctx.ui.ui_in_progress or !bindLegacyPipeline(ctx, false)) return; const x = rect.x; const y = rect.y; @@ -161,18 +197,7 @@ pub fn drawRect2D(ctx: anytype, rect: rhi.Rect, color: rhi.Color) void { pub fn bindUIPipeline(ctx: anytype, textured: bool) void { if (!ctx.frames.frame_in_progress) return; - - flushUI(ctx); - ctx.ui.ui_active_textured = textured; - if (!textured) ctx.ui.ui_active_texture = 0; - - ctx.draw.terrain_pipeline_bound = false; - - const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; - - const pipeline = getUIPipeline(ctx, textured); - if (pipeline == null) return; - c.vkCmdBindPipeline(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + _ = bindLegacyPipeline(ctx, textured); } pub fn drawTexture2D(ctx: anytype, texture: rhi.TextureHandle, rect: rhi.Rect) void { @@ -193,17 +218,11 @@ pub fn drawTextureRegion2D(ctx: anytype, texture: rhi.TextureHandle, rect: rhi.R // preserve their sampled color, otherwise map/image widgets render grayscale. const alpha = if (tex.format == .red) -color.a else color.a; const tint = [_]f32{ color.r, color.g, color.b, alpha }; - const needs_bind = !ctx.ui.ui_active_textured or ctx.ui.ui_active_texture != texture or !sameTint(ctx.ui.ui_active_tint, tint); - if (needs_bind) { - flushUI(ctx); - } + const needs_bind = !ctx.ui.legacy_pipeline_bound or !ctx.ui.ui_active_textured or ctx.ui.ui_active_texture != texture or !sameTint(ctx.ui.ui_active_tint, tint); const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; if (needs_bind) { - const textured_pipeline = getUIPipeline(ctx, true); - if (textured_pipeline == null) return; - c.vkCmdBindPipeline(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, textured_pipeline); - ctx.draw.terrain_pipeline_bound = false; + if (!bindLegacyPipeline(ctx, true)) return; var image_info = std.mem.zeroes(c.VkDescriptorImageInfo); image_info.imageLayout = sampledImageLayout(tex.format); @@ -213,7 +232,11 @@ pub fn drawTextureRegion2D(ctx: anytype, texture: rhi.TextureHandle, rect: rhi.R const frame = ctx.frames.current_frame; const idx = ctx.ui.ui_tex_descriptor_next[frame]; const pool_len = ctx.ui.ui_tex_descriptor_pool[frame].len; - ctx.ui.ui_tex_descriptor_next[frame] = @intCast((idx + 1) % pool_len); + if (idx >= pool_len) { + log.log.err("UI texture descriptor storage exhausted for frame {}", .{frame}); + return; + } + ctx.ui.ui_tex_descriptor_next[frame] = idx + 1; const ds = ctx.ui.ui_tex_descriptor_pool[frame][idx]; var write = std.mem.zeroes(c.VkWriteDescriptorSet); @@ -227,8 +250,6 @@ pub fn drawTextureRegion2D(ctx: anytype, texture: rhi.TextureHandle, rect: rhi.R c.vkUpdateDescriptorSets(ctx.vulkan_device.vk_device, 1, &write, 0, null); c.vkCmdBindDescriptorSets(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, ctx.pipeline_manager.ui_tex_pipeline_layout, 0, 1, &ds, 0, null); - const proj = Mat4.orthographic(0, ctx.ui.ui_screen_width, ctx.ui.ui_screen_height, 0, -1, 1); - c.vkCmdPushConstants(command_buffer, ctx.pipeline_manager.ui_tex_pipeline_layout, UI_PUSH_STAGES, 0, @sizeOf(Mat4), &proj.data); c.vkCmdPushConstants(command_buffer, ctx.pipeline_manager.ui_tex_pipeline_layout, UI_PUSH_STAGES, @sizeOf(Mat4), @sizeOf(@TypeOf(tint)), &tint); ctx.ui.ui_active_textured = true; @@ -262,6 +283,135 @@ pub fn drawTextureRegion2D(ctx: anytype, texture: rhi.TextureHandle, rect: rhi.R } } +/// Copies one retained UI mesh into the bounded per-frame geometry buffers and +/// emits an indexed draw immediately. This lets RmlUi change texture/scissor +/// state per element without disturbing queued legacy UI vertices. +pub fn drawIndexedGeometry(ctx: anytype, vertices: []const rhi.UiVertex, indices: []const u32, texture: rhi.TextureHandle, translation: [2]f32) void { + if (!ctx.frames.frame_in_progress or !ctx.ui.ui_in_progress or vertices.len == 0 or indices.len == 0) return; + if (vertices.len > std.math.maxInt(u32) or indices.len > std.math.maxInt(u32)) { + log.log.err("RmlUi geometry exceeds Vulkan draw count limits (vertices={}, indices={})", .{ vertices.len, indices.len }); + return; + } + for (indices) |index| { + if (index >= @as(u32, @intCast(vertices.len))) { + log.log.err("RmlUi geometry index {} exceeds vertex count {}", .{ index, vertices.len }); + return; + } + } + + const vertex_bytes = std.math.mul(usize, vertices.len, @sizeOf(rhi.UiVertex)) catch { + log.log.err("RmlUi vertex byte size overflow", .{}); + return; + }; + const index_bytes = std.math.mul(usize, indices.len, @sizeOf(u32)) catch { + log.log.err("RmlUi index byte size overflow", .{}); + return; + }; + const frame = ctx.frames.current_frame; + const textured = texture != rhi.InvalidTextureHandle; + const tex = if (textured) ctx.resources.textures.get(texture) orelse { + log.log.err("RmlUi texture handle {} was not found", .{texture}); + return; + } else null; + const descriptor_index = ctx.ui.ui_tex_descriptor_next[frame]; + if (textured and descriptor_index >= ctx.ui.ui_tex_descriptor_pool[frame].len) { + log.log.err("RmlUi texture descriptor storage exhausted for frame {}", .{frame}); + return; + } + const vbo = ctx.ui.rml_vbos[frame]; + const ibo = ctx.ui.rml_ibos[frame]; + const vertex_bytes_u64: u64 = @intCast(vertex_bytes); + const index_bytes_u64: u64 = @intCast(index_bytes); + const vertex_end = std.math.add(u64, ctx.ui.rml_vertex_offset, vertex_bytes_u64) catch { + log.log.err("RmlUi vertex storage offset overflow", .{}); + return; + }; + const index_end = std.math.add(u64, ctx.ui.rml_index_offset, index_bytes_u64) catch { + log.log.err("RmlUi index storage offset overflow", .{}); + return; + }; + if (vertex_end > vbo.size or index_end > ibo.size) { + log.log.err("RmlUi per-frame geometry storage overflow: vertices {}/{} bytes, indices {}/{} bytes", .{ vertex_end, vbo.size, index_end, ibo.size }); + return; + } + + const vertex_ptr = vbo.mapped_ptr orelse { + log.log.err("RmlUi vertex storage is not mapped", .{}); + return; + }; + const index_ptr = ibo.mapped_ptr orelse { + log.log.err("RmlUi index storage is not mapped", .{}); + return; + }; + const vertex_offset_usize: usize = @intCast(ctx.ui.rml_vertex_offset); + const index_offset_usize: usize = @intCast(ctx.ui.rml_index_offset); + @memcpy((@as([*]u8, @ptrCast(vertex_ptr)) + vertex_offset_usize)[0..vertex_bytes], std.mem.sliceAsBytes(vertices)); + @memcpy((@as([*]u8, @ptrCast(index_ptr)) + index_offset_usize)[0..index_bytes], std.mem.sliceAsBytes(indices)); + + flushUI(ctx); + const command_buffer = ctx.frames.command_buffers[frame]; + const pipeline = getRmlUIPipeline(ctx, textured); + if (pipeline == null) { + log.log.err("RmlUi pipeline is unavailable", .{}); + return; + } + c.vkCmdBindPipeline(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + const layout = if (textured) ctx.pipeline_manager.ui_tex_pipeline_layout else ctx.pipeline_manager.ui_pipeline_layout; + const projection = Mat4.orthographic(0, ctx.ui.ui_screen_width, ctx.ui.ui_screen_height, 0, -1, 1); + c.vkCmdPushConstants(command_buffer, layout, UI_PUSH_STAGES, 0, @sizeOf(Mat4), &projection.data); + c.vkCmdPushConstants(command_buffer, layout, c.VK_SHADER_STAGE_VERTEX_BIT, @sizeOf(Mat4), @sizeOf([2]f32), &translation); + + if (tex) |texture_resource| { + ctx.ui.ui_tex_descriptor_next[frame] = descriptor_index + 1; + const descriptor_set = ctx.ui.ui_tex_descriptor_pool[frame][descriptor_index]; + if (descriptor_set == null) { + log.log.err("RmlUi texture descriptor {} is unavailable for frame {}", .{ descriptor_index, frame }); + return; + } + var image_info = std.mem.zeroes(c.VkDescriptorImageInfo); + image_info.imageLayout = sampledImageLayout(texture_resource.format); + image_info.imageView = texture_resource.view; + image_info.sampler = texture_resource.sampler; + var write = std.mem.zeroes(c.VkWriteDescriptorSet); + write.sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = descriptor_set; + write.dstBinding = 0; + write.descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.descriptorCount = 1; + write.pImageInfo = &image_info; + c.vkUpdateDescriptorSets(ctx.vulkan_device.vk_device, 1, &write, 0, null); + c.vkCmdBindDescriptorSets(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 0, 1, &descriptor_set, 0, null); + } + + const vertex_offset: c.VkDeviceSize = ctx.ui.rml_vertex_offset; + c.vkCmdBindVertexBuffers(command_buffer, 0, 1, &vbo.buffer, &vertex_offset); + c.vkCmdBindIndexBuffer(command_buffer, ibo.buffer, ctx.ui.rml_index_offset, c.VK_INDEX_TYPE_UINT32); + c.vkCmdDrawIndexed(command_buffer, @intCast(indices.len), 1, 0, 0, 0); + ctx.ui.rml_vertex_offset = vertex_end; + ctx.ui.rml_index_offset = index_end; + ctx.ui.legacy_pipeline_bound = false; + ctx.ui.ui_active_textured = false; + ctx.ui.ui_active_texture = 0; + ctx.draw.terrain_pipeline_bound = false; + ctx.runtime.draw_call_count += 1; +} + +pub fn setScissorRegion(ctx: anytype, region: rhi.UiScissor) void { + if (!ctx.frames.frame_in_progress or !ctx.ui.ui_in_progress) return; + const max_width: i64 = @intFromFloat(@max(ctx.ui.ui_screen_width, 0.0)); + const max_height: i64 = @intFromFloat(@max(ctx.ui.ui_screen_height, 0.0)); + const x0 = std.math.clamp(@as(i64, region.x), @as(i64, 0), max_width); + const y0 = std.math.clamp(@as(i64, region.y), @as(i64, 0), max_height); + const x1 = std.math.clamp(@as(i64, region.x) + @as(i64, @intCast(region.width)), @as(i64, 0), max_width); + const y1 = std.math.clamp(@as(i64, region.y) + @as(i64, @intCast(region.height)), @as(i64, 0), max_height); + const scissor = c.VkRect2D{ + .offset = .{ .x = @intCast(x0), .y = @intCast(y0) }, + .extent = .{ .width = @intCast(@max(x1 - x0, 0)), .height = @intCast(@max(y1 - y0, 0)) }, + }; + const command_buffer = ctx.frames.command_buffers[ctx.frames.current_frame]; + c.vkCmdSetScissor(command_buffer, 0, 1, &scissor); +} + pub fn drawDepthTexture(ctx: anytype, texture: rhi.TextureHandle, rect: rhi.Rect) void { if (comptime !build_options.debug_shadows) return; if (!ctx.frames.frame_in_progress or !ctx.ui.ui_in_progress) return; @@ -335,5 +485,9 @@ pub fn drawDepthTexture(ctx: anytype, texture: rhi.TextureHandle, rect: rhi.Rect if (restore_pipeline != null) { c.vkCmdBindPipeline(command_buffer, c.VK_PIPELINE_BIND_POINT_GRAPHICS, restore_pipeline); c.vkCmdPushConstants(command_buffer, ctx.pipeline_manager.ui_pipeline_layout, UI_PUSH_STAGES, 0, @sizeOf(Mat4), &proj.data); + setFullUIScissor(ctx, command_buffer); + ctx.ui.legacy_pipeline_bound = true; + ctx.ui.ui_active_textured = false; + ctx.ui.ui_active_texture = 0; } } diff --git a/modules/engine-graphics/src/vulkan/screenshot.zig b/modules/engine-graphics/src/vulkan/screenshot.zig index 98767cc7..c54b3e6c 100644 --- a/modules/engine-graphics/src/vulkan/screenshot.zig +++ b/modules/engine-graphics/src/vulkan/screenshot.zig @@ -3,19 +3,43 @@ const fs = @import("fs"); const c = @import("c").c; const log = @import("engine-core").log; const Utils = @import("utils.zig"); -const VulkanContext = @import("rhi_context_types.zig").VulkanContext; -pub fn captureScreenshot(ctx: *VulkanContext, path: []const u8) bool { +/// A readback recorded after all composition (including UI) and completed once +/// the frame submission fence signals. +pub const PendingCapture = struct { + staging: ?Utils.VulkanBuffer = null, + path: []const u8 = &.{}, + width: u32 = 0, + height: u32 = 0, + format: c.VkFormat = c.VK_FORMAT_UNDEFINED, + source_layout: c.VkImageLayout = c.VK_IMAGE_LAYOUT_UNDEFINED, + source_image: c.VkImage = null, + frame_index: usize = 0, + path_owned: bool = false, +}; + +/// Queues a readback of the active frame. Frame orchestration records it after +/// all composition, including UI and post-processing, has completed. +pub fn requestCapture(ctx: anytype, path: []const u8) bool { const device = &ctx.vulkan_device; - const vk = device.vk_device; + if (!ctx.frames.frame_in_progress) { + log.log.err("screenshot: capture must be requested before endFrame", .{}); + return false; + } + if (ctx.screenshot_capture.staging != null) { + log.log.err("screenshot: a capture is already pending", .{}); + return false; + } if (ctx.swapchain.swapchain.images.items.len == 0) { log.log.err("screenshot: no swapchain images available", .{}); return false; } + if (!ctx.swapchain.swapchain.screenshot_capture_supported) { + log.log.err("screenshot: active surface does not support transfer-source swapchain images", .{}); + return false; + } - const image_index = ctx.frames.current_image_index; - const swapchain_image = ctx.swapchain.swapchain.images.items[image_index]; const extent = ctx.swapchain.getExtent(); const image_format = ctx.swapchain.getImageFormat(); const width = extent.width; @@ -32,51 +56,49 @@ pub fn captureScreenshot(ctx: *VulkanContext, path: []const u8) bool { log.log.err("screenshot: failed to create staging buffer", .{}); return false; }; - defer { - c.vkDestroyBuffer(vk, staging.buffer, null); - c.vkFreeMemory(vk, staging.memory, null); - } - - var pool_info = std.mem.zeroes(c.VkCommandPoolCreateInfo); - pool_info.sType = c.VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; - pool_info.queueFamilyIndex = device.graphics_family; - pool_info.flags = c.VK_COMMAND_POOL_CREATE_TRANSIENT_BIT; - - var command_pool: c.VkCommandPool = null; - Utils.checkVk(c.vkCreateCommandPool(vk, &pool_info, null, &command_pool)) catch { - log.log.err("screenshot: failed to create command pool", .{}); + const owned_path = ctx.allocator.dupe(u8, path) catch { + c.vkDestroyBuffer(device.vk_device, staging.buffer, null); + c.vkFreeMemory(device.vk_device, staging.memory, null); + log.log.err("screenshot: failed to retain output path", .{}); return false; }; - defer c.vkDestroyCommandPool(vk, command_pool, null); - - var cmd_alloc_info = std.mem.zeroes(c.VkCommandBufferAllocateInfo); - cmd_alloc_info.sType = c.VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; - cmd_alloc_info.commandPool = command_pool; - cmd_alloc_info.level = c.VK_COMMAND_BUFFER_LEVEL_PRIMARY; - cmd_alloc_info.commandBufferCount = 1; - - var cmd_buffer: c.VkCommandBuffer = null; - Utils.checkVk(c.vkAllocateCommandBuffers(vk, &cmd_alloc_info, &cmd_buffer)) catch { - log.log.err("screenshot: failed to allocate command buffer", .{}); - return false; + ctx.screenshot_capture = .{ + .staging = staging, + .path = owned_path, + .width = width, + .height = height, + .format = image_format, + .frame_index = ctx.frames.current_frame, + .path_owned = true, }; - var begin_info = std.mem.zeroes(c.VkCommandBufferBeginInfo); - begin_info.sType = c.VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - begin_info.flags = c.VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - _ = c.vkBeginCommandBuffer(cmd_buffer, &begin_info); + return true; +} - const is_headless = ctx.swapchain.swapchain.headless_mode; - const src_layout: c.VkImageLayout = if (is_headless) c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL else c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; +/// Appends the copy after final composition but before the command buffer is +/// submitted or (on normal paths) the swapchain image is presented. +pub fn recordCapture(ctx: anytype) void { + const capture = &ctx.screenshot_capture; + const staging = capture.staging orelse return; + const final_image = ctx.runtime.final_composed; + if (!final_image.isCurrentImage(ctx.frames.current_image_index)) { + log.log.err("screenshot: no final composed image is available for this frame", .{}); + discardCapture(ctx); + return; + } + + capture.source_image = final_image.image; + capture.source_layout = final_image.layout; + const cmd_buffer = ctx.frames.getCurrentCommandBuffer(); const dst_layout = c.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; var barrier = std.mem.zeroes(c.VkImageMemoryBarrier); barrier.sType = c.VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; barrier.srcAccessMask = c.VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | c.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; barrier.dstAccessMask = c.VK_ACCESS_TRANSFER_READ_BIT; - barrier.oldLayout = src_layout; + barrier.oldLayout = capture.source_layout; barrier.newLayout = dst_layout; - barrier.image = swapchain_image; + barrier.image = capture.source_image; barrier.subresourceRange = .{ .aspectMask = c.VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, @@ -109,21 +131,43 @@ pub fn captureScreenshot(ctx: *VulkanContext, path: []const u8) bool { .layerCount = 1, }; region.imageOffset = .{ .x = 0, .y = 0, .z = 0 }; - region.imageExtent = .{ .width = width, .height = height, .depth = 1 }; + region.imageExtent = .{ .width = capture.width, .height = capture.height, .depth = 1 }; c.vkCmdCopyImageToBuffer( cmd_buffer, - swapchain_image, + capture.source_image, c.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, staging.buffer, 1, ®ion, ); + var host_barrier = std.mem.zeroes(c.VkBufferMemoryBarrier); + host_barrier.sType = c.VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + host_barrier.srcAccessMask = c.VK_ACCESS_TRANSFER_WRITE_BIT; + host_barrier.dstAccessMask = c.VK_ACCESS_HOST_READ_BIT; + host_barrier.srcQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; + host_barrier.dstQueueFamilyIndex = c.VK_QUEUE_FAMILY_IGNORED; + host_barrier.buffer = staging.buffer; + host_barrier.offset = 0; + host_barrier.size = c.VK_WHOLE_SIZE; + c.vkCmdPipelineBarrier( + cmd_buffer, + c.VK_PIPELINE_STAGE_TRANSFER_BIT, + c.VK_PIPELINE_STAGE_HOST_BIT, + 0, + 0, + null, + 1, + &host_barrier, + 0, + null, + ); + barrier.srcAccessMask = c.VK_ACCESS_TRANSFER_READ_BIT; barrier.dstAccessMask = c.VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | c.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; barrier.oldLayout = dst_layout; - barrier.newLayout = src_layout; + barrier.newLayout = capture.source_layout; c.vkCmdPipelineBarrier( cmd_buffer, @@ -137,27 +181,17 @@ pub fn captureScreenshot(ctx: *VulkanContext, path: []const u8) bool { 1, &barrier, ); +} - _ = c.vkEndCommandBuffer(cmd_buffer); - - var fence_info = std.mem.zeroes(c.VkFenceCreateInfo); - fence_info.sType = c.VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; - var fence: c.VkFence = null; - Utils.checkVk(c.vkCreateFence(vk, &fence_info, null, &fence)) catch { - log.log.err("screenshot: failed to create fence", .{}); - return false; - }; - defer c.vkDestroyFence(vk, fence, null); - - var submit_info = std.mem.zeroes(c.VkSubmitInfo); - submit_info.sType = c.VK_STRUCTURE_TYPE_SUBMIT_INFO; - submit_info.commandBufferCount = 1; - submit_info.pCommandBuffers = &cmd_buffer; +/// Encodes the capture after the frame submission containing the copy has +/// completed. Called by frame orchestration, never by application code. +pub fn completeCapture(ctx: anytype) bool { + const capture = &ctx.screenshot_capture; + const staging = capture.staging orelse return true; + defer discardCapture(ctx); - device.submitGuarded(submit_info, fence) catch |err| { - log.log.err("screenshot: vkQueueSubmit failed: {}", .{err}); - return false; - }; + const vk = ctx.vulkan_device.vk_device; + const fence = ctx.frames.in_flight_fences[capture.frame_index]; const fence_result = c.vkWaitForFences(vk, 1, &fence, c.VK_TRUE, 5_000_000_000); if (fence_result != c.VK_SUCCESS) { log.log.err("screenshot: fence wait failed or timed out ({})", .{fence_result}); @@ -169,8 +203,17 @@ pub fn captureScreenshot(ctx: *VulkanContext, path: []const u8) bool { return false; }; const bytes: [*]const u8 = @ptrCast(@alignCast(mapped_ptr)); + return writeImage(bytes, capture.width, capture.height, capture.path, capture.format); +} - return writeImage(bytes, width, height, path, image_format); +pub fn discardCapture(ctx: anytype) void { + if (ctx.screenshot_capture.staging) |staging| { + if (staging.mapped_ptr != null) c.vkUnmapMemory(ctx.vulkan_device.vk_device, staging.memory); + c.vkDestroyBuffer(ctx.vulkan_device.vk_device, staging.buffer, null); + c.vkFreeMemory(ctx.vulkan_device.vk_device, staging.memory, null); + } + if (ctx.screenshot_capture.path_owned) ctx.allocator.free(ctx.screenshot_capture.path); + ctx.screenshot_capture = .{}; } const ScreenshotFormat = enum { @@ -231,9 +274,6 @@ fn writePNG(data: [*]const u8, width: u32, height: u32, path: []const u8, format const is_bgra = format == c.VK_FORMAT_B8G8R8A8_UNORM or format == c.VK_FORMAT_B8G8R8A8_SRGB; const needs_srgb_encode = format == c.VK_FORMAT_B8G8R8A8_UNORM or format == c.VK_FORMAT_R8G8B8A8_UNORM; - const red_index: usize = if (is_bgra) 2 else 0; - const blue_index: usize = if (is_bgra) 0 else 2; - var y: u32 = 0; while (y < height) : (y += 1) { const row_start = @as(usize, y) * (row_bytes + 1); @@ -242,12 +282,10 @@ fn writePNG(data: [*]const u8, width: u32, height: u32, path: []const u8, format while (x < width) : (x += 1) { const src_offset: usize = (@as(usize, y) * width + x) * 4; const dst_offset: usize = row_start + 1 + @as(usize, x) * 3; - const red = data[src_offset + red_index]; - const green = data[src_offset + 1]; - const blue = data[src_offset + blue_index]; - raw[dst_offset] = if (needs_srgb_encode) linearByteToSrgb(red) else red; - raw[dst_offset + 1] = if (needs_srgb_encode) linearByteToSrgb(green) else green; - raw[dst_offset + 2] = if (needs_srgb_encode) linearByteToSrgb(blue) else blue; + const rgb = finalDisplayRgb(data[src_offset..][0..4], is_bgra); + raw[dst_offset] = if (needs_srgb_encode) linearByteToSrgb(rgb[0]) else rgb[0]; + raw[dst_offset + 1] = if (needs_srgb_encode) linearByteToSrgb(rgb[1]) else rgb[1]; + raw[dst_offset + 2] = if (needs_srgb_encode) linearByteToSrgb(rgb[2]) else rgb[2]; } } @@ -287,6 +325,16 @@ fn writePNG(data: [*]const u8, width: u32, height: u32, path: []const u8, format return true; } +fn finalDisplayRgb(pixel: []const u8, is_bgra: bool) [3]u8 { + std.debug.assert(pixel.len >= 4); + return if (is_bgra) .{ pixel[2], pixel[1], pixel[0] } else .{ pixel[0], pixel[1], pixel[2] }; +} + +test "final display screenshot preserves BGRA UNORM byte encoding" { + try std.testing.expectEqual([3]u8{ 101, 47, 23 }, finalDisplayRgb(&.{ 23, 47, 101, 255 }, true)); + try std.testing.expectEqual([3]u8{ 101, 47, 23 }, finalDisplayRgb(&.{ 101, 47, 23, 255 }, false)); +} + fn linearByteToSrgb(value: u8) u8 { const linear = @as(f32, @floatFromInt(value)) / 255.0; const encoded = if (linear <= 0.0031308) @@ -296,7 +344,7 @@ fn linearByteToSrgb(value: u8) u8 { return @intFromFloat(@round(std.math.clamp(encoded, 0.0, 1.0) * 255.0)); } -test "linear screenshot bytes encode to sRGB" { +test "linear final target bytes encode to sRGB PNG values" { try std.testing.expectEqual(@as(u8, 0), linearByteToSrgb(0)); try std.testing.expectEqual(@as(u8, 255), linearByteToSrgb(255)); try std.testing.expectApproxEqAbs(@as(f32, 118.0), @as(f32, @floatFromInt(linearByteToSrgb(46))), 1.0); diff --git a/modules/engine-graphics/src/vulkan/shader_registry.zig b/modules/engine-graphics/src/vulkan/shader_registry.zig index d72371ed..ec64c495 100644 --- a/modules/engine-graphics/src/vulkan/shader_registry.zig +++ b/modules/engine-graphics/src/vulkan/shader_registry.zig @@ -32,6 +32,10 @@ pub const UI_FRAG = "assets/shaders/vulkan/ui.frag.spv"; pub const UI_TEX_VERT = "assets/shaders/vulkan/ui_tex.vert.spv"; pub const UI_TEX_FRAG = "assets/shaders/vulkan/ui_tex.frag.spv"; +pub const UI_RML_VERT = "assets/shaders/vulkan/ui_rml.vert.spv"; +pub const UI_RML_FRAG = "assets/shaders/vulkan/ui_rml.frag.spv"; +pub const UI_RML_TEX_FRAG = "assets/shaders/vulkan/ui_rml_tex.frag.spv"; + pub const DEBUG_SHADOW_VERT = "assets/shaders/vulkan/debug_shadow.vert.spv"; pub const DEBUG_SHADOW_FRAG = "assets/shaders/vulkan/debug_shadow.frag.spv"; diff --git a/modules/engine-graphics/src/vulkan/shader_registry_tests.zig b/modules/engine-graphics/src/vulkan/shader_registry_tests.zig index 93c4d169..9c25dfe6 100644 --- a/modules/engine-graphics/src/vulkan/shader_registry_tests.zig +++ b/modules/engine-graphics/src/vulkan/shader_registry_tests.zig @@ -36,6 +36,9 @@ test "all shader paths follow SPIR-V naming convention" { shader_registry.UI_FRAG, shader_registry.UI_TEX_VERT, shader_registry.UI_TEX_FRAG, + shader_registry.UI_RML_VERT, + shader_registry.UI_RML_FRAG, + shader_registry.UI_RML_TEX_FRAG, shader_registry.DEBUG_SHADOW_VERT, shader_registry.DEBUG_SHADOW_FRAG, }; @@ -91,6 +94,11 @@ test "vertex and fragment shader pairs exist for complete pipelines" { try testing.expect(std.mem.endsWith(u8, shader_registry.UI_TEX_VERT, ".vert.spv")); try testing.expect(std.mem.endsWith(u8, shader_registry.UI_TEX_FRAG, ".frag.spv")); + // RmlUi retained geometry + try testing.expect(std.mem.endsWith(u8, shader_registry.UI_RML_VERT, ".vert.spv")); + try testing.expect(std.mem.endsWith(u8, shader_registry.UI_RML_FRAG, ".frag.spv")); + try testing.expect(std.mem.endsWith(u8, shader_registry.UI_RML_TEX_FRAG, ".frag.spv")); + // Debug Shadow try testing.expect(std.mem.endsWith(u8, shader_registry.DEBUG_SHADOW_VERT, ".vert.spv")); try testing.expect(std.mem.endsWith(u8, shader_registry.DEBUG_SHADOW_FRAG, ".frag.spv")); diff --git a/modules/engine-graphics/src/vulkan_swapchain.zig b/modules/engine-graphics/src/vulkan_swapchain.zig index 576ad500..705e9d31 100644 --- a/modules/engine-graphics/src/vulkan_swapchain.zig +++ b/modules/engine-graphics/src/vulkan_swapchain.zig @@ -30,6 +30,7 @@ pub const VulkanSwapchain = struct { headless_mode: bool = false, headless_image: c.VkImage = null, headless_memory: c.VkDeviceMemory = null, + screenshot_capture_supported: bool = false, // Resolution scaling pixel_width: u32 = 0, @@ -120,6 +121,7 @@ pub const VulkanSwapchain = struct { if (self.headless_mode) { log.log.info("VulkanSwapchain: Initializing in HEADLESS mode (offscreen)", .{}); self.image_format = c.VK_FORMAT_B8G8R8A8_UNORM; + self.screenshot_capture_supported = true; self.extent = .{ .width = 1920, .height = 1080 }; self.pixel_width = 1920; self.pixel_height = 1080; @@ -232,7 +234,9 @@ pub const VulkanSwapchain = struct { swapchain_info.imageColorSpace = surface_format.colorSpace; swapchain_info.imageExtent = self.extent; swapchain_info.imageArrayLayers = 1; - swapchain_info.imageUsage = c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + self.screenshot_capture_supported = (cap.supportedUsageFlags & c.VK_IMAGE_USAGE_TRANSFER_SRC_BIT) != 0; + swapchain_info.imageUsage = @as(u32, @intCast(c.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT)) | + (if (self.screenshot_capture_supported) @as(u32, @intCast(c.VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) else 0); swapchain_info.imageSharingMode = c.VK_SHARING_MODE_EXCLUSIVE; swapchain_info.preTransform = cap.currentTransform; // Select a supported composite alpha mode (prefer opaque, but fall back if unsupported) diff --git a/modules/engine-input/src/input.zig b/modules/engine-input/src/input.zig index 10776e04..e34a791d 100644 --- a/modules/engine-input/src/input.zig +++ b/modules/engine-input/src/input.zig @@ -13,7 +13,14 @@ const Modifiers = interfaces.Modifiers; const c = @import("c").c; -pub const RawEventProcessor = *const fn (event: *const c.SDL_Event) void; +/// A context-bound raw SDL event sink. The context is explicitly owned by the +/// caller, so a processor can point at heap-stable subsystem state instead of +/// capturing an address of a temporary `App` value during initialization. +pub const RawEventProcessor = struct { + context: *anyopaque, + /// Returns true when the event was consumed and must not update game input. + process: *const fn (context: *anyopaque, event: *const c.SDL_Event) bool, +}; pub const Input = struct { /// Currently pressed keys @@ -82,7 +89,9 @@ pub const Input = struct { pub fn pollEvents(self: *Input) void { var event: c.SDL_Event = undefined; while (c.SDL_PollEvent(&event)) { - if (self.raw_event_processor) |process| process(&event); + if (self.raw_event_processor) |processor| { + if (processor.process(processor.context, &event)) continue; + } self.processEvent(event); } } @@ -91,6 +100,13 @@ pub const Input = struct { self.raw_event_processor = processor; } + /// Delivers a raw event without modifying input state. Kept public for + /// focused tests and for platforms which supply events externally. + pub fn dispatchRawEvent(self: *const Input, event: *const c.SDL_Event) bool { + if (self.raw_event_processor) |processor| return processor.process(processor.context, event); + return false; + } + fn processEvent(self: *Input, event: c.SDL_Event) void { switch (event.type) { c.SDL_EVENT_QUIT => { diff --git a/modules/engine-input/src/input_tests.zig b/modules/engine-input/src/input_tests.zig index aaed45e8..51441d8f 100644 --- a/modules/engine-input/src/input_tests.zig +++ b/modules/engine-input/src/input_tests.zig @@ -8,6 +8,7 @@ const std = @import("std"); const testing = std.testing; const Input = @import("engine-input").Input; +const c = @import("c").c; test "recordKeyDown registers key as both pressed and down" { var input = Input.init(testing.allocator); @@ -91,3 +92,28 @@ test "recordMouseWheel accumulates per frame (regression for #711)" { try testing.expectEqual(@as(f32, 0.0), input.scroll_x); try testing.expectEqual(@as(f32, 0.0), input.scroll_y); } + +test "raw event processor retains its explicit context" { + const Receiver = struct { + calls: u32 = 0, + + fn process(context: *anyopaque, _: *const c.SDL_Event) bool { + const self: *@This() = @ptrCast(@alignCast(context)); + self.calls += 1; + return true; + } + }; + + var input = Input.init(testing.allocator); + defer input.deinit(); + var receiver = Receiver{}; + input.setRawEventProcessor(.{ .context = &receiver, .process = Receiver.process }); + + var event = std.mem.zeroes(c.SDL_Event); + try testing.expect(input.dispatchRawEvent(&event)); + try testing.expectEqual(@as(u32, 1), receiver.calls); + + input.setRawEventProcessor(null); + try testing.expect(!input.dispatchRawEvent(&event)); + try testing.expectEqual(@as(u32, 1), receiver.calls); +} diff --git a/modules/engine-input/src/root.zig b/modules/engine-input/src/root.zig index b4b9748f..5ef2d47d 100644 --- a/modules/engine-input/src/root.zig +++ b/modules/engine-input/src/root.zig @@ -3,6 +3,7 @@ pub const interfaces = @import("interfaces.zig"); pub const input_tests = @import("input_tests.zig"); pub const Input = input.Input; +pub const RawEventProcessor = input.RawEventProcessor; pub const ActionBinding = interfaces.ActionBinding; pub const GameAction = interfaces.GameAction; pub const IInputMapper = interfaces.IInputMapper; diff --git a/modules/engine-rhi/src/rhi.zig b/modules/engine-rhi/src/rhi.zig index ab4bf933..d381e915 100644 --- a/modules/engine-rhi/src/rhi.zig +++ b/modules/engine-rhi/src/rhi.zig @@ -95,6 +95,8 @@ pub const ShadowParams = rhi_types.ShadowParams; pub const Color = rhi_types.Color; pub const Rect = rhi_types.Rect; pub const UVRect = rhi_types.UVRect; +pub const UiVertex = rhi_types.UiVertex; +pub const UiScissor = rhi_types.UiScissor; pub const GpuTimingResults = rhi_types.GpuTimingResults; pub const ICullingSystem = culling.ICullingSystem; @@ -647,8 +649,13 @@ pub const IUIContext = struct { drawTextureRegion: *const fn (ptr: *anyopaque, texture: TextureHandle, rect: Rect, uv: UVRect, color: Color) void, drawDepthTexture: *const fn (ptr: *anyopaque, texture: TextureHandle, rect: Rect) void, bindPipeline: *const fn (ptr: *anyopaque, textured: bool) void, + drawIndexedGeometry: *const fn (ptr: *anyopaque, vertices: []const UiVertex, indices: []const u32, texture: TextureHandle, translation: [2]f32) void = unsupportedDrawIndexedGeometry, + setScissorRegion: *const fn (ptr: *anyopaque, region: UiScissor) void = unsupportedSetScissorRegion, }; + fn unsupportedDrawIndexedGeometry(_: *anyopaque, _: []const UiVertex, _: []const u32, _: TextureHandle, _: [2]f32) void {} + fn unsupportedSetScissorRegion(_: *anyopaque, _: UiScissor) void {} + /// Begins the immediate-mode UI pass for a framebuffer of `width` by `height` pixels. /// UI draw calls must be issued between this call and `endPass` on the render thread. pub fn beginPass(self: IUIContext, width: f32, height: f32) void { @@ -684,6 +691,16 @@ pub const IUIContext = struct { pub fn bindPipeline(self: IUIContext, textured: bool) void { self.vtable.bindPipeline(self.ptr, textured); } + /// Draws arbitrary indexed UI geometry. A zero texture handle selects the + /// untextured pipeline; non-zero handles are sampled and modulated by the + /// per-vertex RGBA color. `translation` is in UI pixels. + pub fn drawIndexedGeometry(self: IUIContext, vertices: []const UiVertex, indices: []const u32, texture: TextureHandle, translation: [2]f32) void { + self.vtable.drawIndexedGeometry(self.ptr, vertices, indices, texture, translation); + } + /// Sets the dynamic pixel scissor for subsequent indexed UI geometry. + pub fn setScissorRegion(self: IUIContext, region: UiScissor) void { + self.vtable.setScissorRegion(self.ptr, region); + } }; pub const IImGuiContext = struct { @@ -769,6 +786,15 @@ pub const UIRenderer = struct { pub fn bindPipeline(self: UIRenderer, textured: bool) void { self.ctx.bindPipeline(textured); } + /// Draws retained indexed geometry, including RmlUi meshes, in the active + /// UI pass. A zero texture handle selects the untextured pipeline. + pub fn drawIndexedGeometry(self: UIRenderer, vertices: []const UiVertex, indices: []const u32, texture: TextureHandle, translation: [2]f32) void { + self.ctx.drawIndexedGeometry(vertices, indices, texture, translation); + } + /// Sets the dynamic pixel scissor for following retained UI geometry. + pub fn setScissorRegion(self: UIRenderer, region: UiScissor) void { + self.ctx.setScissorRegion(region); + } }; pub const IGraphicsCommandEncoder = struct { @@ -1553,8 +1579,9 @@ pub const IScreenshotContext = struct { captureFrame: *const fn (ctx: *anyopaque, path: []const u8) bool, }; - /// Captures the current framebuffer to the requested output path. - /// The path must be writable and capture resources must be available for the active backend. Must be called from the render thread that owns the backend context. + /// Requests a capture of the final composed framebuffer, including UI, to + /// the requested path. Must be called after UI rendering and before + /// `endFrame`; encoding completes as part of frame submission. pub fn captureFrame(self: IScreenshotContext, path: []const u8) bool { return self.vtable.captureFrame(self.ptr, path); } diff --git a/modules/engine-rhi/src/rhi_contract_tests.zig b/modules/engine-rhi/src/rhi_contract_tests.zig index a0e23874..e9a869d9 100644 --- a/modules/engine-rhi/src/rhi_contract_tests.zig +++ b/modules/engine-rhi/src/rhi_contract_tests.zig @@ -429,6 +429,10 @@ test "RHI contract declares IUIContext.drawDepthTexture" { test "RHI contract declares IUIContext.bindPipeline" { try std.testing.expect(@hasDecl(rhi.IUIContext, "bindPipeline")); } +test "RHI contract declares IUIContext retained geometry operations" { + try std.testing.expect(@hasDecl(rhi.IUIContext, "drawIndexedGeometry")); + try std.testing.expect(@hasDecl(rhi.IUIContext, "setScissorRegion")); +} test "RHI contract declares UIRenderer.beginPass" { try std.testing.expect(@hasDecl(rhi.UIRenderer, "beginPass")); } @@ -450,6 +454,16 @@ test "RHI contract declares UIRenderer.drawDepthTexture" { test "RHI contract declares UIRenderer.bindPipeline" { try std.testing.expect(@hasDecl(rhi.UIRenderer, "bindPipeline")); } +test "RHI contract declares UIRenderer retained geometry operations" { + try std.testing.expect(@hasDecl(rhi.UIRenderer, "drawIndexedGeometry")); + try std.testing.expect(@hasDecl(rhi.UIRenderer, "setScissorRegion")); +} +test "RHI UI geometry types retain the RmlUi-compatible memory layout" { + try std.testing.expectEqual(@as(usize, 20), @sizeOf(rhi.UiVertex)); + try std.testing.expectEqual(@as(usize, 0), @offsetOf(rhi.UiVertex, "position")); + try std.testing.expectEqual(@as(usize, 8), @offsetOf(rhi.UiVertex, "color")); + try std.testing.expectEqual(@as(usize, 12), @offsetOf(rhi.UiVertex, "uv")); +} test "RHI contract declares IGraphicsCommandEncoder.bindTexture" { try std.testing.expect(@hasDecl(rhi.IGraphicsCommandEncoder, "bindTexture")); } diff --git a/modules/engine-rhi/src/rhi_types.zig b/modules/engine-rhi/src/rhi_types.zig index d3a7d38e..bfcb3494 100644 --- a/modules/engine-rhi/src/rhi_types.zig +++ b/modules/engine-rhi/src/rhi_types.zig @@ -90,6 +90,24 @@ pub const TextureAtlasHandles = struct { env: TextureHandle, }; +/// Vertex format used by retained UI geometry such as RmlUi meshes. +/// +/// `color` is stored in RGBA byte order so it maps directly to an +/// `R8G8B8A8_UNORM` vertex attribute on graphics backends. +pub const UiVertex = extern struct { + position: [2]f32, + color: [4]u8, + uv: [2]f32, +}; + +/// Pixel-aligned clipping rectangle for retained UI geometry. +pub const UiScissor = struct { + x: i32, + y: i32, + width: u32, + height: u32, +}; + pub const Vertex = extern struct { pos: [3]f32, color: u32, diff --git a/modules/engine-rhi/src/root.zig b/modules/engine-rhi/src/root.zig index fb17de26..e86bd523 100644 --- a/modules/engine-rhi/src/root.zig +++ b/modules/engine-rhi/src/root.zig @@ -49,6 +49,8 @@ pub const ShadowParams = rhi_types.ShadowParams; pub const Color = rhi_types.Color; pub const Rect = rhi_types.Rect; pub const UVRect = rhi_types.UVRect; +pub const UiVertex = rhi_types.UiVertex; +pub const UiScissor = rhi_types.UiScissor; pub const GpuTimingResults = rhi_types.GpuTimingResults; pub const BLOOM_MIP_COUNT = rhi.BLOOM_MIP_COUNT; pub const RenderResolution = rhi.RenderResolution; diff --git a/modules/engine-ui/src/chunk_inspector_overlay.zig b/modules/engine-ui/src/chunk_inspector_overlay.zig index a9007a8f..40a3e193 100644 --- a/modules/engine-ui/src/chunk_inspector_overlay.zig +++ b/modules/engine-ui/src/chunk_inspector_overlay.zig @@ -20,6 +20,8 @@ pub const ChunkInspectorOverlay = struct { self.enabled = !self.enabled; } + /// Submits inspector draw calls to an active caller-owned UI pass. + /// The caller must pair `ui.begin()` and `ui.end()` around this call. pub fn draw(self: *ChunkInspectorOverlay, ui: *UISystem, render_stats: ChunkRenderStats, counts: ChunkStateCounts, world_state: WorldStateData) void { if (!self.enabled) return; diff --git a/modules/engine-ui/src/debug_lpv_overlay.zig b/modules/engine-ui/src/debug_lpv_overlay.zig index 86eba35a..09eaa537 100644 --- a/modules/engine-ui/src/debug_lpv_overlay.zig +++ b/modules/engine-ui/src/debug_lpv_overlay.zig @@ -22,6 +22,8 @@ pub const DebugLPVOverlay = struct { }; } + /// Submits the LPV preview to an active caller-owned UI pass. + /// The caller must pair `ui.begin()` and `ui.end()` around this call. pub fn draw(ui: *UISystem, lpv_texture: rhi.TextureHandle, screen_height: f32, config: Config) void { if (lpv_texture == 0) return; diff --git a/modules/engine-ui/src/debug_shadow_overlay.zig b/modules/engine-ui/src/debug_shadow_overlay.zig index ea62f7de..59530a6c 100644 --- a/modules/engine-ui/src/debug_shadow_overlay.zig +++ b/modules/engine-ui/src/debug_shadow_overlay.zig @@ -1,5 +1,6 @@ const std = @import("std"); const rhi = @import("engine-rhi").rhi; +const Mat4 = @import("engine-math").Mat4; const UISystem = @import("ui_system.zig").UISystem; const ShadowSystemWrapper = rhi.ShadowSystemWrapper; const Font = @import("font.zig"); @@ -14,6 +15,8 @@ pub const DebugShadowOverlay = struct { debug_shadow_seam_diag: bool = false, }; + /// Submits shadow-map preview draw calls to an active caller-owned UI pass. + /// The caller must pair `ui.begin()` and `ui.end()` around this call. pub fn draw( ui: *UISystem, shadow: ShadowSystemWrapper, @@ -22,9 +25,6 @@ pub const DebugShadowOverlay = struct { config: Config, cascade_splits: []const f32, ) void { - ui.begin(); - defer ui.end(); - const label_height: f32 = 16.0; for (0..rhi.SHADOW_CASCADE_COUNT) |i| { @@ -71,3 +71,76 @@ pub const DebugShadowOverlay = struct { } } }; + +test "DebugShadowOverlay draws within the caller-owned UI pass" { + const MockUI = struct { + begin_count: u32 = 0, + end_count: u32 = 0, + depth_texture_count: u32 = 0, + + const VTABLE = rhi.IUIContext.VTable{ + .beginPass = beginPass, + .endPass = endPass, + .drawRect = drawRect, + .drawTexture = drawTexture, + .drawTextureRegion = drawTextureRegion, + .drawDepthTexture = drawDepthTexture, + .bindPipeline = bindPipeline, + }; + + fn renderer(self: *@This()) rhi.UIRenderer { + return .{ .ctx = .{ .ptr = self, .vtable = &VTABLE } }; + } + + fn beginPass(ptr: *anyopaque, _: f32, _: f32) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.begin_count += 1; + } + + fn endPass(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.end_count += 1; + } + + fn drawRect(_: *anyopaque, _: rhi.Rect, _: rhi.Color) void {} + fn drawTexture(_: *anyopaque, _: rhi.TextureHandle, _: rhi.Rect) void {} + fn drawTextureRegion(_: *anyopaque, _: rhi.TextureHandle, _: rhi.Rect, _: rhi.UVRect, _: rhi.Color) void {} + fn drawDepthTexture(ptr: *anyopaque, _: rhi.TextureHandle, _: rhi.Rect) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.depth_texture_count += 1; + } + fn bindPipeline(_: *anyopaque, _: bool) void {} + }; + + const MockShadow = struct { + const VTABLE = rhi.IShadowContext.VTable{ + .beginPass = beginPass, + .endPass = endPass, + .updateUniforms = updateUniforms, + .getShadowMapHandle = getShadowMapHandle, + }; + + fn wrapper(self: *@This()) ShadowSystemWrapper { + return .{ .ctx = .{ .ptr = self, .vtable = &VTABLE } }; + } + + fn beginPass(_: *anyopaque, _: u32, _: Mat4) void {} + fn endPass(_: *anyopaque) void {} + fn updateUniforms(_: *anyopaque, _: rhi.ShadowParams) anyerror!void {} + fn getShadowMapHandle(_: *anyopaque, cascade_index: u32) rhi.TextureHandle { + return cascade_index + 1; + } + }; + + var mock_ui = MockUI{}; + var ui = try UISystem.init(mock_ui.renderer(), 1280, 720); + var mock_shadow = MockShadow{}; + + ui.begin(); + DebugShadowOverlay.draw(&ui, mock_shadow.wrapper(), 1280, 720, .{ .show_labels = false }, &.{}); + ui.end(); + + try std.testing.expectEqual(@as(u32, 1), mock_ui.begin_count); + try std.testing.expectEqual(@as(u32, 1), mock_ui.end_count); + try std.testing.expectEqual(@as(u32, rhi.SHADOW_CASCADE_COUNT), mock_ui.depth_texture_count); +} diff --git a/modules/engine-ui/src/rmlui.zig b/modules/engine-ui/src/rmlui.zig new file mode 100644 index 00000000..1408eebd --- /dev/null +++ b/modules/engine-ui/src/rmlui.zig @@ -0,0 +1,343 @@ +//! Opt-in RmlUi adapter backed by ZigCraft's existing UI pass and RHI. +//! +//! The bridge owns RmlUi's process-global runtime; this module owns the +//! heap-stable callback target and retained CPU geometry used by that runtime. + +const std = @import("std"); +const build_options = @import("engine_ui_options"); +const rhi = @import("engine-rhi").rhi; +const sdl = @import("c").c; + +pub const available = build_options.rmlui; + +const c = if (available) @cImport({ + @cInclude("zigcraft/zigcraft_rmlui.h"); +}) else struct {}; + +const NativeRuntime = if (available) c.ZigCraftRmlUi else opaque {}; +const NativeContext = if (available) c.ZigCraftRmlUiContext else opaque {}; +const NativeDocument = if (available) c.ZigCraftRmlUiDocument else opaque {}; +const NativeAction = if (available) c.ZigCraftRmlUiAction else opaque {}; + +pub const Document = struct { + native: *NativeDocument, +}; + +pub const Action = struct { + native: *NativeAction, + data: *ActionData, +}; + +pub const ActionCallback = *const fn (context: *anyopaque, event_type: []const u8, target_id: []const u8) void; + +const Geometry = struct { + vertices: []rhi.UiVertex, + indices: []u32, +}; + +/// Retained RmlUi geometry and generated texture ownership for one context. +/// Allocate this object once and retain its pointer for the life of the UI +/// manager: C callbacks use the pointer as their user data. +pub const RmlUi = struct { + allocator: std.mem.Allocator, + renderer: rhi.UIRenderer, + resources: rhi.ResourceManager, + runtime: *NativeRuntime, + context: *NativeContext, + geometries: std.AutoHashMapUnmanaged(usize, Geometry) = .empty, + generated_textures: std.AutoHashMapUnmanaged(rhi.TextureHandle, void) = .empty, + next_geometry: usize = 1, + screen_width: u32, + screen_height: u32, + scissor_enabled: bool = false, + frame_geometry_draws: usize = 0, + + pub fn init(allocator: std.mem.Allocator, renderer: rhi.UIRenderer, resources: rhi.ResourceManager, window: *sdl.SDL_Window, width: u32, height: u32) !*RmlUi { + if (!available) return error.RmlUiDisabled; + + const self = try allocator.create(RmlUi); + errdefer allocator.destroy(self); + self.* = .{ + .allocator = allocator, + .renderer = renderer, + .resources = resources, + .runtime = undefined, + .context = undefined, + .screen_width = width, + .screen_height = height, + }; + + const callbacks = c.ZigCraftRmlUiRenderCallbacks{ + .user_data = self, + .compile_geometry = compileGeometry, + .render_geometry = renderGeometry, + .release_geometry = releaseGeometry, + .load_texture = loadTexture, + .generate_texture = generateTexture, + .release_texture = releaseTexture, + .enable_scissor = enableScissor, + .set_scissor = setScissor, + }; + self.runtime = c.zigcraft_rmlui_init(&callbacks) orelse return error.RmlUiInitFailed; + errdefer c.zigcraft_rmlui_destroy(self.runtime); + c.zigcraft_rmlui_set_sdl_window(self.runtime, @as(?*c.SDL_Window, @ptrCast(window))); + self.context = c.zigcraft_rmlui_context_create(self.runtime, "zigcraft", @intCast(width), @intCast(height)) orelse return error.RmlUiContextInitFailed; + errdefer c.zigcraft_rmlui_context_destroy(self.runtime, self.context); + + if (!c.zigcraft_rmlui_load_font_face("assets/fonts/Inter-Regular.ttf", true)) return error.RmlUiFontLoadFailed; + return self; + } + + pub fn deinit(self: *RmlUi) void { + if (available) { + c.zigcraft_rmlui_context_destroy(self.runtime, self.context); + c.zigcraft_rmlui_destroy(self.runtime); + } + + var geometry_iterator = self.geometries.valueIterator(); + while (geometry_iterator.next()) |geometry| self.releaseOwnedGeometry(geometry.*); + self.geometries.deinit(self.allocator); + + var texture_iterator = self.generated_textures.keyIterator(); + while (texture_iterator.next()) |texture| self.resources.destroyTexture(texture.*); + self.generated_textures.deinit(self.allocator); + self.allocator.destroy(self); + } + + pub fn resize(self: *RmlUi, width: u32, height: u32) void { + self.screen_width = width; + self.screen_height = height; + if (available) c.zigcraft_rmlui_context_resize(self.context, @intCast(width), @intCast(height)); + } + + pub fn processEvent(self: *RmlUi, window: *sdl.SDL_Window, event: *const sdl.SDL_Event) bool { + if (!available) return false; + const not_consumed = c.zigcraft_rmlui_process_sdl_event(self.runtime, self.context, @as(?*c.SDL_Window, @ptrCast(window)), @ptrCast(event)); + return !not_consumed; + } + + pub fn loadDocument(self: *RmlUi, path: [*:0]const u8) !Document { + if (!available) return error.RmlUiDisabled; + return .{ .native = c.zigcraft_rmlui_context_load_document(self.context, path) orelse return error.RmlUiDocumentLoadFailed }; + } + + pub fn showDocument(self: *RmlUi, document: Document) void { + _ = self; + if (available) c.zigcraft_rmlui_document_show(document.native); + } + + pub fn hideDocument(self: *RmlUi, document: Document) void { + _ = self; + if (available) c.zigcraft_rmlui_document_hide(document.native); + } + + pub fn setInnerRml(self: *RmlUi, document: Document, element_id: [*:0]const u8, markup: [*:0]const u8) bool { + _ = self; + return available and c.zigcraft_rmlui_document_set_inner_rml(document.native, element_id, markup); + } + + pub fn setClass(self: *RmlUi, document: Document, element_id: [*:0]const u8, class_name: [*:0]const u8, active: bool) bool { + _ = self; + return available and c.zigcraft_rmlui_document_set_class(document.native, element_id, class_name, active); + } + + pub fn setProperty(self: *RmlUi, document: Document, element_id: [*:0]const u8, property_name: [*:0]const u8, value: [*:0]const u8) bool { + _ = self; + return available and c.zigcraft_rmlui_document_set_property(document.native, element_id, property_name, value); + } + + pub fn getValue(self: *RmlUi, document: Document, element_id: [*:0]const u8, buffer: []u8) ?[]const u8 { + _ = self; + if (!available or buffer.len == 0) return null; + const value_len = c.zigcraft_rmlui_document_get_value(document.native, element_id, buffer.ptr, buffer.len); + if (value_len >= buffer.len) return null; + return buffer[0..value_len]; + } + + pub fn setValue(self: *RmlUi, document: Document, element_id: [*:0]const u8, value: [*:0]const u8) bool { + _ = self; + return available and c.zigcraft_rmlui_document_set_value(document.native, element_id, value); + } + + pub fn setDisabled(self: *RmlUi, document: Document, element_id: [*:0]const u8, disabled: bool) bool { + _ = self; + return available and c.zigcraft_rmlui_document_set_disabled(document.native, element_id, disabled); + } + + pub fn focus(self: *RmlUi, document: Document, element_id: [*:0]const u8, focus_visible: bool) bool { + _ = self; + return available and c.zigcraft_rmlui_document_focus(document.native, element_id, focus_visible); + } + + pub fn closeDocument(self: *RmlUi, document: Document) void { + if (available) { + c.zigcraft_rmlui_document_close(self.runtime, document.native); + // RmlUi defers Close() cleanup until the next context update. + _ = c.zigcraft_rmlui_context_update(self.context); + } + } + + pub fn addAction(self: *RmlUi, document: Document, event_type: [*:0]const u8, callback: ActionCallback, context: *anyopaque) !Action { + if (!available) return error.RmlUiDisabled; + const data = try self.allocator.create(ActionData); + errdefer self.allocator.destroy(data); + data.* = .{ .context = context, .callback = callback }; + const native = c.zigcraft_rmlui_document_add_action(self.runtime, document.native, event_type, actionCallback, data) orelse return error.RmlUiActionAttachFailed; + return .{ .native = native, .data = data }; + } + + pub fn removeAction(self: *RmlUi, action: Action) void { + if (available) c.zigcraft_rmlui_action_remove(self.runtime, action.native); + self.allocator.destroy(action.data); + } + + /// Called inside a caller-owned `UISystem.begin()` / `end()` pair. + pub fn updateAndRender(self: *RmlUi) usize { + if (!available) return 0; + self.frame_geometry_draws = 0; + _ = c.zigcraft_rmlui_context_update(self.context); + _ = c.zigcraft_rmlui_context_render(self.context); + return self.frame_geometry_draws; + } + + fn compileGeometry(user_data: ?*anyopaque, vertices: [*c]const c.ZigCraftRmlUiVertex, vertex_count: usize, indices: [*c]const i32, index_count: usize) callconv(.c) usize { + const self = fromUserData(user_data) orelse return 0; + const source_vertices = vertices[0..vertex_count]; + const source_indices = indices[0..index_count]; + if (!validateIndices(vertex_count, source_indices)) return 0; + + const owned_vertices = self.allocator.alloc(rhi.UiVertex, vertex_count) catch return 0; + for (source_vertices, owned_vertices) |source, *destination| { + destination.* = .{ .position = .{ source.x, source.y }, .color = source.color, .uv = .{ source.u, source.v } }; + } + + const owned_indices = self.allocator.alloc(u32, index_count) catch { + self.allocator.free(owned_vertices); + return 0; + }; + for (source_indices, owned_indices) |source, *destination| destination.* = @intCast(source); + + const handle = self.next_geometry; + self.next_geometry +|= 1; + self.geometries.put(self.allocator, handle, .{ .vertices = owned_vertices, .indices = owned_indices }) catch { + self.allocator.free(owned_indices); + self.allocator.free(owned_vertices); + return 0; + }; + return handle; + } + + fn renderGeometry(user_data: ?*anyopaque, geometry_handle: usize, translation_x: f32, translation_y: f32, texture_handle: usize) callconv(.c) void { + const self = fromUserData(user_data) orelse return; + const geometry = self.geometries.get(geometry_handle) orelse return; + const texture: rhi.TextureHandle = std.math.cast(rhi.TextureHandle, texture_handle) orelse return; + self.renderer.drawIndexedGeometry(geometry.vertices, geometry.indices, texture, .{ translation_x, translation_y }); + self.frame_geometry_draws += 1; + } + + fn releaseGeometry(user_data: ?*anyopaque, geometry_handle: usize) callconv(.c) void { + const self = fromUserData(user_data) orelse return; + if (self.geometries.fetchRemove(geometry_handle)) |entry| self.releaseOwnedGeometry(entry.value); + } + + fn loadTexture(_: ?*anyopaque, _: [*c]const u8, out_width: [*c]c_int, out_height: [*c]c_int) callconv(.c) usize { + // This vertical slice intentionally uses generated glyph textures only. + // Returning zero makes RmlUi handle missing decorative image assets. + out_width.* = 0; + out_height.* = 0; + return 0; + } + + fn generateTexture(user_data: ?*anyopaque, pixels: [*c]const u8, size: usize, width: c_int, height: c_int) callconv(.c) usize { + const self = fromUserData(user_data) orelse return 0; + if (width <= 0 or height <= 0) return 0; + const width_u32: u32 = @intCast(width); + const height_u32: u32 = @intCast(height); + const expected_size = std.math.mul(usize, std.math.mul(usize, width_u32, height_u32) catch return 0, 4) catch return 0; + if (size != expected_size) return 0; + const texture = self.resources.createTexture(width_u32, height_u32, .rgba, .{ .min_filter = .linear, .mag_filter = .linear, .wrap_s = .clamp_to_edge, .wrap_t = .clamp_to_edge, .generate_mipmaps = false }, pixels[0..size]) catch return 0; + self.generated_textures.put(self.allocator, texture, {}) catch { + self.resources.destroyTexture(texture); + return 0; + }; + return texture; + } + + fn releaseTexture(user_data: ?*anyopaque, texture_handle: usize) callconv(.c) void { + const self = fromUserData(user_data) orelse return; + const texture: rhi.TextureHandle = std.math.cast(rhi.TextureHandle, texture_handle) orelse return; + if (self.generated_textures.remove(texture)) self.resources.destroyTexture(texture); + } + + fn enableScissor(user_data: ?*anyopaque, enable: bool) callconv(.c) void { + const self = fromUserData(user_data) orelse return; + self.scissor_enabled = enable; + if (!enable) self.renderer.setScissorRegion(fullScissor(self.screen_width, self.screen_height)); + } + + fn setScissor(user_data: ?*anyopaque, x: c_int, y: c_int, width: c_int, height: c_int) callconv(.c) void { + const self = fromUserData(user_data) orelse return; + self.renderer.setScissorRegion(mapScissor(self.screen_width, self.screen_height, self.scissor_enabled, x, y, width, height)); + } + + fn actionCallback(user_data: ?*anyopaque, event_type: [*c]const u8, target_id: [*c]const u8) callconv(.c) void { + const action: *const ActionData = @ptrCast(@alignCast(user_data orelse return)); + action.callback(action.context, cString(event_type), cString(target_id)); + } + + fn fromUserData(user_data: ?*anyopaque) ?*RmlUi { + return @ptrCast(@alignCast(user_data orelse return null)); + } + + fn releaseOwnedGeometry(self: *RmlUi, geometry: Geometry) void { + self.allocator.free(geometry.vertices); + self.allocator.free(geometry.indices); + } +}; + +const ActionData = struct { + context: *anyopaque, + callback: ActionCallback, +}; + +fn cString(value: [*c]const u8) []const u8 { + if (value == null) return ""; + return std.mem.span(@as([*:0]const u8, @ptrCast(value))); +} + +/// Pure validation kept separate from GPU-facing compilation for unit tests. +pub fn validateIndices(vertex_count: usize, indices: []const i32) bool { + for (indices) |index| { + if (index < 0 or @as(usize, @intCast(index)) >= vertex_count) return false; + } + return true; +} + +/// Maps RmlUi's signed rectangle to a safe framebuffer scissor. Disabling +/// clipping deliberately restores the full current framebuffer. +pub fn mapScissor(screen_width: u32, screen_height: u32, enabled: bool, x: i32, y: i32, width: i32, height: i32) rhi.UiScissor { + if (!enabled) return fullScissor(screen_width, screen_height); + const max_x: i32 = @intCast(screen_width); + const max_y: i32 = @intCast(screen_height); + const left = std.math.clamp(x, 0, max_x); + const top = std.math.clamp(y, 0, max_y); + const right = std.math.clamp(x +| @max(width, 0), 0, max_x); + const bottom = std.math.clamp(y +| @max(height, 0), 0, max_y); + return .{ .x = left, .y = top, .width = @intCast(@max(right - left, 0)), .height = @intCast(@max(bottom - top, 0)) }; +} + +fn fullScissor(screen_width: u32, screen_height: u32) rhi.UiScissor { + return .{ .x = 0, .y = 0, .width = screen_width, .height = screen_height }; +} + +test "RmlUi geometry index validation rejects invalid source indices" { + try std.testing.expect(validateIndices(3, &.{ 0, 1, 2 })); + try std.testing.expect(!validateIndices(3, &.{ 0, -1, 2 })); + try std.testing.expect(!validateIndices(3, &.{ 0, 3, 2 })); +} + +test "RmlUi disabled scissor restores the full framebuffer" { + const clipped = mapScissor(1280, 720, true, -20, 10, 100, 30); + try std.testing.expectEqual(rhi.UiScissor{ .x = 0, .y = 10, .width = 80, .height = 30 }, clipped); + try std.testing.expectEqual(rhi.UiScissor{ .x = 0, .y = 0, .width = 1280, .height = 720 }, mapScissor(1280, 720, false, 5, 5, 1, 1)); +} diff --git a/modules/engine-ui/src/root.zig b/modules/engine-ui/src/root.zig index 6e485476..619270d5 100644 --- a/modules/engine-ui/src/root.zig +++ b/modules/engine-ui/src/root.zig @@ -7,6 +7,7 @@ pub const debug_ui = @import("debug_ui.zig"); pub const font = @import("font.zig"); pub const font_atlas = @import("font_atlas.zig"); pub const imgui_backend = @import("imgui/imgui_backend.zig"); +pub const rmlui = @import("rmlui.zig"); pub const timing_overlay = @import("timing_overlay.zig"); pub const ui_system = @import("ui_system.zig"); pub const ui_system_manager = @import("ui_system_manager.zig"); @@ -28,6 +29,7 @@ pub const InputEvent = ui_system.InputEvent; pub const LODStatsDisplay = timing_overlay.LODStatsDisplay; pub const PerformanceData = timing_overlay.PerformanceData; pub const Rect = ui_system.Rect; +pub const RmlUi = rmlui.RmlUi; pub const TimingOverlay = timing_overlay.TimingOverlay; pub const UISystem = ui_system.UISystem; pub const UISystemManager = ui_system_manager.UISystemManager; diff --git a/modules/engine-ui/src/ui_system_manager.zig b/modules/engine-ui/src/ui_system_manager.zig index 04d69b3e..ff7eb255 100644 --- a/modules/engine-ui/src/ui_system_manager.zig +++ b/modules/engine-ui/src/ui_system_manager.zig @@ -12,11 +12,16 @@ const rhi = @import("engine-rhi").rhi; const Time = @import("engine-core").Time; const WindowManager = @import("engine-core").WindowManager; const imgui_backend = @import("imgui/imgui_backend.zig"); +const rmlui = @import("rmlui.zig"); +const sdl = @import("c").c; pub const UISystemManager = struct { ui: ?UISystem, font_atlas: ?FontAtlas = null, imgui: ?imgui_backend.Backend = null, + rmlui: ?*rmlui.RmlUi = null, + rmlui_input_enabled: bool = false, + window: *sdl.SDL_Window, timing_overlay: TimingOverlay, last_debug_toggle_time: f32 = 0, @@ -27,15 +32,23 @@ pub const UISystemManager = struct { logImguiInitFailure(err); break :blk null; } else null; + const rml = if (rmlui.available) rmlui.RmlUi.init(allocator, renderer, resources, window_manager.window, width, height) catch |err| blk: { + logRmlUiInitFailure(err); + break :blk null; + } else null; return .{ .ui = ui, .font_atlas = font_atlas, .imgui = imgui, + .rmlui = rml, + .window = window_manager.window, .timing_overlay = .{ .enabled = smoke_test_enabled }, }; } pub fn deinit(self: *UISystemManager, resources: rhi.ResourceManager) void { + self.rmlui_input_enabled = false; + if (self.rmlui) |backend| backend.deinit(); if (self.imgui) |*backend| backend.deinit(); if (self.font_atlas) |*atlas| atlas.deinit(resources); if (self.ui) |*u| u.deinit(); @@ -43,6 +56,7 @@ pub const UISystemManager = struct { pub fn resize(self: *UISystemManager, width: u32, height: u32) void { if (self.ui) |*u| u.resize(width, height); + if (self.rmlui) |backend| backend.resize(width, height); } pub fn handleTimingToggle(self: *UISystemManager, timing_toggle_pressed: bool, time: *Time, rhi_ptr: *rhi.RHI) void { @@ -103,9 +117,36 @@ pub const UISystemManager = struct { if (self.imgui) |*backend| return backend; return null; } + + pub fn getRmlUi(self: *UISystemManager) ?*rmlui.RmlUi { + return self.rmlui; + } + + pub fn setRmlUiInputEnabled(self: *UISystemManager, enabled: bool) void { + self.rmlui_input_enabled = enabled and self.rmlui != null; + } + + /// Context-bound sink for `Input.RawEventProcessor`. It fans SDL events + /// out to both UI systems; RmlUi only receives input while its screen owns + /// the active document. + pub fn processRawEvent(context: *anyopaque, event: *const sdl.SDL_Event) bool { + const self: *UISystemManager = @ptrCast(@alignCast(context)); + if (self.rmlui_input_enabled) { + if (self.rmlui) |backend| { + if (backend.processEvent(self.window, event)) return true; + } + } + if (imgui_backend.available) imgui_backend.Backend.processEvent(event); + return false; + } }; fn logImguiInitFailure(err: anyerror) void { const log = @import("engine-core").log; log.log.warn("ImGui backend disabled after init failure: {}", .{err}); } + +fn logRmlUiInitFailure(err: anyerror) void { + const log = @import("engine-core").log; + log.log.warn("RmlUi backend disabled after init failure: {}", .{err}); +} diff --git a/modules/game-core/src/ui/hotbar.zig b/modules/game-core/src/ui/hotbar.zig index 74c2c956..0aaf8b04 100644 --- a/modules/game-core/src/ui/hotbar.zig +++ b/modules/game-core/src/ui/hotbar.zig @@ -8,7 +8,6 @@ const UISystem = @import("engine-ui").UISystem; const Color = @import("engine-ui").Color; const Font = @import("engine-ui").font; const Inventory = @import("../inventory.zig").Inventory; -const BlockType = @import("world-core").BlockType; const block_registry = @import("world-core").block_registry; /// Hotbar rendering configuration @@ -23,8 +22,54 @@ pub const HotbarConfig = struct { border_thickness: f32 = 2, /// Inner margin for block icon icon_margin: f32 = 6, + /// Minimum horizontal margin from either screen edge. + horizontal_margin: f32 = 10, }; +const HotbarLayout = struct { + config: HotbarConfig, + start_x: f32, + y: f32, + total_width: f32, +}; + +fn calculateLayout(screen_width: f32, screen_height: f32, config: HotbarConfig) HotbarLayout { + const slot_count = @as(f32, @floatFromInt(Inventory.HOTBAR_SIZE)); + const gap_count = @as(f32, @floatFromInt(Inventory.HOTBAR_SIZE - 1)); + const requested_width = slot_count * config.slot_size + gap_count * config.slot_padding; + const available_width = @max(0.0, screen_width - 2.0 * config.horizontal_margin); + const scale = if (requested_width > available_width and requested_width > 0.0) + available_width / requested_width + else + 1.0; + const fitted_config = HotbarConfig{ + .slot_size = config.slot_size * scale, + .slot_padding = config.slot_padding * scale, + .bottom_margin = config.bottom_margin * scale, + .border_thickness = config.border_thickness * scale, + .icon_margin = config.icon_margin * scale, + .horizontal_margin = config.horizontal_margin, + }; + const total_width = slot_count * fitted_config.slot_size + gap_count * fitted_config.slot_padding; + + return .{ + .config = fitted_config, + .start_x = (screen_width - total_width) / 2.0, + .y = screen_height - fitted_config.slot_size - fitted_config.bottom_margin, + .total_width = total_width, + }; +} + +fn rgba8(r: u8, g: u8, b: u8, a: u8) Color { + const scale = 1.0 / 255.0; + return Color.rgba( + @as(f32, @floatFromInt(r)) * scale, + @as(f32, @floatFromInt(g)) * scale, + @as(f32, @floatFromInt(b)) * scale, + @as(f32, @floatFromInt(a)) * scale, + ); +} + /// Draw the hotbar at the bottom center of the screen. pub fn draw( ui: *UISystem, @@ -33,23 +78,20 @@ pub fn draw( screen_height: f32, config: HotbarConfig, ) void { - const total_width = @as(f32, @floatFromInt(Inventory.HOTBAR_SIZE)) * config.slot_size + - @as(f32, @floatFromInt(Inventory.HOTBAR_SIZE - 1)) * config.slot_padding; - const start_x = (screen_width - total_width) / 2.0; - const y = screen_height - config.slot_size - config.bottom_margin; + const layout = calculateLayout(screen_width, screen_height, config); // Draw each slot for (0..Inventory.HOTBAR_SIZE) |i| { const slot_index: u8 = @intCast(i); - const x = start_x + @as(f32, @floatFromInt(i)) * (config.slot_size + config.slot_padding); + const x = layout.start_x + @as(f32, @floatFromInt(i)) * (layout.config.slot_size + layout.config.slot_padding); const is_selected = slot_index == inventory.selected_slot; - drawSlot(ui, x, y, config.slot_size, is_selected, inventory.slots[i], config); + drawSlot(ui, x, layout.y, layout.config.slot_size, is_selected, inventory.slots[i], layout.config); // Draw slot number var num_buf: [2]u8 = undefined; const num_text = std.fmt.bufPrint(&num_buf, "{d}", .{i + 1}) catch "?"; - Font.drawText(ui, num_text, x + 2, y + 2, 1.5, Color.rgba(200, 200, 200, 180)); + Font.drawText(ui, num_text, x + 2, layout.y + 2, 1.5, rgba8(200, 200, 200, 180)); } } @@ -65,18 +107,18 @@ fn drawSlot( ) void { // Background color const bg_color = if (selected) - Color.rgba(180, 180, 180, 220) + rgba8(180, 180, 180, 220) else - Color.rgba(40, 40, 40, 200); + rgba8(40, 40, 40, 200); // Draw slot background ui.drawRect(.{ .x = x, .y = y, .width = size, .height = size }, bg_color); // Draw border const border_color = if (selected) - Color.rgba(255, 255, 255, 255) + rgba8(255, 255, 255, 255) else - Color.rgba(80, 80, 80, 255); + rgba8(80, 80, 80, 255); ui.drawRectOutline( .{ .x = x, .y = y, .width = size, .height = size }, @@ -125,3 +167,32 @@ pub fn drawDefault( ) void { draw(ui, inventory, screen_width, screen_height, .{}); } + +test "hotbar layout keeps the default size when it fits" { + const testing = std.testing; + const layout = calculateLayout(1_000, 600, .{}); + + try testing.expectEqual(@as(f32, 428.0), layout.total_width); + try testing.expectEqual(@as(f32, 286.0), layout.start_x); + try testing.expectEqual(@as(f32, 44.0), layout.config.slot_size); +} + +test "hotbar layout fits narrow screens within its margins" { + const testing = std.testing; + const layout = calculateLayout(320, 240, .{}); + + try testing.expectApproxEqAbs(@as(f32, 300.0), layout.total_width, 0.000_001); + try testing.expectApproxEqAbs(@as(f32, 10.0), layout.start_x, 0.000_001); + try testing.expect(layout.start_x + layout.total_width <= 310.0); + try testing.expect(layout.config.slot_size < (HotbarConfig{}).slot_size); +} + +test "rgba8 normalizes hotbar colors" { + const testing = std.testing; + const color = rgba8(40, 80, 255, 200); + + try testing.expectApproxEqAbs(@as(f32, 40.0 / 255.0), color.r, 0.000_001); + try testing.expectApproxEqAbs(@as(f32, 80.0 / 255.0), color.g, 0.000_001); + try testing.expectApproxEqAbs(@as(f32, 1.0), color.b, 0.000_001); + try testing.expectApproxEqAbs(@as(f32, 200.0 / 255.0), color.a, 0.000_001); +} diff --git a/modules/game-core/src/ui/session_hud.zig b/modules/game-core/src/ui/session_hud.zig index 1166cd62..7291fffc 100644 --- a/modules/game-core/src/ui/session_hud.zig +++ b/modules/game-core/src/ui/session_hud.zig @@ -8,6 +8,39 @@ const hotbar = @import("hotbar.zig"); const region_pkg = @import("world-worldgen").region; const worldToChunkFromFloat = @import("world-core").worldToChunkFromFloat; +const TELEMETRY_PANEL_Y: f32 = 50.0; +const TELEMETRY_ROW_OFFSET: f32 = 5.0; +const TELEMETRY_ROW_HEIGHT: f32 = 20.0; + +const TelemetryRow = enum(u8) { + position, + chunks, + visible, + lods, + queued_gen, + queued_mesh, + pending_upload, + time, + sun, + role, + gpu_faults, +}; + +fn telemetryRowY(row: TelemetryRow) f32 { + return TELEMETRY_PANEL_Y + TELEMETRY_ROW_OFFSET + + @as(f32, @floatFromInt(@intFromEnum(row))) * TELEMETRY_ROW_HEIGHT; +} + +fn rgba8(r: u8, g: u8, b: u8, a: u8) Color { + const scale = 1.0 / 255.0; + return Color.rgba( + @as(f32, @floatFromInt(r)) * scale, + @as(f32, @floatFromInt(g)) * scale, + @as(f32, @floatFromInt(b)) * scale, + @as(f32, @floatFromInt(a)) * scale, + ); +} + pub fn draw(session: anytype, ui: *UISystem, atlas: *const TextureAtlas, active_pack: ?[]const u8, fps: f32, screen_w: f32, screen_h: f32, mouse_x: f32, mouse_y: f32, mouse_clicked: bool) !void { const world = session.world.interface(); const telemetry = world.telemetry(); @@ -20,57 +53,56 @@ pub fn draw(session: anytype, ui: *UISystem, atlas: *const TextureAtlas, active_ if (session.debug_show_fps) { ui.drawRect(.{ .x = 10, .y = 10, .width = 80, .height = 30 }, Color.rgba(0, 0, 0, 0.7)); Font.drawNumber(ui, @intFromFloat(fps), 15, 15, Color.white); - } - const stats = telemetry.getStats(); - const rs = telemetry.getRenderStats(); - const pc = worldToChunkFromFloat(session.camera.position.x, session.camera.position.z); - const hy: f32 = 50.0; - const fault_count = session.rhi.query().getFaultCount(); - const hud_h: f32 = if (fault_count > 0) 230 else 210; - ui.drawRect(.{ .x = 10, .y = hy, .width = 220, .height = hud_h }, Color.rgba(0, 0, 0, 0.6)); - Font.drawText(ui, "POS:", 15, hy + 5, 1.5, Color.white); - Font.drawNumber(ui, pc.chunk_x, 120, hy + 5, Color.white); - Font.drawNumber(ui, pc.chunk_z, 170, hy + 5, Color.white); - Font.drawText(ui, "CHUNKS:", 15, hy + 25, 1.5, Color.white); - Font.drawNumber(ui, @intCast(stats.chunks_loaded), 140, hy + 25, Color.white); - Font.drawText(ui, "VISIBLE:", 15, hy + 45, 1.5, Color.white); - Font.drawNumber(ui, @intCast(rs.chunks_rendered), 140, hy + 45, Color.white); - - if (telemetry.getLODStats()) |ls| { - Font.drawText(ui, "LODS:", 15, hy + 65, 1.5, Color.rgba(0.5, 0.8, 1.0, 1.0)); - Font.drawNumber(ui, @intCast(ls.totalLoaded()), 140, hy + 65, Color.rgba(0.5, 0.8, 1.0, 1.0)); - } + const stats = telemetry.getStats(); + const rs = telemetry.getRenderStats(); + const pc = worldToChunkFromFloat(session.camera.position.x, session.camera.position.z); + const fault_count = session.rhi.query().getFaultCount(); + const hud_h: f32 = if (fault_count > 0) 230 else 210; + ui.drawRect(.{ .x = 10, .y = TELEMETRY_PANEL_Y, .width = 220, .height = hud_h }, Color.rgba(0, 0, 0, 0.6)); + Font.drawText(ui, "POS:", 15, telemetryRowY(.position), 1.5, Color.white); + Font.drawNumber(ui, pc.chunk_x, 120, telemetryRowY(.position), Color.white); + Font.drawNumber(ui, pc.chunk_z, 170, telemetryRowY(.position), Color.white); + Font.drawText(ui, "CHUNKS:", 15, telemetryRowY(.chunks), 1.5, Color.white); + Font.drawNumber(ui, @intCast(stats.chunks_loaded), 140, telemetryRowY(.chunks), Color.white); + Font.drawText(ui, "VISIBLE:", 15, telemetryRowY(.visible), 1.5, Color.white); + Font.drawNumber(ui, @intCast(rs.chunks_rendered), 140, telemetryRowY(.visible), Color.white); + + if (telemetry.getLODStats()) |ls| { + Font.drawText(ui, "LODS:", 15, telemetryRowY(.lods), 1.5, Color.rgba(0.5, 0.8, 1.0, 1.0)); + Font.drawNumber(ui, @intCast(ls.totalLoaded()), 140, telemetryRowY(.lods), Color.rgba(0.5, 0.8, 1.0, 1.0)); + } - Font.drawText(ui, "QUEUED GEN:", 15, hy + 85, 1.5, Color.white); - Font.drawNumber(ui, @intCast(stats.gen_queue), 140, hy + 85, Color.white); - Font.drawText(ui, "QUEUED MESH:", 15, hy + 105, 1.5, Color.white); - Font.drawNumber(ui, @intCast(stats.mesh_queue), 140, hy + 105, Color.white); - Font.drawText(ui, "PENDING UP:", 15, hy + 125, 1.5, Color.white); - Font.drawNumber(ui, @intCast(stats.upload_queue), 140, hy + 125, Color.white); - const h = session.atmosphere.getHours(); - const hr = @as(i32, @intFromFloat(h)); - const mn = @as(i32, @intFromFloat((h - @as(f32, @floatFromInt(hr))) * 60.0)); - Font.drawText(ui, "TIME:", 15, hy + 145, 1.5, Color.white); - Font.drawNumber(ui, hr, 100, hy + 145, Color.white); - Font.drawText(ui, ":", 125, hy + 145, 1.5, Color.white); - Font.drawNumber(ui, mn, 140, hy + 145, Color.white); - Font.drawText(ui, "SUN:", 15, hy + 165, 1.5, Color.white); - Font.drawNumber(ui, @intFromFloat(session.atmosphere.sun_intensity * 100.0), 100, hy + 165, Color.white); - - const px_i: i32 = @intFromFloat(session.camera.position.x); - const pz_i: i32 = @intFromFloat(session.camera.position.z); - const region = telemetry.getRegionInfo(px_i, pz_i); - const c3 = region_pkg.getRoleColor(region.role); - Font.drawText(ui, "ROLE:", 15, hy + 185, 1.5, Color.rgba(c3[0], c3[1], c3[2], 1.0)); - var buf: [32]u8 = undefined; - const label = std.fmt.bufPrint(&buf, "{s}", .{@tagName(region.role)}) catch "???"; - Font.drawText(ui, label, 100, hy + 165, 1.5, Color.white); - - if (fault_count > 0) { - var buf_f: [32]u8 = undefined; - const fault_text = std.fmt.bufPrint(&buf_f, "GPU FAULTS: {d}", .{fault_count}) catch "GPU FAULTS: ???"; - Font.drawText(ui, fault_text, 15, hy + 185, 1.5, Color.red); + Font.drawText(ui, "QUEUED GEN:", 15, telemetryRowY(.queued_gen), 1.5, Color.white); + Font.drawNumber(ui, @intCast(stats.gen_queue), 140, telemetryRowY(.queued_gen), Color.white); + Font.drawText(ui, "QUEUED MESH:", 15, telemetryRowY(.queued_mesh), 1.5, Color.white); + Font.drawNumber(ui, @intCast(stats.mesh_queue), 140, telemetryRowY(.queued_mesh), Color.white); + Font.drawText(ui, "PENDING UP:", 15, telemetryRowY(.pending_upload), 1.5, Color.white); + Font.drawNumber(ui, @intCast(stats.upload_queue), 140, telemetryRowY(.pending_upload), Color.white); + const h = session.atmosphere.getHours(); + const hr = @as(i32, @intFromFloat(h)); + const mn = @as(i32, @intFromFloat((h - @as(f32, @floatFromInt(hr))) * 60.0)); + Font.drawText(ui, "TIME:", 15, telemetryRowY(.time), 1.5, Color.white); + Font.drawNumber(ui, hr, 100, telemetryRowY(.time), Color.white); + Font.drawText(ui, ":", 125, telemetryRowY(.time), 1.5, Color.white); + Font.drawNumber(ui, mn, 140, telemetryRowY(.time), Color.white); + Font.drawText(ui, "SUN:", 15, telemetryRowY(.sun), 1.5, Color.white); + Font.drawNumber(ui, @intFromFloat(session.atmosphere.sun_intensity * 100.0), 100, telemetryRowY(.sun), Color.white); + + const px_i: i32 = @intFromFloat(session.camera.position.x); + const pz_i: i32 = @intFromFloat(session.camera.position.z); + const region = telemetry.getRegionInfo(px_i, pz_i); + const c3 = region_pkg.getRoleColor(region.role); + Font.drawText(ui, "ROLE:", 15, telemetryRowY(.role), 1.5, Color.rgba(c3[0], c3[1], c3[2], 1.0)); + var buf: [32]u8 = undefined; + const label = std.fmt.bufPrint(&buf, "{s}", .{@tagName(region.role)}) catch "???"; + Font.drawText(ui, label, 100, telemetryRowY(.role), 1.5, Color.white); + + if (fault_count > 0) { + var buf_f: [32]u8 = undefined; + const fault_text = std.fmt.bufPrint(&buf_f, "GPU FAULTS: {d}", .{fault_count}) catch "GPU FAULTS: ???"; + Font.drawText(ui, fault_text, 15, telemetryRowY(.gpu_faults), 1.5, Color.red); + } } if (session.debug_show_block_info) { @@ -124,7 +156,26 @@ pub fn draw(session: anytype, ui: *UISystem, atlas: *const TextureAtlas, active_ } if (session.creative_mode) { - Font.drawText(ui, "CREATIVE", screen_w - 100, 10, 1.5, Color.rgba(100, 200, 255, 200)); - if (session.player.fly_mode) Font.drawText(ui, "FLYING", screen_w - 80, 25, 1.5, Color.rgba(150, 255, 150, 200)); + Font.drawText(ui, "CREATIVE", screen_w - 100, 10, 1.5, rgba8(100, 200, 255, 200)); + if (session.player.fly_mode) Font.drawText(ui, "FLYING", screen_w - 80, 25, 1.5, rgba8(150, 255, 150, 200)); } } + +test "telemetry rows do not overlap" { + const testing = std.testing; + + try testing.expect(telemetryRowY(.sun) < telemetryRowY(.role)); + try testing.expect(telemetryRowY(.role) < telemetryRowY(.gpu_faults)); + try testing.expectEqual(@as(f32, 235.0), telemetryRowY(.role)); + try testing.expectEqual(@as(f32, 255.0), telemetryRowY(.gpu_faults)); +} + +test "rgba8 normalizes color channels" { + const testing = std.testing; + const color = rgba8(100, 200, 255, 200); + + try testing.expectApproxEqAbs(@as(f32, 100.0 / 255.0), color.r, 0.000_001); + try testing.expectApproxEqAbs(@as(f32, 200.0 / 255.0), color.g, 0.000_001); + try testing.expectApproxEqAbs(@as(f32, 1.0), color.b, 0.000_001); + try testing.expectApproxEqAbs(@as(f32, 200.0 / 255.0), color.a, 0.000_001); +} diff --git a/modules/game-ui/src/menu_theme.zig b/modules/game-ui/src/menu_theme.zig index 608b37b3..1ab91cc2 100644 --- a/modules/game-ui/src/menu_theme.zig +++ b/modules/game-ui/src/menu_theme.zig @@ -41,14 +41,14 @@ pub const copper = Color.rgba(0.45, 0.70, 0.78, 1.0); pub const amber = Color.rgba(0.94, 0.73, 0.33, 1.0); pub const signal = Color.rgba(0.25, 0.75, 0.92, 1.0); pub const danger = Color.rgba(0.95, 0.36, 0.40, 1.0); -pub const panel = Color.rgba(0.025, 0.040, 0.055, 0.97); -pub const panel_soft = Color.rgba(0.070, 0.100, 0.126, 0.98); -pub const panel_header = Color.rgba(0.055, 0.082, 0.105, 1.0); -pub const outline = Color.rgba(0.32, 0.42, 0.50, 1.0); - -const backdrop_base = Color.rgba(0.012, 0.020, 0.030, 1.0); -const glass_top = Color.rgba(0.94, 0.98, 1.0, 0.16); -const glass_edge = Color.rgba(0.78, 0.88, 0.94, 0.42); +pub const panel = Color.rgba(0.024, 0.075, 0.122, 0.87); +pub const panel_soft = Color.rgba(0.045, 0.090, 0.125, 0.46); +pub const panel_header = Color.rgba(0.035, 0.090, 0.125, 0.34); +pub const outline = Color.rgba(0.78, 0.95, 1.0, 0.38); + +const backdrop_base = Color.rgba(0, 0, 0, 0.75); +const glass_top = Color.rgba(0.94, 0.98, 1.0, 0.62); +const glass_edge = Color.rgba(0.78, 0.95, 1.0, 0.38); const shadow = Color.rgba(0, 0, 0, 0.32); pub fn scaleFor(screen_h: f32, user_scale: f32) f32 { @@ -60,13 +60,24 @@ pub fn scaleFor(screen_h: f32, user_scale: f32) f32 { pub fn drawBackdrop(ui: *UISystem, screen_w: f32, screen_h: f32, scale: f32, tone: Tone) void { ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, backdrop_base); const accent = toneAccent(tone); - ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = 3.0 * scale }, accent); - ui.drawRect(.{ .x = 0, .y = 0, .width = 5.0 * scale, .height = screen_h }, Color.rgba(accent.r, accent.g, accent.b, 0.12)); + ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = 1.0 * scale }, Color.rgba(accent.r, accent.g, accent.b, 0.52)); } pub fn drawWorldScrim(ui: *UISystem, screen_w: f32, screen_h: f32, scale: f32) void { - ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, Color.rgba(0.006, 0.012, 0.018, 0.36)); - ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = 3.0 * scale }, signal); + ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, backdrop_base); + ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = 1.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.52)); +} + +pub fn drawGlassPanel(ui: *UISystem, rect: Rect, scale: f32) void { + ui.drawRect(rect, panel); + ui.drawRectOutline(rect, glass_edge, 1.0 * scale); + const highlight_inset = @min(20.0 * scale, rect.width * 0.08); + ui.drawRect(.{ + .x = rect.x + highlight_inset, + .y = rect.y, + .width = @max(0.0, rect.width - highlight_inset * 2.0), + .height = 1.0 * scale, + }, glass_top); } pub fn drawShell(ui: *UISystem, rect: Rect, scale: f32, kicker: []const u8, heading: []const u8, subtitle: []const u8) Shell { @@ -80,18 +91,14 @@ pub fn drawShell(ui: *UISystem, rect: Rect, scale: f32, kicker: []const u8, head .height = rect.height - header_h - footer_h - 26.0 * scale, }; - ui.drawRect(.{ .x = rect.x + 10.0 * scale, .y = rect.y + 12.0 * scale, .width = rect.width, .height = rect.height }, shadow); - ui.drawRect(rect, panel); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = rect.width, .height = header_h }, panel_header); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = rect.width, .height = 1.0 * scale }, glass_top); - ui.drawRect(.{ .x = rect.x, .y = rect.y + header_h - 2.0 * scale, .width = rect.width, .height = 2.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.52)); - ui.drawRectOutline(rect, outline, 1.0 * scale); + drawGlassPanel(ui, rect, scale); + ui.drawRect(.{ .x = rect.x + padding, .y = rect.y + header_h - 1.0 * scale, .width = rect.width - padding * 2.0, .height = 1.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.28)); - Font.drawText(ui, kicker, rect.x + padding, rect.y + 17.0 * scale, 0.82 * scale, signal); + Font.drawText(ui, kicker, rect.x + padding, rect.y + 17.0 * scale, 0.90 * scale, signal); Font.drawText(ui, heading, rect.x + padding, rect.y + 39.0 * scale, 2.80 * scale, title); - Font.drawText(ui, subtitle, rect.x + padding, rect.y + header_h - 20.0 * scale, 0.98 * scale, text); + Font.drawText(ui, subtitle, rect.x + padding, rect.y + header_h - 20.0 * scale, 1.05 * scale, text); - ui.drawRect(.{ .x = rect.x + padding, .y = rect.y + rect.height - footer_h, .width = rect.width - padding * 2.0, .height = 1.0 * scale }, outline); + ui.drawRect(.{ .x = rect.x + padding, .y = rect.y + rect.height - footer_h, .width = rect.width - padding * 2.0, .height = 1.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.20)); return .{ .rect = rect, @@ -125,11 +132,12 @@ pub fn drawButtonFocused(ui: *UISystem, rect: Rect, label: []const u8, text_scal const highlighted = hovered or focused; const colors = buttonColors(style, highlighted); - ui.drawRect(.{ .x = rect.x + 5.0 * scale, .y = rect.y + 6.0 * scale, .width = rect.width, .height = rect.height }, colors.shadow_color); ui.drawRect(rect, colors.bg); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 3.0 * scale, .height = rect.height }, colors.accent); - ui.drawRectOutline(rect, colors.border, if (highlighted) 2.0 * scale else 1.0 * scale); - if (focused) ui.drawRectOutline(.{ .x = rect.x - 3.0 * scale, .y = rect.y - 3.0 * scale, .width = rect.width + 6.0 * scale, .height = rect.height + 6.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.72), 1.0 * scale); + if (highlighted or style == .primary or style == .danger) { + ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = if (highlighted) 3.0 * scale else 2.0 * scale, .height = rect.height }, colors.accent); + } + ui.drawRectOutline(rect, colors.border, 1.0 * scale); + if (focused) ui.drawRectOutline(.{ .x = rect.x - 2.0 * scale, .y = rect.y - 2.0 * scale, .width = rect.width + 4.0 * scale, .height = rect.height + 4.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.64), 1.0 * scale); const max_text_width = @max(1.0, rect.width - 42.0 * scale); Font.drawTextCenteredFit(ui, label, rect.x + rect.width * 0.5 + 2.0 * scale, rect.y + (rect.height - 7.0 * text_scale) * 0.5, text_scale, max_text_width, colors.text); @@ -139,19 +147,22 @@ pub fn drawButtonFocused(ui: *UISystem, rect: Rect, label: []const u8, text_scal pub fn drawActionCard(ui: *UISystem, rect: Rect, heading: []const u8, description: []const u8, meta: []const u8, mx: f32, my: f32, clicked: bool, primary: bool, focused: bool, scale: f32) bool { const hovered = rect.contains(mx, my); const active = hovered or focused; - const edge = if (primary) signal else copper; + const edge = signal; const bg = if (primary) - if (active) Color.rgba(0.055, 0.225, 0.290, 0.99) else Color.rgba(0.045, 0.160, 0.210, 0.99) + if (active) Color.rgba(0.04, 0.60, 0.71, 0.91) else Color.rgba(0.03, 0.50, 0.60, 0.83) else if (active) - Color.rgba(0.095, 0.145, 0.180, 0.99) + Color.rgba(0.60, 0.91, 1.0, 0.11) else - panel_soft; + Color.rgba(0.60, 0.91, 1.0, 0.03); - ui.drawRect(.{ .x = rect.x + 7.0 * scale, .y = rect.y + 8.0 * scale, .width = rect.width, .height = rect.height }, Color.rgba(0, 0, 0, 0.46)); ui.drawRect(rect, bg); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = if (primary) 7.0 * scale else 4.0 * scale, .height = rect.height }, edge); - ui.drawRectOutline(rect, if (active) edge else outline, if (active) 2.0 * scale else 1.0 * scale); - if (focused) ui.drawRectOutline(.{ .x = rect.x - 3.0 * scale, .y = rect.y - 3.0 * scale, .width = rect.width + 6.0 * scale, .height = rect.height + 6.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.72), 1.0 * scale); + if (primary or active) ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = if (primary) 3.0 * scale else 2.0 * scale, .height = rect.height }, edge); + if (primary) { + ui.drawRectOutline(rect, if (active) title else Color.rgba(0.82, 0.97, 1.0, 0.72), 1.0 * scale); + } else { + ui.drawRect(.{ .x = rect.x, .y = rect.y + rect.height - 1.0 * scale, .width = rect.width, .height = 1.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, if (active) 0.34 else 0.16)); + } + if (focused) ui.drawRectOutline(.{ .x = rect.x - 2.0 * scale, .y = rect.y - 2.0 * scale, .width = rect.width + 4.0 * scale, .height = rect.height + 4.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, 0.64), 1.0 * scale); const heading_scale: f32 = if (primary) 1.92 * scale else 1.28 * scale; const description_offset: f32 = if (primary) 62.0 else 49.0; @@ -167,9 +178,9 @@ pub fn drawActionCard(ui: *UISystem, rect: Rect, heading: []const u8, descriptio pub fn drawNavItem(ui: *UISystem, rect: Rect, label: []const u8, index: usize, selected: bool, mx: f32, my: f32, clicked: bool, scale: f32) bool { const hovered = rect.contains(mx, my); const active = selected or hovered; - ui.drawRect(rect, if (active) Color.rgba(0.075, 0.125, 0.155, 1.0) else Color.rgba(0.040, 0.060, 0.078, 0.92)); - if (selected) ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 5.0 * scale, .height = rect.height }, signal); - ui.drawRectOutline(rect, if (active) Color.rgba(signal.r, signal.g, signal.b, 0.54) else outline, 1.0 * scale); + ui.drawRect(rect, if (active) Color.rgba(0.60, 0.91, 1.0, 0.10) else Color.rgba(0, 0, 0, 0)); + if (selected) ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 3.0 * scale, .height = rect.height }, signal); + ui.drawRect(.{ .x = rect.x, .y = rect.y + rect.height - 1.0 * scale, .width = rect.width, .height = 1.0 * scale }, Color.rgba(signal.r, signal.g, signal.b, if (active) 0.32 else 0.14)); Font.drawText(ui, label, rect.x + 18.0 * scale, rect.y + (rect.height - 7.0 * 0.92 * scale) * 0.5, 0.92 * scale, if (selected) title else text); var index_buf: [8]u8 = undefined; const index_text = std.fmt.bufPrint(&index_buf, "0{}", .{index + 1}) catch ""; @@ -180,8 +191,7 @@ pub fn drawNavItem(ui: *UISystem, rect: Rect, label: []const u8, index: usize, s pub fn drawTextInput(ui: *UISystem, rect: Rect, value: []const u8, placeholder: []const u8, text_scale: f32, focused: bool, caret: bool, scale: f32) void { const border = if (focused) signal else Color.rgba(0.42, 0.54, 0.62, 0.50); - ui.drawRect(.{ .x = rect.x + 3.0 * scale, .y = rect.y + 4.0 * scale, .width = rect.width, .height = rect.height }, shadow); - ui.drawRect(rect, if (focused) Color.rgba(0.070, 0.135, 0.175, 1.0) else Color.rgba(0.055, 0.080, 0.105, 1.0)); + ui.drawRect(rect, if (focused) Color.rgba(0.035, 0.125, 0.170, 0.90) else Color.rgba(0.020, 0.055, 0.080, 0.82)); ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 3.0 * scale, .height = rect.height }, border); ui.drawRectOutline(rect, Color.rgba(border.r, border.g, border.b, if (focused) 0.78 else 0.46), if (focused) 2.0 * scale else 1.0 * scale); @@ -199,17 +209,17 @@ pub fn drawTextInput(ui: *UISystem, rect: Rect, value: []const u8, placeholder: pub fn drawSectionLabel(ui: *UISystem, x: f32, y: f32, label: []const u8, scale: f32) void { ui.drawRect(.{ .x = x, .y = y + 7.0 * scale, .width = 24.0 * scale, .height = 2.0 * scale }, signal); - Font.drawText(ui, label, x + 36.0 * scale, y, 0.78 * scale, muted); + Font.drawText(ui, label, x + 36.0 * scale, y, 0.88 * scale, muted); } pub fn drawOptionRow(ui: *UISystem, rect: Rect, label: []const u8, description: []const u8, label_scale: f32, selected: bool, scale: f32) void { - const bg = if (selected) Color.rgba(0.065, 0.185, 0.240, 0.98) else Color.rgba(0.055, 0.082, 0.105, 0.98); - const edge = if (selected) signal else Color.rgba(0.40, 0.52, 0.60, 0.40); + const bg = if (selected) Color.rgba(0.60, 0.91, 1.0, 0.10) else Color.rgba(0, 0, 0, 0); + const edge = if (selected) signal else Color.rgba(signal.r, signal.g, signal.b, 0.14); ui.drawRect(rect, bg); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 3.0 * scale, .height = rect.height }, edge); - ui.drawRectOutline(rect, Color.rgba(edge.r, edge.g, edge.b, if (selected) 0.62 else 0.34), 1.0 * scale); + if (selected) ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 2.0 * scale, .height = rect.height }, edge); + ui.drawRect(.{ .x = rect.x, .y = rect.y + rect.height - 1.0 * scale, .width = rect.width, .height = 1.0 * scale }, Color.rgba(edge.r, edge.g, edge.b, if (selected) 0.42 else 0.18)); Font.drawText(ui, label, rect.x + 20.0 * scale, rect.y + 10.0 * scale, label_scale, if (selected) title else text); - if (description.len > 0) Font.drawText(ui, description, rect.x + 20.0 * scale, rect.y + 35.0 * scale, @max(label_scale * 0.62, 0.72 * scale), muted); + if (description.len > 0) Font.drawText(ui, description, rect.x + 20.0 * scale, rect.y + 35.0 * scale, @max(label_scale * 0.72, 0.96 * scale), muted); } pub fn drawValueText(ui: *UISystem, rect: Rect, value: []const u8, text_scale: f32, scale: f32) void { @@ -219,10 +229,8 @@ pub fn drawValueText(ui: *UISystem, rect: Rect, value: []const u8, text_scale: f } pub fn drawListRail(ui: *UISystem, rect: Rect, scale: f32) void { - ui.drawRect(rect, Color.rgba(0.032, 0.052, 0.070, 0.98)); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 3.0 * scale, .height = rect.height }, Color.rgba(signal.r, signal.g, signal.b, 0.44)); - ui.drawRect(.{ .x = rect.x + rect.width - 1.0 * scale, .y = rect.y, .width = 1.0 * scale, .height = rect.height }, Color.rgba(1.0, 1.0, 1.0, 0.14)); - ui.drawRectOutline(rect, Color.rgba(0.48, 0.60, 0.68, 0.42), 1.0 * scale); + ui.drawRect(rect, Color.rgba(0.02, 0.05, 0.08, 0.48)); + ui.drawRectOutline(rect, Color.rgba(signal.r, signal.g, signal.b, 0.20), 1.0 * scale); } pub fn drawScrollbar(ui: *UISystem, x: f32, y: f32, h: f32, content_h: f32, viewport_h: f32, scroll: f32, max_scroll: f32, scale: f32) void { @@ -235,15 +243,14 @@ pub fn drawScrollbar(ui: *UISystem, x: f32, y: f32, h: f32, content_h: f32, view } pub fn drawModal(ui: *UISystem, screen_w: f32, screen_h: f32, rect: Rect, scale: f32, heading: []const u8, subtitle: []const u8, destructive: bool) void { - ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, Color.rgba(0.010, 0.016, 0.024, 0.72)); + ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, Color.rgba(0, 0, 0, 0.76)); const edge = if (destructive) danger else signal; - ui.drawRect(.{ .x = rect.x + 14.0 * scale, .y = rect.y + 16.0 * scale, .width = rect.width, .height = rect.height }, shadow); - ui.drawRect(rect, Color.rgba(0.060, 0.095, 0.125, 0.82)); + ui.drawRect(rect, Color.rgba(0.024, 0.075, 0.122, 0.96)); ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = rect.width, .height = 42.0 * scale }, Color.rgba(edge.r, edge.g, edge.b, 0.14)); ui.drawRect(.{ .x = rect.x, .y = rect.y + 42.0 * scale, .width = rect.width, .height = 1.0 * scale }, Color.rgba(edge.r, edge.g, edge.b, 0.56)); ui.drawRectOutline(rect, Color.rgba(edge.r, edge.g, edge.b, 0.64), 1.0 * scale); - Font.drawTextCentered(ui, heading, rect.x + rect.width * 0.5, rect.y + 14.0 * scale, 1.52 * scale, title); - if (subtitle.len > 0) Font.drawTextCentered(ui, subtitle, rect.x + rect.width * 0.5, rect.y + 64.0 * scale, 0.88 * scale, muted); + Font.drawTextCentered(ui, heading, rect.x + rect.width * 0.5, rect.y + 14.0 * scale, 1.70 * scale, title); + if (subtitle.len > 0) Font.drawTextCentered(ui, subtitle, rect.x + rect.width * 0.5, rect.y + 64.0 * scale, 1.00 * scale, muted); } fn buttonColors(style: ButtonStyle, hovered: bool) struct { bg: Color, top: Color, bottom: Color, border: Color, accent: Color, text: Color, shadow_color: Color } { @@ -271,45 +278,40 @@ fn buttonColors(style: ButtonStyle, hovered: bool) struct { bg: Color, top: Colo } if (style == .primary) { return .{ - .bg = if (hovered) Color.rgba(0.10, 0.48, 0.61, 1.0) else Color.rgba(0.07, 0.36, 0.48, 1.0), + .bg = if (hovered) Color.rgba(0.04, 0.60, 0.71, 0.91) else Color.rgba(0.03, 0.50, 0.60, 0.83), .top = Color.rgba(0.94, 0.98, 1.0, if (hovered) 0.28 else 0.18), .bottom = signal, - .border = if (hovered) title else signal, + .border = if (hovered) title else Color.rgba(0.82, 0.97, 1.0, 0.72), .accent = signal, .text = title, - .shadow_color = Color.rgba(0, 0.02, 0.04, 0.52), + .shadow_color = Color.rgba(0, 0, 0, 0), }; } if (style == .ghost) { return .{ - .bg = if (hovered) Color.rgba(0.105, 0.150, 0.185, 1.0) else Color.rgba(0.055, 0.080, 0.105, 0.96), + .bg = if (hovered) Color.rgba(0.60, 0.91, 1.0, 0.10) else Color.rgba(0.02, 0.05, 0.08, 0.46), .top = Color.rgba(1.0, 1.0, 1.0, if (hovered) 0.16 else 0.08), .bottom = Color.rgba(0.50, 0.62, 0.70, if (hovered) 0.32 else 0.18), - .border = if (hovered) Color.rgba(0.60, 0.72, 0.80, 0.62) else Color.rgba(0.40, 0.52, 0.60, 0.42), - .accent = Color.rgba(0.48, 0.60, 0.68, 0.74), + .border = if (hovered) Color.rgba(0.70, 0.92, 1.0, 0.56) else Color.rgba(0.65, 0.88, 0.96, 0.28), + .accent = signal, .text = text, - .shadow_color = Color.rgba(0, 0, 0, 0.24), + .shadow_color = Color.rgba(0, 0, 0, 0), }; } return .{ - .bg = if (hovered) Color.rgba(0.125, 0.190, 0.225, 1.0) else Color.rgba(0.075, 0.120, 0.150, 1.0), + .bg = if (hovered) Color.rgba(0.60, 0.91, 1.0, 0.12) else Color.rgba(0.60, 0.91, 1.0, 0.04), .top = Color.rgba(0.94, 0.98, 1.0, if (hovered) 0.19 else 0.12), .bottom = Color.rgba(copper.r, copper.g, copper.b, 0.36), - .border = if (hovered) Color.rgba(0.68, 0.78, 0.84, 0.66) else Color.rgba(copper.r, copper.g, copper.b, 0.48), - .accent = copper, + .border = if (hovered) Color.rgba(0.70, 0.92, 1.0, 0.56) else Color.rgba(0.65, 0.88, 0.96, 0.24), + .accent = signal, .text = title, - .shadow_color = Color.rgba(0, 0, 0, 0.28), + .shadow_color = Color.rgba(0, 0, 0, 0), }; } fn toneAccent(tone: Tone) Color { - return switch (tone) { - .home, .create, .library => signal, - .settings, .graphics => Color.rgba(0.46, 0.64, 0.98, 1.0), - .packs => Color.rgba(0.38, 0.80, 0.60, 1.0), - .environment => Color.rgba(0.64, 0.52, 0.95, 1.0), - .paused => amber, - }; + _ = tone; + return signal; } fn drawSoftHalo(ui: *UISystem, rect: Rect, scale: f32, alpha: f32) void { diff --git a/modules/game-ui/src/rml_markup.zig b/modules/game-ui/src/rml_markup.zig new file mode 100644 index 00000000..1f465f44 --- /dev/null +++ b/modules/game-ui/src/rml_markup.zig @@ -0,0 +1,35 @@ +const std = @import("std"); + +pub fn appendEscaped(out: *std.ArrayList(u8), allocator: std.mem.Allocator, text: []const u8) !void { + for (text) |byte| { + switch (byte) { + '&' => try out.appendSlice(allocator, "&"), + '<' => try out.appendSlice(allocator, "<"), + '>' => try out.appendSlice(allocator, ">"), + '"' => try out.appendSlice(allocator, """), + 0...8, 11, 12, 14...31 => try out.appendSlice(allocator, "\xEF\xBF\xBD"), + else => try out.append(allocator, byte), + } + } +} + +pub fn sentinel(out: *std.ArrayList(u8), allocator: std.mem.Allocator) ![:0]const u8 { + try out.append(allocator, 0); + return out.items[0 .. out.items.len - 1 :0]; +} + +test "Rml markup escaping handles user-controlled text" { + var out = std.ArrayList(u8).empty; + defer out.deinit(std.testing.allocator); + try appendEscaped(&out, std.testing.allocator, "A&B "); + const result = try sentinel(&out, std.testing.allocator); + try std.testing.expectEqualStrings("A&B <world>", result); +} + +test "Rml markup escaping replaces embedded control bytes" { + var out = std.ArrayList(u8).empty; + defer out.deinit(std.testing.allocator); + try appendEscaped(&out, std.testing.allocator, "before\x00after"); + const result = try sentinel(&out, std.testing.allocator); + try std.testing.expectEqualStrings("before\xEF\xBF\xBDafter", result); +} diff --git a/modules/game-ui/src/rml_page.zig b/modules/game-ui/src/rml_page.zig new file mode 100644 index 00000000..edf4157c --- /dev/null +++ b/modules/game-ui/src/rml_page.zig @@ -0,0 +1,58 @@ +//! Shared lifecycle wrapper for player-facing RmlUi documents. + +const UISystem = @import("engine-ui").UISystem; +const rmlui = @import("engine-ui").rmlui; +const Screen = @import("screen.zig"); +const EngineContext = Screen.EngineContext; + +pub const Page = struct { + context: EngineContext, + backend: *rmlui.RmlUi, + document: rmlui.Document, + click_action: ?rmlui.Action = null, + change_action: ?rmlui.Action = null, + warned_empty_render: bool = false, + + pub fn init(context: EngineContext, path: [*:0]const u8, owner: *anyopaque, callback: rmlui.ActionCallback) !Page { + const backend = context.ui_manager.getRmlUi() orelse return error.RmlUiUnavailable; + const document = try backend.loadDocument(path); + errdefer backend.closeDocument(document); + const click_action = try backend.addAction(document, "click", callback, owner); + errdefer backend.removeAction(click_action); + const change_action = try backend.addAction(document, "change", callback, owner); + return .{ + .context = context, + .backend = backend, + .document = document, + .click_action = click_action, + .change_action = change_action, + }; + } + + pub fn deinit(self: *Page) void { + if (self.click_action) |action| self.backend.removeAction(action); + if (self.change_action) |action| self.backend.removeAction(action); + self.backend.closeDocument(self.document); + self.* = undefined; + } + + pub fn draw(self: *Page, ui: *UISystem) void { + ui.begin(); + defer ui.end(); + if (self.backend.updateAndRender() == 0 and !self.warned_empty_render) { + @import("engine-core").log.log.warn("RmlUi menu document produced no geometry", .{}); + self.warned_empty_render = true; + } + } + + pub fn onEnter(self: *Page) void { + self.context.input.setMouseCapture(@ptrCast(self.context.window_manager.window), false); + self.context.ui_manager.setRmlUiInputEnabled(true); + self.backend.showDocument(self.document); + } + + pub fn onExit(self: *Page) void { + self.context.ui_manager.setRmlUiInputEnabled(false); + self.backend.hideDocument(self.document); + } +}; diff --git a/modules/game-ui/src/root.zig b/modules/game-ui/src/root.zig index 93b19a3b..bf13908d 100644 --- a/modules/game-ui/src/root.zig +++ b/modules/game-ui/src/root.zig @@ -1,6 +1,8 @@ pub const screen = @import("screen.zig"); pub const menu_theme = @import("menu_theme.zig"); pub const menu_theme_tests = @import("menu_theme_tests.zig"); +pub const rml_markup = @import("rml_markup.zig"); +pub const rml_page = @import("rml_page.zig"); pub const screen_tests = @import("screen_tests.zig"); pub const settings_ui = @import("settings_ui.zig"); pub const settings_ui_tests = @import("settings_ui_tests.zig"); @@ -10,8 +12,17 @@ pub const graphics = @import("screens/graphics.zig"); pub const home = @import("screens/home.zig"); pub const paused = @import("screens/paused.zig"); pub const resource_packs = @import("screens/resource_packs.zig"); +pub const rml_home = @import("screens/rml_home.zig"); +pub const rml_create_world = @import("screens/rml_create_world.zig"); +pub const rml_environment = @import("screens/rml_environment.zig"); +pub const rml_paused = @import("screens/rml_paused.zig"); +pub const rml_resource_packs = @import("screens/rml_resource_packs.zig"); +pub const rml_settings = @import("screens/rml_settings.zig"); +pub const rml_world_list = @import("screens/rml_world_list.zig"); pub const settings = @import("screens/settings.zig"); pub const singleplayer = @import("screens/singleplayer.zig"); +pub const singleplayer_wizard = @import("screens/singleplayer_wizard.zig"); +pub const world_save = @import("screens/world_save.zig"); pub const world = @import("screens/world.zig"); pub const world_debug = @import("screens/world_debug.zig"); pub const world_frame_params = @import("screens/world_frame_params.zig"); @@ -32,6 +43,13 @@ pub const GraphicsScreen = graphics.GraphicsScreen; pub const HomeScreen = home.HomeScreen; pub const PausedScreen = paused.PausedScreen; pub const ResourcePacksScreen = resource_packs.ResourcePacksScreen; +pub const RmlHomeScreen = rml_home.RmlHomeScreen; +pub const RmlCreateWorldScreen = rml_create_world.RmlCreateWorldScreen; +pub const RmlEnvironmentScreen = rml_environment.RmlEnvironmentScreen; +pub const RmlPausedScreen = rml_paused.RmlPausedScreen; +pub const RmlResourcePacksScreen = rml_resource_packs.RmlResourcePacksScreen; +pub const RmlSettingsScreen = rml_settings.RmlSettingsScreen; +pub const RmlWorldListScreen = rml_world_list.RmlWorldListScreen; pub const SettingsScreen = settings.SettingsScreen; pub const SingleplayerScreen = singleplayer.SingleplayerScreen; pub const WorldListScreen = world_list.WorldListScreen; diff --git a/modules/game-ui/src/screen.zig b/modules/game-ui/src/screen.zig index e00e0610..280a7bc6 100644 --- a/modules/game-ui/src/screen.zig +++ b/modules/game-ui/src/screen.zig @@ -188,6 +188,7 @@ pub const IScreen = struct { deinit: *const fn (ptr: *anyopaque) void, update: ?*const fn (ptr: *anyopaque, dt: f32) anyerror!void = null, draw: ?*const fn (ptr: *anyopaque, ui: *UISystem) anyerror!void = null, + drawBackground: ?*const fn (ptr: *anyopaque, ui: *UISystem) anyerror!void = null, onEnter: ?*const fn (ptr: *anyopaque) void = null, onExit: ?*const fn (ptr: *anyopaque) void = null, getWorldStats: ?*const fn (ptr: *anyopaque) ?WorldStats = null, @@ -210,6 +211,14 @@ pub const IScreen = struct { } } + pub fn drawBackground(self: IScreen, ui: *UISystem) !bool { + if (self.vtable.drawBackground) |draw_fn| { + try draw_fn(self.ptr, ui); + return true; + } + return false; + } + pub fn onEnter(self: IScreen) void { if (self.vtable.onEnter) |onEnter_fn| { onEnter_fn(self.ptr); @@ -370,6 +379,21 @@ pub const ScreenManager = struct { } } + /// Draws the nearest background provider below the current screen without + /// rendering intermediate menu pages. This keeps one shared world preview + /// behind nested menus such as Create World -> My Worlds. + pub fn drawBackgroundFor(self: *ScreenManager, current_ptr: *anyopaque, ui: *UISystem) !void { + for (self.stack.items, 0..) |screen, current_index| { + if (screen.ptr != current_ptr) continue; + var index = current_index; + while (index > 0) { + index -= 1; + if (try self.stack.items[index].drawBackground(ui)) return; + } + return; + } + } + pub fn interface(self: *ScreenManager) core_interfaces.IScreenManager { return .{ .ptr = self, diff --git a/modules/game-ui/src/screen_tests.zig b/modules/game-ui/src/screen_tests.zig index 7da914cb..f9392dce 100644 --- a/modules/game-ui/src/screen_tests.zig +++ b/modules/game-ui/src/screen_tests.zig @@ -8,11 +8,13 @@ const MockScreen = struct { enter_count: u32 = 0, exit_count: u32 = 0, deinit_count: u32 = 0, + background_draw_count: u32 = 0, last_dt: f32 = 0.0, const vtable = screen_mod.IScreen.VTable{ .deinit = deinit, .update = update, + .drawBackground = drawBackground, .onEnter = onEnter, .onExit = onExit, .getWorldStats = getWorldStats, @@ -36,6 +38,11 @@ const MockScreen = struct { self.last_dt = dt; } + fn drawBackground(ptr: *anyopaque, ui: *@import("engine-ui").UISystem) anyerror!void { + _ = ui; + cast(ptr).background_draw_count += 1; + } + fn onEnter(ptr: *anyopaque) void { cast(ptr).enter_count += 1; } @@ -183,3 +190,21 @@ test "ScreenManager interface queues screen transitions" { try testing.expectEqual(@as(usize, 1), manager.stack.items.len); try testing.expectEqual(@as(u32, 1), first.enter_count); } + +test "ScreenManager draws the nearest background provider below a child" { + var manager = screen_mod.ScreenManager.init(testing.allocator); + defer manager.deinit(); + var parent = MockScreen{}; + var child = MockScreen{}; + + manager.pushScreen(parent.asScreen()); + try manager.update(0.1); + manager.pushScreen(child.asScreen()); + try manager.update(0.1); + + var ui: @import("engine-ui").UISystem = undefined; + try manager.drawBackgroundFor(&child, &ui); + + try testing.expectEqual(@as(u32, 1), parent.background_draw_count); + try testing.expectEqual(@as(u32, 0), child.background_draw_count); +} diff --git a/modules/game-ui/src/screens/environment.zig b/modules/game-ui/src/screens/environment.zig index 3577aebe..c5e371c8 100644 --- a/modules/game-ui/src/screens/environment.zig +++ b/modules/game-ui/src/screens/environment.zig @@ -3,7 +3,6 @@ const fs = @import("fs"); const UISystem = @import("engine-ui").UISystem; const Font = @import("engine-ui").font; const Theme = @import("../menu_theme.zig"); -const Color = Theme.Color; const Screen = @import("../screen.zig"); const IScreen = Screen.IScreen; const EngineContext = Screen.EngineContext; @@ -12,8 +11,8 @@ const settings_pkg = @import("game-core").settings; const Texture = @import("engine-rhi").Texture; const log = @import("engine-core").log; -const PANEL_WIDTH_MAX = 920.0; -const PANEL_HEIGHT_MAX = 760.0; +const PANEL_WIDTH_MAX = 900.0; +const PANEL_HEIGHT_MAX = 660.0; pub const EnvironmentScreen = struct { context: EnvironmentContext, @@ -64,6 +63,7 @@ pub const EnvironmentScreen = struct { const ctx = self.context; const settings = ctx.settings; + try ctx.screen_manager.drawBackgroundFor(ptr, ui); ui.begin(); defer ui.end(); @@ -83,27 +83,21 @@ pub const EnvironmentScreen = struct { const panel_y = (screen_h - panel_h) * 0.5; const shell = Theme.drawShell(ui, .{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }, ui_scale, "LIGHT PROBE", "ENVIRONMENT", "Choose the sky probe that lights the scene."); - const compact = shell.content.width < 640.0 * ui_scale; - const preview_w = if (compact) 0.0 else @min(260.0 * ui_scale, shell.content.width * 0.34); - const list_x = shell.content.x + preview_w + 24.0 * ui_scale; - const list_w = shell.content.width - preview_w - 24.0 * ui_scale; - if (!compact) drawSkyPreview(ui, shell.content.x, shell.content.y, preview_w, shell.content.height, settings.environment_map, ui_scale); - - Theme.drawListRail(ui, .{ .x = list_x, .y = shell.content.y, .width = list_w, .height = shell.content.height }, ui_scale); - const row_x = list_x + 18.0 * ui_scale; - const row_w = list_w - 36.0 * ui_scale; - const row_h = 56.0 * ui_scale; + Theme.drawListRail(ui, shell.content, ui_scale); + const row_x = shell.content.x + 18.0 * ui_scale; + const row_w = shell.content.width - 36.0 * ui_scale; + const row_h = 64.0 * ui_scale; const btn_scale = 1.12 * ui_scale; const content_h = @as(f32, @floatFromInt(self.environment_maps.items.len + 1)) * (row_h + 10.0 * ui_scale) + 26.0 * ui_scale; const max_scroll = @max(0.0, content_h - shell.content.height); self.scroll_offset -= ctx.input.getScrollDelta().y * 32.0 * ui_scale; self.scroll_offset = @max(0.0, @min(self.scroll_offset, max_scroll)); var y = shell.content.y + 18.0 * ui_scale - self.scroll_offset; - Theme.drawScrollbar(ui, list_x + list_w - 10.0 * ui_scale, shell.content.y + 12.0 * ui_scale, shell.content.height - 24.0 * ui_scale, content_h, shell.content.height, self.scroll_offset, max_scroll, ui_scale); + Theme.drawScrollbar(ui, shell.content.x + shell.content.width - 10.0 * ui_scale, shell.content.y + 12.0 * ui_scale, shell.content.height - 24.0 * ui_scale, content_h, shell.content.height, self.scroll_offset, max_scroll, ui_scale); const is_default = std.mem.eql(u8, settings.environment_map, "default"); if (y + row_h >= shell.content.y and y <= shell.content.y + shell.content.height) { - if (drawEnvironmentButton(ui, row_x, y, row_w, row_h, "DEFAULT SKY", "Neutral white environment texture.", is_default, btn_scale, mouse_x, mouse_y, mouse_clicked, ui_scale)) { + if (drawEnvironmentButton(ui, row_x, y, row_w, row_h, "DEFAULT SKY", "Neutral white environment texture.", is_default, mouse_x, mouse_y, mouse_clicked, ui_scale)) { if (!is_default) { try settings_pkg.persistence.setEnvironmentMap(settings, ctx.allocator, "default"); try self.reloadEnvMap(); @@ -120,7 +114,7 @@ pub const EnvironmentScreen = struct { } const is_selected = std.mem.eql(u8, settings.environment_map, environment_map); const label = std.fmt.bufPrint(&buffer, "{s}", .{environment_map}) catch "ENVIRONMENT"; - if (drawEnvironmentButton(ui, row_x, y, row_w, row_h, label, "HDR/EXR sky probe from the working directory.", is_selected, btn_scale, mouse_x, mouse_y, mouse_clicked, ui_scale)) { + if (drawEnvironmentButton(ui, row_x, y, row_w, row_h, label, "HDR/EXR sky probe from the working directory.", is_selected, mouse_x, mouse_y, mouse_clicked, ui_scale)) { if (!is_selected) { try settings_pkg.persistence.setEnvironmentMap(settings, ctx.allocator, environment_map); try self.reloadEnvMap(); @@ -196,25 +190,20 @@ pub const EnvironmentScreen = struct { } }; -fn drawSkyPreview(ui: *UISystem, x: f32, y: f32, w: f32, h: f32, active_name: []const u8, scale: f32) void { - Theme.drawListRail(ui, .{ .x = x, .y = y, .width = w, .height = h }, scale); - Font.drawText(ui, "ACTIVE SKY", x + 18.0 * scale, y + 22.0 * scale, 0.78 * scale, Theme.muted); - Font.drawText(ui, active_name, x + 18.0 * scale, y + 52.0 * scale, 1.04 * scale, Theme.title); - - const orb = @min(w - 60.0 * scale, 150.0 * scale); - const ox = x + (w - orb) * 0.5; - const oy = y + h * 0.44 - orb * 0.5; - ui.drawRect(.{ .x = ox - 20.0 * scale, .y = oy - 20.0 * scale, .width = orb + 40.0 * scale, .height = orb + 40.0 * scale }, Color.rgba(0.58, 0.72, 0.82, 0.055)); - ui.drawRect(.{ .x = ox, .y = oy, .width = orb, .height = orb }, Color.rgba(0.42, 0.54, 0.62, 0.16)); - ui.drawRect(.{ .x = ox + 22.0 * scale, .y = oy + 22.0 * scale, .width = orb - 44.0 * scale, .height = orb - 44.0 * scale }, Color.rgba(0.82, 0.88, 0.91, 0.12)); - Font.drawTextCentered(ui, "ACTIVE LIGHT", x + w * 0.5, y + h - 70.0 * scale, 0.86 * scale, Theme.signal); -} - -fn drawEnvironmentButton(ui: *UISystem, x: f32, y: f32, w: f32, h: f32, label: []const u8, description: []const u8, selected: bool, btn_scale: f32, mx: f32, my: f32, clicked: bool, scale: f32) bool { +fn drawEnvironmentButton(ui: *UISystem, x: f32, y: f32, w: f32, h: f32, label: []const u8, description: []const u8, selected: bool, mx: f32, my: f32, clicked: bool, scale: f32) bool { const row = Theme.Rect{ .x = x, .y = y, .width = w, .height = h }; - Theme.drawOptionRow(ui, row, label, description, 1.02 * scale, selected, scale); - const action_w = 150.0 * scale; - const action_x = x + w - action_w - 12.0 * scale; - const action_clicked = Theme.drawButton(ui, .{ .x = action_x, .y = y + 9.0 * scale, .width = action_w, .height = h - 18.0 * scale }, if (selected) "ACTIVE" else "USE SKY", btn_scale, mx, my, clicked, if (selected) .primary else .secondary, scale); - return action_clicked or (clicked and row.contains(mx, my)); + const label_scale = 1.14 * scale; + const active_scale = 0.90 * scale; + const active_w = if (selected) Font.measureTextWidthWithUI(ui, "ACTIVE", active_scale) + 20.0 * scale else 0.0; + const max_label_w = row.width - 40.0 * scale - active_w; + const label_w = Font.measureTextWidthWithUI(ui, label, label_scale); + const fitted_label_scale = if (label_w > max_label_w and label_w > 0.0) label_scale * (max_label_w / label_w) else label_scale; + + Theme.drawOptionRow(ui, row, "", "", label_scale, selected or row.contains(mx, my), scale); + Font.drawText(ui, label, x + 20.0 * scale, y + 10.0 * scale, fitted_label_scale, if (selected) Theme.title else Theme.text); + Font.drawText(ui, description, x + 20.0 * scale, y + 39.0 * scale, 0.96 * scale, Theme.muted); + if (selected) { + Font.drawText(ui, "ACTIVE", x + w - active_w, y + 17.0 * scale, active_scale, Theme.signal); + } + return clicked and row.contains(mx, my); } diff --git a/modules/game-ui/src/screens/graphics.zig b/modules/game-ui/src/screens/graphics.zig index 6fe8ba4b..b75e2eb7 100644 --- a/modules/game-ui/src/screens/graphics.zig +++ b/modules/game-ui/src/screens/graphics.zig @@ -56,6 +56,7 @@ pub const GraphicsScreen = struct { const settings = ctx.settings; const rs = ctx.render_settings; + try ctx.screen_manager.drawBackgroundFor(ptr, ui); ui.begin(); defer ui.end(); diff --git a/modules/game-ui/src/screens/home.zig b/modules/game-ui/src/screens/home.zig index d8d86296..d638ad0c 100644 --- a/modules/game-ui/src/screens/home.zig +++ b/modules/game-ui/src/screens/home.zig @@ -7,7 +7,7 @@ const Rect = Theme.Rect; const Screen = @import("../screen.zig"); const IScreen = Screen.IScreen; const EngineContext = Screen.EngineContext; -const SingleplayerScreen = @import("singleplayer.zig").SingleplayerScreen; +const WorldListScreen = @import("world_list.zig").WorldListScreen; const SettingsScreen = @import("settings.zig").SettingsScreen; const ResourcePacksScreen = @import("resource_packs.zig").ResourcePacksScreen; const EnvironmentScreen = @import("environment.zig").EnvironmentScreen; @@ -24,6 +24,7 @@ pub const HomeScreen = struct { .deinit = deinit, .update = update, .draw = draw, + .drawBackground = drawBackground, .onEnter = onEnter, .getWorldStats = getWorldStats, .isReadyForPresentation = isReadyForPresentation, @@ -61,7 +62,7 @@ pub const HomeScreen = struct { pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); const ctx = self.context; - try WorldScreen.draw(self.preview, ui); + try drawBackground(ptr, ui); ui.begin(); defer ui.end(); @@ -91,6 +92,11 @@ pub const HomeScreen = struct { self.context.input.setMouseCapture(@ptrCast(self.context.window_manager.window), false); } + fn drawBackground(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try WorldScreen.draw(self.preview, ui); + } + fn getWorldStats(ptr: *anyopaque) ?@import("engine-ui").WorldStats { const self: *@This() = @ptrCast(@alignCast(ptr)); return self.preview.getWorldStats(); @@ -108,54 +114,52 @@ pub const HomeScreen = struct { }; fn drawLaunchPanel(ui: *UISystem, self: *HomeScreen, screen_w: f32, screen_h: f32, scale: f32, compact: bool, mouse_x: f32, mouse_y: f32, mouse_clicked: bool, ctx: EngineContext) !void { - const panel_w: f32 = if (compact) @min(screen_w - 40.0 * scale, 680.0 * scale) else @min(screen_w * 0.46, 820.0 * scale); - const panel_h: f32 = if (compact) @min(520.0 * scale, screen_h * 0.62) else 520.0 * scale; - const panel_x: f32 = if (compact) (screen_w - panel_w) * 0.5 else screen_w - panel_w - 82.0 * scale; - const panel_y: f32 = if (compact) @min(screen_h * 0.40, screen_h - panel_h - 24.0 * scale) else screen_h * 0.22; + const panel_w: f32 = @min(screen_w - 40.0 * scale, 460.0 * scale); + const panel_h: f32 = @min(540.0 * scale, screen_h - 48.0 * scale); + const panel_x: f32 = if (compact) (screen_w - panel_w) * 0.5 else screen_w - panel_w - 64.0 * scale; + const panel_y: f32 = if (compact) screen_h - panel_h - 24.0 * scale else (screen_h - panel_h) * 0.5; const panel = Rect{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }; - const gap: f32 = 12.0 * scale; - ui.drawRect(.{ .x = panel.x + 14.0 * scale, .y = panel.y + 16.0 * scale, .width = panel.width, .height = panel.height }, Color.rgba(0, 0, 0, 0.48)); - ui.drawRect(panel, Theme.panel); - ui.drawRectOutline(panel, Theme.outline, 1.0 * scale); + Theme.drawGlassPanel(ui, panel, scale); - Font.drawText(ui, "START", panel.x + 30.0 * scale, panel.y + 20.0 * scale, 0.86 * scale, Theme.signal); - Font.drawText(ui, "Choose an activity", panel.x + 30.0 * scale, panel.y + 45.0 * scale, 0.98 * scale, Theme.text); + Font.drawText(ui, "WORLD MENU", panel.x + 26.0 * scale, panel.y + 18.0 * scale, 0.86 * scale, Theme.signal); + Font.drawText(ui, "Choose your adventure", panel.x + 26.0 * scale, panel.y + 43.0 * scale, 1.42 * scale, Theme.title); - var y = panel.y + 84.0 * scale; - const x = panel.x + 30.0 * scale; - const w = panel.width - 60.0 * scale; + var y = panel.y + 82.0 * scale; + const x = panel.x + 26.0 * scale; + const w = panel.width - 52.0 * scale; const confirm = ctx.input_mapper.isActionPressed(ctx.input, .ui_confirm); - const play_h: f32 = 112.0 * scale; - if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = w, .height = play_h }, "PLAY", "Create a world or continue from a local save.", "ENTER", mouse_x, mouse_y, mouse_clicked, true, self.focused_action == 0, scale) or (confirm and self.focused_action == 0)) { - const sp_screen = try SingleplayerScreen.init(ctx.allocator, ctx); - errdefer sp_screen.deinit(sp_screen); - ctx.screen_manager.pushScreen(sp_screen.screen()); + const play_h: f32 = 86.0 * scale; + if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = w, .height = play_h }, "WORLD LIBRARY", "Choose, create, or continue a local world.", "ENTER", mouse_x, mouse_y, mouse_clicked, true, self.focused_action == 0, scale) or (confirm and self.focused_action == 0)) { + const world_list_screen = try WorldListScreen.init(ctx.allocator, ctx); + errdefer world_list_screen.deinit(world_list_screen); + ctx.screen_manager.pushScreen(world_list_screen.screen()); } - y += play_h + 22.0 * scale; - Theme.drawSectionLabel(ui, x, y, "CUSTOMIZE", scale); - y += 34.0 * scale; + y += play_h + 18.0 * scale; + Theme.drawSectionLabel(ui, x, y, "GAME OPTIONS", scale); + y += 30.0 * scale; - const card_w = (w - gap) * 0.5; - const card_h: f32 = 88.0 * scale; - if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = card_w, .height = card_h }, "RESOURCE PACKS", "Change block textures.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 1, scale) or (confirm and self.focused_action == 1)) { + const card_h: f32 = 62.0 * scale; + if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = w, .height = card_h }, "RESOURCE PACKS", "Change block textures.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 1, scale) or (confirm and self.focused_action == 1)) { const rp_screen = try ResourcePacksScreen.init(ctx.allocator, ctx); errdefer rp_screen.deinit(rp_screen); ctx.screen_manager.pushScreen(rp_screen.screen()); } - if (Theme.drawActionCard(ui, .{ .x = x + card_w + gap, .y = y, .width = card_w, .height = card_h }, "SKY & LIGHTING", "Choose environment light.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 2, scale) or (confirm and self.focused_action == 2)) { + y += card_h; + if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = w, .height = card_h }, "SKY & LIGHTING", "Choose environment light.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 2, scale) or (confirm and self.focused_action == 2)) { const env_screen = try EnvironmentScreen.init(ctx.allocator, ctx); errdefer env_screen.deinit(env_screen); ctx.screen_manager.pushScreen(env_screen.screen()); } - y += card_h + gap; - if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = card_w, .height = card_h }, "SETTINGS", "Display, controls, and graphics.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 3, scale) or (confirm and self.focused_action == 3)) { + y += card_h; + if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = w, .height = card_h }, "SETTINGS", "Display, controls, and graphics.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 3, scale) or (confirm and self.focused_action == 3)) { const settings_screen = try SettingsScreen.init(ctx.allocator, ctx); errdefer settings_screen.deinit(settings_screen); ctx.screen_manager.pushScreen(settings_screen.screen()); } - if (Theme.drawActionCard(ui, .{ .x = x + card_w + gap, .y = y, .width = card_w, .height = card_h }, "EXIT", "Close ZigCraft safely.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 4, scale) or (confirm and self.focused_action == 4)) { + y += card_h; + if (Theme.drawActionCard(ui, .{ .x = x, .y = y, .width = w, .height = card_h }, "EXIT", "Close ZigCraft safely.", "", mouse_x, mouse_y, mouse_clicked, false, self.focused_action == 4, scale) or (confirm and self.focused_action == 4)) { ctx.input.setShouldQuit(true); } } diff --git a/modules/game-ui/src/screens/paused.zig b/modules/game-ui/src/screens/paused.zig index dddd0040..73510e2f 100644 --- a/modules/game-ui/src/screens/paused.zig +++ b/modules/game-ui/src/screens/paused.zig @@ -1,12 +1,14 @@ const std = @import("std"); const UISystem = @import("engine-ui").UISystem; -const Color = @import("engine-ui").Color; +const Font = @import("engine-ui").font; const Theme = @import("../menu_theme.zig"); const Screen = @import("../screen.zig"); const IScreen = Screen.IScreen; const EngineContext = Screen.EngineContext; const HomeScreen = @import("home.zig").HomeScreen; +const RmlHomeScreen = @import("rml_home.zig").RmlHomeScreen; const SettingsScreen = @import("settings.zig").SettingsScreen; +const rmlui = @import("engine-ui").rmlui; pub const PausedScreen = struct { context: EngineContext, @@ -16,6 +18,7 @@ pub const PausedScreen = struct { .deinit = deinit, .update = update, .draw = draw, + .drawBackground = drawBackground, .onEnter = onEnter, .onExit = onExit, }; @@ -59,25 +62,23 @@ pub const PausedScreen = struct { const mouse_clicked = ctx.input.isMouseButtonPressed(.left); const ui_scale = Theme.scaleFor(screen_h, ctx.settings.ui_scale); - ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, Color.rgba(0.002, 0.006, 0.010, 0.72)); Theme.drawBackdrop(ui, screen_w, screen_h, ui_scale, .paused); - ui.drawRect(.{ .x = 0, .y = 0, .width = screen_w, .height = screen_h }, Color.rgba(0, 0, 0, 0.36)); - const panel_w = @min(520.0 * ui_scale, screen_w - 80.0 * ui_scale); - const panel_h = @min(420.0 * ui_scale, screen_h - 32.0 * ui_scale); + const panel_w = @min(440.0 * ui_scale, screen_w - 48.0 * ui_scale); + const panel_h = @min(340.0 * ui_scale, screen_h - 32.0 * ui_scale); const panel_x = (screen_w - panel_w) * 0.5; const panel_y = (screen_h - panel_h) * 0.5; const shell = Theme.drawShell(ui, .{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }, ui_scale, "SESSION", "PAUSED", "Resume or leave the current world."); - const bw = @min(360.0 * ui_scale, shell.content.width); + const bw = @min(320.0 * ui_scale, shell.content.width); const bx = shell.content.x + (shell.content.width - bw) * 0.5; - var by = shell.content.y + 10.0 * ui_scale; - const bh = 56.0 * ui_scale; - const gap = 13.0 * ui_scale; - const btn_scale = 1.42 * ui_scale; + var by = shell.content.y + 8.0 * ui_scale; + const bh = 44.0 * ui_scale; + const gap = 8.0 * ui_scale; + const btn_scale = 1.16 * ui_scale; const confirm = ctx.input_mapper.isActionPressed(ctx.input, .ui_confirm); - if (Theme.drawButtonFocused(ui, .{ .x = bx, .y = by, .width = bw, .height = bh }, "RESUME WORLD", btn_scale, mouse_x, mouse_y, mouse_clicked, .primary, self.focused_action == 0, ui_scale) or (confirm and self.focused_action == 0)) ctx.screen_manager.popScreen(); + if (Theme.drawButtonFocused(ui, .{ .x = bx, .y = by, .width = bw, .height = bh }, "RESUME", btn_scale, mouse_x, mouse_y, mouse_clicked, .primary, self.focused_action == 0, ui_scale) or (confirm and self.focused_action == 0)) ctx.screen_manager.popScreen(); by += bh + gap; if (Theme.drawButtonFocused(ui, .{ .x = bx, .y = by, .width = bw, .height = bh }, "SETTINGS", btn_scale, mouse_x, mouse_y, mouse_clicked, .secondary, self.focused_action == 1, ui_scale) or (confirm and self.focused_action == 1)) { const settings_screen = try SettingsScreen.init(ctx.allocator, ctx); @@ -86,10 +87,9 @@ pub const PausedScreen = struct { } by += bh + gap; if (Theme.drawButtonFocused(ui, .{ .x = bx, .y = by, .width = bw, .height = bh }, "QUIT TO TITLE", btn_scale, mouse_x, mouse_y, mouse_clicked, .ghost, self.focused_action == 2, ui_scale) or (confirm and self.focused_action == 2)) { - const home_screen = try HomeScreen.init(ctx.allocator, ctx); - errdefer home_screen.deinit(home_screen); - ctx.screen_manager.setScreen(home_screen.screen()); + ctx.screen_manager.setScreen(try createHomeScreen(ctx)); } + Font.drawTextCentered(ui, "ESC / BACK TO RESUME", panel_x + panel_w * 0.5, shell.footer_y + 12.0 * ui_scale, 0.86 * ui_scale, Theme.muted); } pub fn onEnter(ptr: *anyopaque) void { @@ -97,6 +97,11 @@ pub const PausedScreen = struct { self.context.input.setMouseCapture(self.context.window_manager.window, false); } + fn drawBackground(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawParentScreen(ptr, ui); + } + pub fn onExit(ptr: *anyopaque) void { _ = ptr; } @@ -105,3 +110,12 @@ pub const PausedScreen = struct { return Screen.makeScreen(@This(), self); } }; + +fn createHomeScreen(ctx: EngineContext) !IScreen { + if (rmlui.available and ctx.ui_manager.getRmlUi() != null) { + const screen = try RmlHomeScreen.init(ctx.allocator, ctx); + return screen.screen(); + } + const screen = try HomeScreen.init(ctx.allocator, ctx); + return screen.screen(); +} diff --git a/modules/game-ui/src/screens/resource_packs.zig b/modules/game-ui/src/screens/resource_packs.zig index 0aa6467a..8ebfdf78 100644 --- a/modules/game-ui/src/screens/resource_packs.zig +++ b/modules/game-ui/src/screens/resource_packs.zig @@ -11,7 +11,7 @@ const TextureAtlas = @import("engine-graphics").TextureAtlas; const BLOCK_TEXTURE_DEFINITIONS = @import("game-core").BLOCK_TEXTURE_DEFINITIONS; const PANEL_WIDTH_MAX = 900.0; -const PANEL_HEIGHT_MAX = 760.0; +const PANEL_HEIGHT_MAX = 660.0; pub const ResourcePacksScreen = struct { context: ResourcePacksContext, @@ -55,6 +55,7 @@ pub const ResourcePacksScreen = struct { const settings = ctx.settings; const manager = ctx.render_system.getResourcePackManager(); + try ctx.screen_manager.drawBackgroundFor(ptr, ui); ui.begin(); defer ui.end(); @@ -77,8 +78,8 @@ pub const ResourcePacksScreen = struct { Theme.drawListRail(ui, shell.content, ui_scale); const row_x = shell.content.x + 18.0 * ui_scale; const row_w = shell.content.width - 36.0 * ui_scale; - const row_h = 56.0 * ui_scale; - const btn_scale = 1.18 * ui_scale; + const row_h = 64.0 * ui_scale; + const btn_scale = 1.12 * ui_scale; const packs = manager.getPackNames(); const content_h = @as(f32, @floatFromInt(packs.len + 1)) * (row_h + 10.0 * ui_scale) + 26.0 * ui_scale; const max_scroll = @max(0.0, content_h - shell.content.height); @@ -89,7 +90,7 @@ pub const ResourcePacksScreen = struct { const is_default = std.mem.eql(u8, settings.texture_pack, "default"); if (y + row_h >= shell.content.y and y <= shell.content.y + shell.content.height) { - if (drawPackButton(ui, row_x, y, row_w, row_h, "DEFAULT / BUILT-IN", "Base texture atlas shipped with ZigCraft.", is_default, btn_scale, mouse_x, mouse_y, mouse_clicked, ui_scale)) { + if (drawPackButton(ui, row_x, y, row_w, row_h, "DEFAULT / BUILT-IN", "Base texture atlas shipped with ZigCraft.", is_default, mouse_x, mouse_y, mouse_clicked, ui_scale)) { if (!is_default) { try settings_pkg.persistence.setTexturePack(settings, ctx.allocator, "default"); try manager.setActivePack("default"); @@ -109,7 +110,7 @@ pub const ResourcePacksScreen = struct { } const is_selected = std.mem.eql(u8, settings.texture_pack, pack.name); const label = std.fmt.bufPrint(&buffer, "{s}", .{pack.name}) catch "PACK"; - if (drawPackButton(ui, row_x, y, row_w, row_h, label, "External pack discovered by the resource manager.", is_selected, btn_scale, mouse_x, mouse_y, mouse_clicked, ui_scale)) { + if (drawPackButton(ui, row_x, y, row_w, row_h, label, "External pack discovered by the resource manager.", is_selected, mouse_x, mouse_y, mouse_clicked, ui_scale)) { if (!is_selected) { try settings_pkg.persistence.setTexturePack(settings, ctx.allocator, pack.name); try manager.setActivePack(pack.name); @@ -149,11 +150,20 @@ pub const ResourcePacksScreen = struct { } }; -fn drawPackButton(ui: *UISystem, x: f32, y: f32, w: f32, h: f32, label: []const u8, description: []const u8, selected: bool, btn_scale: f32, mx: f32, my: f32, clicked: bool, scale: f32) bool { +fn drawPackButton(ui: *UISystem, x: f32, y: f32, w: f32, h: f32, label: []const u8, description: []const u8, selected: bool, mx: f32, my: f32, clicked: bool, scale: f32) bool { const row = Theme.Rect{ .x = x, .y = y, .width = w, .height = h }; - Theme.drawOptionRow(ui, row, label, description, 1.04 * scale, selected, scale); - const action_w = 150.0 * scale; - const action_x = x + w - action_w - 12.0 * scale; - const action_clicked = Theme.drawButton(ui, .{ .x = action_x, .y = y + 9.0 * scale, .width = action_w, .height = h - 18.0 * scale }, if (selected) "ACTIVE" else "USE PACK", btn_scale, mx, my, clicked, if (selected) .primary else .secondary, scale); - return action_clicked or (clicked and row.contains(mx, my)); + const label_scale = 1.14 * scale; + const active_scale = 0.90 * scale; + const active_w = if (selected) Font.measureTextWidthWithUI(ui, "ACTIVE", active_scale) + 20.0 * scale else 0.0; + const max_label_w = row.width - 40.0 * scale - active_w; + const label_w = Font.measureTextWidthWithUI(ui, label, label_scale); + const fitted_label_scale = if (label_w > max_label_w and label_w > 0.0) label_scale * (max_label_w / label_w) else label_scale; + + Theme.drawOptionRow(ui, row, "", "", label_scale, selected or row.contains(mx, my), scale); + Font.drawText(ui, label, x + 20.0 * scale, y + 10.0 * scale, fitted_label_scale, if (selected) Theme.title else Theme.text); + Font.drawText(ui, description, x + 20.0 * scale, y + 39.0 * scale, 0.96 * scale, Theme.muted); + if (selected) { + Font.drawText(ui, "ACTIVE", x + w - active_w, y + 17.0 * scale, active_scale, Theme.signal); + } + return clicked and row.contains(mx, my); } diff --git a/modules/game-ui/src/screens/rml_create_world.zig b/modules/game-ui/src/screens/rml_create_world.zig new file mode 100644 index 00000000..31dbfb5b --- /dev/null +++ b/modules/game-ui/src/screens/rml_create_world.zig @@ -0,0 +1,259 @@ +//! RmlUi implementation of the three-step Create World wizard. + +const std = @import("std"); +const UISystem = @import("engine-ui").UISystem; +const log = @import("engine-core").log; +const registry = @import("world-worldgen").registry; +const seed_gen = @import("game-core").seed; +const Screen = @import("../screen.zig"); +const IScreen = Screen.IScreen; +const EngineContext = Screen.EngineContext; +const Page = @import("../rml_page.zig").Page; +const markup = @import("../rml_markup.zig"); +const wizard = @import("singleplayer_wizard.zig"); +const world_save = @import("world_save.zig"); +const WorldScreen = @import("world.zig").WorldScreen; + +pub const RmlCreateWorldScreen = struct { + context: EngineContext, + page: Page, + step: wizard.Step = .details, + selected_generator_index: usize = 0, + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + .onEnter = onEnter, + .onExit = onExit, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*RmlCreateWorldScreen { + const self = try allocator.create(RmlCreateWorldScreen); + errdefer allocator.destroy(self); + self.* = .{ .context = context, .page = undefined }; + self.page = try Page.init(context, "assets/ui/rmlui/create_world.rml", self, onDocumentAction); + errdefer self.page.deinit(); + try self.render(); + return self; + } + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.deinit(); + self.context.allocator.destroy(self); + } + + pub fn update(ptr: *anyopaque, dt: f32) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + _ = dt; + if (self.context.input_mapper.isActionPressed(self.context.input, .ui_back)) self.goBack(); + } + + pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); + self.page.draw(ui); + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onEnter(); + self.render() catch |err| self.showError(err); + if (self.step == .details) _ = self.page.backend.focus(self.page.document, "world-name", true); + } + + pub fn onExit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onExit(); + } + + pub fn screen(self: *@This()) IScreen { + return Screen.makeScreen(@This(), self); + } + + fn onDocumentAction(context: *anyopaque, event_type: []const u8, target_id: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(context)); + if (std.mem.eql(u8, event_type, "change")) { + // Input values are deliberately owned by RmlUi. They are read at + // step boundaries so IME and cursor editing remain native controls. + if (std.mem.eql(u8, target_id, "world-name") or std.mem.eql(u8, target_id, "world-seed")) return; + return; + } + if (!std.mem.eql(u8, event_type, "click")) return; + self.handleClick(target_id) catch |err| self.showError(err); + } + + fn handleClick(self: *@This(), target_id: []const u8) !void { + if (std.mem.eql(u8, target_id, "wizard-back")) { + self.goBack(); + return; + } + if (std.mem.eql(u8, target_id, "wizard-continue")) { + try self.confirmStep(); + return; + } + if (std.mem.eql(u8, target_id, "random-seed")) { + try self.randomizeSeed(); + return; + } + if (std.mem.startsWith(u8, target_id, "terrain-")) { + const index = std.fmt.parseInt(usize, target_id[8..], 10) catch return; + if (index < registry.getGeneratorCount()) { + self.selected_generator_index = index; + try self.renderTerrainRows(); + } + } + } + + fn render(self: *@This()) !void { + const active_index = stepIndex(self.step); + _ = self.page.backend.setClass(self.page.document, "details-page", "active", self.step == .details); + _ = self.page.backend.setClass(self.page.document, "terrain-page", "active", self.step == .terrain); + _ = self.page.backend.setClass(self.page.document, "review-page", "active", self.step == .review); + try self.setStepState("step-details", 0, active_index); + try self.setStepState("step-terrain", 1, active_index); + try self.setStepState("step-review", 2, active_index); + _ = self.page.backend.setInnerRml(self.page.document, "wizard-subtitle", stepSubtitle(self.step)); + const back_label: [:0]const u8 = if (self.step == .details) "CANCEL" else "BACK"; + const continue_label: [:0]const u8 = if (self.step == .review) "CREATE WORLD" else "CONTINUE"; + _ = self.page.backend.setInnerRml(self.page.document, "wizard-back", back_label); + _ = self.page.backend.setInnerRml(self.page.document, "wizard-continue", continue_label); + _ = self.page.backend.setProperty(self.page.document, "wizard-continue", "min-width", if (self.step == .review) "190dp" else "156dp"); + _ = self.page.backend.setDisabled(self.page.document, "wizard-continue", registry.getGeneratorCount() == 0 and self.step != .details); + try self.renderTerrainRows(); + if (self.step == .review) try self.renderReview(); + } + + fn setStepState(self: *@This(), id: [*:0]const u8, index: usize, active_index: usize) !void { + _ = self.page.backend.setClass(self.page.document, id, "active", index == active_index); + _ = self.page.backend.setClass(self.page.document, id, "complete", index < active_index); + } + + fn renderTerrainRows(self: *@This()) !void { + const count = registry.getGeneratorCount(); + if (count > 0 and self.selected_generator_index >= count) self.selected_generator_index = 0; + + var out = std.ArrayList(u8).empty; + defer out.deinit(self.context.allocator); + if (count == 0) { + try out.appendSlice(self.context.allocator, "

No terrain generators are available.

"); + } else for (0..count) |index| { + const info = registry.getGeneratorInfo(index); + var id_buffer: [32]u8 = undefined; + const id = std.fmt.bufPrint(&id_buffer, "terrain-{}", .{index}) catch unreachable; + try out.appendSlice(self.context.allocator, ""); + } + const document_rml = try markup.sentinel(&out, self.context.allocator); + _ = self.page.backend.setInnerRml(self.page.document, "terrain-rows", document_rml); + } + + fn renderReview(self: *@This()) !void { + var name_buffer: [128]u8 = undefined; + var seed_buffer: [160]u8 = undefined; + const name_value = self.page.backend.getValue(self.page.document, "world-name", &name_buffer) orelse ""; + const seed_value = self.page.backend.getValue(self.page.document, "world-seed", &seed_buffer) orelse ""; + const generator_name = if (registry.getGeneratorCount() > 0) registry.getGeneratorInfo(self.selected_generator_index).name else "Unavailable"; + + var out = std.ArrayList(u8).empty; + defer out.deinit(self.context.allocator); + try appendSummaryItem(&out, self.context.allocator, "WORLD NAME", wizard.displayWorldName(name_value)); + try appendSummaryItem(&out, self.context.allocator, "TERRAIN", generator_name); + try appendSummaryItem(&out, self.context.allocator, "SEED", if (std.mem.trim(u8, seed_value, " \t\r\n").len == 0) "Random on creation" else seed_value); + const document_rml = try markup.sentinel(&out, self.context.allocator); + _ = self.page.backend.setInnerRml(self.page.document, "review-summary", document_rml); + } + + fn randomizeSeed(self: *@This()) !void { + var seed_input = std.ArrayListUnmanaged(u8).empty; + defer seed_input.deinit(self.context.allocator); + try seed_gen.setSeedInput(&seed_input, self.context.allocator, seed_gen.randomSeedValue()); + var value = std.ArrayList(u8).empty; + defer value.deinit(self.context.allocator); + try value.appendSlice(self.context.allocator, seed_input.items); + _ = self.page.backend.setValue(self.page.document, "world-seed", try markup.sentinel(&value, self.context.allocator)); + _ = self.page.backend.focus(self.page.document, "world-seed", true); + } + + fn goBack(self: *@This()) void { + switch (wizard.backAction(self.step)) { + .show => |step| { + self.step = step; + self.render() catch |err| self.showError(err); + if (step == .details) _ = self.page.backend.focus(self.page.document, "world-name", true); + }, + .exit_wizard => self.context.screen_manager.popScreen(), + } + } + + fn confirmStep(self: *@This()) !void { + switch (wizard.confirmAction(self.step)) { + .show => |step| { + if (step != .details and registry.getGeneratorCount() == 0) return error.NoTerrainGenerators; + self.step = step; + try self.render(); + }, + .create_world => try self.finishCreation(), + } + } + + fn finishCreation(self: *@This()) !void { + if (registry.getGeneratorCount() == 0) return error.NoTerrainGenerators; + var name_buffer: [128]u8 = undefined; + var seed_buffer: [160]u8 = undefined; + const name_input = self.page.backend.getValue(self.page.document, "world-name", &name_buffer) orelse ""; + const seed_input = self.page.backend.getValue(self.page.document, "world-seed", &seed_buffer) orelse ""; + + var seed_values = std.ArrayListUnmanaged(u8).empty; + defer seed_values.deinit(self.context.allocator); + try seed_values.appendSlice(self.context.allocator, seed_input); + const seed = try seed_gen.resolveSeed(&seed_values, self.context.allocator); + const world_name = wizard.displayWorldName(name_input); + const generator = registry.getGeneratorInfo(self.selected_generator_index); + log.log.info("World seed: {} | Type: {s} | Name: {s}", .{ seed, generator.name, world_name }); + world_save.saveNewWorld(self.context.allocator, seed, self.selected_generator_index, world_name) catch |err| { + log.log.warn("Failed to save level.dat for new world: {}", .{err}); + }; + const world_screen = try WorldScreen.init(self.context.allocator, self.context, seed, self.selected_generator_index); + errdefer world_screen.deinit(world_screen); + self.context.screen_manager.setScreen(world_screen.screen()); + } + + fn showError(self: *@This(), err: anyerror) void { + log.log.err("RmlUi Create World action failed: {}", .{err}); + _ = self.page.backend.setInnerRml(self.page.document, "wizard-subtitle", "The requested action failed. Check logs and try again."); + _ = self.page.backend.setProperty(self.page.document, "wizard-subtitle", "color", "#FFB8C1"); + } +}; + +fn stepIndex(step: wizard.Step) usize { + return switch (step) { + .details => 0, + .terrain => 1, + .review => 2, + }; +} + +fn stepSubtitle(step: wizard.Step) [:0]const u8 { + return switch (step) { + .details => "Name the world and choose how its seed is generated.", + .terrain => "Choose the terrain generator that fits this world.", + .review => "Review the choices below before generating the world.", + }; +} + +fn appendSummaryItem(out: *std.ArrayList(u8), allocator: std.mem.Allocator, label: []const u8, value: []const u8) !void { + try out.appendSlice(allocator, "
"); + try markup.appendEscaped(out, allocator, label); + try out.appendSlice(allocator, ""); + try markup.appendEscaped(out, allocator, value); + try out.appendSlice(allocator, "
"); +} diff --git a/modules/game-ui/src/screens/rml_environment.zig b/modules/game-ui/src/screens/rml_environment.zig new file mode 100644 index 00000000..b7675588 --- /dev/null +++ b/modules/game-ui/src/screens/rml_environment.zig @@ -0,0 +1,207 @@ +//! RmlUi environment-map picker with immediate lighting reloads. + +const std = @import("std"); +const fs = @import("fs"); +const UISystem = @import("engine-ui").UISystem; +const log = @import("engine-core").log; +const Texture = @import("engine-rhi").Texture; +const settings_pkg = @import("game-core").settings; +const Screen = @import("../screen.zig"); +const IScreen = Screen.IScreen; +const EngineContext = Screen.EngineContext; +const EnvironmentContext = Screen.EnvironmentContext; +const Page = @import("../rml_page.zig").Page; +const markup = @import("../rml_markup.zig"); + +pub const RmlEnvironmentScreen = struct { + context: EnvironmentContext, + page: Page, + environment_maps: std.ArrayListUnmanaged([]const u8) = .empty, + status: []const u8 = "READY", + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + .onEnter = onEnter, + .onExit = onExit, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*RmlEnvironmentScreen { + const self = try allocator.create(RmlEnvironmentScreen); + errdefer allocator.destroy(self); + + self.* = .{ .context = context.environmentContext(), .page = undefined }; + errdefer { + self.clearEnvironmentMaps(); + self.environment_maps.deinit(allocator); + } + try self.refreshEnvironmentMaps(); + self.page = try Page.init(context, "assets/ui/rmlui/environment.rml", self, onDocumentAction); + errdefer self.page.deinit(); + try self.render(); + return self; + } + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.deinit(); + self.clearEnvironmentMaps(); + self.environment_maps.deinit(self.context.allocator); + self.context.allocator.destroy(self); + } + + pub fn update(ptr: *anyopaque, dt: f32) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + _ = dt; + if (self.context.input_mapper.isActionPressed(self.context.input, .ui_back)) self.back(); + } + + pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); + self.page.draw(ui); + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onEnter(); + self.refreshEnvironmentMaps() catch |err| { + log.log.warn("Failed to refresh environment map list: {}", .{err}); + self.status = "FAILED TO REFRESH ENVIRONMENTS"; + }; + self.render() catch |err| log.log.err("Failed to refresh RmlUi environment: {}", .{err}); + _ = self.page.backend.focus(self.page.document, "back", false); + } + + pub fn onExit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onExit(); + } + + fn onDocumentAction(context: *anyopaque, _: []const u8, target_id: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(context)); + if (std.mem.eql(u8, target_id, "back")) { + self.back(); + } else if (std.mem.eql(u8, target_id, "environment-default")) { + self.selectEnvironment("default") catch |err| self.reportFailure(err); + } else if (std.mem.startsWith(u8, target_id, "environment-")) { + const index = std.fmt.parseInt(usize, target_id["environment-".len..], 10) catch return; + if (index >= self.environment_maps.items.len) return; + self.selectEnvironment(self.environment_maps.items[index]) catch |err| self.reportFailure(err); + } + } + + fn selectEnvironment(self: *@This(), name: []const u8) !void { + if (std.mem.eql(u8, self.context.settings.environment_map, name)) return; + + self.status = "RELOADING ENVIRONMENT..."; + try self.activateEnvironment(name); + self.context.saveSettings(); + self.status = "ENVIRONMENT RELOADED AND SAVED"; + try self.render(); + } + + fn activateEnvironment(self: *@This(), name: []const u8) !void { + var replacement = try self.loadEnvironmentMap(name); + errdefer replacement.deinit(); + try settings_pkg.persistence.setEnvironmentMap(self.context.settings, self.context.allocator, name); + + const render_system = self.context.render_system; + const env_ptr = render_system.getEnvMapPtr(); + render_system.getRHI().waitIdle(); + if (env_ptr.*) |*texture| texture.deinit(); + env_ptr.* = replacement; + } + + fn loadEnvironmentMap(self: *@This(), name: []const u8) !Texture { + const render_system = self.context.render_system; + const rhi = render_system.getRHI(); + if (!std.mem.eql(u8, name, "default")) { + var data = render_system.getResourcePackManager().loadImageFileFloat(name) orelse { + log.log.warn("Could not load environment map: {s}", .{name}); + return error.InvalidEnvironmentMap; + }; + defer data.deinit(self.context.allocator); + const texture = try Texture.initFloat(rhi.resourceManager(), data.width, data.height, data.pixels); + log.log.info("Loaded Environment Map: {s}", .{name}); + return texture; + } + + const white_pixel = [_]f32{ 1.0, 1.0, 1.0, 1.0 }; + return Texture.initFloat(rhi.resourceManager(), 1, 1, &white_pixel); + } + + fn back(self: *@This()) void { + self.context.saveSettings(); + self.context.screen_manager.popScreen(); + } + + fn reportFailure(self: *@This(), err: anyerror) void { + log.log.err("Environment change failed: {}", .{err}); + self.status = "CHANGE FAILED — SEE LOG"; + self.render() catch |render_err| log.log.err("Failed to refresh RmlUi environment: {}", .{render_err}); + } + + fn clearEnvironmentMaps(self: *@This()) void { + for (self.environment_maps.items) |name| self.context.allocator.free(name); + self.environment_maps.clearRetainingCapacity(); + } + + fn refreshEnvironmentMaps(self: *@This()) !void { + self.clearEnvironmentMaps(); + var directory = fs.cwd().openDir(".", .{ .iterate = true }) catch return; + defer directory.close(); + + var iterator = directory.iterate(); + while (try iterator.next()) |entry| { + if (entry.kind != .file) continue; + const is_exr = std.mem.endsWith(u8, entry.name, ".exr"); + const is_hdr = std.mem.endsWith(u8, entry.name, ".hdr"); + if (!is_exr and !is_hdr) continue; + if (is_hdr and std.mem.endsWith(u8, entry.name, ".exr.hdr")) continue; + const name = try self.context.allocator.dupe(u8, entry.name); + errdefer self.context.allocator.free(name); + try self.environment_maps.append(self.context.allocator, name); + } + } + + fn render(self: *@This()) !void { + var rows = std.ArrayList(u8).empty; + defer rows.deinit(self.context.allocator); + + try appendRow(&rows, self.context.allocator, "environment-default", "DEFAULT SKY", "Neutral white environment texture.", std.mem.eql(u8, self.context.settings.environment_map, "default")); + for (self.environment_maps.items, 0..) |name, index| { + var id_buffer: [56]u8 = undefined; + const id = try std.fmt.bufPrint(&id_buffer, "environment-{}", .{index}); + try appendRow(&rows, self.context.allocator, id, name, "HDR/EXR sky probe from the working directory.", std.mem.eql(u8, self.context.settings.environment_map, name)); + } + + const row_rml = try markup.sentinel(&rows, self.context.allocator); + if (!self.page.backend.setInnerRml(self.page.document, "environment-list", row_rml)) return error.RmlUiElementNotFound; + + var status = std.ArrayList(u8).empty; + defer status.deinit(self.context.allocator); + try markup.appendEscaped(&status, self.context.allocator, self.status); + const status_rml = try markup.sentinel(&status, self.context.allocator); + if (!self.page.backend.setInnerRml(self.page.document, "status", status_rml)) return error.RmlUiElementNotFound; + } + + pub fn screen(self: *@This()) IScreen { + return Screen.makeScreen(@This(), self); + } +}; + +fn appendRow(out: *std.ArrayList(u8), allocator: std.mem.Allocator, id: []const u8, title: []const u8, description: []const u8, active: bool) !void { + try out.appendSlice(allocator, ""); +} diff --git a/modules/game-ui/src/screens/rml_home.zig b/modules/game-ui/src/screens/rml_home.zig new file mode 100644 index 00000000..c9620e90 --- /dev/null +++ b/modules/game-ui/src/screens/rml_home.zig @@ -0,0 +1,143 @@ +//! RmlUi player-menu root. It retains the deterministic menu +//! world preview from the legacy HomeScreen and composites the Rml document in +//! the existing UISystem pass. + +const std = @import("std"); +const UISystem = @import("engine-ui").UISystem; +const rmlui = @import("engine-ui").rmlui; +const log = @import("engine-core").log; +const Screen = @import("../screen.zig"); +const IScreen = Screen.IScreen; +const EngineContext = Screen.EngineContext; +const RmlWorldListScreen = @import("rml_world_list.zig").RmlWorldListScreen; +const RmlSettingsScreen = @import("rml_settings.zig").RmlSettingsScreen; +const RmlResourcePacksScreen = @import("rml_resource_packs.zig").RmlResourcePacksScreen; +const RmlEnvironmentScreen = @import("rml_environment.zig").RmlEnvironmentScreen; +const WorldScreen = @import("world.zig").WorldScreen; + +const MENU_PREVIEW_SEED: u64 = 0x5A49_4743_5241_4654; + +pub const RmlHomeScreen = struct { + context: EngineContext, + preview: *WorldScreen, + document: rmlui.Document, + click_action: ?rmlui.Action = null, + warned_empty_render: bool = false, + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + .drawBackground = drawBackground, + .onEnter = onEnter, + .onExit = onExit, + .getWorldStats = getWorldStats, + .isReadyForPresentation = isReadyForPresentation, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*RmlHomeScreen { + const backend = context.ui_manager.getRmlUi() orelse return error.RmlUiUnavailable; + const self = try allocator.create(RmlHomeScreen); + errdefer allocator.destroy(self); + const preview = try WorldScreen.initMenuPreview(allocator, context, MENU_PREVIEW_SEED, 0); + errdefer WorldScreen.deinit(preview); + const document = try backend.loadDocument("assets/ui/rmlui/home.rml"); + errdefer backend.closeDocument(document); + + self.* = .{ .context = context, .preview = preview, .document = document }; + self.click_action = try backend.addAction(document, "click", onDocumentAction, self); + return self; + } + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + if (self.context.ui_manager.getRmlUi()) |backend| { + if (self.click_action) |action| backend.removeAction(action); + backend.closeDocument(self.document); + } + WorldScreen.deinit(self.preview); + self.context.allocator.destroy(self); + } + + pub fn update(ptr: *anyopaque, dt: f32) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try WorldScreen.update(self.preview, dt); + } + + pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try drawBackground(ptr, ui); + + const backend = self.context.ui_manager.getRmlUi() orelse return; + ui.begin(); + defer ui.end(); + if (backend.updateAndRender() == 0 and !self.warned_empty_render) { + log.log.warn("RmlUi home document produced no geometry", .{}); + self.warned_empty_render = true; + } + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.context.input.setMouseCapture(@ptrCast(self.context.window_manager.window), false); + self.context.ui_manager.setRmlUiInputEnabled(true); + if (self.context.ui_manager.getRmlUi()) |backend| backend.showDocument(self.document); + } + + fn drawBackground(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try WorldScreen.draw(self.preview, ui); + } + + pub fn onExit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.context.ui_manager.setRmlUiInputEnabled(false); + if (self.context.ui_manager.getRmlUi()) |backend| backend.hideDocument(self.document); + } + + fn getWorldStats(ptr: *anyopaque) ?@import("engine-ui").WorldStats { + const self: *@This() = @ptrCast(@alignCast(ptr)); + return self.preview.getWorldStats(); + } + + fn isReadyForPresentation(ptr: *anyopaque) bool { + const self: *@This() = @ptrCast(@alignCast(ptr)); + const stats = self.preview.getWorldStats() orelse return false; + return stats.chunks_rendered > 0 and !self.preview.world.telemetry().isStartupBusy(); + } + + fn onDocumentAction(context: *anyopaque, _: []const u8, target_id: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(context)); + if (std.mem.eql(u8, target_id, "play")) { + const next_screen = RmlWorldListScreen.init(self.context.allocator, self.context) catch |err| { + log.log.err("RmlUi World Library action failed: {}", .{err}); + return; + }; + self.context.screen_manager.pushScreen(next_screen.screen()); + } else if (std.mem.eql(u8, target_id, "settings")) { + const next_screen = RmlSettingsScreen.init(self.context.allocator, self.context) catch |err| { + log.log.err("RmlUi Settings action failed: {}", .{err}); + return; + }; + self.context.screen_manager.pushScreen(next_screen.screen()); + } else if (std.mem.eql(u8, target_id, "resource-packs")) { + const next_screen = RmlResourcePacksScreen.init(self.context.allocator, self.context) catch |err| { + log.log.err("RmlUi Resource Packs action failed: {}", .{err}); + return; + }; + self.context.screen_manager.pushScreen(next_screen.screen()); + } else if (std.mem.eql(u8, target_id, "environment")) { + const next_screen = RmlEnvironmentScreen.init(self.context.allocator, self.context) catch |err| { + log.log.err("RmlUi Environment action failed: {}", .{err}); + return; + }; + self.context.screen_manager.pushScreen(next_screen.screen()); + } else if (std.mem.eql(u8, target_id, "exit")) { + self.context.input.setShouldQuit(true); + } + } + + pub fn screen(self: *@This()) IScreen { + return Screen.makeScreen(@This(), self); + } +}; diff --git a/modules/game-ui/src/screens/rml_paused.zig b/modules/game-ui/src/screens/rml_paused.zig new file mode 100644 index 00000000..797c21d3 --- /dev/null +++ b/modules/game-ui/src/screens/rml_paused.zig @@ -0,0 +1,92 @@ +//! RmlUi pause overlay. The shared menu stylesheet supplies its 75% black tint. + +const std = @import("std"); +const UISystem = @import("engine-ui").UISystem; +const log = @import("engine-core").log; +const Screen = @import("../screen.zig"); +const IScreen = Screen.IScreen; +const EngineContext = Screen.EngineContext; +const Page = @import("../rml_page.zig").Page; +const RmlHomeScreen = @import("rml_home.zig").RmlHomeScreen; +const RmlSettingsScreen = @import("rml_settings.zig").RmlSettingsScreen; + +pub const RmlPausedScreen = struct { + context: EngineContext, + page: Page, + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + .drawBackground = drawBackground, + .onEnter = onEnter, + .onExit = onExit, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*RmlPausedScreen { + const self = try allocator.create(RmlPausedScreen); + errdefer allocator.destroy(self); + + self.* = .{ .context = context, .page = undefined }; + self.page = try Page.init(context, "assets/ui/rmlui/paused.rml", self, onDocumentAction); + errdefer self.page.deinit(); + return self; + } + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.deinit(); + self.context.allocator.destroy(self); + } + + pub fn update(ptr: *anyopaque, dt: f32) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + _ = dt; + if (self.context.input_mapper.isActionPressed(self.context.input, .ui_back)) self.context.screen_manager.popScreen(); + } + + pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawParentScreen(ptr, ui); + self.page.draw(ui); + } + + fn drawBackground(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawParentScreen(ptr, ui); + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onEnter(); + _ = self.page.backend.focus(self.page.document, "resume", true); + } + + pub fn onExit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onExit(); + } + + fn onDocumentAction(context: *anyopaque, _: []const u8, target_id: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(context)); + if (std.mem.eql(u8, target_id, "resume")) { + self.context.screen_manager.popScreen(); + } else if (std.mem.eql(u8, target_id, "settings")) { + const settings_screen = RmlSettingsScreen.init(self.context.allocator, self.context) catch |err| { + log.log.err("RmlUi pause Settings action failed: {}", .{err}); + return; + }; + self.context.screen_manager.pushScreen(settings_screen.screen()); + } else if (std.mem.eql(u8, target_id, "quit-to-title")) { + const home_screen = RmlHomeScreen.init(self.context.allocator, self.context) catch |err| { + log.log.err("RmlUi pause Quit to Title action failed: {}", .{err}); + return; + }; + self.context.screen_manager.setScreen(home_screen.screen()); + } + } + + pub fn screen(self: *@This()) IScreen { + return Screen.makeScreen(@This(), self); + } +}; diff --git a/modules/game-ui/src/screens/rml_resource_packs.zig b/modules/game-ui/src/screens/rml_resource_packs.zig new file mode 100644 index 00000000..21e864f8 --- /dev/null +++ b/modules/game-ui/src/screens/rml_resource_packs.zig @@ -0,0 +1,173 @@ +//! RmlUi resource-pack picker with immediate atlas reloads. + +const std = @import("std"); +const UISystem = @import("engine-ui").UISystem; +const log = @import("engine-core").log; +const TextureAtlas = @import("engine-graphics").TextureAtlas; +const BLOCK_TEXTURE_DEFINITIONS = @import("game-core").BLOCK_TEXTURE_DEFINITIONS; +const settings_pkg = @import("game-core").settings; +const Screen = @import("../screen.zig"); +const IScreen = Screen.IScreen; +const EngineContext = Screen.EngineContext; +const ResourcePacksContext = Screen.ResourcePacksContext; +const Page = @import("../rml_page.zig").Page; +const markup = @import("../rml_markup.zig"); + +pub const RmlResourcePacksScreen = struct { + context: ResourcePacksContext, + page: Page, + status: []const u8 = "READY", + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + .onEnter = onEnter, + .onExit = onExit, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*RmlResourcePacksScreen { + const self = try allocator.create(RmlResourcePacksScreen); + errdefer allocator.destroy(self); + + self.* = .{ .context = context.resourcePacksContext(), .page = undefined }; + self.page = try Page.init(context, "assets/ui/rmlui/resource_packs.rml", self, onDocumentAction); + errdefer self.page.deinit(); + try self.render(); + return self; + } + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.deinit(); + self.context.allocator.destroy(self); + } + + pub fn update(ptr: *anyopaque, dt: f32) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + _ = dt; + if (self.context.input_mapper.isActionPressed(self.context.input, .ui_back)) self.back(); + } + + pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); + self.page.draw(ui); + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onEnter(); + _ = self.page.backend.focus(self.page.document, "back", false); + } + + pub fn onExit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onExit(); + } + + fn onDocumentAction(context: *anyopaque, _: []const u8, target_id: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(context)); + if (std.mem.eql(u8, target_id, "back")) { + self.back(); + } else if (std.mem.eql(u8, target_id, "pack-default")) { + self.selectPack("default") catch |err| self.reportFailure("Resource pack change failed", err); + } else if (std.mem.startsWith(u8, target_id, "pack-")) { + const index = std.fmt.parseInt(usize, target_id["pack-".len..], 10) catch return; + const packs = self.context.render_system.getResourcePackManager().getPackNames(); + if (index >= packs.len) return; + self.selectPack(packs[index].name) catch |err| self.reportFailure("Resource pack change failed", err); + } + } + + fn selectPack(self: *@This(), name: []const u8) !void { + if (std.mem.eql(u8, self.context.settings.texture_pack, name)) return; + + self.status = "RELOADING TEXTURE ATLAS..."; + try self.activatePack(name); + self.context.saveSettings(); + self.status = "TEXTURE PACK RELOADED AND SAVED"; + try self.render(); + } + + fn activatePack(self: *@This(), name: []const u8) !void { + const previous_name = try self.context.allocator.dupe(u8, self.context.settings.texture_pack); + defer self.context.allocator.free(previous_name); + try settings_pkg.persistence.setTexturePack(self.context.settings, self.context.allocator, name); + errdefer settings_pkg.persistence.setTexturePack(self.context.settings, self.context.allocator, previous_name) catch |err| { + log.log.err("Failed to restore resource pack setting: {}", .{err}); + }; + try self.context.render_system.getResourcePackManager().setActivePack(name); + errdefer self.context.render_system.getResourcePackManager().setActivePack(previous_name) catch |err| { + log.log.err("Failed to restore active resource pack: {}", .{err}); + }; + try self.reloadAtlas(); + } + + fn reloadAtlas(self: *@This()) !void { + const render_system = self.context.render_system; + const rhi = render_system.getRHI(); + var replacement = try TextureAtlas.init( + self.context.allocator, + rhi.resourceManager(), + render_system.getResourcePackManager(), + self.context.settings.max_texture_resolution, + &BLOCK_TEXTURE_DEFINITIONS, + ); + errdefer replacement.deinit(); + + rhi.waitIdle(); + render_system.getAtlas().deinit(); + render_system.getAtlas().* = replacement; + } + + fn back(self: *@This()) void { + self.context.saveSettings(); + self.context.screen_manager.popScreen(); + } + + fn reportFailure(self: *@This(), message: []const u8, err: anyerror) void { + log.log.err("{s}: {}", .{ message, err }); + self.status = "CHANGE FAILED — SEE LOG"; + self.render() catch |render_err| log.log.err("Failed to refresh RmlUi resource packs: {}", .{render_err}); + } + + fn render(self: *@This()) !void { + var rows = std.ArrayList(u8).empty; + defer rows.deinit(self.context.allocator); + + try appendRow(&rows, self.context.allocator, "pack-default", "DEFAULT / BUILT-IN", "Base texture atlas shipped with ZigCraft.", std.mem.eql(u8, self.context.settings.texture_pack, "default")); + for (self.context.render_system.getResourcePackManager().getPackNames(), 0..) |pack, index| { + var id_buffer: [48]u8 = undefined; + const id = try std.fmt.bufPrint(&id_buffer, "pack-{}", .{index}); + try appendRow(&rows, self.context.allocator, id, pack.name, "External pack discovered by the resource manager.", std.mem.eql(u8, self.context.settings.texture_pack, pack.name)); + } + + const row_rml = try markup.sentinel(&rows, self.context.allocator); + if (!self.page.backend.setInnerRml(self.page.document, "pack-list", row_rml)) return error.RmlUiElementNotFound; + + var status = std.ArrayList(u8).empty; + defer status.deinit(self.context.allocator); + try markup.appendEscaped(&status, self.context.allocator, self.status); + const status_rml = try markup.sentinel(&status, self.context.allocator); + if (!self.page.backend.setInnerRml(self.page.document, "status", status_rml)) return error.RmlUiElementNotFound; + } + + pub fn screen(self: *@This()) IScreen { + return Screen.makeScreen(@This(), self); + } +}; + +fn appendRow(out: *std.ArrayList(u8), allocator: std.mem.Allocator, id: []const u8, title: []const u8, description: []const u8, active: bool) !void { + try out.appendSlice(allocator, ""); +} diff --git a/modules/game-ui/src/screens/rml_settings.zig b/modules/game-ui/src/screens/rml_settings.zig new file mode 100644 index 00000000..3d75ae90 --- /dev/null +++ b/modules/game-ui/src/screens/rml_settings.zig @@ -0,0 +1,501 @@ +//! RmlUi implementation of the settings menu. +//! +//! Rows are generated rather than kept in RML so the renderer metadata remains +//! the single source of truth for its labels, values, and control kinds. + +const std = @import("std"); +const UISystem = @import("engine-ui").UISystem; +const log = @import("engine-core").log; +const Screen = @import("../screen.zig"); +const IScreen = Screen.IScreen; +const EngineContext = Screen.EngineContext; +const RmlPage = @import("../rml_page.zig").Page; +const rml_markup = @import("../rml_markup.zig"); +const SettingsUi = @import("../settings_ui.zig"); +const settings_pkg = @import("game-core").settings; +const apply_logic = settings_pkg.apply_logic; +const Settings = settings_pkg.Settings; +const render_settings_mod = @import("engine-rhi").render_settings; +const RenderDistancePreset = render_settings_mod.RenderDistancePreset; + +const SettingsTab = enum { display, camera, world, rendering }; +const SettingAction = enum { previous, next, toggle }; + +/// Settings page backed by `assets/ui/rmlui/settings.rml`. +pub const RmlSettingsScreen = struct { + context: EngineContext, + page: RmlPage, + active_tab: SettingsTab = .display, + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + .onEnter = onEnter, + .onExit = onExit, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*RmlSettingsScreen { + const self = try allocator.create(RmlSettingsScreen); + errdefer allocator.destroy(self); + self.* = .{ + .context = context, + .page = undefined, + }; + self.page = try RmlPage.init(context, "assets/ui/rmlui/settings.rml", self, onDocumentAction); + errdefer self.page.deinit(); + try self.refreshRows(); + return self; + } + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.deinit(); + self.context.allocator.destroy(self); + } + + pub fn update(ptr: *anyopaque, dt: f32) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + _ = dt; + if (self.context.input_mapper.isActionPressed(self.context.input, .ui_back)) { + self.close(); + return; + } + + // Retain the legacy keyboard tab navigation while buttons remain the + // primary RmlUi interaction. + const input = self.context.input; + if (input.isKeyPressed(.right_arrow) or input.isKeyPressed(.down) or input.isKeyPressed(.tab)) { + self.active_tab = nextTab(self.active_tab); + try self.refreshRows(); + } else if (input.isKeyPressed(.left_arrow) or input.isKeyPressed(.up)) { + self.active_tab = previousTab(self.active_tab); + try self.refreshRows(); + } + } + + pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); + self.page.draw(ui); + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onEnter(); + } + + pub fn onExit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onExit(); + } + + pub fn screen(self: *@This()) IScreen { + return Screen.makeScreen(@This(), self); + } + + fn close(self: *@This()) void { + self.context.saveSettings(); + self.context.screen_manager.popScreen(); + } + + fn onDocumentAction(callback_context: *anyopaque, _: []const u8, target_id: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(callback_context)); + self.handleAction(target_id) catch |err| log.log.err("RmlUi settings action failed: {}", .{err}); + } + + fn handleAction(self: *@This(), target_id: []const u8) !void { + if (std.mem.eql(u8, target_id, "done")) { + self.close(); + return; + } + + if (tabFromId(target_id)) |tab| { + self.active_tab = tab; + try self.refreshRows(); + return; + } + + switch (self.active_tab) { + .display => self.handleDisplayAction(target_id), + .camera => self.handleCameraAction(target_id), + .world => self.handleWorldAction(target_id), + .rendering => self.handleRenderingAction(target_id), + } + try self.refreshRows(); + } + + fn handleDisplayAction(self: *@This(), id: []const u8) void { + const settings = self.context.settings; + if (std.mem.eql(u8, id, "resolution-prev") or std.mem.eql(u8, id, "resolution-next")) { + const index = settings.getResolutionIndex(); + const next_index = if (std.mem.eql(u8, id, "resolution-prev")) + if (index == 0) settings_pkg.RESOLUTIONS.len - 1 else index - 1 + else + (index + 1) % settings_pkg.RESOLUTIONS.len; + settings.setResolutionByIndex(next_index); + self.context.window_manager.setSize(settings.window_width, settings.window_height); + } else if (std.mem.eql(u8, id, "vsync-toggle")) { + settings.vsync = !settings.vsync; + apply_logic.applyToRenderSettings(settings, self.context.render_settings); + } else if (std.mem.eql(u8, id, "ui-scale-prev")) { + settings.ui_scale = settings_pkg.ui_helpers.prevUIScale(settings.ui_scale); + } else if (std.mem.eql(u8, id, "ui-scale-next")) { + settings.ui_scale = settings_pkg.ui_helpers.cycleUIScale(settings.ui_scale); + } + } + + fn handleCameraAction(self: *@This(), id: []const u8) void { + const settings = self.context.settings; + if (std.mem.eql(u8, id, "sensitivity-prev") and settings.mouse_sensitivity > 10.0) { + settings.mouse_sensitivity -= 5.0; + } else if (std.mem.eql(u8, id, "sensitivity-next") and settings.mouse_sensitivity < 200.0) { + settings.mouse_sensitivity += 5.0; + } else if (std.mem.eql(u8, id, "fov-prev") and settings.fov > 30.0) { + settings.fov -= 5.0; + } else if (std.mem.eql(u8, id, "fov-next") and settings.fov < 120.0) { + settings.fov += 5.0; + } + } + + fn handleWorldAction(self: *@This(), id: []const u8) void { + const settings = self.context.settings; + if (std.mem.eql(u8, id, "render-distance-prev") and settings.render_distance > 1) { + settings.render_distance -= 1; + } else if (std.mem.eql(u8, id, "render-distance-next")) { + settings.render_distance += 1; + } else if (std.mem.eql(u8, id, "horizon-distance-prev") or std.mem.eql(u8, id, "horizon-distance-next")) { + const values = [_]i32{ 256, 512, 1024, 2048 }; + var index: usize = 1; + for (values, 0..) |value, i| { + if (settings.horizon_distance == value) index = i; + } + index = if (std.mem.eql(u8, id, "horizon-distance-prev")) + if (index == 0) values.len - 1 else index - 1 + else + (index + 1) % values.len; + settings.horizon_distance = values[index]; + } else if (std.mem.eql(u8, id, "lod-toggle")) { + settings.lod_enabled = !settings.lod_enabled; + if (settings_pkg.sanitizeRuntimeConflicts(settings)) { + apply_logic.applyToRenderSettings(settings, self.context.render_settings); + } + } + } + + fn handleRenderingAction(self: *@This(), id: []const u8) void { + if (std.mem.eql(u8, id, "preset-prev")) { + self.stepOverallPreset(.previous); + } else if (std.mem.eql(u8, id, "preset-next")) { + self.stepOverallPreset(.next); + } else if (std.mem.eql(u8, id, "render-distance-preset-prev")) { + self.stepRenderDistancePreset(.previous); + } else if (std.mem.eql(u8, id, "render-distance-preset-next")) { + self.stepRenderDistancePreset(.next); + } else if (std.mem.eql(u8, id, "wireframe-toggle")) { + self.context.settings.wireframe_enabled = !self.context.settings.wireframe_enabled; + apply_logic.applyToRenderSettings(self.context.settings, self.context.render_settings); + } else { + inline for (RENDER_SETTING_NAMES) |name| { + if (settingActionFromId(name, id)) |action| self.applyMetadataAction(name, action); + } + } + } + + fn stepOverallPreset(self: *@This(), action: SettingAction) void { + const settings = self.context.settings; + const loaded_count = settings_pkg.json_presets.count(); + const preset_index = if (loaded_count > 0) settings_pkg.json_presets.getIndex(settings) else 0; + const preset_count = loaded_count + 1; + if (loaded_count == 0) return; + const index = switch (action) { + .previous => if (preset_index == 0) preset_count - 1 else preset_index - 1, + .next => (preset_index + 1) % preset_count, + .toggle => return, + }; + if (index < loaded_count) { + settings_pkg.json_presets.apply(settings, index); + SettingsUi.applyPresetSideEffects(settings, self.context.render_settings); + } + } + + fn stepRenderDistancePreset(self: *@This(), action: SettingAction) void { + const settings = self.context.settings; + const current: u32 = @intFromEnum(settings.render_distance_preset); + const count: u32 = @as(u32, RenderDistancePreset.count); + const next = switch (action) { + .previous => if (current == 0) count - 1 else current - 1, + .next => (current + 1) % count, + .toggle => return, + }; + settings.render_distance_preset = @enumFromInt(next); + const config = render_settings_mod.getPresetConfig(settings.render_distance_preset); + settings.render_distance = config.lod_radii[0]; + settings.horizon_distance = config.horizon_radius; + } + + fn applyMetadataAction(self: *@This(), comptime name: []const u8, action: SettingAction) void { + const settings = self.context.settings; + const meta = @field(Settings.metadata, name); + const value = &@field(settings, name); + const Value = @TypeOf(value.*); + const old_value = value.*; + + switch (meta.kind) { + .toggle => { + if (action == .toggle) value.* = !value.*; + }, + .choice => |choice| if (choice.values) |values| { + var current: usize = 0; + for (values, 0..) |candidate, i| { + if (candidate == value.*) current = i; + } + if (action == .previous) { + const previous = if (current == 0) values.len - 1 else current - 1; + value.* = @as(Value, @intCast(values[previous])); + } else if (action == .next) { + value.* = @as(Value, @intCast(values[(current + 1) % values.len])); + } + }, + .slider => |slider| { + if (action == .previous) { + if (value.* - slider.step < slider.min - 0.001) value.* = slider.max - slider.step else value.* -= slider.step; + } else if (action == .next) { + if (value.* + slider.step > slider.max + 0.001) value.* = slider.max - slider.step else value.* += slider.step; + } + }, + .int_range => |range| { + if (action == .previous) { + if (value.* - range.step < range.min) value.* = range.max - range.step else value.* -= range.step; + } else if (action == .next) { + if (value.* + range.step > range.max) value.* = range.max - range.step else value.* += range.step; + } + }, + } + + if (value.* != old_value) { + const sanitized_conflict = settings_pkg.sanitizeRuntimeConflicts(settings); + SettingsUi.applyChangedSetting(name, settings, self.context.render_settings); + if (comptime std.mem.eql(u8, name, "msaa_samples")) { + self.context.render_settings.setMSAA(settings.msaa_samples); + } + if (sanitized_conflict) self.context.render_settings.setFXAA(settings.fxaa_enabled and !settings.taa_enabled); + } + } + + fn refreshRows(self: *@This()) !void { + var markup = std.ArrayList(u8).empty; + defer markup.deinit(self.context.allocator); + + switch (self.active_tab) { + .display => try self.appendDisplayRows(&markup), + .camera => try self.appendCameraRows(&markup), + .world => try self.appendWorldRows(&markup), + .rendering => try self.appendRenderingRows(&markup), + } + + const document_markup = try rml_markup.sentinel(&markup, self.context.allocator); + if (!self.page.backend.setInnerRml(self.page.document, "settings-list", document_markup.ptr)) { + log.log.warn("RmlUi settings list element was not found", .{}); + } + self.setTabClasses(); + } + + fn setTabClasses(self: *@This()) void { + _ = self.page.backend.setClass(self.page.document, "tab-display", "active", self.active_tab == .display); + _ = self.page.backend.setClass(self.page.document, "tab-camera", "active", self.active_tab == .camera); + _ = self.page.backend.setClass(self.page.document, "tab-world", "active", self.active_tab == .world); + _ = self.page.backend.setClass(self.page.document, "tab-rendering", "active", self.active_tab == .rendering); + } + + fn appendDisplayRows(self: *@This(), out: *std.ArrayList(u8)) !void { + const settings = self.context.settings; + try appendSection(out, self.context.allocator, "WINDOW"); + try appendStepperRow(out, self.context.allocator, "RESOLUTION", "Swap the output size instantly.", settings_pkg.RESOLUTIONS[settings.getResolutionIndex()].label, "resolution"); + try appendToggleRow(out, self.context.allocator, "VSYNC", "Lock presentation to display refresh.", settings.vsync, "vsync"); + try appendSection(out, self.context.allocator, "INTERFACE"); + try appendStepperRow(out, self.context.allocator, "UI SCALE", "Resize the menu system.", settings_pkg.ui_helpers.getUIScaleLabel(settings.ui_scale), "ui-scale"); + } + + fn appendCameraRows(self: *@This(), out: *std.ArrayList(u8)) !void { + var buffer: [32]u8 = undefined; + const settings = self.context.settings; + try appendSection(out, self.context.allocator, "MOUSE"); + const sensitivity = try std.fmt.bufPrint(&buffer, "{d}", .{@as(i32, @intFromFloat(settings.mouse_sensitivity))}); + try appendStepperRow(out, self.context.allocator, "SENSITIVITY", "Mouse movement response.", sensitivity, "sensitivity"); + try appendSection(out, self.context.allocator, "VIEW"); + const fov = try std.fmt.bufPrint(&buffer, "{d} FOV", .{@as(i32, @intFromFloat(settings.fov))}); + try appendStepperRow(out, self.context.allocator, "FIELD OF VIEW", "Camera lens angle.", fov, "fov"); + } + + fn appendWorldRows(self: *@This(), out: *std.ArrayList(u8)) !void { + var buffer: [32]u8 = undefined; + const settings = self.context.settings; + try appendSection(out, self.context.allocator, "DISTANCE"); + const render_distance = try std.fmt.bufPrint(&buffer, "{} CHUNKS", .{settings.render_distance}); + try appendStepperRow(out, self.context.allocator, "RENDER DISTANCE", "Near-field chunk budget.", render_distance, "render-distance"); + const horizon_distance = try std.fmt.bufPrint(&buffer, "{} CHUNKS", .{settings.horizon_distance}); + try appendStepperRow(out, self.context.allocator, "HORIZON DISTANCE", "Coarsest LOD radius, independent of near chunks.", horizon_distance, "horizon-distance"); + try appendSection(out, self.context.allocator, "STREAMING"); + try appendToggleRow(out, self.context.allocator, "LOD SYSTEM", "Distance terrain streaming.", settings.lod_enabled, "lod"); + } + + fn appendRenderingRows(self: *@This(), out: *std.ArrayList(u8)) !void { + const settings = self.context.settings; + try appendSection(out, self.context.allocator, "BASELINE"); + const preset_index = if (settings_pkg.json_presets.count() > 0) settings_pkg.json_presets.getIndex(settings) else 0; + try appendStepperRow(out, self.context.allocator, "OVERALL QUALITY", "Preset target for renderer cost and quality.", SettingsUi.getPresetLabel(preset_index), "preset"); + try appendStepperRow(out, self.context.allocator, "RENDER DISTANCE", "LOD radius profile and streamer pressure.", settings.render_distance_preset.label(), "render-distance-preset"); + try appendToggleRow(out, self.context.allocator, "WIREFRAME", "Debug mesh visibility.", settings.wireframe_enabled, "wireframe"); + + try appendMetadataSection(out, self.context.allocator, settings, "MATERIALS", MATERIAL_SETTING_NAMES); + try appendMetadataSection(out, self.context.allocator, settings, "SHADOWS", SHADOW_SETTING_NAMES); + try appendMetadataSection(out, self.context.allocator, settings, "IMAGE", IMAGE_SETTING_NAMES); + try appendMetadataSection(out, self.context.allocator, settings, "ATMOSPHERE AND GI", ATMOSPHERE_SETTING_NAMES); + try appendMetadataSection(out, self.context.allocator, settings, "DYNAMIC RESOLUTION", DYNAMIC_RESOLUTION_SETTING_NAMES); + } +}; + +const MATERIAL_SETTING_NAMES = [_][]const u8{ "textures_enabled", "pbr_enabled", "pbr_quality", "anisotropic_filtering", "max_texture_resolution", "water_quality" }; +const SHADOW_SETTING_NAMES = [_][]const u8{ "shadow_quality", "shadow_pcf_samples", "shadow_cascade_blend", "shadow_distance", "shadow_caster_distance" }; +const IMAGE_SETTING_NAMES = [_][]const u8{ "msaa_samples", "taa_enabled", "taa_blend_factor", "taa_velocity_rejection", "fxaa_enabled", "ssao_enabled", "bloom_enabled", "bloom_intensity", "vignette_enabled", "vignette_intensity", "film_grain_enabled", "film_grain_intensity" }; +const ATMOSPHERE_SETTING_NAMES = [_][]const u8{ "volumetric_density", "volumetric_steps", "volumetric_scattering", "lpv_enabled", "lpv_quality_preset", "lpv_intensity", "lpv_cell_size" }; +const DYNAMIC_RESOLUTION_SETTING_NAMES = [_][]const u8{ "dynamic_resolution_enabled", "dynamic_resolution_min_scale", "dynamic_resolution_max_scale", "target_fps" }; +const RENDER_SETTING_NAMES = MATERIAL_SETTING_NAMES ++ SHADOW_SETTING_NAMES ++ IMAGE_SETTING_NAMES ++ ATMOSPHERE_SETTING_NAMES ++ DYNAMIC_RESOLUTION_SETTING_NAMES; + +fn appendMetadataSection(out: *std.ArrayList(u8), allocator: std.mem.Allocator, settings: *Settings, label: []const u8, comptime names: anytype) !void { + try appendSection(out, allocator, label); + inline for (names) |name| try appendMetadataRow(out, allocator, settings, name); +} + +fn appendMetadataRow(out: *std.ArrayList(u8), allocator: std.mem.Allocator, settings: *Settings, comptime name: []const u8) !void { + var value_buffer: [64]u8 = undefined; + const meta = @field(Settings.metadata, name); + const value = @field(settings, name); + const value_label = try metadataValueLabel(name, value, &value_buffer); + switch (meta.kind) { + .toggle => try appendToggleRow(out, allocator, meta.label, settingDescription(name), value, "setting-" ++ name), + else => try appendStepperRow(out, allocator, meta.label, settingDescription(name), value_label, "setting-" ++ name), + } +} + +fn metadataValueLabel(comptime name: []const u8, value: anytype, buffer: []u8) ![]const u8 { + const meta = @field(Settings.metadata, name); + return switch (meta.kind) { + .toggle => if (value) "ENABLED" else "DISABLED", + .choice => |choice| blk: { + if (choice.values) |values| { + for (values, 0..) |candidate, index| { + if (candidate == value) break :blk if (index < choice.labels.len) choice.labels[index] else "UNKNOWN"; + } + } + break :blk "UNKNOWN"; + }, + .slider => try std.fmt.bufPrint(buffer, "{d:.2}", .{value}), + .int_range => try std.fmt.bufPrint(buffer, "{d}", .{value}), + }; +} + +fn appendSection(out: *std.ArrayList(u8), allocator: std.mem.Allocator, label: []const u8) !void { + try out.appendSlice(allocator, "
"); + try rml_markup.appendEscaped(out, allocator, label); + try out.appendSlice(allocator, "
"); +} + +fn appendStepperRow(out: *std.ArrayList(u8), allocator: std.mem.Allocator, label: []const u8, description: []const u8, value: []const u8, id_prefix: []const u8) !void { + try appendRowOpen(out, allocator, label, description); + try out.appendSlice(allocator, "
"); + try rml_markup.appendEscaped(out, allocator, value); + try out.appendSlice(allocator, "
"); +} + +fn appendToggleRow(out: *std.ArrayList(u8), allocator: std.mem.Allocator, label: []const u8, description: []const u8, enabled: bool, id_prefix: []const u8) !void { + try appendRowOpen(out, allocator, label, description); + try out.appendSlice(allocator, "
"); +} + +fn appendRowOpen(out: *std.ArrayList(u8), allocator: std.mem.Allocator, label: []const u8, description: []const u8) !void { + try out.appendSlice(allocator, "
"); + try rml_markup.appendEscaped(out, allocator, label); + try out.appendSlice(allocator, ""); + try rml_markup.appendEscaped(out, allocator, description); + try out.appendSlice(allocator, "
"); +} + +fn settingActionFromId(comptime name: []const u8, id: []const u8) ?SettingAction { + if (std.mem.eql(u8, id, "setting-" ++ name ++ "-prev")) return .previous; + if (std.mem.eql(u8, id, "setting-" ++ name ++ "-next")) return .next; + if (std.mem.eql(u8, id, "setting-" ++ name ++ "-toggle")) return .toggle; + return null; +} + +fn tabFromId(id: []const u8) ?SettingsTab { + if (std.mem.eql(u8, id, "tab-display")) return .display; + if (std.mem.eql(u8, id, "tab-camera")) return .camera; + if (std.mem.eql(u8, id, "tab-world")) return .world; + if (std.mem.eql(u8, id, "tab-rendering")) return .rendering; + return null; +} + +fn nextTab(tab: SettingsTab) SettingsTab { + return switch (tab) { + .display => .camera, + .camera => .world, + .world => .rendering, + .rendering => .display, + }; +} + +fn previousTab(tab: SettingsTab) SettingsTab { + return switch (tab) { + .display => .rendering, + .camera => .display, + .world => .camera, + .rendering => .world, + }; +} + +fn settingDescription(comptime name: []const u8) []const u8 { + return if (comptime std.mem.eql(u8, name, "textures_enabled")) + "Material atlas sampling." + else if (comptime std.mem.eql(u8, name, "shadow_quality")) + "Depth map budget for directional shadows." + else if (comptime std.mem.eql(u8, name, "shadow_pcf_samples")) + "PCF sample count and edge softness." + else if (comptime std.mem.eql(u8, name, "pbr_enabled")) + "Material response and packed surface channels." + else if (comptime std.mem.eql(u8, name, "taa_enabled")) + "Temporal anti-aliasing pipeline." + else if (comptime std.mem.eql(u8, name, "max_texture_resolution")) + "Upper bound for atlas texture detail." + else if (comptime std.mem.eql(u8, name, "lpv_enabled")) + "Light propagation volume GI experiment." + else if (comptime std.mem.eql(u8, name, "volumetric_density")) + "Fog volume strength." + else if (comptime std.mem.eql(u8, name, "dynamic_resolution_enabled")) + "Scale rendering resolution to hold target FPS." + else + @field(Settings.metadata, name).description; +} + +test "metadata settings produce RmlUi rows" { + var settings = Settings{}; + var markup = std.ArrayList(u8).empty; + defer markup.deinit(std.testing.allocator); + + inline for (RENDER_SETTING_NAMES) |name| { + try appendMetadataRow(&markup, std.testing.allocator, &settings, name); + } + try std.testing.expect(std.mem.indexOf(u8, markup.items, "setting-shadow_quality-next") != null); + try std.testing.expect(std.mem.indexOf(u8, markup.items, "setting-dynamic_resolution_enabled-toggle") != null); +} diff --git a/modules/game-ui/src/screens/rml_world_list.zig b/modules/game-ui/src/screens/rml_world_list.zig new file mode 100644 index 00000000..16fe6ae1 --- /dev/null +++ b/modules/game-ui/src/screens/rml_world_list.zig @@ -0,0 +1,358 @@ +//! RmlUi implementation of the saved-world library. + +const std = @import("std"); +const UISystem = @import("engine-ui").UISystem; +const log = @import("engine-core").log; +const registry = @import("world-worldgen").registry; +const fs = @import("fs"); +const Screen = @import("../screen.zig"); +const IScreen = Screen.IScreen; +const EngineContext = Screen.EngineContext; +const Page = @import("../rml_page.zig").Page; +const markup = @import("../rml_markup.zig"); +const world_list = @import("world_list.zig"); +const world_save = @import("world_save.zig"); +const WorldScreen = @import("world.zig").WorldScreen; +const RmlCreateWorldScreen = @import("rml_create_world.zig").RmlCreateWorldScreen; + +const Modal = enum { none, rename, delete, clear }; + +pub const RmlWorldListScreen = struct { + context: EngineContext, + page: Page, + worlds: []world_list.WorldEntry, + selected: ?usize = null, + modal: Modal = .none, + error_message: ?[]const u8 = null, + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + .onEnter = onEnter, + .onExit = onExit, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*RmlWorldListScreen { + const self = try allocator.create(RmlWorldListScreen); + errdefer allocator.destroy(self); + + const worlds = world_list.scanWorlds(allocator) catch try allocator.alloc(world_list.WorldEntry, 0); + errdefer freeWorlds(allocator, worlds); + self.* = .{ + .context = context, + .page = undefined, + .worlds = worlds, + .selected = if (worlds.len > 0) 0 else null, + }; + self.page = try Page.init(context, "assets/ui/rmlui/world_library.rml", self, onDocumentAction); + errdefer self.page.deinit(); + try self.render(); + return self; + } + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.deinit(); + freeWorlds(self.context.allocator, self.worlds); + self.context.allocator.destroy(self); + } + + pub fn update(ptr: *anyopaque, dt: f32) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + _ = dt; + if (!self.context.input_mapper.isActionPressed(self.context.input, .ui_back)) return; + + if (self.modal != .none) { + self.closeModal(); + } else { + self.context.screen_manager.popScreen(); + } + } + + pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); + self.page.draw(ui); + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onEnter(); + self.rescan() catch |err| self.showError(err); + self.render() catch |err| self.showError(err); + } + + pub fn onExit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.page.onExit(); + } + + pub fn screen(self: *@This()) IScreen { + return Screen.makeScreen(@This(), self); + } + + fn onDocumentAction(context: *anyopaque, event_type: []const u8, target_id: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(context)); + if (std.mem.eql(u8, event_type, "change")) { + // The rename input is intentionally read only when confirmed. The + // change event still documents its target for future validation. + if (std.mem.eql(u8, target_id, "rename-input")) return; + return; + } + if (!std.mem.eql(u8, event_type, "click")) return; + + self.handleClick(target_id) catch |err| self.showError(err); + } + + fn handleClick(self: *@This(), target_id: []const u8) !void { + if (std.mem.eql(u8, target_id, "library-back")) { + if (self.modal == .none) self.context.screen_manager.popScreen() else self.closeModal(); + return; + } + if (std.mem.eql(u8, target_id, "new-world")) { + if (self.modal == .none) try self.openCreateWorld(); + return; + } + if (std.mem.eql(u8, target_id, "clear-worlds")) { + if (self.modal == .none and self.worlds.len > 0) try self.openModal(.clear); + return; + } + if (std.mem.eql(u8, target_id, "detail-play")) { + if (self.modal == .none) try self.loadSelectedWorld(); + return; + } + if (std.mem.eql(u8, target_id, "detail-rename")) { + if (self.modal == .none and self.selected != null) try self.openModal(.rename); + return; + } + if (std.mem.eql(u8, target_id, "detail-delete")) { + if (self.modal == .none and self.selected != null) try self.openModal(.delete); + return; + } + if (std.mem.eql(u8, target_id, "modal-cancel")) { + self.closeModal(); + return; + } + if (std.mem.eql(u8, target_id, "modal-confirm")) { + try self.confirmModal(); + return; + } + if (std.mem.startsWith(u8, target_id, "world-")) { + const index = std.fmt.parseInt(usize, target_id[6..], 10) catch return; + if (index < self.worlds.len and self.modal == .none) { + self.selected = index; + try self.render(); + } + } + } + + fn render(self: *@This()) !void { + try self.renderWorldList(); + try self.renderDetails(); + try self.renderModal(); + + var count_markup = std.ArrayList(u8).empty; + defer count_markup.deinit(self.context.allocator); + var count_buffer: [48]u8 = undefined; + const count = std.fmt.bufPrint(&count_buffer, "{} WORLDS", .{self.worlds.len}) catch "WORLDS"; + try count_markup.appendSlice(self.context.allocator, count); + _ = self.page.backend.setInnerRml(self.page.document, "world-count", try markup.sentinel(&count_markup, self.context.allocator)); + _ = self.page.backend.setDisabled(self.page.document, "clear-worlds", self.worlds.len == 0 or self.modal != .none); + // Keep the generated rail independently scrollable as its content grows. + _ = self.page.backend.setProperty(self.page.document, "world-list", "overflow", "auto"); + } + + fn renderWorldList(self: *@This()) !void { + var out = std.ArrayList(u8).empty; + defer out.deinit(self.context.allocator); + + if (self.worlds.len == 0) { + try out.appendSlice(self.context.allocator, "
NO WORLDS YET
CREATE A NEW WORLD TO BEGIN.
"); + } else for (self.worlds, 0..) |world, index| { + var id_buffer: [32]u8 = undefined; + const id = std.fmt.bufPrint(&id_buffer, "world-{}", .{index}) catch unreachable; + try out.appendSlice(self.context.allocator, ""); + } + + const document_rml = try markup.sentinel(&out, self.context.allocator); + _ = self.page.backend.setInnerRml(self.page.document, "world-list", document_rml); + } + + fn renderDetails(self: *@This()) !void { + var out = std.ArrayList(u8).empty; + defer out.deinit(self.context.allocator); + + if (self.selected) |index| { + if (index < self.worlds.len) { + const world = self.worlds[index]; + try out.appendSlice(self.context.allocator, "

"); + try markup.appendEscaped(&out, self.context.allocator, world.name); + try out.appendSlice(self.context.allocator, "

TERRAIN"); + try markup.appendEscaped(&out, self.context.allocator, registry.getGeneratorInfo(world.generator_index).name); + try out.appendSlice(self.context.allocator, "SEED"); + try appendUnsigned(&out, self.context.allocator, world.seed); + try out.appendSlice(self.context.allocator, "LAST PLAYED"); + try appendSigned(&out, self.context.allocator, world.last_played); + try out.appendSlice(self.context.allocator, "
"); + } + } + if (out.items.len == 0) try out.appendSlice(self.context.allocator, "

Select a world to see its details and actions.

"); + + const document_rml = try markup.sentinel(&out, self.context.allocator); + _ = self.page.backend.setInnerRml(self.page.document, "world-detail", document_rml); + } + + fn renderModal(self: *@This()) !void { + const visible = self.modal != .none; + _ = self.page.backend.setClass(self.page.document, "modal-layer", "visible", visible); + if (!visible) return; + + const title: [:0]const u8 = switch (self.modal) { + .rename => "RENAME WORLD", + .delete => "DELETE WORLD?", + .clear => "CLEAR ALL WORLDS?", + .none => unreachable, + }; + const copy: [:0]const u8 = switch (self.modal) { + .rename => "Update the display name in level.dat.", + .delete => "This cannot be undone.", + .clear => "Every saved world will be removed.", + .none => unreachable, + }; + _ = self.page.backend.setInnerRml(self.page.document, "modal-title", title); + _ = self.page.backend.setInnerRml(self.page.document, "modal-copy", copy); + const body: [:0]const u8 = switch (self.modal) { + .rename => "", + .delete => "

Delete the selected world and all of its saved data?

", + .clear => "

Delete every saved world and all of their data?

", + .none => unreachable, + }; + const confirm_label: [:0]const u8 = if (self.modal == .rename) "SAVE NAME" else "CONFIRM"; + _ = self.page.backend.setInnerRml(self.page.document, "modal-body", body); + _ = self.page.backend.setInnerRml(self.page.document, "modal-confirm", confirm_label); + } + + fn openModal(self: *@This(), modal: Modal) !void { + self.modal = modal; + try self.render(); + if (modal == .rename) { + if (self.selected) |index| { + var value = std.ArrayList(u8).empty; + defer value.deinit(self.context.allocator); + try value.appendSlice(self.context.allocator, self.worlds[index].name); + const name = try markup.sentinel(&value, self.context.allocator); + _ = self.page.backend.setValue(self.page.document, "rename-input", name); + } + } + if (modal == .rename) _ = self.page.backend.focus(self.page.document, "rename-input", true); + } + + fn closeModal(self: *@This()) void { + self.modal = .none; + _ = self.page.backend.setClass(self.page.document, "modal-layer", "visible", false); + } + + fn confirmModal(self: *@This()) !void { + switch (self.modal) { + .none => {}, + .rename => try self.renameSelectedWorld(), + .delete => try self.deleteSelectedWorld(), + .clear => try self.clearAllWorlds(), + } + } + + fn openCreateWorld(self: *@This()) !void { + const create_screen = try RmlCreateWorldScreen.init(self.context.allocator, self.context); + errdefer create_screen.deinit(create_screen); + self.context.screen_manager.pushScreen(create_screen.screen()); + } + + fn loadSelectedWorld(self: *@This()) !void { + const index = self.selected orelse return; + if (index >= self.worlds.len) return; + const world = self.worlds[index]; + const world_screen = try WorldScreen.init(self.context.allocator, self.context, world.seed, world.generator_index); + errdefer world_screen.deinit(world_screen); + self.context.screen_manager.setScreen(world_screen.screen()); + } + + fn renameSelectedWorld(self: *@This()) !void { + const index = self.selected orelse return; + if (index >= self.worlds.len) return; + var value_buffer: [128]u8 = undefined; + const value = self.page.backend.getValue(self.page.document, "rename-input", &value_buffer) orelse return error.RenameValueUnavailable; + const name = std.mem.trim(u8, value, " \t\r\n"); + if (name.len == 0) return error.EmptyWorldName; + + const world = &self.worlds[index]; + var save_dir = try fs.openDirAbsolute(world.dir_path, .{}); + defer save_dir.close(); + try world_save.writeLevelDat(self.context.allocator, save_dir, name, world.seed, world.generator_index, world.last_played); + const name_copy = try self.context.allocator.dupe(u8, name); + self.context.allocator.free(world.name); + world.name = name_copy; + self.modal = .none; + self.error_message = null; + try self.render(); + } + + fn deleteSelectedWorld(self: *@This()) !void { + const index = self.selected orelse return; + if (index >= self.worlds.len) return; + try world_list.deleteWorld(self.worlds[index].dir_path); + self.modal = .none; + try self.rescan(); + try self.render(); + } + + fn clearAllWorlds(self: *@This()) !void { + for (self.worlds) |world| try world_list.deleteWorld(world.dir_path); + self.modal = .none; + try self.rescan(); + try self.render(); + } + + fn rescan(self: *@This()) !void { + const worlds = try world_list.scanWorlds(self.context.allocator); + freeWorlds(self.context.allocator, self.worlds); + self.worlds = worlds; + self.selected = if (worlds.len > 0) 0 else null; + self.modal = .none; + } + + fn showError(self: *@This(), err: anyerror) void { + log.log.err("RmlUi World Library action failed: {}", .{err}); + self.error_message = "The requested world action failed. Check logs."; + _ = self.page.backend.setInnerRml(self.page.document, "modal-title", "WORLD LIBRARY ERROR"); + _ = self.page.backend.setInnerRml(self.page.document, "modal-copy", "The requested world action failed. Check logs."); + _ = self.page.backend.setInnerRml(self.page.document, "modal-body", "

Nothing was changed unless the operation completed.

"); + _ = self.page.backend.setClass(self.page.document, "modal-layer", "visible", true); + } +}; + +fn freeWorlds(allocator: std.mem.Allocator, worlds: []world_list.WorldEntry) void { + for (worlds) |world| { + allocator.free(world.name); + if (world.dir_path.len > 0) allocator.free(world.dir_path); + } + allocator.free(worlds); +} + +fn appendUnsigned(out: *std.ArrayList(u8), allocator: std.mem.Allocator, value: u64) !void { + var buffer: [32]u8 = undefined; + try out.appendSlice(allocator, try std.fmt.bufPrint(&buffer, "{}", .{value})); +} + +fn appendSigned(out: *std.ArrayList(u8), allocator: std.mem.Allocator, value: i64) !void { + var buffer: [32]u8 = undefined; + try out.appendSlice(allocator, try std.fmt.bufPrint(&buffer, "{}", .{value})); +} diff --git a/modules/game-ui/src/screens/settings.zig b/modules/game-ui/src/screens/settings.zig index cd429de7..309df5b8 100644 --- a/modules/game-ui/src/screens/settings.zig +++ b/modules/game-ui/src/screens/settings.zig @@ -73,6 +73,7 @@ pub const SettingsScreen = struct { const settings = ctx.settings; const rs = ctx.render_settings; + try ctx.screen_manager.drawBackgroundFor(ptr, ui); ui.begin(); defer ui.end(); @@ -87,34 +88,21 @@ pub const SettingsScreen = struct { Theme.drawBackdrop(ui, screen_w, screen_h, ui_scale, .settings); - const margin: f32 = 48.0 * ui_scale; - const page_w: f32 = @min(screen_w - margin * 2.0, 1460.0 * ui_scale); - const page_x = (screen_w - page_w) * 0.5; - const page_top = @max(38.0 * ui_scale, (screen_h - 900.0 * ui_scale) * 0.5); - const header_h: f32 = 132.0 * ui_scale; - const footer_h: f32 = 72.0 * ui_scale; - const workspace_y = page_top + header_h; - const workspace_h = @min(650.0 * ui_scale, screen_h - workspace_y - footer_h - 30.0 * ui_scale); - - Font.drawText(ui, "PREFERENCES", page_x, page_top, 0.88 * ui_scale, Theme.signal); - Font.drawText(ui, "Make ZigCraft yours.", page_x, page_top + 28.0 * ui_scale, 4.2 * ui_scale, Theme.title); - Font.drawText(ui, "Changes apply immediately and are saved when you leave.", page_x, page_top + 88.0 * ui_scale, 1.02 * ui_scale, Theme.muted); - - const sidebar_layout = page_w >= 900.0 * ui_scale; - const nav_w: f32 = if (sidebar_layout) 220.0 * ui_scale else page_w; - const nav_h: f32 = if (sidebar_layout) workspace_h else 46.0 * ui_scale; - drawTabs(ui, self, page_x, workspace_y, nav_w, nav_h, sidebar_layout, mouse_x, mouse_y, mouse_clicked, ui_scale); - - const body = if (sidebar_layout) Rect{ - .x = page_x + nav_w + 38.0 * ui_scale, - .y = workspace_y, - .width = page_w - nav_w - 38.0 * ui_scale, - .height = workspace_h, - } else Rect{ - .x = page_x, - .y = workspace_y + nav_h + 20.0 * ui_scale, - .width = page_w, - .height = workspace_h - nav_h - 20.0 * ui_scale, + const margin: f32 = 34.0 * ui_scale; + const panel_w: f32 = @min(screen_w - margin * 2.0, 1080.0 * ui_scale); + const panel_h: f32 = @min(screen_h - margin * 2.0, 760.0 * ui_scale); + const panel_x = (screen_w - panel_w) * 0.5; + const panel_y = (screen_h - panel_h) * 0.5; + const shell = Theme.drawShell(ui, .{ .x = panel_x, .y = panel_y, .width = panel_w, .height = panel_h }, ui_scale, "PREFERENCES", "SETTINGS", "Changes apply immediately and save when you leave."); + + const nav_h = 48.0 * ui_scale; + drawTabs(ui, self, shell.content.x, shell.content.y, shell.content.width, nav_h, false, mouse_x, mouse_y, mouse_clicked, ui_scale); + + const body = Rect{ + .x = shell.content.x, + .y = shell.content.y + nav_h + 18.0 * ui_scale, + .width = shell.content.width, + .height = shell.content.height - nav_h - 18.0 * ui_scale, }; const inner = Rect{ .x = body.x + 4.0 * ui_scale, @@ -141,7 +129,7 @@ pub const SettingsScreen = struct { } const done_w: f32 = 176.0 * ui_scale; - if (Theme.drawButton(ui, .{ .x = page_x + page_w - done_w, .y = workspace_y + workspace_h + 18.0 * ui_scale, .width = done_w, .height = 52.0 * ui_scale }, "DONE", button_scale, mouse_x, mouse_y, mouse_clicked, .primary, ui_scale)) { + if (Theme.drawButton(ui, .{ .x = shell.content.x + shell.content.width - done_w, .y = shell.footer_y, .width = done_w, .height = 46.0 * ui_scale }, "DONE", button_scale, mouse_x, mouse_y, mouse_clicked, .primary, ui_scale)) { ctx.saveSettings(); ctx.screen_manager.popScreen(); } @@ -166,7 +154,7 @@ const ColumnLayout = struct { }; fn columnLayout(inner: Rect, scale: f32) ColumnLayout { - const two_column = inner.width > 760.0 * scale; + const two_column = false; const gap: f32 = 18.0 * scale; const col_w: f32 = if (two_column) (inner.width - gap) * 0.5 else inner.width; return .{ @@ -184,7 +172,7 @@ fn drawTabs(ui: *UISystem, self: *SettingsScreen, x: f32, y: f32, w: f32, h: f32 const tab_w = if (vertical) w else (w - gap * @as(f32, @floatFromInt(tabs.len - 1))) / @as(f32, @floatFromInt(tabs.len)); const tab_h = if (vertical) 58.0 * scale else h; if (vertical) { - Font.drawText(ui, "SETTINGS", x, y, 0.76 * scale, Theme.muted); + Font.drawText(ui, "SETTINGS", x, y, 0.90 * scale, Theme.muted); ui.drawRect(.{ .x = x, .y = y + 24.0 * scale, .width = w, .height = 1.0 * scale }, Theme.outline); } for (tabs, 0..) |tab, i| { @@ -201,7 +189,7 @@ fn drawTabs(ui: *UISystem, self: *SettingsScreen, x: f32, y: f32, w: f32, h: f32 } if (vertical) { ui.drawRect(.{ .x = x + w + 10.0 * scale, .y = y, .width = 1.0 * scale, .height = h }, Theme.outline); - Font.drawText(ui, "ARROWS Switch section", x, y + h - 18.0 * scale, 0.68 * scale, Theme.dim); + Font.drawText(ui, "ARROWS Switch section", x, y + h - 18.0 * scale, 0.84 * scale, Theme.dim); } } @@ -350,7 +338,11 @@ fn drawRenderingTab(ui: *UISystem, self: *SettingsScreen, ctx: EngineContext, se const left_content_h = renderingContentHeight(left_sections.len, baseline_rows.len + material_settings.len + shadow_settings.len, row_h, row_gap, section_h, scale) + warning_h; const right_content_h = renderingContentHeight(right_sections.len, image_settings.len + atmosphere_settings.len + dynamic_resolution_settings.len, row_h, row_gap, section_h, scale); - const total_content_h = @max(left_content_h, right_content_h); + const column_gap: f32 = 28.0 * scale; + const total_content_h = if (layout.two_column) + @max(left_content_h, right_content_h) + else + left_content_h + column_gap + right_content_h; const max_scroll = @max(0.0, total_content_h - inner.height); self.render_scroll_offset -= ctx.input.getScrollDelta().y * 36.0 * scale; self.render_scroll_offset = @max(0.0, @min(self.render_scroll_offset, max_scroll)); @@ -359,7 +351,7 @@ fn drawRenderingTab(ui: *UISystem, self: *SettingsScreen, ctx: EngineContext, se const top = inner.y; const bottom = inner.y + inner.height; var y_left = layout.top_y - self.render_scroll_offset; - var y_right = (if (layout.two_column) layout.top_y else layout.top_y + left_content_h + 28.0 * scale) - self.render_scroll_offset; + var y_right = (if (layout.two_column) layout.top_y else layout.top_y + left_content_h + column_gap) - self.render_scroll_offset; drawRenderSection(ui, layout.left_x, y_left, left_sections[0], top, bottom, scale); y_left += section_h; @@ -376,7 +368,7 @@ fn drawRenderingTab(ui: *UISystem, self: *SettingsScreen, ctx: EngineContext, se if (rowVisible(y_left, warning_h, top, bottom)) { ui.drawRect(.{ .x = layout.left_x, .y = y_left, .width = layout.col_w, .height = 30.0 * scale }, Color.rgba(0.28, 0.040, 0.030, 0.80)); ui.drawRect(.{ .x = layout.left_x, .y = y_left, .width = 5.0 * scale, .height = 30.0 * scale }, Theme.danger); - Font.drawText(ui, "EXTREME DISTANCE CAN DESTABILIZE GPUS BELOW 8GB VRAM", layout.left_x + 16.0 * scale, y_left + 8.0 * scale, 0.78 * scale, Theme.title); + Font.drawText(ui, "EXTREME DISTANCE CAN DESTABILIZE GPUS BELOW 8GB VRAM", layout.left_x + 16.0 * scale, y_left + 8.0 * scale, 0.90 * scale, Theme.title); } y_left += warning_h; } @@ -538,7 +530,7 @@ fn drawSettingRow(ui: *UISystem, comptime name: []const u8, settings: *Settings, if (comptime std.mem.eql(u8, name, "lpv_quality_preset")) { const legend = SettingsUi.getLPVQualityLegend(settings.lpv_quality_preset); - Font.drawText(ui, legend, row.x + row.width - 338.0 * scale, row.y + row.height - 16.0 * scale, 0.68 * scale, Theme.signal); + Font.drawText(ui, legend, row.x + row.width - 338.0 * scale, row.y + row.height - 16.0 * scale, 0.82 * scale, Theme.signal); } if (val_ptr.* != old_val) { diff --git a/modules/game-ui/src/screens/singleplayer.zig b/modules/game-ui/src/screens/singleplayer.zig index 262d2593..b519b9b0 100644 --- a/modules/game-ui/src/screens/singleplayer.zig +++ b/modules/game-ui/src/screens/singleplayer.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const fs = @import("fs"); const UISystem = @import("engine-ui").UISystem; const Font = @import("engine-ui").font; const Theme = @import("../menu_theme.zig"); @@ -9,43 +8,36 @@ const Screen = @import("../screen.zig"); const IScreen = Screen.IScreen; const EngineContext = Screen.EngineContext; const seed_gen = @import("game-core").seed; -const log = @import("engine-core").log; const text_input = @import("game-core").text_input; -const WorldScreen = @import("world.zig").WorldScreen; -const WorldListScreen = @import("world_list.zig").WorldListScreen; -const world_list = @import("world_list.zig"); +const log = @import("engine-core").log; const registry = @import("world-worldgen").registry; - -fn getenv(name: [:0]const u8) ?[]const u8 { - const value = std.c.getenv(name) orelse return null; - return std.mem.span(value); -} - -const PANEL_WIDTH_MAX = 1160.0; -const PANEL_HEIGHT_MAX = 760.0; +const WorldScreen = @import("world.zig").WorldScreen; +const wizard = @import("singleplayer_wizard.zig"); +const world_save = @import("world_save.zig"); pub const SingleplayerScreen = struct { context: EngineContext, + step: wizard.Step, + details_field: wizard.DetailsField, seed_input: std.ArrayListUnmanaged(u8), - seed_focused: bool, name_input: std.ArrayListUnmanaged(u8), - name_focused: bool, selected_generator_index: usize, pub const vtable = IScreen.VTable{ .deinit = deinit, .update = update, .draw = draw, + .onEnter = onEnter, }; pub fn init(allocator: std.mem.Allocator, context: EngineContext) !*SingleplayerScreen { const self = try allocator.create(SingleplayerScreen); self.* = .{ .context = context, - .seed_input = std.ArrayListUnmanaged(u8).empty, - .seed_focused = false, - .name_input = std.ArrayListUnmanaged(u8).empty, - .name_focused = true, + .step = .details, + .details_field = .name, + .seed_input = .empty, + .name_input = .empty, .selected_generator_index = 0, }; return self; @@ -61,233 +53,246 @@ pub const SingleplayerScreen = struct { pub fn update(ptr: *anyopaque, dt: f32) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); _ = dt; + const ctx = self.context; + const input = ctx.input; - if (self.context.input_mapper.isActionPressed(self.context.input, .ui_back)) { - self.context.screen_manager.popScreen(); + if (ctx.input_mapper.isActionPressed(input, .ui_back)) { + self.goBack(); return; } - if (self.context.input.isKeyPressed(.tab)) { - self.name_focused = !self.name_focused; - self.seed_focused = !self.name_focused; + + switch (self.step) { + .details => { + if (input.isKeyPressed(.tab)) self.details_field = wizard.nextField(self.details_field); + if (self.details_field == .name) { + try text_input.handleTextTyping(&self.name_input, ctx.allocator, input, 32); + } else { + try text_input.handleTextTyping(&self.seed_input, ctx.allocator, input, 32); + } + }, + .terrain => { + const generator_count = registry.getGeneratorCount(); + const scale = Theme.scaleFor(@floatFromInt(input.getWindowHeight()), ctx.settings.ui_scale); + const columns: usize = if (@as(f32, @floatFromInt(input.getWindowWidth())) >= 920.0 * scale) 2 else 1; + if (input.isKeyPressed(.tab)) self.selected_generator_index = wizard.nextGenerator(self.selected_generator_index, generator_count); + if (input.isKeyPressed(.left_arrow)) self.selected_generator_index = wizard.moveGenerator(self.selected_generator_index, generator_count, columns, .left); + if (input.isKeyPressed(.right_arrow)) self.selected_generator_index = wizard.moveGenerator(self.selected_generator_index, generator_count, columns, .right); + if (input.isKeyPressed(.up)) self.selected_generator_index = wizard.moveGenerator(self.selected_generator_index, generator_count, columns, .up); + if (input.isKeyPressed(.down)) self.selected_generator_index = wizard.moveGenerator(self.selected_generator_index, generator_count, columns, .down); + }, + .review => {}, } - if (self.seed_focused) try text_input.handleTextTyping(&self.seed_input, self.context.allocator, self.context.input, 32); - if (self.name_focused) try text_input.handleTextTyping(&self.name_input, self.context.allocator, self.context.input, 32); + + if (ctx.input_mapper.isActionPressed(input, .ui_confirm)) try self.confirmStep(); } pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); const ctx = self.context; + try ctx.screen_manager.drawBackgroundFor(ptr, ui); ui.begin(); defer ui.end(); const mouse_pos = ctx.input.getMousePosition(); - const mouse_x: f32 = @floatFromInt(mouse_pos.x); - const mouse_y: f32 = @floatFromInt(mouse_pos.y); - const mouse_clicked = ctx.input.isMouseButtonPressed(.left); - + const mx: f32 = @floatFromInt(mouse_pos.x); + const my: f32 = @floatFromInt(mouse_pos.y); + const clicked = ctx.input.isMouseButtonPressed(.left); const screen_w: f32 = @floatFromInt(ctx.input.getWindowWidth()); const screen_h: f32 = @floatFromInt(ctx.input.getWindowHeight()); - const ui_scale = Theme.scaleFor(screen_h, ctx.settings.ui_scale); - const compact = screen_w < 940.0 * ui_scale; + const scale = Theme.scaleFor(screen_h, ctx.settings.ui_scale); + Theme.drawBackdrop(ui, screen_w, screen_h, scale, .create); + + const margin = 32.0 * scale; + const frame_w = @min(screen_w - margin * 2.0, 980.0 * scale); + const frame_h = @min(screen_h - margin * 2.0, 700.0 * scale); + const frame_x = (screen_w - frame_w) * 0.5; + const frame_y = (screen_h - frame_h) * 0.5; + const shell = Theme.drawShell(ui, .{ .x = frame_x, .y = frame_y, .width = frame_w, .height = frame_h }, scale, "NEW WORLD", "CREATE WORLD", stepSubtitle(self.step)); + + const progress_h = 58.0 * scale; + drawProgress(ui, .{ .x = shell.content.x, .y = shell.content.y, .width = shell.content.width, .height = progress_h }, self.step, scale); + const body = Rect{ + .x = shell.content.x, + .y = shell.content.y + progress_h + 18.0 * scale, + .width = shell.content.width, + .height = shell.content.height - progress_h - 18.0 * scale, + }; + const cursor_visible = @as(u32, @truncate(@as(u64, @intFromFloat(ctx.time.elapsed * 2.0)))) % 2 == 0; - Theme.drawBackdrop(ui, screen_w, screen_h, ui_scale, .create); + switch (self.step) { + .details => try self.drawDetails(ui, body, mx, my, clicked, cursor_visible, scale), + .terrain => self.drawTerrain(ui, body, mx, my, clicked, scale), + .review => self.drawReview(ui, body, scale), + } - const cursor_visible = @as(u32, @truncate(@as(u64, @intFromFloat(ctx.time.elapsed * 2.0)))) % 2 == 0; - const form_scale: f32 = 1.18 * ui_scale; - const btn_scale: f32 = 1.16 * ui_scale; - const input_h: f32 = 58.0 * ui_scale; - - const margin: f32 = 48.0 * ui_scale; - const page_w = @min(screen_w - margin * 2.0, 1420.0 * ui_scale); - const page_x = (screen_w - page_w) * 0.5; - const page_top = @max(38.0 * ui_scale, (screen_h - 860.0 * ui_scale) * 0.5); - const header_h: f32 = 128.0 * ui_scale; - const footer_h: f32 = 74.0 * ui_scale; - const body_y = page_top + header_h; - const body_h = @min(610.0 * ui_scale, screen_h - body_y - footer_h - 32.0 * ui_scale); - const column_gap: f32 = 18.0 * ui_scale; - const rail_w: f32 = if (compact) 0.0 else 190.0 * ui_scale; - const summary_visible = page_w >= 1120.0 * ui_scale; - const summary_w: f32 = if (summary_visible) 330.0 * ui_scale else 0.0; - const form_x = page_x + rail_w + (if (compact) 0.0 else column_gap); - const form_w = page_w - rail_w - summary_w - (if (compact) 0.0 else column_gap) - (if (summary_visible) column_gap else 0.0); - const summary_x = form_x + form_w + column_gap; - - Font.drawText(ui, "NEW WORLD", page_x, page_top, 0.88 * ui_scale, Theme.signal); - Font.drawText(ui, "Create something new.", page_x, page_top + 28.0 * ui_scale, 4.2 * ui_scale, Theme.title); - Font.drawText(ui, "Name your world, choose its terrain, then jump in.", page_x, page_top + 86.0 * ui_scale, 1.06 * ui_scale, Theme.muted); - - if (!compact) drawCreateSteps(ui, .{ .x = page_x, .y = body_y, .width = rail_w, .height = body_h }, ui_scale); - - const form_rect = Rect{ .x = form_x, .y = body_y, .width = form_w, .height = body_h }; - ui.drawRect(.{ .x = form_rect.x + 9.0 * ui_scale, .y = form_rect.y + 10.0 * ui_scale, .width = form_rect.width, .height = form_rect.height }, Color.rgba(0, 0, 0, 0.38)); - ui.drawRect(form_rect, Color.rgba(0.045, 0.070, 0.090, 0.98)); - ui.drawRect(.{ .x = form_rect.x, .y = form_rect.y, .width = 7.0 * ui_scale, .height = form_rect.height }, Theme.signal); - - const content_x = form_rect.x + 34.0 * ui_scale; - const content_w = form_rect.width - 68.0 * ui_scale; - var y = form_rect.y + 28.0 * ui_scale; - Font.drawText(ui, "World details", content_x, y, 1.62 * ui_scale, Theme.title); - y += 35.0 * ui_scale; - Font.drawText(ui, "Give this save a recognizable name.", content_x, y, 0.84 * ui_scale, Theme.muted); - y += 35.0 * ui_scale; - - Font.drawText(ui, "NAME", content_x, y, 0.76 * ui_scale, Theme.signal); - y += 21.0 * ui_scale; - const name_rect = Rect{ .x = content_x, .y = y, .width = content_w, .height = input_h }; - y += input_h + 20.0 * ui_scale; - - Font.drawText(ui, "SEED", content_x, y, 0.76 * ui_scale, Theme.signal); - y += 21.0 * ui_scale; - const random_w: f32 = @min(144.0 * ui_scale, content_w * 0.30); - const seed_rect = Rect{ .x = content_x, .y = y, .width = content_w - random_w - 12.0 * ui_scale, .height = input_h }; - const random_rect = Rect{ .x = seed_rect.x + seed_rect.width + 12.0 * ui_scale, .y = y, .width = random_w, .height = input_h }; - y += input_h + 30.0 * ui_scale; - - if (mouse_clicked) { - self.name_focused = name_rect.contains(mouse_x, mouse_y); - self.seed_focused = seed_rect.contains(mouse_x, mouse_y); + const button_h = 46.0 * scale; + const button_w = 156.0 * scale; + const back_label: []const u8 = if (self.step == .details) "CANCEL" else "BACK"; + if (Theme.drawButton(ui, .{ .x = shell.content.x, .y = shell.footer_y, .width = button_w, .height = button_h }, back_label, 1.06 * scale, mx, my, clicked, .ghost, scale)) self.goBack(); + + const confirm_label: []const u8 = if (self.step == .review) "CREATE WORLD" else "CONTINUE"; + const confirm_w: f32 = if (self.step == .review) 190.0 * scale else button_w; + if (Theme.drawButton(ui, .{ .x = shell.content.x + shell.content.width - confirm_w, .y = shell.footer_y, .width = confirm_w, .height = button_h }, confirm_label, 1.06 * scale, mx, my, clicked, .primary, scale)) try self.confirmStep(); + } + + fn drawDetails(self: *@This(), ui: *UISystem, body: Rect, mx: f32, my: f32, clicked: bool, cursor_visible: bool, scale: f32) !void { + const content_w = @min(body.width, 720.0 * scale); + const x = body.x + (body.width - content_w) * 0.5; + var y = body.y + 10.0 * scale; + Theme.drawSectionLabel(ui, x, y, "WORLD DETAILS", scale); + y += 38.0 * scale; + + Font.drawText(ui, "WORLD NAME", x, y, 0.92 * scale, Theme.signal); + y += 24.0 * scale; + const name_rect = Rect{ .x = x, .y = y, .width = content_w, .height = 58.0 * scale }; + Theme.drawTextInput(ui, name_rect, self.name_input.items, "ENTER A WORLD NAME", 1.20 * scale, self.details_field == .name, cursor_visible, scale); + y += 82.0 * scale; + + Font.drawText(ui, "SEED", x, y, 0.92 * scale, Theme.signal); + y += 24.0 * scale; + const random_w = 146.0 * scale; + const seed_rect = Rect{ .x = x, .y = y, .width = content_w - random_w - 12.0 * scale, .height = 58.0 * scale }; + const random_rect = Rect{ .x = seed_rect.x + seed_rect.width + 12.0 * scale, .y = y, .width = random_w, .height = seed_rect.height }; + Theme.drawTextInput(ui, seed_rect, self.seed_input.items, "LEAVE BLANK FOR RANDOM", 1.04 * scale, self.details_field == .seed, cursor_visible, scale); + if (Theme.drawButton(ui, random_rect, "RANDOMIZE", 0.94 * scale, mx, my, clicked, .secondary, scale)) { + try seed_gen.setSeedInput(&self.seed_input, self.context.allocator, seed_gen.randomSeedValue()); + self.details_field = .seed; } - Theme.drawTextInput(ui, name_rect, self.name_input.items, "ENTER WORLD NAME", form_scale, self.name_focused, cursor_visible, ui_scale); - Theme.drawTextInput(ui, seed_rect, self.seed_input.items, "BLANK MEANS RANDOM", form_scale * 0.92, self.seed_focused, cursor_visible, ui_scale); - if (Theme.drawButton(ui, random_rect, "RANDOM", btn_scale, mouse_x, mouse_y, mouse_clicked, .secondary, ui_scale)) { - const gen = seed_gen.randomSeedValue(); - try seed_gen.setSeedInput(&self.seed_input, ctx.allocator, gen); - self.seed_focused = true; - self.name_focused = false; + + if (clicked) { + if (name_rect.contains(mx, my)) self.details_field = .name; + if (seed_rect.contains(mx, my)) self.details_field = .seed; } + Font.drawText(ui, "TAB switches fields. A blank seed creates a new random world.", x, y + 76.0 * scale, 0.92 * scale, Theme.muted); + } - Font.drawText(ui, "CHOOSE TERRAIN", content_x, y, 0.76 * ui_scale, Theme.signal); - y += 24.0 * ui_scale; - const gen_count = registry.getGeneratorCount(); - const generator_gap: f32 = 12.0 * ui_scale; - const generator_cols: usize = if (content_w >= 480.0 * ui_scale) 2 else 1; - const generator_w = (content_w - generator_gap * @as(f32, @floatFromInt(generator_cols - 1))) / @as(f32, @floatFromInt(generator_cols)); - const generator_h: f32 = 82.0 * ui_scale; - var generator_index: usize = 0; - while (generator_index < gen_count) : (generator_index += 1) { - const col = generator_index % generator_cols; - const row = generator_index / generator_cols; - const generator_rect = Rect{ - .x = content_x + @as(f32, @floatFromInt(col)) * (generator_w + generator_gap), - .y = y + @as(f32, @floatFromInt(row)) * (generator_h + generator_gap), - .width = generator_w, - .height = generator_h, + fn drawTerrain(self: *@This(), ui: *UISystem, body: Rect, mx: f32, my: f32, clicked: bool, scale: f32) void { + const content_w = @min(body.width, 780.0 * scale); + const x = body.x + (body.width - content_w) * 0.5; + var y = body.y + 8.0 * scale; + Theme.drawSectionLabel(ui, x, y, "CHOOSE TERRAIN", scale); + y += 36.0 * scale; + Font.drawText(ui, "Pick the generator that will shape this world.", x, y, 1.02 * scale, Theme.text); + y += 34.0 * scale; + + const count = registry.getGeneratorCount(); + const columns: usize = if (content_w >= 700.0 * scale) 2 else 1; + const gap = 12.0 * scale; + const row_w = (content_w - gap * @as(f32, @floatFromInt(columns - 1))) / @as(f32, @floatFromInt(columns)); + const row_h = 92.0 * scale; + var index: usize = 0; + while (index < count) : (index += 1) { + const column = index % columns; + const row = index / columns; + const rect = Rect{ + .x = x + @as(f32, @floatFromInt(column)) * (row_w + gap), + .y = y + @as(f32, @floatFromInt(row)) * (row_h + gap), + .width = row_w, + .height = row_h, }; - const info = registry.getGeneratorInfo(generator_index); - const selected = self.selected_generator_index == generator_index; - drawTerrainTile(ui, generator_rect, info.name, info.description, generator_index, selected, ui_scale); - if (mouse_clicked and generator_rect.contains(mouse_x, mouse_y)) self.selected_generator_index = generator_index; + const hovered = rect.contains(mx, my); + drawTerrainRow(ui, rect, registry.getGeneratorInfo(index), self.selected_generator_index == index, hovered, scale); + if (clicked and hovered) self.selected_generator_index = index; } + } - if (summary_visible) drawWorldSummary(ui, .{ .x = summary_x, .y = body_y, .width = summary_w, .height = body_h }, self.selected_generator_index, self.seed_input.items, ui_scale); + fn drawReview(self: *@This(), ui: *UISystem, body: Rect, scale: f32) void { + const content_w = @min(body.width, 720.0 * scale); + const x = body.x + (body.width - content_w) * 0.5; + var y = body.y + 8.0 * scale; + Theme.drawSectionLabel(ui, x, y, "READY TO CREATE", scale); + y += 40.0 * scale; + const card = Rect{ .x = x, .y = y, .width = content_w, .height = @min(body.height - 48.0 * scale, 292.0 * scale) }; + Theme.drawListRail(ui, card, scale); + const inner_x = card.x + 24.0 * scale; + var fact_y = card.y + 24.0 * scale; + drawReviewFact(ui, inner_x, fact_y, "WORLD NAME", wizard.displayWorldName(self.name_input.items), scale); + fact_y += 72.0 * scale; + drawReviewFact(ui, inner_x, fact_y, "TERRAIN", registry.getGeneratorInfo(self.selected_generator_index).name, scale); + fact_y += 72.0 * scale; + drawReviewFact(ui, inner_x, fact_y, "SEED", if (self.seed_input.items.len == 0) "Random on creation" else self.seed_input.items, scale); + ui.drawRect(.{ .x = inner_x, .y = card.y + card.height - 54.0 * scale, .width = card.width - 48.0 * scale, .height = 1.0 * scale }, Color.rgba(Theme.signal.r, Theme.signal.g, Theme.signal.b, 0.24)); + Font.drawText(ui, "This world will be stored locally in My Worlds.", inner_x, card.y + card.height - 34.0 * scale, 0.94 * scale, Theme.muted); + } - const bottom_y = body_y + body_h + 20.0 * ui_scale; - const load_w = @min(220.0 * ui_scale, page_w * 0.30); - const action_w = 176.0 * ui_scale; - const actions_x = page_x + page_w - action_w * 2.0 - 12.0 * ui_scale; - if (Theme.drawButton(ui, .{ .x = page_x, .y = bottom_y, .width = load_w, .height = 52.0 * ui_scale }, "MY WORLDS", btn_scale, mouse_x, mouse_y, mouse_clicked, .ghost, ui_scale)) { - const wl_screen = try WorldListScreen.init(ctx.allocator, ctx); - errdefer wl_screen.deinit(wl_screen); - ctx.screen_manager.pushScreen(wl_screen.screen()); - } - if (Theme.drawButton(ui, .{ .x = actions_x, .y = bottom_y, .width = action_w, .height = 52.0 * ui_scale }, "CANCEL", btn_scale, mouse_x, mouse_y, mouse_clicked, .ghost, ui_scale)) { - ctx.screen_manager.popScreen(); + fn goBack(self: *@This()) void { + switch (wizard.backAction(self.step)) { + .show => |step| self.step = step, + .exit_wizard => self.context.screen_manager.popScreen(), } - if (Theme.drawButton(ui, .{ .x = actions_x + action_w + 12.0 * ui_scale, .y = bottom_y, .width = action_w, .height = 52.0 * ui_scale }, "CREATE", btn_scale, mouse_x, mouse_y, mouse_clicked, .primary, ui_scale) or ctx.input_mapper.isActionPressed(ctx.input, .ui_confirm)) { - const seed = try seed_gen.resolveSeed(&self.seed_input, ctx.allocator); - const trimmed_name = std.mem.trim(u8, self.name_input.items, " \t\r\n"); - const world_name = if (trimmed_name.len > 0) trimmed_name else "New World"; - log.log.info("World seed: {} | Type: {s} | Name: {s}", .{ seed, registry.getGeneratorInfo(self.selected_generator_index).name, world_name }); - saveNewWorld(ctx.allocator, seed, self.selected_generator_index, world_name) catch |err| log.log.warn("Failed to save level.dat for new world: {}", .{err}); - const world_screen = try WorldScreen.init(ctx.allocator, ctx, seed, self.selected_generator_index); - errdefer world_screen.deinit(world_screen); - ctx.screen_manager.setScreen(world_screen.screen()); + } + + fn confirmStep(self: *@This()) !void { + switch (wizard.confirmAction(self.step)) { + .show => |step| self.step = step, + .create_world => try self.finishCreation(), } } + fn finishCreation(self: *@This()) !void { + const ctx = self.context; + const seed = try seed_gen.resolveSeed(&self.seed_input, ctx.allocator); + const world_name = wizard.displayWorldName(self.name_input.items); + const generator = registry.getGeneratorInfo(self.selected_generator_index); + log.log.info("World seed: {} | Type: {s} | Name: {s}", .{ seed, generator.name, world_name }); + world_save.saveNewWorld(ctx.allocator, seed, self.selected_generator_index, world_name) catch |err| log.log.warn("Failed to save level.dat for new world: {}", .{err}); + const world_screen = try WorldScreen.init(ctx.allocator, ctx, seed, self.selected_generator_index); + errdefer world_screen.deinit(world_screen); + ctx.screen_manager.setScreen(world_screen.screen()); + } + + pub fn onEnter(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.context.input.setMouseCapture(self.context.window_manager.window, false); + } + pub fn screen(self: *@This()) IScreen { return Screen.makeScreen(@This(), self); } }; -fn drawWorldSummary(ui: *UISystem, rect: Rect, selected_generator_index: usize, seed: []const u8, scale: f32) void { - const g_info = registry.getGeneratorInfo(selected_generator_index); - ui.drawRect(rect, Color.rgba(0.035, 0.145, 0.185, 0.98)); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = rect.width, .height = 8.0 * scale }, Theme.signal); - Font.drawText(ui, "READY TO CREATE", rect.x + 28.0 * scale, rect.y + 30.0 * scale, 0.78 * scale, Color.rgba(0.72, 0.92, 1.0, 1.0)); - Font.drawText(ui, g_info.name, rect.x + 28.0 * scale, rect.y + 68.0 * scale, 1.82 * scale, Theme.title); - Font.drawText(ui, g_info.description, rect.x + 28.0 * scale, rect.y + 110.0 * scale, 0.86 * scale, Theme.text); - - const divider_y = rect.y + 156.0 * scale; - ui.drawRect(.{ .x = rect.x + 28.0 * scale, .y = divider_y, .width = rect.width - 56.0 * scale, .height = 1.0 * scale }, Color.rgba(0.55, 0.82, 0.90, 0.42)); - drawSummaryFact(ui, rect.x + 28.0 * scale, divider_y + 28.0 * scale, "SEED", if (seed.len == 0) "Random on launch" else "Custom seed", scale); - drawSummaryFact(ui, rect.x + 28.0 * scale, divider_y + 90.0 * scale, "SAVED TO", "My Worlds", scale); - drawSummaryFact(ui, rect.x + 28.0 * scale, divider_y + 152.0 * scale, "ACCESS", "Offline / local", scale); - - const note_y = rect.y + rect.height - 62.0 * scale; - Font.drawText(ui, "PRESS ENTER TO CREATE", rect.x + 28.0 * scale, note_y, 0.74 * scale, Color.rgba(0.72, 0.92, 1.0, 1.0)); +fn stepSubtitle(step: wizard.Step) []const u8 { + return switch (step) { + .details => "Name the world and choose how its seed is generated.", + .terrain => "Choose the terrain generator that fits this world.", + .review => "Review the choices below before generating the world.", + }; } -fn drawSummaryFact(ui: *UISystem, x: f32, y: f32, label: []const u8, value: []const u8, scale: f32) void { - Font.drawText(ui, label, x, y, 0.68 * scale, Color.rgba(0.58, 0.80, 0.88, 1.0)); - Font.drawText(ui, value, x, y + 22.0 * scale, 1.04 * scale, Theme.title); +fn stepIndex(step: wizard.Step) usize { + return switch (step) { + .details => 0, + .terrain => 1, + .review => 2, + }; } -fn drawCreateSteps(ui: *UISystem, rect: Rect, scale: f32) void { - ui.drawRect(rect, Color.rgba(0.024, 0.038, 0.052, 0.92)); - const labels = [_][]const u8{ "DETAILS", "TERRAIN", "REVIEW" }; - for (labels, 0..) |label, i| { - const y = rect.y + 30.0 * scale + @as(f32, @floatFromInt(i)) * 82.0 * scale; - var number_buf: [4]u8 = undefined; - const number = std.fmt.bufPrint(&number_buf, "0{}", .{i + 1}) catch ""; - Font.drawText(ui, number, rect.x + 22.0 * scale, y, 1.38 * scale, if (i == 0) Theme.signal else Theme.dim); - Font.drawText(ui, label, rect.x + 68.0 * scale, y + 5.0 * scale, 0.82 * scale, if (i == 0) Theme.title else Theme.muted); - if (i == 0) ui.drawRect(.{ .x = rect.x, .y = y - 8.0 * scale, .width = 5.0 * scale, .height = 42.0 * scale }, Theme.signal); +fn drawProgress(ui: *UISystem, rect: Rect, active_step: wizard.Step, scale: f32) void { + const labels = [_][]const u8{ "01 DETAILS", "02 TERRAIN", "03 REVIEW" }; + const gap = 8.0 * scale; + const segment_w = (rect.width - gap * 2.0) / 3.0; + const active_index = stepIndex(active_step); + for (labels, 0..) |label, index| { + const segment = Rect{ .x = rect.x + @as(f32, @floatFromInt(index)) * (segment_w + gap), .y = rect.y, .width = segment_w, .height = rect.height }; + const completed = index < active_index; + const active = index == active_index; + ui.drawRect(segment, if (active) Color.rgba(Theme.signal.r, Theme.signal.g, Theme.signal.b, 0.16) else Color.rgba(0, 0, 0, 0.16)); + ui.drawRect(.{ .x = segment.x, .y = segment.y + segment.height - 2.0 * scale, .width = segment.width, .height = 2.0 * scale }, if (active or completed) Theme.signal else Color.rgba(Theme.signal.r, Theme.signal.g, Theme.signal.b, 0.16)); + Font.drawTextCenteredFit(ui, label, segment.x + segment.width * 0.5, segment.y + 19.0 * scale, 0.88 * scale, segment.width - 16.0 * scale, if (active) Theme.title else if (completed) Theme.signal else Theme.muted); } } -fn drawTerrainTile(ui: *UISystem, rect: Rect, label: []const u8, description: []const u8, index: usize, selected: bool, scale: f32) void { - const accents = [_]Color{ Theme.signal, Color.rgba(0.42, 0.78, 0.56, 1.0), Color.rgba(0.88, 0.52, 0.38, 1.0), Color.rgba(0.62, 0.54, 0.94, 1.0) }; - const accent = accents[index % accents.len]; - ui.drawRect(rect, if (selected) Color.rgba(accent.r * 0.24, accent.g * 0.24, accent.b * 0.24, 1.0) else Color.rgba(0.025, 0.044, 0.060, 1.0)); - ui.drawRect(.{ .x = rect.x, .y = rect.y, .width = 8.0 * scale, .height = rect.height }, accent); - Font.drawText(ui, label, rect.x + 22.0 * scale, rect.y + 17.0 * scale, 1.08 * scale, Theme.title); - Font.drawText(ui, description, rect.x + 22.0 * scale, rect.y + 48.0 * scale, 0.68 * scale, Theme.muted); - if (selected) Font.drawText(ui, "SELECTED", rect.x + rect.width - 82.0 * scale, rect.y + 18.0 * scale, 0.62 * scale, accent); +fn drawTerrainRow(ui: *UISystem, rect: Rect, info: anytype, selected: bool, hovered: bool, scale: f32) void { + Theme.drawOptionRow(ui, rect, info.name, info.description, 1.12 * scale, selected or hovered, scale); + if (selected) { + const label = "SELECTED"; + const label_w = Font.measureTextWidth(label, 0.86 * scale); + Font.drawText(ui, label, rect.x + rect.width - label_w - 16.0 * scale, rect.y + 17.0 * scale, 0.86 * scale, Theme.signal); + } } -fn saveNewWorld(allocator: std.mem.Allocator, seed: u64, generator_index: usize, world_name: []const u8) !void { - const home = getenv("HOME") orelse { - log.log.warn("Cannot save world: HOME not set", .{}); - return error.NoHome; - }; - var home_dir = fs.openDirAbsolute(home, .{}) catch |err| { - log.log.warn("Cannot save world: failed to open home dir: {}", .{err}); - return err; - }; - defer home_dir.close(); - home_dir.makePath(world_list.SAVE_DIR) catch |err| { - log.log.warn("Cannot save world: failed to create saves dir: {}", .{err}); - return err; - }; - var dir_name_buf: [128]u8 = undefined; - const timestamp = std.Io.Clock.real.now(std.Options.debug_io).toMilliseconds(); - const dir_name = std.fmt.bufPrint(&dir_name_buf, "world_{}", .{timestamp}) catch "world_new"; - const world_dir_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ world_list.SAVE_DIR, dir_name }); - defer allocator.free(world_dir_path); - home_dir.makePath(world_dir_path) catch |err| { - log.log.warn("Cannot save world: failed to create world dir: {}", .{err}); - return err; - }; - var save_dir = home_dir.openDir(world_dir_path, .{}) catch |err| { - log.log.warn("Cannot save world: failed to open world dir: {}", .{err}); - return err; - }; - defer save_dir.close(); - world_list.writeLevelDat(allocator, save_dir, world_name, seed, generator_index, timestamp) catch |err| { - log.log.warn("Cannot save world: failed to write level.dat: {}", .{err}); - return err; - }; +fn drawReviewFact(ui: *UISystem, x: f32, y: f32, label: []const u8, value: []const u8, scale: f32) void { + Font.drawText(ui, label, x, y, 0.90 * scale, Theme.signal); + Font.drawText(ui, value, x, y + 24.0 * scale, 1.30 * scale, Theme.title); } diff --git a/modules/game-ui/src/screens/singleplayer_wizard.zig b/modules/game-ui/src/screens/singleplayer_wizard.zig new file mode 100644 index 00000000..c13add06 --- /dev/null +++ b/modules/game-ui/src/screens/singleplayer_wizard.zig @@ -0,0 +1,112 @@ +const std = @import("std"); + +pub const Step = enum { + details, + terrain, + review, +}; + +pub const DetailsField = enum { + name, + seed, +}; + +pub const Direction = enum { + left, + right, + up, + down, +}; + +pub const ConfirmAction = union(enum) { + show: Step, + create_world, +}; + +pub const BackAction = union(enum) { + show: Step, + exit_wizard, +}; + +pub fn confirmAction(step: Step) ConfirmAction { + return switch (step) { + .details => .{ .show = .terrain }, + .terrain => .{ .show = .review }, + .review => .create_world, + }; +} + +pub fn backAction(step: Step) BackAction { + return switch (step) { + .details => .exit_wizard, + .terrain => .{ .show = .details }, + .review => .{ .show = .terrain }, + }; +} + +pub fn nextField(field: DetailsField) DetailsField { + return switch (field) { + .name => .seed, + .seed => .name, + }; +} + +pub fn nextGenerator(index: usize, count: usize) usize { + if (count == 0) return 0; + return (index + 1) % count; +} + +pub fn moveGenerator(index: usize, count: usize, columns: usize, direction: Direction) usize { + if (count == 0) return 0; + const safe_index = @min(index, count - 1); + const safe_columns = @max(columns, 1); + const row = safe_index / safe_columns; + const column = safe_index % safe_columns; + return switch (direction) { + .left => if (column == 0) safe_index else safe_index - 1, + .right => if (column + 1 >= safe_columns or safe_index + 1 >= count) safe_index else safe_index + 1, + .up => if (row == 0) safe_index else safe_index - safe_columns, + .down => blk: { + const candidate = safe_index + safe_columns; + if (candidate < count) break :blk candidate; + const final_row_start = ((count - 1) / safe_columns) * safe_columns; + if (final_row_start > safe_index) break :blk @min(final_row_start + column, count - 1); + break :blk safe_index; + }, + }; +} + +pub fn displayWorldName(name: []const u8) []const u8 { + const trimmed = std.mem.trim(u8, name, " \t\r\n"); + return if (trimmed.len > 0) trimmed else "New World"; +} + +test "wizard confirm advances and creates only from review" { + try std.testing.expectEqual(ConfirmAction{ .show = .terrain }, confirmAction(.details)); + try std.testing.expectEqual(ConfirmAction{ .show = .review }, confirmAction(.terrain)); + try std.testing.expectEqual(ConfirmAction.create_world, confirmAction(.review)); +} + +test "wizard back retreats before exiting" { + try std.testing.expectEqual(BackAction.exit_wizard, backAction(.details)); + try std.testing.expectEqual(BackAction{ .show = .details }, backAction(.terrain)); + try std.testing.expectEqual(BackAction{ .show = .terrain }, backAction(.review)); +} + +test "wizard details field toggles" { + try std.testing.expectEqual(DetailsField.seed, nextField(.name)); + try std.testing.expectEqual(DetailsField.name, nextField(.seed)); +} + +test "wizard terrain navigation handles partial rows" { + try std.testing.expectEqual(@as(usize, 1), moveGenerator(0, 4, 2, .right)); + try std.testing.expectEqual(@as(usize, 0), moveGenerator(0, 4, 2, .left)); + try std.testing.expectEqual(@as(usize, 3), moveGenerator(1, 4, 2, .down)); + try std.testing.expectEqual(@as(usize, 4), moveGenerator(2, 5, 2, .down)); + try std.testing.expectEqual(@as(usize, 0), moveGenerator(0, 0, 2, .down)); +} + +test "wizard display name trims input and supplies a default" { + try std.testing.expectEqualStrings("My World", displayWorldName(" My World ")); + try std.testing.expectEqualStrings("New World", displayWorldName(" \t")); +} diff --git a/modules/game-ui/src/screens/world.zig b/modules/game-ui/src/screens/world.zig index e5521398..b4fc8a1b 100644 --- a/modules/game-ui/src/screens/world.zig +++ b/modules/game-ui/src/screens/world.zig @@ -12,6 +12,8 @@ const Camera = @import("engine-camera").Camera; const RenderSystem = @import("engine-graphics").RenderSystem; const render_graph_pkg = @import("engine-graphics").render_graph; const PausedScreen = @import("paused.zig").PausedScreen; +const RmlPausedScreen = @import("rml_paused.zig").RmlPausedScreen; +const rmlui = @import("engine-ui").rmlui; const DebugShadowOverlay = @import("engine-ui").DebugShadowOverlay; const DebugLPVOverlay = @import("engine-ui").DebugLPVOverlay; const DebugUI = @import("engine-ui").DebugUI; @@ -200,9 +202,15 @@ pub const WorldScreen = struct { } if (ctx.input_mapper.isActionPressed(ctx.input, .ui_back)) { - const paused_screen = try PausedScreen.init(ctx.allocator, self.parent_context); - errdefer paused_screen.deinit(paused_screen); - ctx.screen_manager.pushScreen(paused_screen.screen()); + if (rmlui.available and ctx.ui_manager.getRmlUi() != null) { + const paused_screen = try RmlPausedScreen.init(ctx.allocator, self.parent_context); + errdefer paused_screen.deinit(paused_screen); + ctx.screen_manager.pushScreen(paused_screen.screen()); + } else { + const paused_screen = try PausedScreen.init(ctx.allocator, self.parent_context); + errdefer paused_screen.deinit(paused_screen); + ctx.screen_manager.pushScreen(paused_screen.screen()); + } return true; } @@ -380,15 +388,6 @@ pub const WorldScreen = struct { const shadow_sun_dir = if (shadow_sandbox_active) self.resolveStableShadowSunDir(render_sun_dir) else render_sun_dir; if (!shadow_sandbox_active) self.stable_shadow_sun_initialized = false; - if (self.save_failure_warning_count > 0) { - var save_warning_buf: [96]u8 = undefined; - const save_warning = std.fmt.bufPrint(&save_warning_buf, "SAVE WARNING: {} save failure(s). Check logs.", .{self.save_failure_warning_count}) catch "SAVE WARNING: save failures. Check logs."; - const warning_rect = Rect{ .x = 14.0 * ctx.settings.ui_scale, .y = 14.0 * ctx.settings.ui_scale, .width = 360.0 * ctx.settings.ui_scale, .height = 34.0 * ctx.settings.ui_scale }; - ui.drawRect(warning_rect, Color.rgba(0.18, 0.04, 0.05, 0.88)); - ui.drawRectOutline(warning_rect, Color.rgba(0.78, 0.30, 0.34, 1.0), 1.0 * ctx.settings.ui_scale); - Font.drawText(ui, save_warning, warning_rect.x + 10.0 * ctx.settings.ui_scale, warning_rect.y + 10.0 * ctx.settings.ui_scale, 0.78 * ctx.settings.ui_scale, Color.rgba(1.0, 0.90, 0.84, 1.0)); - } - const lpv_quality = resolveLPVQuality(ctx.settings.lpv_quality_preset); const lpv_system = render_system.getLPVSystem(); try lpv_system.setSettings( @@ -516,6 +515,15 @@ pub const WorldScreen = struct { ui.begin(); defer ui.end(); + if (self.save_failure_warning_count > 0) { + var save_warning_buf: [96]u8 = undefined; + const save_warning = std.fmt.bufPrint(&save_warning_buf, "SAVE WARNING: {} save failure(s). Check logs.", .{self.save_failure_warning_count}) catch "SAVE WARNING: save failures. Check logs."; + const warning_rect = Rect{ .x = 14.0 * ctx.settings.ui_scale, .y = 14.0 * ctx.settings.ui_scale, .width = 360.0 * ctx.settings.ui_scale, .height = 34.0 * ctx.settings.ui_scale }; + ui.drawRect(warning_rect, Color.rgba(0.18, 0.04, 0.05, 0.88)); + ui.drawRectOutline(warning_rect, Color.rgba(0.78, 0.30, 0.34, 1.0), 1.0 * ctx.settings.ui_scale); + Font.drawText(ui, save_warning, warning_rect.x + 10.0 * ctx.settings.ui_scale, warning_rect.y + 10.0 * ctx.settings.ui_scale, 0.78 * ctx.settings.ui_scale, Color.rgba(1.0, 0.90, 0.84, 1.0)); + } + const mouse_pos = ctx.input.getMousePosition(); const mouse_x: f32 = @floatFromInt(mouse_pos.x); const mouse_y: f32 = @floatFromInt(mouse_pos.y); diff --git a/modules/game-ui/src/screens/world_list.zig b/modules/game-ui/src/screens/world_list.zig index ae278b5e..b140d987 100644 --- a/modules/game-ui/src/screens/world_list.zig +++ b/modules/game-ui/src/screens/world_list.zig @@ -11,15 +11,17 @@ const log = @import("engine-core").log; const fs = @import("fs"); const text_input = @import("game-core").text_input; const registry = @import("world-worldgen").registry; +const world_save = @import("world_save.zig"); +const SingleplayerScreen = @import("singleplayer.zig").SingleplayerScreen; fn getenv(name: [:0]const u8) ?[]const u8 { const value = std.c.getenv(name) orelse return null; return std.mem.span(value); } -const PANEL_WIDTH_MAX = 900.0; -const PANEL_HEIGHT_BASE = 640.0; -pub const SAVE_DIR = ".local/share/zigcraft/saves"; +const PANEL_WIDTH_MAX = 1120.0; +const PANEL_HEIGHT_BASE = 720.0; +pub const SAVE_DIR = world_save.SAVE_DIR; pub const WorldEntry = struct { name: []const u8, @@ -36,21 +38,7 @@ pub const LevelDat = struct { generator_index: usize, }; -pub fn writeLevelDat(allocator: std.mem.Allocator, save_dir: fs.Dir, name: []const u8, seed: u64, generator_index: usize, last_played: i64) !void { - const generator_id = if (generator_index < registry.getGeneratorCount()) registry.getGeneratorId(generator_index) else registry.getGeneratorId(0); - const payload = .{ - .name = name, - .seed = seed, - .last_played = last_played, - .generator_index = generator_index, - .generator_id = generator_id, - }; - const json_str = try std.json.Stringify.valueAlloc(allocator, payload, .{ .whitespace = .indent_2 }); - defer allocator.free(json_str); - const file = try save_dir.createFile("level.dat", .{}); - defer file.close(); - try file.writeAll(json_str); -} +pub const writeLevelDat = world_save.writeLevelDat; pub fn readLevelDat(allocator: std.mem.Allocator, save_dir: fs.Dir) ?LevelDat { const content = save_dir.readFileAlloc("level.dat", allocator, 4096) catch return null; @@ -70,7 +58,7 @@ pub fn readLevelDat(allocator: std.mem.Allocator, save_dir: fs.Dir) ?LevelDat { else => return null, }; const seed: u64 = switch (seed_val) { - .integer => |i| @intCast(i), + .integer => |i| if (i >= 0) @intCast(i) else return null, else => return null, }; const last_played: i64 = if (last_val) |lv| switch (lv) { @@ -78,7 +66,7 @@ pub fn readLevelDat(allocator: std.mem.Allocator, save_dir: fs.Dir) ?LevelDat { else => 0, } else 0; var generator_index: usize = switch (gen_val) { - .integer => |i| @intCast(i), + .integer => |i| if (i >= 0 and i < registry.getGeneratorCount()) @intCast(i) else 0, else => 0, }; const generator_id_source = if (gen_id_val) |giv| switch (giv) { @@ -194,7 +182,7 @@ pub const WorldListScreen = struct { self.* = .{ .context = context, .worlds = worlds, - .selected = null, + .selected = if (worlds.len > 0) 0 else null, .scroll_offset = 0.0, .confirm_delete = false, .confirm_clear_all = false, @@ -243,12 +231,15 @@ pub const WorldListScreen = struct { if (self.context.input_mapper.isActionPressed(input, .ui_confirm)) { if (self.selected) |idx| try self.loadWorld(idx); } + } else if (!self.confirm_delete and !self.confirm_clear_all and !self.confirm_rename and self.context.input_mapper.isActionPressed(self.context.input, .ui_confirm)) { + try self.openCreateWizard(); } } pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); const ctx = self.context; + try ctx.screen_manager.drawBackgroundFor(ptr, ui); ui.begin(); defer ui.end(); @@ -268,13 +259,13 @@ pub const WorldListScreen = struct { const ph: f32 = @min(screen_h - margin * 2.0, PANEL_HEIGHT_BASE * ui_scale); const px: f32 = (screen_w - pw) * 0.5; const py: f32 = (screen_h - ph) * 0.5; - const shell = Theme.drawShell(ui, .{ .x = px, .y = py, .width = pw, .height = ph }, ui_scale, "PLAY", "YOUR WORLDS", "Select a save to see details and available actions."); + const shell = Theme.drawShell(ui, .{ .x = px, .y = py, .width = pw, .height = ph }, ui_scale, "PLAY", "WORLD LIBRARY", "Choose an existing world or create something new."); if (self.error_message) |message| { const banner = Rect{ .x = shell.content.x + 12.0 * ui_scale, .y = shell.content.y + 8.0 * ui_scale, .width = shell.content.width - 24.0 * ui_scale, .height = 34.0 * ui_scale }; ui.drawRect(banner, Theme.Color.rgba(0.18, 0.04, 0.05, 0.92)); ui.drawRectOutline(banner, Theme.danger, 1.0 * ui_scale); - Font.drawText(ui, message, banner.x + 12.0 * ui_scale, banner.y + 9.0 * ui_scale, 0.72 * ui_scale, Theme.text); + Font.drawText(ui, message, banner.x + 12.0 * ui_scale, banner.y + 9.0 * ui_scale, 0.90 * ui_scale, Theme.text); } var count_buf: [64]u8 = undefined; @@ -287,7 +278,7 @@ pub const WorldListScreen = struct { const list_rect = Rect{ .x = shell.content.x, .y = shell.content.y, - .width = if (detail_layout) shell.content.width * 0.62 else shell.content.width, + .width = if (detail_layout) shell.content.width * 0.58 else shell.content.width, .height = shell.content.height, }; const detail_rect = Rect{ @@ -309,8 +300,8 @@ pub const WorldListScreen = struct { Theme.drawScrollbar(ui, list_rect.x + list_rect.width - 12.0 * ui_scale, list_top + 12.0 * ui_scale, list_rect.height - 24.0 * ui_scale, @as(f32, @floatFromInt(self.worlds.len)) * row_h, list_rect.height, self.scroll_offset, max_scroll, ui_scale); if (self.worlds.len == 0) { - Font.drawTextCentered(ui, "NO SAVED WORLDS FOUND", screen_w * 0.5, list_top + (list_bottom - list_top) * 0.40, 1.35 * ui_scale, Theme.title); - Font.drawTextCentered(ui, "CREATE A NEW WORLD FIRST", screen_w * 0.5, list_top + (list_bottom - list_top) * 0.40 + 28.0 * ui_scale, 0.82 * ui_scale, Theme.muted); + Font.drawTextCentered(ui, "NO WORLDS YET", list_rect.x + list_rect.width * 0.5, list_top + (list_bottom - list_top) * 0.40, 1.35 * ui_scale, Theme.title); + Font.drawTextCentered(ui, "PRESS ENTER OR CHOOSE NEW WORLD", list_rect.x + list_rect.width * 0.5, list_top + (list_bottom - list_top) * 0.40 + 32.0 * ui_scale, 0.92 * ui_scale, Theme.muted); } var i: usize = 0; @@ -330,9 +321,6 @@ pub const WorldListScreen = struct { var desc_buf: [128]u8 = undefined; const description = std.fmt.bufPrint(&desc_buf, "{s} // LAST PLAYED: {s}", .{ seed_text, last_text }) catch seed_text; Theme.drawOptionRow(ui, row_rect, world.name, description, 1.28 * ui_scale, is_selected or row_hovered, ui_scale); - if (is_selected and !detail_layout) { - Theme.drawValueText(ui, .{ .x = row_rect.x + row_rect.width - 136.0 * ui_scale, .y = row_rect.y + 12.0 * ui_scale, .width = 118.0 * ui_scale, .height = 36.0 * ui_scale }, "SELECTED", 0.72 * ui_scale, ui_scale); - } } if (detail_layout) drawWorldDetails(ui, detail_rect, self, ui_scale); @@ -340,22 +328,31 @@ pub const WorldListScreen = struct { const btn_h: f32 = 46.0 * ui_scale; const byy: f32 = shell.footer_y; const btn_gap: f32 = 10.0 * ui_scale; - const btn_w: f32 = (shell.content.width - 4.0 * btn_gap) / 5.0; const bx_base: f32 = shell.content.x; - const btn_scale: f32 = 0.98 * ui_scale; + const btn_scale: f32 = 1.04 * ui_scale; + const footer_button_w = 150.0 * ui_scale; - if (Theme.drawButton(ui, .{ .x = bx_base, .y = byy, .width = if (detail_layout) 150.0 * ui_scale else btn_w, .height = btn_h }, "BACK", btn_scale, mx, my, mc, .ghost, ui_scale)) { + if (Theme.drawButton(ui, .{ .x = bx_base, .y = byy, .width = footer_button_w, .height = btn_h }, "BACK", btn_scale, mx, my, mc, .ghost, ui_scale)) { ctx.screen_manager.popScreen(); } + const clear_enabled = self.worlds.len > 0 and !modal_open; + const clear_rect = Rect{ .x = bx_base + footer_button_w + btn_gap, .y = byy, .width = footer_button_w, .height = btn_h }; + if (detail_layout and Theme.drawButton(ui, clear_rect, "CLEAR ALL", btn_scale, mx, my, mc, if (clear_enabled) .danger else .disabled, ui_scale)) self.confirm_clear_all = true; + + const new_world_w = 180.0 * ui_scale; + const new_world_rect = Rect{ .x = shell.content.x + shell.content.width - new_world_w, .y = byy, .width = new_world_w, .height = btn_h }; + if (Theme.drawButton(ui, new_world_rect, "NEW WORLD", btn_scale, mx, my, mc, if (modal_open) .disabled else .primary, ui_scale)) try self.openCreateWizard(); + const load_enabled = self.selected != null and !modal_open; - const load_rect = if (detail_layout) Rect{ .x = detail_rect.x + 18.0 * ui_scale, .y = detail_rect.y + detail_rect.height - 168.0 * ui_scale, .width = detail_rect.width - 36.0 * ui_scale, .height = 44.0 * ui_scale } else Rect{ .x = bx_base + btn_w + btn_gap, .y = byy, .width = btn_w, .height = btn_h }; + const compact_action_w = (shell.content.width - new_world_w - footer_button_w - btn_gap * 4.0) / 3.0; + const load_rect = if (detail_layout) Rect{ .x = detail_rect.x + 18.0 * ui_scale, .y = detail_rect.y + detail_rect.height - 168.0 * ui_scale, .width = detail_rect.width - 36.0 * ui_scale, .height = 44.0 * ui_scale } else Rect{ .x = bx_base + footer_button_w + btn_gap, .y = byy, .width = compact_action_w, .height = btn_h }; if (Theme.drawButton(ui, load_rect, "PLAY WORLD", btn_scale, mx, my, mc, if (load_enabled) .primary else .disabled, ui_scale)) { if (self.selected) |idx| try self.loadWorld(idx); } const rename_enabled = self.selected != null and !modal_open; - const rename_rect = if (detail_layout) Rect{ .x = detail_rect.x + 18.0 * ui_scale, .y = detail_rect.y + detail_rect.height - 114.0 * ui_scale, .width = (detail_rect.width - 46.0 * ui_scale) * 0.5, .height = 42.0 * ui_scale } else Rect{ .x = bx_base + 2.0 * (btn_w + btn_gap), .y = byy, .width = btn_w, .height = btn_h }; + const rename_rect = if (detail_layout) Rect{ .x = detail_rect.x + 18.0 * ui_scale, .y = detail_rect.y + detail_rect.height - 114.0 * ui_scale, .width = (detail_rect.width - 46.0 * ui_scale) * 0.5, .height = 42.0 * ui_scale } else Rect{ .x = load_rect.x + compact_action_w + btn_gap, .y = byy, .width = compact_action_w, .height = btn_h }; if (Theme.drawButton(ui, rename_rect, "RENAME", btn_scale, mx, my, mc, if (rename_enabled) .secondary else .disabled, ui_scale)) { if (self.selected) |idx| { self.rename_buffer.clearRetainingCapacity(); @@ -366,27 +363,21 @@ pub const WorldListScreen = struct { } const del_enabled = self.selected != null and !modal_open; - const delete_rect = if (detail_layout) Rect{ .x = rename_rect.x + rename_rect.width + 10.0 * ui_scale, .y = rename_rect.y, .width = rename_rect.width, .height = rename_rect.height } else Rect{ .x = bx_base + 3.0 * (btn_w + btn_gap), .y = byy, .width = btn_w, .height = btn_h }; + const delete_rect = if (detail_layout) Rect{ .x = rename_rect.x + rename_rect.width + 10.0 * ui_scale, .y = rename_rect.y, .width = rename_rect.width, .height = rename_rect.height } else Rect{ .x = rename_rect.x + compact_action_w + btn_gap, .y = byy, .width = compact_action_w, .height = btn_h }; if (Theme.drawButton(ui, delete_rect, "DELETE", btn_scale, mx, my, mc, if (del_enabled) .danger else .disabled, ui_scale)) { self.confirm_delete = true; } - const clear_enabled = self.worlds.len > 0 and !modal_open; - const clear_rect = if (detail_layout) Rect{ .x = shell.content.x + shell.content.width - 150.0 * ui_scale, .y = byy, .width = 150.0 * ui_scale, .height = btn_h } else Rect{ .x = bx_base + 4.0 * (btn_w + btn_gap), .y = byy, .width = btn_w, .height = btn_h }; - if (Theme.drawButton(ui, clear_rect, "CLEAR ALL", btn_scale, mx, my, mc, if (clear_enabled) .danger else .disabled, ui_scale)) { - self.confirm_clear_all = true; - } - if (self.confirm_delete) { if (self.selected) |idx| { - const cw: f32 = 420.0 * ui_scale; - const ch: f32 = 176.0 * ui_scale; + const cw: f32 = 460.0 * ui_scale; + const ch: f32 = 220.0 * ui_scale; const cx: f32 = (screen_w - cw) * 0.5; const cy: f32 = (screen_h - ch) * 0.5; Theme.drawModal(ui, screen_w, screen_h, .{ .x = cx, .y = cy, .width = cw, .height = ch }, ui_scale, "DELETE WORLD?", "This cannot be undone.", true); var name_buf: [128]u8 = undefined; const confirm_msg = std.fmt.bufPrint(&name_buf, "'{s}'", .{self.worlds[idx].name}) catch "'?'"; - Font.drawTextCentered(ui, confirm_msg, cx + cw * 0.5, cy + 86.0 * ui_scale, 1.05 * ui_scale, Theme.text); + Font.drawTextCentered(ui, confirm_msg, cx + cw * 0.5, cy + 104.0 * ui_scale, 1.12 * ui_scale, Theme.text); const cbw: f32 = (cw - 30.0 * ui_scale) / 2.0; const cby: f32 = cy + ch - 55.0 * ui_scale; if (Theme.drawButton(ui, .{ .x = cx + 10.0 * ui_scale, .y = cby, .width = cbw, .height = 40.0 * ui_scale }, "CANCEL", btn_scale, mx, my, mc, .ghost, ui_scale)) { @@ -402,14 +393,14 @@ pub const WorldListScreen = struct { } if (self.confirm_clear_all) { - const cw: f32 = 460.0 * ui_scale; - const ch: f32 = 184.0 * ui_scale; + const cw: f32 = 480.0 * ui_scale; + const ch: f32 = 220.0 * ui_scale; const cx: f32 = (screen_w - cw) * 0.5; const cy: f32 = (screen_h - ch) * 0.5; Theme.drawModal(ui, screen_w, screen_h, .{ .x = cx, .y = cy, .width = cw, .height = ch }, ui_scale, "CLEAR ALL WORLDS?", "Every saved world will be removed.", true); var count_buf2: [64]u8 = undefined; const count_msg = std.fmt.bufPrint(&count_buf2, "THIS WILL DELETE {} WORLDS", .{self.worlds.len}) catch "?"; - Font.drawTextCentered(ui, count_msg, cx + cw * 0.5, cy + 88.0 * ui_scale, 0.96 * ui_scale, Theme.text); + Font.drawTextCentered(ui, count_msg, cx + cw * 0.5, cy + 104.0 * ui_scale, 1.04 * ui_scale, Theme.text); const cbw: f32 = (cw - 30.0 * ui_scale) / 2.0; const cby: f32 = cy + ch - 55.0 * ui_scale; if (Theme.drawButton(ui, .{ .x = cx + 10.0 * ui_scale, .y = cby, .width = cbw, .height = 40.0 * ui_scale }, "CANCEL", btn_scale, mx, my, mc, .ghost, ui_scale)) { @@ -425,16 +416,16 @@ pub const WorldListScreen = struct { if (self.confirm_rename) { if (self.selected) |idx| { - const cw: f32 = 460.0 * ui_scale; - const ch: f32 = 180.0 * ui_scale; + const cw: f32 = 500.0 * ui_scale; + const ch: f32 = 230.0 * ui_scale; const cx: f32 = (screen_w - cw) * 0.5; const cy: f32 = (screen_h - ch) * 0.5; Theme.drawModal(ui, screen_w, screen_h, .{ .x = cx, .y = cy, .width = cw, .height = ch }, ui_scale, "RENAME WORLD", "Update the display name in level.dat.", false); var name_buf2: [128]u8 = undefined; const current_msg = std.fmt.bufPrint(&name_buf2, "CURRENT: '{s}'", .{self.worlds[idx].name}) catch "'?'"; - Font.drawTextCentered(ui, current_msg, cx + cw * 0.5, cy + 72.0 * ui_scale, 0.76 * ui_scale, Theme.muted); - const input_h: f32 = 40.0 * ui_scale; - const input_y: f32 = cy + 92.0 * ui_scale; + Font.drawTextCentered(ui, current_msg, cx + cw * 0.5, cy + 92.0 * ui_scale, 0.92 * ui_scale, Theme.muted); + const input_h: f32 = 46.0 * ui_scale; + const input_y: f32 = cy + 116.0 * ui_scale; const input_rect = Rect{ .x = cx + 20.0 * ui_scale, .y = input_y, .width = cw - 40.0 * ui_scale, .height = input_h }; const cursor_visible = @as(u32, @truncate(@as(u64, @intFromFloat(ctx.time.elapsed * 2.0)))) % 2 == 0; if (mc) { @@ -442,12 +433,12 @@ pub const WorldListScreen = struct { } Theme.drawTextInput(ui, input_rect, self.rename_buffer.items, "ENTER NEW NAME", 1.18 * ui_scale, self.rename_focused, cursor_visible, ui_scale); const cbw: f32 = (cw - 30.0 * ui_scale) / 2.0; - const cby: f32 = cy + ch - 45.0 * ui_scale; - if (Theme.drawButton(ui, .{ .x = cx + 10.0 * ui_scale, .y = cby, .width = cbw, .height = 38.0 * ui_scale }, "CANCEL", btn_scale, mx, my, mc, .ghost, ui_scale)) { + const cby: f32 = cy + ch - 52.0 * ui_scale; + if (Theme.drawButton(ui, .{ .x = cx + 10.0 * ui_scale, .y = cby, .width = cbw, .height = 42.0 * ui_scale }, "CANCEL", btn_scale, mx, my, mc, .ghost, ui_scale)) { self.confirm_rename = false; self.rename_buffer.clearRetainingCapacity(); } - if (Theme.drawButton(ui, .{ .x = cx + cbw + 20.0 * ui_scale, .y = cby, .width = cbw, .height = 38.0 * ui_scale }, "OK", btn_scale, mx, my, mc, .primary, ui_scale)) { + if (Theme.drawButton(ui, .{ .x = cx + cbw + 20.0 * ui_scale, .y = cby, .width = cbw, .height = 42.0 * ui_scale }, "OK", btn_scale, mx, my, mc, .primary, ui_scale)) { self.renameWorld(idx) catch |err| { log.log.err("Failed to rename world '{s}': {}", .{ self.worlds[idx].name, err }); self.error_message = "Failed to rename world. Check logs."; @@ -466,6 +457,12 @@ pub const WorldListScreen = struct { return Screen.makeScreen(@This(), self); } + fn openCreateWizard(self: *@This()) !void { + const create_screen = try SingleplayerScreen.init(self.context.allocator, self.context); + errdefer create_screen.deinit(create_screen); + self.context.screen_manager.pushScreen(create_screen.screen()); + } + fn loadWorld(self: *@This(), idx: usize) !void { const world = self.worlds[idx]; const world_screen = try WorldScreen.init(self.context.allocator, self.context, world.seed, world.generator_index); @@ -489,7 +486,7 @@ pub const WorldListScreen = struct { allocator.free(empty_worlds); self.worlds = new_worlds; } else |_| {} - self.selected = null; + self.selected = if (self.worlds.len > 0) 0 else null; self.confirm_delete = false; self.scroll_offset = 0.0; self.error_message = null; @@ -534,19 +531,19 @@ pub const WorldListScreen = struct { fn drawWorldDetails(ui: *UISystem, rect: Rect, self: *WorldListScreen, scale: f32) void { Theme.drawListRail(ui, rect, scale); - Font.drawText(ui, "WORLD DETAILS", rect.x + 18.0 * scale, rect.y + 18.0 * scale, 0.72 * scale, Theme.signal); + Font.drawText(ui, "WORLD DETAILS", rect.x + 18.0 * scale, rect.y + 18.0 * scale, 0.90 * scale, Theme.signal); ui.drawRect(.{ .x = rect.x + 18.0 * scale, .y = rect.y + 44.0 * scale, .width = rect.width - 36.0 * scale, .height = 1.0 * scale }, Theme.outline); if (self.selected) |idx| { const world = self.worlds[idx]; Font.drawText(ui, world.name, rect.x + 18.0 * scale, rect.y + 64.0 * scale, 1.36 * scale, Theme.title); - Font.drawText(ui, registry.getGeneratorInfo(world.generator_index).name, rect.x + 18.0 * scale, rect.y + 100.0 * scale, 0.82 * scale, Theme.text); + Font.drawText(ui, registry.getGeneratorInfo(world.generator_index).name, rect.x + 18.0 * scale, rect.y + 100.0 * scale, 1.00 * scale, Theme.text); var seed_buf: [64]u8 = undefined; const seed_text = std.fmt.bufPrint(&seed_buf, "Seed {}", .{world.seed}) catch "Seed unknown"; - Font.drawText(ui, seed_text, rect.x + 18.0 * scale, rect.y + 132.0 * scale, 0.76 * scale, Theme.muted); - Font.drawText(ui, "Use Play World to resume this save.", rect.x + 18.0 * scale, rect.y + 176.0 * scale, 0.72 * scale, Theme.muted); + Font.drawText(ui, seed_text, rect.x + 18.0 * scale, rect.y + 132.0 * scale, 0.90 * scale, Theme.muted); + Font.drawText(ui, "Use Play World to resume this save.", rect.x + 18.0 * scale, rect.y + 176.0 * scale, 0.90 * scale, Theme.muted); } else { Font.drawText(ui, "Select a world", rect.x + 18.0 * scale, rect.y + 68.0 * scale, 1.10 * scale, Theme.title); - Font.drawText(ui, "Its details and actions will appear here.", rect.x + 18.0 * scale, rect.y + 102.0 * scale, 0.70 * scale, Theme.muted); + Font.drawText(ui, "Its details and actions will appear here.", rect.x + 18.0 * scale, rect.y + 102.0 * scale, 0.90 * scale, Theme.muted); } } diff --git a/modules/game-ui/src/screens/world_list_tests.zig b/modules/game-ui/src/screens/world_list_tests.zig index 5e2f85e3..d7986a25 100644 --- a/modules/game-ui/src/screens/world_list_tests.zig +++ b/modules/game-ui/src/screens/world_list_tests.zig @@ -71,6 +71,25 @@ test "readLevelDat defaults missing last_played to zero" { try testing.expectEqual(@as(i64, 0), level.last_played); } +test "readLevelDat falls back for an out-of-range generator index" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "level.dat", .data = "{\"name\":\"Old World\",\"seed\":7,\"generator_index\":999}" }); + const level = world_list.readLevelDat(testing.allocator, tmp.dir) orelse return error.MissingLevelDat; + defer testing.allocator.free(level.name); + + try testing.expectEqual(@as(usize, 0), level.generator_index); +} + +test "readLevelDat rejects a negative seed" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "level.dat", .data = "{\"name\":\"Broken\",\"seed\":-1,\"generator_index\":0}" }); + try testing.expectEqual(@as(?world_list.LevelDat, null), world_list.readLevelDat(testing.allocator, tmp.dir)); +} + test "scanWorldsInHome returns empty for absent home" { const entries = try world_list.scanWorldsInHome(testing.allocator, "/definitely/not/a/zigcraft/home"); defer freeWorldEntries(testing.allocator, entries); diff --git a/modules/game-ui/src/screens/world_save.zig b/modules/game-ui/src/screens/world_save.zig new file mode 100644 index 00000000..831157f9 --- /dev/null +++ b/modules/game-ui/src/screens/world_save.zig @@ -0,0 +1,61 @@ +const std = @import("std"); +const fs = @import("fs"); +const registry = @import("world-worldgen").registry; +const log = @import("engine-core").log; + +pub const SAVE_DIR = ".local/share/zigcraft/saves"; + +fn getenv(name: [:0]const u8) ?[]const u8 { + const value = std.c.getenv(name) orelse return null; + return std.mem.span(value); +} + +pub fn writeLevelDat(allocator: std.mem.Allocator, save_dir: fs.Dir, name: []const u8, seed: u64, generator_index: usize, last_played: i64) !void { + const generator_id = if (generator_index < registry.getGeneratorCount()) registry.getGeneratorId(generator_index) else registry.getGeneratorId(0); + const payload = .{ + .name = name, + .seed = seed, + .last_played = last_played, + .generator_index = generator_index, + .generator_id = generator_id, + }; + const json_str = try std.json.Stringify.valueAlloc(allocator, payload, .{ .whitespace = .indent_2 }); + defer allocator.free(json_str); + const file = try save_dir.createFile("level.dat", .{}); + defer file.close(); + try file.writeAll(json_str); +} + +pub fn saveNewWorld(allocator: std.mem.Allocator, seed: u64, generator_index: usize, world_name: []const u8) !void { + const home = getenv("HOME") orelse { + log.log.warn("Cannot save world: HOME not set", .{}); + return error.NoHome; + }; + var home_dir = fs.openDirAbsolute(home, .{}) catch |err| { + log.log.warn("Cannot save world: failed to open home dir: {}", .{err}); + return err; + }; + defer home_dir.close(); + home_dir.makePath(SAVE_DIR) catch |err| { + log.log.warn("Cannot save world: failed to create saves dir: {}", .{err}); + return err; + }; + var dir_name_buf: [128]u8 = undefined; + const timestamp = std.Io.Clock.real.now(std.Options.debug_io).toMilliseconds(); + const dir_name = std.fmt.bufPrint(&dir_name_buf, "world_{}", .{timestamp}) catch "world_new"; + const world_dir_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ SAVE_DIR, dir_name }); + defer allocator.free(world_dir_path); + home_dir.makePath(world_dir_path) catch |err| { + log.log.warn("Cannot save world: failed to create world dir: {}", .{err}); + return err; + }; + var save_dir = home_dir.openDir(world_dir_path, .{}) catch |err| { + log.log.warn("Cannot save world: failed to open world dir: {}", .{err}); + return err; + }; + defer save_dir.close(); + writeLevelDat(allocator, save_dir, world_name, seed, generator_index, timestamp) catch |err| { + log.log.warn("Cannot save world: failed to write level.dat: {}", .{err}); + return err; + }; +} diff --git a/scripts/compare_visual_golden.sh b/scripts/compare_visual_golden.sh index 0ebe0958..b090f3f4 100755 --- a/scripts/compare_visual_golden.sh +++ b/scripts/compare_visual_golden.sh @@ -21,6 +21,22 @@ if [[ ! -f "$golden" ]]; then exit 2 fi +require_non_black_image() { + local image=$1 + local label=$2 + local mean + mean=$(magick "$image" -colorspace RGB -format '%[fx:mean]' info:) + if awk -v value="$mean" 'BEGIN { exit !(value <= 0.0001) }'; then + printf '%s is effectively black (mean %s); refusing an invalid visual comparison\n' "$label" "$mean" >&2 + exit 1 + fi +} + +# A black baseline can make the test look healthy while proving nothing about +# menu composition. Validate both inputs before calculating their difference. +require_non_black_image "$golden" "Golden screenshot" +require_non_black_image "$actual" "Actual screenshot" + metric_output=$(magick compare -metric RMSE "$golden" "$actual" "$diff" 2>&1 || true) normalized=$(printf '%s\n' "$metric_output" | sed -n 's/.*(\([0-9.]*\)).*/\1/p') if [[ -z "$normalized" ]]; then diff --git a/src/game/app.zig b/src/game/app.zig index 8303a862..69207620 100644 --- a/src/game/app.zig +++ b/src/game/app.zig @@ -7,7 +7,7 @@ const WindowManager = @import("engine-core").WindowManager; const Input = @import("engine-input").Input; const Time = @import("engine-core").Time; const UISystemManager = @import("engine-ui").UISystemManager; -const imgui_backend = @import("engine-ui").imgui_backend; +const rmlui = @import("engine-ui").rmlui; const WorldStats = @import("engine-ui").WorldStats; const Vec3 = @import("engine-math").Vec3; const Mat4 = @import("engine-math").Mat4; @@ -27,6 +27,7 @@ const screen_pkg = @import("game-ui").screen; const ScreenManager = screen_pkg.ScreenManager; const EngineContext = screen_pkg.EngineContext; const HomeScreen = @import("game-ui").HomeScreen; +const RmlHomeScreen = @import("game-ui").RmlHomeScreen; const WorldScreen = @import("game-ui").WorldScreen; const RenderSettingsAdapter = @import("engine-rhi").RenderSettingsAdapter; const runtime_env = @import("engine-core").runtime_env; @@ -38,10 +39,6 @@ fn getenv(name: [:0]const u8) ?[]const u8 { return std.mem.span(value); } -fn processImGuiEvent(event: *const c.SDL_Event) void { - if (imgui_backend.available) imgui_backend.Backend.processEvent(event); -} - fn applySettingsToRhi(ctx: *const anyopaque, rhi: *RHI) void { const settings: *const Settings = @ptrCast(@alignCast(ctx)); @import("game-core").settings.apply_logic.applyToRHI(settings, rhi); @@ -191,7 +188,6 @@ pub const App = struct { log.log.info("App.init: initializing UISystemManager", .{}); var ui_manager = try UISystemManager.init(allocator, render_system.getRHI().uiRenderer(), render_system.getRHI().resourceManager(), render_system.getRHI(), &wm, input.window_width, input.window_height, build_options.smoke_test); errdefer ui_manager.deinit(render_system.getRHI().resourceManager()); - input.setRawEventProcessor(processImGuiEvent); const input_mapper = InputSettings.loadAndReturnMapper(allocator); @@ -231,12 +227,15 @@ pub const App = struct { .reveal_window_when_menu_ready = preload_menu_preview, }; errdefer app.screen_manager.deinit(); + // `app` is heap allocated now. Bind the raw event sink only after the + // final UISystemManager address is stable, avoiding a callback into the + // temporary manager value used during initialization. + app.input.setRawEventProcessor(.{ .context = &app.ui_manager, .process = UISystemManager.processRawEvent }); if (build_options.smoke_test or build_options.screenshot_path.len > 0 or build_options.benchmark) { app.render_system.getRHI().timing().setTimingEnabled(true); } - const engine_ctx = app.engineContext(); if (build_options.shadow_test_scene) { log.log.info("SHADOW TEST SCENE: Deferring deterministic test world launch", .{}); app.pending_world_launch = .{ .seed = 12345, .generator_index = worldgen_registry.findGeneratorIndex("test") orelse 0 }; @@ -248,8 +247,7 @@ pub const App = struct { if (runtime_env.strictSafeModeAutoEnabled()) app.direct_launch_resize_guard_frames = 240; } else { log.log.info("SCREENSHOT MODE: Loading menu for screenshot capture to '{s}'", .{build_options.screenshot_path}); - const home_screen = try HomeScreen.init(allocator, engine_ctx); - app.screen_manager.setScreen(home_screen.screen()); + app.screen_manager.setScreen(try app.createHomeScreen()); } } else if (build_options.benchmark) { log.log.info("BENCHMARK MODE: Deferring world launch until swapchain settles", .{}); @@ -264,8 +262,7 @@ pub const App = struct { app.pending_world_launch = .{ .seed = 12345, .generator_index = 0 }; if (runtime_env.strictSafeModeAutoEnabled()) app.direct_launch_resize_guard_frames = 240; } else { - const home_screen = try HomeScreen.init(allocator, engine_ctx); - app.screen_manager.setScreen(home_screen.screen()); + app.screen_manager.setScreen(try app.createHomeScreen()); } return app; @@ -274,9 +271,9 @@ pub const App = struct { pub fn deinit(self: *App) void { self.render_system.waitIdle(); - self.ui_manager.deinit(self.render_system.getRHI().resourceManager()); - + self.input.setRawEventProcessor(null); self.screen_manager.deinit(); + self.ui_manager.deinit(self.render_system.getRHI().resourceManager()); if (self.benchmark_runner) |runner| { runner.deinit(); self.allocator.destroy(runner); @@ -347,11 +344,11 @@ pub const App = struct { self.pending_world_launch = null; self.direct_launch_resize_guard_frames = 0; self.resize_debounce_frames = 0; - const home_screen = HomeScreen.init(self.allocator, self.engineContext()) catch |home_err| { + const home_screen = self.createHomeScreen() catch |home_err| { log.log.err("Failed to initialize home screen after world launch failure: {}", .{home_err}); return home_err; }; - self.screen_manager.setScreen(home_screen.screen()); + self.screen_manager.setScreen(home_screen); return; }; self.screen_manager.setScreen(world_screen.screen()); @@ -361,6 +358,15 @@ pub const App = struct { self.render_system.getRHI().renderContext().requestSwapchainRecreate(); } + fn createHomeScreen(self: *App) !screen_pkg.IScreen { + if (rmlui.available and self.ui_manager.getRmlUi() != null) { + const screen = try RmlHomeScreen.init(self.allocator, self.engineContext()); + return screen.screen(); + } + const screen = try HomeScreen.init(self.allocator, self.engineContext()); + return screen.screen(); + } + pub fn runSingleFrame(self: *App) !void { self.frame_start_counter = c.SDL_GetPerformanceCounter(); self.time.update(); @@ -399,7 +405,12 @@ pub const App = struct { self.ui_manager.handleTimingToggle(self.input_mapper.interface().isActionPressed(self.input.interface(), .toggle_timing_overlay), &self.time, self.render_system.getRHI()); - self.ui_manager.resize(window_width, window_height); + // UI geometry is rendered in swapchain pixels. SDL input dimensions can + // remain logical on HiDPI desktops, which previously confined RmlUi to + // the top-left quarter of the framebuffer. + const ui_width = if (swapchain_extent[0] > 0) swapchain_extent[0] else window_width; + const ui_height = if (swapchain_extent[1] > 0) swapchain_extent[1] else window_height; + self.ui_manager.resize(ui_width, ui_height); self.render_system.setViewport(window_width, window_height); @@ -453,25 +464,9 @@ pub const App = struct { const cpu_ms = self.time.delta_time * 1000.0; try self.ui_manager.draw(&self.screen_manager, self.render_system.getRHI(), world_stats, cpu_ms, self.time.fps); - self.render_system.endFrame(); - self.revealMenuWindowWhenReady(); - - if (build_options.benchmark) { - if (self.benchmark_runner) |runner| { - const gpu_timing = self.render_system.getRHI().timing().getTimingResults(); - const rhi = self.render_system.getRHI(); - const draw_calls = rhi.query().getDrawCallCount(); - const gpu_memory_mb = if (rhi.device) |device| gpuMemoryMb(device.getStats()) else 0; - try runner.recordFrame(self.time.delta_time, self.time.fps, gpu_timing, world_stats, draw_calls, gpu_memory_mb); - - if (runner.isComplete()) { - try runner.writeResults(); - log.log.info("BENCHMARK COMPLETE: {} frames written to '{s}'", .{ runner.samples.items.len, runner.output_path }); - self.input.should_quit = true; - } - } - } - + // Capture is recorded before endFrame so Vulkan appends its copy after + // the UI pass, but before normal presentation releases the image. + var finish_screenshot_run = false; if (build_options.smoke_test or build_options.screenshot_path.len > 0) { self.smoke_test_frames += 1; const requires_world_ready = build_options.shadow_test_scene or build_options.auto_world.len > 0; @@ -513,16 +508,39 @@ pub const App = struct { if (should_finish) { if (build_options.screenshot_path.len > 0) { - log.log.info("SCREENSHOT: Capturing frame to '{s}'", .{build_options.screenshot_path}); + log.log.info("SCREENSHOT: Requesting final composed frame to '{s}'", .{build_options.screenshot_path}); if (!self.render_system.getRHI().screenshot().captureFrame(build_options.screenshot_path)) { - log.log.err("SCREENSHOT: Failed to capture screenshot", .{}); + log.log.err("SCREENSHOT: Failed to request screenshot", .{}); } } - log.log.info("SMOKE TEST COMPLETE: {} frames rendered. Exiting.", .{target_frames}); - self.input.should_quit = true; + finish_screenshot_run = true; + } + } + + self.render_system.endFrame(); + self.revealMenuWindowWhenReady(); + + if (build_options.benchmark) { + if (self.benchmark_runner) |runner| { + const gpu_timing = self.render_system.getRHI().timing().getTimingResults(); + const rhi = self.render_system.getRHI(); + const draw_calls = rhi.query().getDrawCallCount(); + const gpu_memory_mb = if (rhi.device) |device| gpuMemoryMb(device.getStats()) else 0; + try runner.recordFrame(self.time.delta_time, self.time.fps, gpu_timing, world_stats, draw_calls, gpu_memory_mb); + + if (runner.isComplete()) { + try runner.writeResults(); + log.log.info("BENCHMARK COMPLETE: {} frames written to '{s}'", .{ runner.samples.items.len, runner.output_path }); + self.input.should_quit = true; + } } } + if (finish_screenshot_run) { + log.log.info("SMOKE TEST COMPLETE: frame rendered and screenshot submitted. Exiting.", .{}); + self.input.should_quit = true; + } + self.limitFrameRateIfNeeded(); } diff --git a/src/tests.zig b/src/tests.zig index 798175ab..d5e63811 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -40,6 +40,7 @@ test { _ = @import("engine-graphics").descriptor_manager_error_tests; _ = @import("engine-graphics").shader_registry_tests; _ = @import("engine-graphics").frame_manager_tests; + _ = @import("engine-graphics").final_composition; _ = @import("engine-graphics").render_pass_manager_tests; _ = @import("engine-graphics").rhi_frame_orchestration_tests; _ = @import("engine-graphics").rhi_pass_orchestration_tests; @@ -116,4 +117,10 @@ test { _ = @import("engine-input").input_tests; _ = @import("text_input_tests.zig"); _ = @import("engine-ui").font; + _ = @import("engine-ui").rmlui; + _ = @import("engine-ui").debug_shadow_overlay; + _ = @import("game-core").hotbar; + _ = @import("game-core").session_hud; + _ = @import("game-ui").singleplayer_wizard; + _ = @import("game-ui").rml_markup; }