diff --git a/src/app.zig b/src/app.zig index 4be36ff..8a06889 100644 --- a/src/app.zig +++ b/src/app.zig @@ -2,6 +2,7 @@ const std = @import("std"); const log = std.log.scoped(.app); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); const STBImage = @import("stb_image"); const STBTrueType = @import("stb_truetype"); @@ -18,6 +19,7 @@ const Color = Platform.Color; const CanvasScaler = @import("./canvas_scaler.zig"); const Font = @import("./font.zig"); const Tilesheet = @import("./tilesheet.zig"); +const UI = @import("./ui.zig"); const App = @This(); @@ -332,9 +334,14 @@ const BoxSprites = struct { }; }; -world: World, -undo_stack: std.ArrayList(UndoFrame) = .empty, -current_level: u32 = 0, +const GameScene = struct { + world: World, + undo_stack: std.ArrayList(UndoFrame) = .empty, + current_level: u32 = 0, + animation_timer: Platform.Nanoseconds = 0, + paused: bool = false, +}; + levels: std.ArrayList(World), roboto_font: Font, @@ -342,13 +349,16 @@ tilesheet: Tilesheet, player_sprites: PlayerSprites, box_sprites_by_color: std.EnumArray(BoxColor, BoxSprites), -animation_timer: Platform.Nanoseconds, footstep_buffer: Audio.BufferId, footstep_sound: Audio.SoundId = .nil, sfx_bus: Audio.BusId, +game_scene: GameScene, +in_main_menu: bool = false, +main_menu_state: UI.State = .empty, + pub fn init(self: *App, plt: Platform.Init) !?u8 { const roboto_font = try Font.init(plt.gpa, .{ .spritesheet = Gfx.initSpritesheet(.{ .format = .r8 }), @@ -409,7 +419,6 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { self.* = App{ .player_sprites = player_sprites, .roboto_font = roboto_font, - .animation_timer = 0, .tilesheet = tilesheet, .footstep_buffer = footstep_sound, .box_sprites_by_color = .init(.{ @@ -441,12 +450,15 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { } }), .sfx_bus = Audio.addBus(.{ .label = "sfx" }), - .world = undefined, .levels = .empty, + + .game_scene = .{ + .world = undefined, + } }; self.levels = try self.loadLevels(plt.gpa, plt.assets); - self.world.copy(self.levels.items[0]); + self.game_scene.world.copy(self.levels.items[0]); return null; } @@ -544,6 +556,7 @@ fn loadLevel( .x = @intCast(ox), .y = @intCast(oy), }, + .visual_pos = .initFromInt(usize, ox, oy), .sprite = self.tilesheet.get(pos.x, pos.y) }); } @@ -629,116 +642,14 @@ fn getPlayerInput(input: *Platform.Input) PlayerInput { return result; } -pub fn frame(self: *App, plt: Platform.Frame) !void { - const input = getPlayerInput(plt.input); - - const clear_color = Color.rgb(40, 40, 40); - Gfx.setClearColor(clear_color); - Gfx.setDefaultSampler(.linear); - - const tile_size = Vec2.init(48, 48); - var canvas = CanvasScaler{ - .size = Vec2.initFromInt(usize, self.world.width, self.world.height).multiply(tile_size) - }; - canvas.push(); - defer canvas.pop(clear_color); - - const dt = plt.deltaTime(); - - if (input.undo) { - if (self.undo_stack.pop()) |undo_frame| { - undo_frame.apply(&self.world); - } - } - - if (input.restart) { - try self.setWorldByLevel(self.current_level); - } - - var tick_context: TickContext = .{ - .world = &self.world - }; - - { - var entity_iter = self.world.entities.iterator(); - while (entity_iter.next()) |entity_id| { - var interpolate_pos = false; - - const entity = self.world.entities.getAssumeExists(entity_id); - if (entity.type == .player) { - interpolate_pos = true; - - const prev_player_direction = entity.player_direction; - if (tick_context.moveEntity(entity_id, input.move_dir)) { - if (entity.player_direction != prev_player_direction) { - entity.player_frame_index = 0; - } - - self.footstep_sound = Audio.play(.{ .buffer = self.footstep_buffer, .bus = self.sfx_bus }); - } - - } else if (entity.type == .box) { - interpolate_pos = true; - } - - const target_visual_pos = Vec2.initFromInt(i32, entity.pos.x, entity.pos.y); - var visual_step = target_visual_pos.sub(entity.visual_pos); - if (interpolate_pos) { - const speed = 10; - visual_step = visual_step.multiplyScalar(dt * speed); - } - entity.visual_pos = entity.visual_pos.add(visual_step); - - if (entity.type == .player) { - const frames = self.player_sprites.getDirection(entity.player_direction); - - if (target_visual_pos.sub(entity.visual_pos).length() > 0.1) { - self.animation_timer += plt.input.delta_time; - const animatin_time = std.time.ns_per_s / 5; - while (self.animation_timer >= animatin_time) { - entity.player_frame_index = (entity.player_frame_index + 1) % @as(u32, @intCast(frames.len)); - self.animation_timer -= animatin_time; - } - } else { - self.animation_timer = 0; - entity.player_frame_index = 0; - } - - entity.sprite = frames[entity.player_frame_index]; - } - } - } - - if (!tick_context.undo_frame.isEmpty()) { - try self.undo_stack.append(plt.gpa, tick_context.undo_frame); - } - - var goal_count: u32 = 0; - var box_at_goal_count: u32 = 0; - { - var goal_iter = self.world.iterEntities(.{ .type = .box_goal }); - while (goal_iter.next()) |goal_id| { - const goal = self.world.entities.getAssumeExists(goal_id); - goal_count += 1; - - if (self.world.findEntity(.{ .pos = goal.pos, .type = .box })) |box| { - var has_correct_color = true; - if (goal.color != .none) { - has_correct_color = goal.color == box.color; - } - - const is_on_top = box.visual_pos.distance(goal.visual_pos) < 0.05; - if (is_on_top and has_correct_color) { - box_at_goal_count += 1; - } - } - } - } +const clear_color = Color.rgb(40, 40, 40); +const tile_size = Vec2.init(48, 48); +pub fn drawWorld(self: *App, world: *World) !void { inline for (.{ Entity.Layer.ground, Entity.Layer.on_ground, Entity.Layer.surface }) |layer| { - var entity_iter = self.world.entities.iterator(); + var entity_iter = world.entities.iterator(); while (entity_iter.next()) |entity_id| { - const entity = self.world.entities.getAssumeExists(entity_id); + const entity = world.entities.getAssumeExists(entity_id); if (entity.visual_layer != layer) { continue; } @@ -747,14 +658,14 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { const box_hole_margin = 0.1; if (entity.type == .box) { - const box_hole_id = self.world.findEntityId(.{ .type = .box_hole, .pos = entity.pos }); - if (self.world.entities.get(box_hole_id)) |box_hole| { + const box_hole_id = world.findEntityId(.{ .type = .box_hole, .pos = entity.pos }); + if (world.entities.get(box_hole_id)) |box_hole| { if (entity.box_hole_id == box_hole_id and box_hole.visual_pos.distance(entity.visual_pos) < box_hole_margin) { sprite = null; } } - if (self.world.findEntity(.{ .type = .box_goal, .pos = entity.pos })) |box_goal| { + if (world.findEntity(.{ .type = .box_goal, .pos = entity.pos })) |box_goal| { if (box_goal.visual_pos.distance(entity.visual_pos) < 0.2) { var is_correct = true; if (box_goal.color != .none) { @@ -767,13 +678,17 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { } } } else if (entity.type == .box_hole) { - var box_iter = self.world.iterEntities(.{ .type = .box, .pos = entity.pos }); + var box_iter = world.iterEntities(.{ .type = .box, .pos = entity.pos }); while (box_iter.next()) |box_id| { - const box = self.world.entities.getAssumeExists(box_id); + const box = world.entities.getAssumeExists(box_id); if (box.visual_pos.distance(entity.visual_pos) < box_hole_margin) { sprite = self.box_sprites_by_color.get(box.color).in_hole; } } + } else if (entity.type == .player) { + const frames = self.player_sprites.getDirection(entity.player_direction); + + sprite = frames[entity.player_frame_index]; } if (sprite != null) { @@ -786,16 +701,185 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { } } } +} - if (goal_count > 0 and goal_count == box_at_goal_count and self.current_level+1 < self.levels.items.len) { - self.current_level += 1; - try self.setWorldByLevel(self.current_level); +pub fn frameGameScene(self: *App, plt: *Platform.Frame) !void { + Gfx.setClearColor(clear_color); + Gfx.setDefaultSampler(.linear); + + const dt = plt.deltaTime(); + + if (plt.input.isKeyPressed(.ESCAPE)) { + self.game_scene.paused = !self.game_scene.paused; + } + + var world = &self.game_scene.world; + + if (!self.game_scene.paused) { + const input = getPlayerInput(plt.input); + + if (input.undo) { + if (self.game_scene.undo_stack.pop()) |undo_frame| { + undo_frame.apply(world); + } + } + + if (input.restart) { + self.setWorldByLevel(self.game_scene.current_level); + } + + var tick_context: TickContext = .{ .world = world }; + + { + var entity_iter = world.entities.iterator(); + while (entity_iter.next()) |entity_id| { + var interpolate_pos = false; + + const entity = world.entities.getAssumeExists(entity_id); + if (entity.type == .player) { + interpolate_pos = true; + + const prev_player_direction = entity.player_direction; + if (tick_context.moveEntity(entity_id, input.move_dir)) { + if (entity.player_direction != prev_player_direction) { + entity.player_frame_index = 0; + } + + self.footstep_sound = Audio.play(.{ .buffer = self.footstep_buffer, .bus = self.sfx_bus }); + } + + } else if (entity.type == .box) { + interpolate_pos = true; + } + + const target_visual_pos = Vec2.initFromInt(i32, entity.pos.x, entity.pos.y); + var visual_step = target_visual_pos.sub(entity.visual_pos); + if (interpolate_pos) { + const speed = 10; + visual_step = visual_step.multiplyScalar(dt * speed); + } + entity.visual_pos = entity.visual_pos.add(visual_step); + + if (entity.type == .player) { + const frames = self.player_sprites.getDirection(entity.player_direction); + + if (target_visual_pos.sub(entity.visual_pos).length() > 0.1) { + self.game_scene.animation_timer += plt.input.delta_time; + const animatin_time = std.time.ns_per_s / 5; + while (self.game_scene.animation_timer >= animatin_time) { + entity.player_frame_index = (entity.player_frame_index + 1) % @as(u32, @intCast(frames.len)); + self.game_scene.animation_timer -= animatin_time; + } + } else { + self.game_scene.animation_timer = 0; + entity.player_frame_index = 0; + } + } + } + } + + if (!tick_context.undo_frame.isEmpty()) { + try self.game_scene.undo_stack.append(plt.gpa, tick_context.undo_frame); + } + + { + var goal_count: u32 = 0; + var box_at_goal_count: u32 = 0; + + var goal_iter = world.iterEntities(.{ .type = .box_goal }); + while (goal_iter.next()) |goal_id| { + const goal = world.entities.getAssumeExists(goal_id); + goal_count += 1; + + if (world.findEntity(.{ .pos = goal.pos, .type = .box })) |box| { + var has_correct_color = true; + if (goal.color != .none) { + has_correct_color = goal.color == box.color; + } + + const is_on_top = box.visual_pos.distance(goal.visual_pos) < 0.05; + if (is_on_top and has_correct_color) { + box_at_goal_count += 1; + } + } + } + + if (goal_count > 0 and goal_count == box_at_goal_count and self.game_scene.current_level+1 < self.levels.items.len) { + self.game_scene.current_level += 1; + self.setWorldByLevel(self.game_scene.current_level); + } + } + } + + { + var canvas = CanvasScaler{ + .size = Vec2.initFromInt(usize, world.width, world.height).multiply(tile_size) + }; + canvas.push(); + defer canvas.pop(clear_color); + + try self.drawWorld(&self.game_scene.world); + + if (self.game_scene.paused) { + var color = clear_color; + color.a = 0.8; + Gfx.drawRectangle(.init(0, 0), canvas.size, color); + } } } -pub fn setWorldByLevel(self: *App, index: u32) !void { - self.world.copy(self.levels.items[index]); - self.undo_stack.clearRetainingCapacity(); +pub fn frameMainMenu(self: *App, plt: *Platform.Frame) !void { + Gfx.setClearColor(clear_color); + Gfx.setDefaultSampler(.linear); + + var canvas = CanvasScaler{ + .size = Vec2.initFromInt(usize, 1920, 1080) + }; + canvas.push(); + defer canvas.pop(clear_color); + + var ui = UI.init(&self.main_menu_state, plt.input); + ui.beginFrame(); + defer ui.endFrame(); + + const ui_frame = ui.top(); + const font = &self.roboto_font; + ui_frame.font = font; + ui_frame.text_color = .black; + ui_frame.text_height = 80; + ui_frame.container = Rect{ .pos = .init(0, 0), .size = canvas.size }; + ui_frame.container = ui_frame.container.shrinkPercent(0.1); + + ui_frame.padding.addHorizontal(100); + ui_frame.padding.addVertical(50); + + var layout = UI.VerticalLayout{ .ui = &ui, .gap = 30 }; + + if (layout.textButton("Play")) { + log.debug("press play", .{}); + } + + if (layout.textButton("Settings")) { + } + + if (!builtin.target.cpu.arch.isWasm()) { + if (layout.textButton("Exit")) { + plt.request_quit = true; + } + } +} + +pub fn frame(self: *App, plt: *Platform.Frame) !void { + if (self.in_main_menu) { + try self.frameMainMenu(plt); + } else { + try self.frameGameScene(plt); + } +} + +pub fn setWorldByLevel(self: *App, index: u32) void { + self.game_scene.world.copy(self.levels.items[index]); + self.game_scene.undo_stack.clearRetainingCapacity(); } pub fn deinit(self: *App, plt: Platform.Deinit) void { @@ -803,5 +887,5 @@ pub fn deinit(self: *App, plt: Platform.Deinit) void { Gfx.deinitSpritesheet(self.roboto_font.spritesheet); self.levels.deinit(plt.gpa); - self.undo_stack.deinit(plt.gpa); + self.game_scene.undo_stack.deinit(plt.gpa); } diff --git a/src/font.zig b/src/font.zig index 91673ec..5fb712a 100644 --- a/src/font.zig +++ b/src/font.zig @@ -93,19 +93,20 @@ pub fn getGlyph(font: *Font, index: Glyph.Index, scale_x: f32, scale_y: f32) ?*F var bitmap_buffer: [Font.Glyph.max_width * Font.Glyph.max_height]u8 = undefined; const bitmap = Gfx.Image{ - .pixels = .{ .r8 = (&bitmap_buffer).ptr }, + .pixels = (&bitmap_buffer).ptr, + .format = .r8, .width = box_width, .height = box_height }; font.stb.makeGlyphBitmap( - bitmap.pixels.?.r8, + bitmap.pixels, box_width, box_height, box_width, glyph_key.scale_x.toF32(), glyph_key.scale_y.toF32(), glyph_key.index ); - Gfx.setSprite(sprite, .{ .image = bitmap }); + Gfx.setSprite(sprite, .{ .mipchain = .initSingle(bitmap) }); glyph.sprite = sprite; } @@ -125,9 +126,17 @@ pub fn getAscent(font: *Font, height: f32) f32 { return @as(f32, @floatFromInt(vmetrics.ascent)) * scale; } -pub fn measureText(font: *Font, height: f32, text: []const u8) Rect { +pub fn getAscentDescent(font: *Font, height: f32) f32 { + const vmetrics = font.stb.getFontVMetrics(); + const scale = font.stb.scaleForPixelHeight(height); + return @as(f32, @floatFromInt(vmetrics.ascent - vmetrics.descent)) * scale; +} + +pub fn measureText(font: *Font, text: []const u8, height: f32) Rect { var bounds: Rect = .init(0, 0, 0, 0); - var first_glyph = false; + var first_glyph = true; + + const ascent = font.getAscent(height); var layout = TextRunLayout.init(font, height); var iter = std.unicode.Utf8Iterator{ @@ -138,11 +147,14 @@ pub fn measureText(font: *Font, height: f32, text: []const u8) Rect { const glyph_index = font.getGlyphIndex(codepoint) orelse continue; const glyph_layout = layout.next(glyph_index) orelse continue; + var glyph_rect = glyph_layout.rect; + glyph_rect.pos.y += ascent; + if (first_glyph) { - bounds = glyph_layout.rect; + bounds = glyph_rect; first_glyph = false; } else { - bounds = bounds.expand(glyph_layout.rect); + bounds = bounds.expand(glyph_rect); } } @@ -153,20 +165,13 @@ pub const DrawTextOptions = struct { text: []const u8, pos: Vec2, height: f32, - color: Gfx.Color = .white, - alignment: Vec2 = .init(0, 0) + color: Gfx.Color, }; pub fn drawText(font: *Font, opts: DrawTextOptions) void { var layout = Font.TextRunLayout.init(font, opts.height); - var pos = opts.pos; - pos.y += font.getAscent(opts.height); - - if (!opts.alignment.eql(.zero)) { - const bounds = font.measureText(opts.height, opts.text); - pos = pos.sub(bounds.size.multiply(opts.alignment)); - } + const ascent = font.getAscent(opts.height); var iter = std.unicode.Utf8Iterator{ .bytes = opts.text, @@ -179,7 +184,8 @@ pub fn drawText(font: *Font, opts: DrawTextOptions) void { continue; } - var glyph_pos = glyph_layout.rect.pos.add(pos); + var glyph_pos = glyph_layout.rect.pos.add(opts.pos); + glyph_pos.y += ascent; // Round to nearest pixel to avoid artifacts glyph_pos.x = @round(glyph_pos.x); @@ -316,9 +322,13 @@ const GlyphCache = struct { found_existing: bool, }; + fn keyToHashMapIndex(self: *GlyphCache, key: Glyph.Key, offset: usize) usize { + return @intCast((key.toInt() + offset) % self.glyphs_hashmap.len); + } + fn insertOrGet(self: *GlyphCache, key: Glyph.Key) ?InsertOrGetResult { for (0..self.glyphs_hashmap.len) |offset| { - const i = (key.toInt() + offset) % self.glyphs_hashmap.len; + const i = self.keyToHashMapIndex(key, offset); const hashmap_id = self.glyphs_hashmap[i]; if (hashmap_id == GlyphSlot.nil_id) { const id = self.popFreeId() orelse return null; @@ -350,7 +360,7 @@ const GlyphCache = struct { } fn removeFromHashmap(self: *GlyphCache, id: GlyphSlot.Id) void { - var bucket = self.glyphs[id].glyph.key.toInt() % self.glyphs_hashmap.len; + var bucket = self.keyToHashMapIndex(self.glyphs[id].glyph.key, 0); for (0..self.glyphs_hashmap.len) |_| { assert(self.glyphs_hashmap[bucket] != GlyphSlot.nil_id); if (self.glyphs_hashmap[bucket] == id) { @@ -367,7 +377,7 @@ const GlyphCache = struct { while (self.glyphs_hashmap[current] != GlyphSlot.nil_id) { const current_glyph_id = self.glyphs_hashmap[current]; - const home = self.glyphs[current_glyph_id].glyph.key.toInt() % self.glyphs_hashmap.len; + const home = self.keyToHashMapIndex(self.glyphs[current_glyph_id].glyph.key, 0); if (self.hashmapDistance(home, current) > self.hashmapDistance(home, empty)) { self.glyphs_hashmap[empty] = self.glyphs_hashmap[current]; diff --git a/src/platform/graphics.zig b/src/platform/graphics.zig index b14d1db..cceb2cc 100644 --- a/src/platform/graphics.zig +++ b/src/platform/graphics.zig @@ -22,6 +22,7 @@ const SlotMapType = @import("slot_map.zig").SlotMapType; const Frame = struct { index: u32, + draw_calls: u32 = 0, quads: std.ArrayList(VertexQuad), transforms: std.ArrayList(TransformFrame), @@ -494,6 +495,8 @@ pub fn flush() void { sg.applyUniforms(shd.UB_fs_params, sg.asRange(&fs_params)); assert(self.frame.quads.items.len > 0); sg.draw(0, @intCast(self.frame.quads.items.len * 6), 1); + + self.frame.draw_calls += 1; } pub fn endFrame() void { @@ -501,6 +504,13 @@ pub fn endFrame() void { flush(); + // TODO: Workaround for when nothing is drawn. + // Just push anything so that screen is cleared + if (self.frame.draw_calls == 0) { + drawRectangle(.init(0, 0), .init(0, 0), .black); + flush(); + } + if (self.frame.pass_started) { sg.endPass(); sg.commit(); @@ -1435,6 +1445,7 @@ pub const Image = struct { } pub fn extrudeEdge(self: Image, rect: Image.Rect, size: u32) void { + // Left edge for (0..rect.width) |ox| { const pixel = self.getPixel(rect.x+ox, rect.y); for (0..size) |i| { @@ -1442,6 +1453,7 @@ pub const Image = struct { } } + // Right edge for (0..rect.width) |ox| { const pixel = self.getPixel(rect.x+ox, rect.y+rect.height-1); for (0..size) |i| { @@ -1449,6 +1461,7 @@ pub const Image = struct { } } + // Top edge for (0..rect.height) |oy| { const pixel = self.getPixel(rect.x, rect.y+oy); for (0..size) |i| { @@ -1456,6 +1469,7 @@ pub const Image = struct { } } + // Bottom edge for (0..rect.height) |oy| { const pixel = self.getPixel(rect.x+rect.width-1, rect.y+oy); for (0..size) |i| { @@ -1463,7 +1477,29 @@ pub const Image = struct { } } - // TODO: Add padding to corners. + // Top left + { + const pixel = self.getPixel(rect.x, rect.y); + self.setRect(rect.x-size, rect.y-size, size, size, pixel); + } + + // Top right + { + const pixel = self.getPixel(rect.x+rect.width-1, rect.y); + self.setRect(rect.x+rect.width, rect.y-size, size, size, pixel); + } + + // Bottom left + { + const pixel = self.getPixel(rect.x, rect.y+rect.height-1); + self.setRect(rect.x-size, rect.y+rect.height, size, size, pixel); + } + + // Bottom right + { + const pixel = self.getPixel(rect.x+rect.width-1, rect.y+rect.height-1); + self.setRect(rect.x+rect.width, rect.y+rect.height, size, size, pixel); + } } }; @@ -1752,4 +1788,8 @@ pub const TransformFrame = struct { pub fn apply(self: TransformFrame, position: Vec2) Vec2 { return position.multiply(self.scale).add(self.offset); } + + pub fn applyInverse(self: TransformFrame, position: Vec2) Vec2 { + return position.sub(self.offset).divide(self.scale); + } }; diff --git a/src/platform/math.zig b/src/platform/math.zig index 037b6fa..a826f69 100644 --- a/src/platform/math.zig +++ b/src/platform/math.zig @@ -464,6 +464,10 @@ pub const Rect = struct { }; } + pub fn shrinkPercent(self: Rect, percent: f32) Rect { + return self.shrink(self.size.multiplyScalar(percent / 2)); + } + pub fn grow(self: Rect, margin: Vec2) Rect { return self.shrink(margin.multiplyScalar(-1)); } diff --git a/src/platform/root.zig b/src/platform/root.zig index f26eb7c..851ba83 100644 --- a/src/platform/root.zig +++ b/src/platform/root.zig @@ -126,16 +126,21 @@ fn PlatformType(App: type) type { ImGUI.beginFrame(self.show_imgui, self.frame_arena.allocator()); if (@hasDecl(App, "frame")) { - const plt = Frame{ + var plt = Frame{ .gpa = self.gpa, .arena = self.arena.allocator(), .io = self.io, .input = &self.input, .input_events = self.events.items, .frame = self.frame_arena.allocator(), - .assets = &self.assets + .assets = &self.assets, + .request_quit = false }; - try self.app.frame(plt); + try self.app.frame(&plt); + + if (plt.request_quit) { + sapp.requestQuit(); + } } Gfx.flush(); @@ -603,6 +608,7 @@ pub const Frame = struct { assets: *Assets, input: *Input, input_events: []Input.Event, + request_quit: bool, fn nanosecondsToSeconds(nanoseconds: Nanoseconds) f32 { return @floatCast(@as(f64, @floatFromInt(nanoseconds)) / std.time.ns_per_s); diff --git a/src/ui.zig b/src/ui.zig new file mode 100644 index 0000000..631b077 --- /dev/null +++ b/src/ui.zig @@ -0,0 +1,335 @@ +const std = @import("std"); +const log = std.log; +const assert = std.debug.assert; +const Font = @import("./font.zig"); + +const Platform = @import("platform"); +const Gfx = Platform.Gfx; +const Math = Platform.Math; +const Color = Platform.Color; +const Vec2 = Platform.Vec2; +const Rect = Math.Rect; + +const UI = @This(); + +pub const Key = enum(u64) { + _, + + const StringHasher = std.hash.Wyhash; + pub const CombineHasher = std.hash.Fnv1a_64; + + pub const nil = Key.init(0); + + pub fn init(hash: u64) Key { + return @enumFromInt(hash); + } + + pub fn initPtr(ptr: anytype) Key { + return Key.initUsize(@intFromPtr(ptr)); + } + + pub fn initUsize(num: usize) Key { + return Key.init(@truncate(num)); + } + + pub fn initString(seed: u64, text: []const u8) Key { + return Key.init(StringHasher.hash(seed, text)); + } + + pub fn combine(self: Key, other: Key) Key { + var hasher = CombineHasher.init(); + hasher.update(std.mem.asBytes(&@intFromEnum(self))); + hasher.update(std.mem.asBytes(&@intFromEnum(other))); + + return Key.init(hasher.final()); + } + + pub fn isNil(self: Key) bool { + return self == nil; + } + + pub fn format(self: Key, writer: *std.io.Writer) std.io.Writer.Error!void { + try writer.print("{s}{{ 0x{x:016} }}", .{ @typeName(Key), @intFromEnum(self) }); + } +}; + +pub const VerticalLayout = struct { + ui: *UI, + used: f32 = 0, + gap: f32 = 0, + + pub fn advance(self: *VerticalLayout, height: f32) void { + self.used += height; + self.used += self.gap; + } + + pub fn next(self: *VerticalLayout, height: f32) Rect { + const container = self.ui.top().container; + const result = Rect{ + .pos = container.pos.add(.init(0, self.used)), + .size = .init(container.size.x, height) + }; + + self.advance(height); + return result; + } + + pub fn textButton(self: *VerticalLayout, text: []const u8) bool { + const frame = self.ui.top(); + const font = frame.font orelse return false; + + var button_size = font.measureText(text, frame.text_height).size; + button_size = button_size.add(frame.padding.toVec2()); + + const row_rect = self.next(button_size.y); + const button_rect = Rect{ + .pos = row_rect.pos, + .size = button_size + }; + + return self.ui.textButton(button_rect, text); + } +}; + +pub const Sides = struct { + left: f32, + right: f32, + top: f32, + bottom: f32, + + pub const empty = Sides{ + .left = 0, + .right = 0, + .top = 0, + .bottom = 0 + }; + + pub fn grow(self: Sides, rect: Rect) Rect { + return Rect{ + .pos = rect.pos.sub(.init(self.left, self.top)), + .size = rect.size.add(.init(self.left+self.right, self.top+self.bottom)) + }; + } + + pub fn shrink(self: Sides, rect: Rect) Rect { + return Rect{ + .pos = rect.pos.add(.init(self.left, self.top)), + .size = rect.size.sub(.init(self.left+self.right, self.top+self.bottom)) + }; + } + + pub fn add(lhs: Sides, rhs: Sides) Sides { + return Sides{ + .left = lhs.left + rhs.left, + .right = lhs.right + rhs.right, + .top = lhs.top + rhs.top, + .bottom = lhs.bottom + rhs.bottom, + }; + } + + pub fn initVertical(amount: f32) Sides { + return Sides{ + .top = amount/2, + .bottom = amount/2, + .left = 0, + .right = 0, + }; + } + + pub fn initHorizontal(amount: f32) Sides { + return Sides{ + .left = amount/2, + .right = amount/2, + .top = 0, + .bottom = 0, + }; + } + + pub fn vertical(self: Sides) f32 { + return self.top + self.bottom; + } + + pub fn horizontal(self: Sides) f32 { + return self.left + self.right; + } + + pub fn addVertical(self: *Sides, amount: f32) void { + self.* = self.add(.initVertical(amount)); + } + + pub fn addHorizontal(self: *Sides, amount: f32) void { + self.* = self.add(.initHorizontal(amount)); + } + + pub fn toVec2(self: Sides) Vec2 { + return Vec2.init(self.horizontal(), self.vertical()); + } +}; + +const Box = struct { + margin: Sides, + padding: Sides, +}; + +pub const Frame = struct { + container: Rect = .zero, + key: Key = .nil, + font: ?*Font = null, + text_color: Color = .white, + text_height: f32 = 64, + margin: Sides = .empty, + padding: Sides = .empty, +}; + +pub const State = struct { + hot: Key, + active: Key, + + pub const empty = State{ + .hot = .nil, + .active = .nil + }; +}; + +state: *State, +input: *Platform.Input, +stack: [32]Frame, +stack_len: u32, + +pub fn init(state: *State, input: *Platform.Input) UI { + var stack: [32]Frame = undefined; + stack[0] = Frame{}; + + return UI{ + .state = state, + .input = input, + .stack = stack, + .stack_len = 1 + }; +} + +pub fn beginFrame(self: *UI) void { + if (self.state.active == Key.nil) { + self.state.hot = .nil; + } +} + +pub fn endFrame(self: *UI) void { + if (self.input.isMouseReleased(.left)) { + self.state.active = .nil; + } +} + +pub fn push(self: *UI) void { + if (self.stack_len == self.stack.len) { + log.warn("UI stack capacity limit reached", .{}); + return; + } + assert(self.stack_len >= 1); + self.stack[self.stack_len] = self.stack[self.stack_len-1]; + self.stack_len += 1; +} + +pub fn pop(self: *UI) void { + if (self.stack_len <= 1) { + log.warn("Attempt to pop from empty UI", .{}); + return; + } + self.stack_len -= 1; +} + +pub fn top(self: *UI) *Frame { + assert(self.stack_len > 0); + return &self.stack[self.stack_len-1]; +} + +pub fn measureText(self: *UI, text: []const u8) Rect { + const frame = self.top(); + const font = frame.font orelse return .zero; + + return font.measureText( + text, + frame.text_height, + ); +} + +pub fn drawText(self: *UI, pos: Vec2, text: []const u8) void { + const frame = self.top(); + const font = frame.font orelse return; + + font.drawText(.{ + .pos = pos, + .text = text, + .height = frame.text_height, + .color = frame.text_color + }); +} + +pub fn getMousePosition(self: *UI) ?Vec2 { + if (self.input.mouse_position) |screen_mouse_position| { + return Gfx.getTransform().applyInverse(screen_mouse_position); + } + return null; +} + +const ButtonState = enum { + normal, + hot, + active, + released, +}; + +fn buttonLogic(self: *UI, rect: Rect, key: Key) ButtonState { + if (self.getMousePosition()) |mouse_position| { + const is_inside = rect.isInside(mouse_position); + if (self.state.hot == Key.nil and is_inside) { + self.state.hot = key; + } + + if (self.state.hot == key and self.input.isMousePressed(.left)) { + self.state.active = key; + } + + if (self.state.active == key and self.input.isMouseReleased(.left) and is_inside) { + return .released; + } + } + + + if (self.state.active == key) { + return .active; + } else if (self.state.hot == key) { + return .hot; + } else { + return .normal; + } +} + +pub fn textButton(self: *UI, rect: Rect, text: []const u8) bool { + const frame = self.top(); + const text_bounds = self.measureText(text); + + const button_state = self.buttonLogic(rect, Key.initString(@intFromEnum(frame.key), text)); + + if (button_state == .active) { + Gfx.drawRectangle(rect.pos, rect.size, .blue); + } else if (button_state == .hot) { + Gfx.drawRectangle(rect.pos, rect.size, .red); + } else { + Gfx.drawRectangle(rect.pos, rect.size, .white); + } + + var content_size = rect.size.sub(frame.padding.toVec2()); + content_size.x = @max(content_size.x, 0); + content_size.y = @max(content_size.y, 0); + + self.drawText( + rect.pos + .sub(text_bounds.pos) + .add(.init(frame.padding.left, frame.padding.top)) + .add(content_size.sub(text_bounds.size).multiplyScalar(0.5)), + text + ); + + return button_state == .released; +}