Pick the generator that will shape this world.
+ +Name the world and choose how its seed is generated.
+Pick the generator that will shape this world.
+ +Choose the sky probe that lights the scene. Changes apply immediately.
+ +Create a place to explore, shape it block by block, and return whenever you are ready.
+Resume or leave the current world.
+Choose the block texture pack used by your worlds. Changes apply immediately.
+ +Changes apply immediately and are saved when you leave.
+ +Choose an existing world or create something new.
+ +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, "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; }