diff --git a/assets/kenney-ui/Sounds/click-b.ogg b/assets/kenney-ui/Sounds/click-b.ogg new file mode 100644 index 0000000..570102c Binary files /dev/null and b/assets/kenney-ui/Sounds/click-b.ogg differ diff --git a/build.zig b/build.zig index e1cd3d8..cbfdc76 100644 --- a/build.zig +++ b/build.zig @@ -148,7 +148,12 @@ fn buildProject(ctx: Context, target: Build.ResolvedTarget, optimize: std.builti "kenney-ui/Font/Kenney Future Narrow.ttf", "tiled/map.tmx", "tiled/tileset.tsx", - "impact-sounds/footstep_concrete_000.ogg" + "impact-sounds/footstep_concrete_000.ogg", + "kenney-ui/PNG/Red/Double/button_square_depth_flat.png", + "kenney-ui/PNG/Red/Default/button_square_depth_flat.png", + "kenney-ui/PNG/Red/Double/button_square_flat.png", + "kenney-ui/PNG/Red/Default/button_square_flat.png", + "kenney-ui/Sounds/tap-b.ogg", }; const is_wasm = target.result.cpu.arch.isWasm(); @@ -202,6 +207,8 @@ fn buildProject(ctx: Context, target: Build.ResolvedTarget, optimize: std.builti const math_mod = b.createModule(.{ .root_source_file = b.path("src/platform/math.zig") }); + const dep_ok_color = b.dependency("ok_color", .{}); + math_mod.addImport("ok_color", dep_ok_color.module("ok_color")); platform_mod.addImport("math", math_mod); dep_sokol = b.dependency("sokol", .{ diff --git a/build.zig.zon b/build.zig.zon index 2fb0014..ea4e36b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,6 +15,9 @@ .stb = .{ .path = "./libs/stb", }, + .ok_color = .{ + .path = "./libs/ok_color", + }, .tiled = .{ .path = "./libs/tiled", }, diff --git a/libs/ok_color/build.zig b/libs/ok_color/build.zig new file mode 100644 index 0000000..7151a52 --- /dev/null +++ b/libs/ok_color/build.zig @@ -0,0 +1,37 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const src_dep = b.dependency("src", .{}); + + + const mod = b.addModule("ok_color", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/root.zig"), + .link_libc = true, + }); + + const write_file_step = b.addWriteFiles(); + + const impl_c = write_file_step.add("impl.c", b.fmt("#include \"ok_color.h\"", .{})); + + { + mod.addCSourceFile(.{ + .file = impl_c, + .flags = &.{} + }); + mod.addCMacro("OK_COLOR_IMPLEMENTATION", ""); + mod.addIncludePath(src_dep.path(".")); + } + + const translate_c = b.addTranslateC(.{ + .root_source_file = impl_c, + .target = target, + .optimize = optimize, + }); + translate_c.addIncludePath(src_dep.path(".")); + mod.addImport("c", translate_c.createModule()); +} diff --git a/libs/ok_color/build.zig.zon b/libs/ok_color/build.zig.zon new file mode 100644 index 0000000..6330d3e --- /dev/null +++ b/libs/ok_color/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .name = .ok_color, + .version = "0.0.1", + .paths = .{"src", "build.zig", "build.zig.zon"}, + .dependencies = .{ + .src = .{ + .url = "git+https://github.com/LievenPetersen/ok_color.git#292a6cd7ac87de1fd7d990acccc95cd740acd927", + .hash = "N-V-__8AABlQAABAjUmBALBI_rkToYvZ7xoI6nvZ-t99DeXm", + }, + }, + .fingerprint = 0xb8f470b7d88e58ea, +} diff --git a/libs/ok_color/src/root.zig b/libs/ok_color/src/root.zig new file mode 100644 index 0000000..3200f96 --- /dev/null +++ b/libs/ok_color/src/root.zig @@ -0,0 +1,14 @@ + +const c = @import("c"); + +pub const RGB = c.RGB; +pub const HSV = c.HSV; +pub const Lab = c.Lab; +pub const Linear_RGB = c.Linear_RGB; + +pub extern fn okhsv_to_srgb(hsv: HSV) RGB; +pub extern fn srgb_to_okhsv(rgb: RGB) HSV; +pub extern fn linear_srgb_to_oklab(linear_rgb: Linear_RGB) Lab; +pub extern fn oklab_to_linear_srgb(lab: Lab) Linear_RGB; +pub extern fn srgb_transfer_function(a: f32) f32; +pub extern fn srgb_transfer_function_inv(a: f32) f32; diff --git a/src/app.zig b/src/app.zig index 26b78b1..71f68bb 100644 --- a/src/app.zig +++ b/src/app.zig @@ -334,26 +334,35 @@ const BoxSprites = struct { }; }; +const MenuState = enum { + main, + level_select, +}; + levels: std.ArrayList(World), roboto_font: Font, -tilesheet: Tilesheet, +sokoban_tilesheet: Tilesheet, player_sprites: PlayerSprites, +button_up_sprite: Gfx.Sprite.Id, +button_down_sprite: Gfx.Sprite.Id, box_sprites_by_color: std.EnumArray(BoxColor, BoxSprites), footstep_buffer: Audio.BufferId, footstep_sound: Audio.SoundId = .nil, sfx_bus: Audio.BusId, +ui_bus: Audio.BusId, +click_buffers: []const Audio.BufferId, -ui_state: UI.State = .empty, +ui_state: UI.State, world: World, undo_stack: std.ArrayList(UndoFrame) = .empty, current_level: u32 = 0, animation_timer: Platform.Nanoseconds = 0, -menu_shown: bool = false, +menu_state: ?MenuState = null, pub fn init(self: *App, plt: Platform.Init) !?u8 { var roboto_font = try Font.init(plt.gpa, .{ @@ -367,9 +376,10 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { assert(tilesheet2_png.width == tilesheet1_png.width*2); assert(tilesheet2_png.height == tilesheet1_png.height*2); - var levels = [_][*]u8{ tilesheet2_png.rgba8_pixels, tilesheet1_png.rgba8_pixels }; + const spritesheet = Gfx.initSpritesheet(.{ .mipmaps = 2 }); - const tilesheet = try Tilesheet.init( + var levels = [_][*]u8{ tilesheet2_png.rgba8_pixels, tilesheet1_png.rgba8_pixels }; + const sokoban_tilesheet = try Tilesheet.init( plt.arena, .init(.{ .width = tilesheet2_png.width, @@ -380,7 +390,7 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { .init(128, 128), .{ .padding = 1, - .spritesheet = Gfx.initSpritesheet(.{ .mipmaps = levels.len }) + .spritesheet = spritesheet } ); @@ -405,10 +415,12 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { }) |entry| { var sprite_ids = player_sprites.getDirection(entry.dir); inline for (0.., entry.positions) |i, pos| { - sprite_ids[i] = tilesheet.get(@intFromFloat(pos.x), @intFromFloat(pos.y)); + sprite_ids[i] = sokoban_tilesheet.get(@intFromFloat(pos.x), @intFromFloat(pos.y)); } } + // button_square_depth_flat.png + const footstep_sound = Audio.addBuffer(.{ .vorbis = plt.assets.readFile("impact-sounds/footstep_concrete_000.ogg") }); @@ -416,48 +428,100 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { self.* = App{ .player_sprites = player_sprites, .roboto_font = roboto_font, - .tilesheet = tilesheet, + .sokoban_tilesheet = sokoban_tilesheet, .footstep_buffer = footstep_sound, .box_sprites_by_color = .init(.{ .none = .nil, .brown = .{ - .regular = tilesheet.get(6, 0), - .darker = tilesheet.get(6, 1), - .in_hole = tilesheet.get(6, 4), + .regular = sokoban_tilesheet.get(6, 0), + .darker = sokoban_tilesheet.get(6, 1), + .in_hole = sokoban_tilesheet.get(6, 4), }, .red = .{ - .regular = tilesheet.get(7, 0), - .darker = tilesheet.get(7, 1), - .in_hole = tilesheet.get(7, 4), + .regular = sokoban_tilesheet.get(7, 0), + .darker = sokoban_tilesheet.get(7, 1), + .in_hole = sokoban_tilesheet.get(7, 4), }, .blue = .{ - .regular = tilesheet.get(8, 0), - .darker = tilesheet.get(8, 1), - .in_hole = tilesheet.get(8, 4), + .regular = sokoban_tilesheet.get(8, 0), + .darker = sokoban_tilesheet.get(8, 1), + .in_hole = sokoban_tilesheet.get(8, 4), }, .green = .{ - .regular = tilesheet.get(9, 0), - .darker = tilesheet.get(9, 1), - .in_hole = tilesheet.get(9, 4), + .regular = sokoban_tilesheet.get(9, 0), + .darker = sokoban_tilesheet.get(9, 1), + .in_hole = sokoban_tilesheet.get(9, 4), }, .gray = .{ - .regular = tilesheet.get(10, 0), - .darker = tilesheet.get(10, 1), - .in_hole = tilesheet.get(10, 4), + .regular = sokoban_tilesheet.get(10, 0), + .darker = sokoban_tilesheet.get(10, 1), + .in_hole = sokoban_tilesheet.get(10, 4), } }), + .button_up_sprite = try initSpriteFromAssets( + plt.assets, + "kenney-ui/PNG/Red/Double/button_square_depth_flat.png", + "kenney-ui/PNG/Red/Default/button_square_depth_flat.png", + .{ .padding = 1, .spritesheet = spritesheet } + ), + .button_down_sprite = try initSpriteFromAssets( + plt.assets, + "kenney-ui/PNG/Red/Double/button_square_flat.png", + "kenney-ui/PNG/Red/Default/button_square_flat.png", + .{ .padding = 1, .spritesheet = spritesheet } + ), .sfx_bus = Audio.addBus(.{ .label = "sfx" }), + .ui_bus = Audio.addBus(.{ .label = "ui" }), .levels = .empty, + .ui_state = try .init(plt.gpa, 0, 64), // TODO: seed + .click_buffers = try plt.arena.dupe(Audio.BufferId, &.{ + Audio.addBuffer(.{ + .vorbis = plt.assets.readFile("kenney-ui/Sounds/tap-b.ogg") + }) + }), .world = undefined, }; + Audio.getBus(self.ui_bus).?.volume = 0.3; + self.levels = try self.loadLevels(plt.gpa, plt.assets); self.world.copy(self.levels.items[0]); return null; } +fn initSpriteFromAssets( + assets: *Platform.Assets, + image0_path: []const u8, + image1_path: []const u8, + opts: Gfx.SpriteOptions +) !Gfx.Sprite.Id { + const image0_png = try STBImage.load(assets.readFile(image0_path)); + defer image0_png.deinit(); + + const image1_png = try STBImage.load(assets.readFile(image1_path)); + defer image1_png.deinit(); + + assert(image0_png.width == image1_png.width*2); + assert(image0_png.height == image1_png.height*2); + + var levels = [_][*]u8{ image0_png.rgba8_pixels, image1_png.rgba8_pixels }; + + const sprite = Gfx.initSprite(opts); + + Gfx.setSprite(sprite, .{ + .mipchain = .init(.{ + .width = image0_png.width, + .height = image0_png.height, + .format = .rgba8, + .levels = &levels, + }), + }); + + return sprite; +} + fn loadLevels(self: *App, gpa: Allocator, assets: *Platform.Assets) !std.ArrayList(World) { var xml_parser = try Tiled.xml.Parser.initCapacity(gpa, .{}); defer xml_parser.deinit(gpa); @@ -552,7 +616,7 @@ fn loadLevel( .y = @intCast(oy), }, .visual_pos = .initFromInt(usize, ox, oy), - .sprite = self.tilesheet.get(pos.x, pos.y) + .sprite = self.sokoban_tilesheet.get(pos.x, pos.y) }); } @@ -593,7 +657,7 @@ fn loadLevel( .y = @intCast(oy), }, .visual_pos = .initFromInt(usize, ox, oy), - .sprite = self.tilesheet.get(pos.x, pos.y), + .sprite = self.sokoban_tilesheet.get(pos.x, pos.y), .color = color }); } @@ -704,13 +768,11 @@ pub fn frame(self: *App, plt: *Platform.Frame) !void { const dt = plt.deltaTime(); - if (plt.input.isKeyPressed(.ESCAPE)) { - self.menu_shown = !self.menu_shown; - } - var world = &self.world; - if (!self.menu_shown) { + const menu_shown = self.menu_state != null; + + if (!menu_shown) { const input = getPlayerInput(plt.input); if (input.undo) { @@ -740,7 +802,7 @@ pub fn frame(self: *App, plt: *Platform.Frame) !void { entity.player_frame_index = 0; } - self.footstep_sound = Audio.play(.{ .buffer = self.footstep_buffer, .bus = self.sfx_bus }); + self.footstep_sound = Audio.play(self.footstep_buffer, .{ .bus = self.sfx_bus }); } } else if (entity.type == .box) { @@ -815,14 +877,14 @@ pub fn frame(self: *App, plt: *Platform.Frame) !void { try self.drawWorld(world); - if (self.menu_shown) { + if (menu_shown) { var color = clear_color; color.a = 0.8; Gfx.drawRectangle(.init(0, 0), canvas.size, color); } } - if (self.menu_shown) { + if (self.menu_state) |menu_state| { var canvas = CanvasScaler{ .size = Vec2.initFromInt(usize, 1920, 1080) }; @@ -836,36 +898,65 @@ pub fn frame(self: *App, plt: *Platform.Frame) !void { const ui_frame = ui.top(); const font = &self.roboto_font; ui_frame.font = font; - ui_frame.text_color = .black; + ui_frame.text_color = Color.rgb(0, 0, 0); + ui_frame.button_normal_sprite = self.button_up_sprite; + ui_frame.button_down_sprite = self.button_down_sprite; 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.audio_bus = self.ui_bus; + ui_frame.button_sound = self.click_buffers; - ui_frame.padding.addHorizontal(150); - ui_frame.padding.addVertical(80); + if (menu_state == .main) { + ui_frame.padding.addHorizontal(150); + ui_frame.padding.addVertical(80); - var layout = UI.VerticalLayout{ .ui = &ui, .gap = 30 }; + var layout = UI.VerticalLayout{ .ui = &ui, .gap = 30 }; - if (layout.textButton("Continue")) { - self.menu_shown = false; - } - - if (layout.textButton("Level select")) { - self.menu_shown = false; - } - - if (layout.textButton("Settings")) { - } - - if (!builtin.target.cpu.arch.isWasm()) { - if (layout.textButton("Exit")) { - plt.request_quit = true; + if (layout.textButton("Continue")) { + self.menu_state = null; } + + if (layout.textButton("Level select")) { + self.menu_state = .level_select; + } + + if (!builtin.target.cpu.arch.isWasm()) { + if (layout.textButton("Exit")) { + plt.request_quit = true; + } + } + + if (plt.input.isKeyPressed(.ESCAPE)) { + self.menu_state = null; + } + } else if (menu_state == .level_select) { + ui_frame.padding.addHorizontal(30); + ui_frame.padding.addVertical(30); + + var layout = UI.VerticalLayout{ .ui = &ui, .gap = 30 }; + + for (0.., self.levels.items) |i, level| { + const label = try std.fmt.allocPrint(plt.frame, "{}", .{level.level_number}); + + if (layout.textButton(label)) { + self.menu_state = null; + self.setWorldByLevel(i); + } + } + + if (plt.input.isKeyPressed(.ESCAPE)) { + self.menu_state = .main; + } + } + } else { + if (plt.input.isKeyPressed(.ESCAPE)) { + self.menu_state = .main; } } } -pub fn setWorldByLevel(self: *App, index: u32) void { +pub fn setWorldByLevel(self: *App, index: usize) void { self.world.copy(self.levels.items[index]); self.undo_stack.clearRetainingCapacity(); } @@ -876,4 +967,5 @@ pub fn deinit(self: *App, plt: Platform.Deinit) void { self.levels.deinit(plt.gpa); self.undo_stack.deinit(plt.gpa); + self.ui_state.deinit(plt.gpa); } diff --git a/src/nine_rect.zig b/src/nine_rect.zig new file mode 100644 index 0000000..8896b72 --- /dev/null +++ b/src/nine_rect.zig @@ -0,0 +1,94 @@ +const std = @import("std"); +const log = std.log; + +const Platform = @import("platform"); +const Gfx = Platform.Gfx; +const Vec2 = Platform.Vec2; +const Color = Platform.Color; +const Rect = Platform.Rect; + +const NineRect = @This(); + +fn splitRect(rect: Rect, corner_size: Vec2) [9]Rect { + var result: [9]Rect = @splat(.zero); + + // Corners + inline for (0.., .{ Vec2.init(0, 0), Vec2.init(1, 0), Vec2.init(0, 1), Vec2.init(1, 1) }) |i, corner_coeff| { + result[i] = Rect{ + .pos = rect.pos + .add(rect.size.multiply(corner_coeff)) + .sub(corner_size.multiply(corner_coeff)), + .size = corner_size + }; + } + + // Horizontal edges + result[4] = Rect{ + .pos = rect.pos.add(.init(corner_size.x, 0)), + .size = .{ + .x = rect.size.x - corner_size.x*2, + .y = corner_size.y + } + }; + + result[5] = Rect{ + .pos = rect.pos.add(.init(corner_size.x, rect.size.y-corner_size.y)), + .size = .{ + .x = rect.size.x - corner_size.x*2, + .y = corner_size.y + } + }; + + // Vertical edges + result[6] = Rect{ + .pos = rect.pos.add(.init(0, corner_size.y)), + .size = .{ + .x = corner_size.x, + .y = rect.size.y - corner_size.y*2, + } + }; + + result[7] = Rect{ + .pos = rect.pos.add(.init(rect.size.x-corner_size.x, corner_size.y)), + .size = .{ + .x = corner_size.x, + .y = rect.size.y - corner_size.y*2, + } + }; + + // Middle + result[8] = rect.shrink(corner_size); + + return result; +} + +pub fn draw(id: Gfx.Sprite.Id, pos: Vec2, size: Vec2, color: Color) void { + + const spritesheet = Gfx.getSpriteOwner(id); + const sprite_rect = Rect{ + .pos = Gfx.getSpritePosition(id) orelse return, + .size = Gfx.getSpriteSize(id) + }; + const spritesheet_size = Gfx.getSpritesheetSize(spritesheet); + + const visual_rect = Rect{ .pos = pos, .size = size }; + const corner_size = Vec2{ + .x = @min(@divFloor(sprite_rect.size.x, 2), size.x/2), + .y = @min(@divFloor(sprite_rect.size.y, 2), size.y/2) + }; + + const uv_rects = splitRect(sprite_rect, corner_size); + const visual_rects = splitRect(visual_rect, corner_size); + + for (0..9) |i| { + var quad: Gfx.VertexQuad = undefined; + quad.setColor(color); + quad.setRect(visual_rects[i]); + quad.setUVRect(uv_rects[i].divide(spritesheet_size)); + + Gfx.draw(.{ + .texture = Gfx.getSpritesheetTexture(spritesheet), + .quad = quad + }); + } +} diff --git a/src/platform/audio.zig b/src/platform/audio.zig index 7e1bdce..262ad25 100644 --- a/src/platform/audio.zig +++ b/src/platform/audio.zig @@ -370,14 +370,13 @@ fn busMultipliedVolume(id: BusId) f32 { } const PlayOptions = struct { - buffer: BufferId, // TODO: adsr: ?Sound.ADSR = null, volume: f32 = 1, loop: bool = false, bus: ?BusId = null }; -pub fn play(opts: PlayOptions) SoundId { +pub fn play(buffer: BufferId, opts: PlayOptions) SoundId { const self = &g_state; var mutex = &self.thread_state.mutex; @@ -394,7 +393,7 @@ pub fn play(opts: PlayOptions) SoundId { return .nil; } - if (opts.buffer == BufferId.nil) { + if (buffer == BufferId.nil) { return .nil; } @@ -409,7 +408,7 @@ pub fn play(opts: PlayOptions) SoundId { .cursor = 0, .volume = opts.volume, .loop = opts.loop, - .buffer = opts.buffer, + .buffer = buffer, .bus = bus, }; @@ -434,7 +433,7 @@ pub fn stop(id: SoundId) void { pub fn getBus(id: BusId) ?*Bus { const self = &g_state; - if (id == .nil) { + if (id == BusId.nil) { return null; } return &self.buses.items[id.index]; diff --git a/src/platform/color.zig b/src/platform/color.zig index 524f3c0..017a58a 100644 --- a/src/platform/color.zig +++ b/src/platform/color.zig @@ -1,6 +1,9 @@ const std = @import("std"); const assert = std.debug.assert; +const Math = @import("math"); +const Vec4 = Math.Vec4; + const Color = @This(); r: f32, @@ -50,3 +53,38 @@ pub fn lerp(self: Color, other: Color, t: f32) Color { .a = std.math.lerp(self.a, other.a, t), }; } + +pub fn invert(self: Color) Color { + return Color{ + .r = 1 - self.r, + .g = 1 - self.g, + .b = 1 - self.b, + .a = self.a, + }; +} + +pub fn lerp_invert(self: Color, t: f32) Color { + return self.lerp(self.invert(), t); +} + +pub fn lerp_in_oklab(self: Color, other: Color, t: f32) Color { + return Color.fromVec4(Math.rgba_lerp_in_oklab(self.toVec4(), other.toVec4(), t)); +} + +pub fn toVec4(self: Color) Vec4 { + return Vec4{ + .x = self.r, + .y = self.g, + .z = self.b, + .w = self.a + }; +} + +pub fn fromVec4(v4: Vec4) Color { + return Color{ + .r = v4.x, + .g = v4.y, + .b = v4.z, + .a = v4.w + }; +} diff --git a/src/platform/graphics.zig b/src/platform/graphics.zig index e9878dd..eed223c 100644 --- a/src/platform/graphics.zig +++ b/src/platform/graphics.zig @@ -1178,19 +1178,46 @@ pub fn getSpriteUVRect(id: Sprite.Id) ?Rect { const sprite = self.sprites.get(id) orelse return null; const sprite_mipchain = sprite.mipchain orelse return null; - const spritesheet = self.spritesheets.get(sprite.spritesheet) orelse return null; + const position = getSpritePosition(id) orelse return null; + + const spritesheet_size = getSpritesheetSize(sprite.spritesheet); + const sprite_size = Vec2.initFromInt(u32, sprite_mipchain.width, sprite_mipchain.height); + return Rect{ + .pos = position.divide(spritesheet_size), + .size = sprite_size.divide(spritesheet_size), + }; +} + +pub fn getSpritePosition(id: Sprite.Id) ?Vec2 { + const self = &g_state; + const sprite = self.sprites.get(id) orelse return null; if (!ensureSpritePacked(id)) { return null; } assert(sprite.position != null); + return sprite.position; +} - const sprite_size = Vec2.initFromInt(u32, sprite_mipchain.width, sprite_mipchain.height); - return Rect{ - .pos = sprite.position.?.divide(spritesheet.size), - .size = sprite_size.divide(spritesheet.size), - }; +pub fn getSpriteSize(id: Sprite.Id) Vec2 { + const self = &g_state; + const sprite = self.sprites.get(id) orelse return .zero; + const sprite_mipchain = sprite.mipchain orelse return .zero; + + return Vec2.initFromInt(u32, sprite_mipchain.width, sprite_mipchain.height); +} + +pub fn getSpriteOwner(id: Sprite.Id) Spritesheet.Id { + const self = &g_state; + const sprite = self.sprites.get(id) orelse return .nil; + return sprite.spritesheet; +} + +pub fn getSpritesheetSize(id: Spritesheet.Id) Vec2 { + const self = &g_state; + const spritesheet = self.spritesheets.get(id) orelse return .zero; + return spritesheet.size; } pub fn drawSpriteQuad(id: Sprite.Id, points: Quad, color: Color) void { @@ -1261,6 +1288,12 @@ pub fn deinitSpritesheet(id: Spritesheet.Id) void { } } +pub fn getSpritesheetTexture(id: Spritesheet.Id) Texture.Id { + const self = &g_state; + const spritesheet = self.spritesheets.get(id) orelse return .nil; + return spritesheet.texture; +} + pub const Image = struct { format: Image.Format, pixels: [*]u8, @@ -1351,9 +1384,13 @@ pub const Image = struct { } pub fn getPixel(self: Image, x: usize, y: usize) []u8 { + return self.getPixelLine(x, y, 1); + } + + pub fn getPixelLine(self: Image, x: usize, y: usize, width: usize) []u8 { const pixel_index = y * self.width + x; const pixel_size = self.format.pixelSize(); - return self.pixels[(pixel_size*pixel_index)..][0..pixel_size]; + return self.pixels[(pixel_size*pixel_index)..][0..(width*pixel_size)]; } pub fn setRect(self: Image, x: u32, y: u32, width: u32, height: u32, pixel: []const u8) void { diff --git a/src/platform/input.zig b/src/platform/input.zig index 7d3745e..2669dd6 100644 --- a/src/platform/input.zig +++ b/src/platform/input.zig @@ -98,6 +98,18 @@ pub fn getMouseDown(self: Input, button: MouseButton) ?Nanoseconds { return self.mouse_buttons.getDown(button, self.now); } +fn nanosecondsToSeconds(nanoseconds: Nanoseconds) f32 { + return @floatCast(@as(f64, @floatFromInt(nanoseconds)) / std.time.ns_per_s); +} + +pub fn deltaTime(self: Input) f32 { + return nanosecondsToSeconds(self.delta_time); +} + +pub fn time(self: Input) f32 { + return nanosecondsToSeconds(self.now); +} + pub const KeyCode = enum(std.math.IntFittingRange(0, sokol.app.max_keycodes-1)) { SPACE = 32, APOSTROPHE = 39, diff --git a/src/platform/math.zig b/src/platform/math.zig index a826f69..34335f2 100644 --- a/src/platform/math.zig +++ b/src/platform/math.zig @@ -1,6 +1,8 @@ const std = @import("std"); const assert = std.debug.assert; +const ok_color = @import("ok_color"); + pub const bytes_per_kib = 1024; pub const bytes_per_mib = bytes_per_kib * 1024; pub const bytes_per_gib = bytes_per_mib * 1024; @@ -485,3 +487,103 @@ pub fn isInsideRect(rect_pos: Vec2, rect_size: Vec2, pos: Vec2) bool { }; return rect.isInside(pos); } + +pub fn rgb_lerp_in_oklab(from: Vec3, to: Vec3, t: f32) Vec3 { + const from_oklab = ok_color.linear_srgb_to_oklab(.{ + .r = from.x, + .g = from.y, + .b = from.z, + }); + + const to_oklab = ok_color.linear_srgb_to_oklab(.{ + .r = to.x, + .g = to.y, + .b = to.z, + }); + + const color = ok_color.oklab_to_linear_srgb(.{ + .L = std.math.lerp(from_oklab.L, to_oklab.L, t), + .a = std.math.lerp(from_oklab.a, to_oklab.a, t), + .b = std.math.lerp(from_oklab.b, to_oklab.b, t) + }); + + return Vec3.init(color.r, color.g, color.b); +} + +pub fn rgba_lerp_in_oklab(from: Vec4, to: Vec4, t: f32) Vec4 { + const lerped = rgb_lerp_in_oklab(from.toVec3XYZ(), to.toVec3XYZ(), t); + const alpha = std.math.lerp(from.w, to.w, t); + return Vec4.initVec3XYZ(lerped, alpha); +} + +pub fn rgb_shift_in_okhsv(rgb_color: Vec3, hue_shift: f32, saturation_shift: f32, value_shift: f32) Vec3 { + var hsv_color = rgb_to_okhv(rgb_color); + hsv_color.x = @mod(hsv_color.x + hue_shift, 1); + hsv_color.y = std.math.clamp(hsv_color.y + saturation_shift, 0, 1); + hsv_color.z = std.math.clamp(hsv_color.z + value_shift, 0, 1); + + var shifted_rgb_color = okhsv_to_rgb(hsv_color); + shifted_rgb_color = shifted_rgb_color.clamp(0, 1); + return shifted_rgb_color; +} + +pub fn rgb_value_shift_in_okhsv(rgb_color: Vec3, value_shift: f32) Vec3 { + return rgb_shift_in_okhsv(rgb_color, 0, 0, value_shift); +} + +pub fn rgba_value_shift_in_okhsv(rgba_color: Vec4, value_shift: f32) Vec4 { + const shifted = rgb_value_shift_in_okhsv(rgba_color.toVec3XYZ(), value_shift); + return Vec4.initVec3XYZ(shifted, rgba_color.w); +} + +pub fn rgb_to_okhv(rgb_color: Vec3) Vec3 { + const srgb = linear_rgb_to_srgb(.{ + .x = rgb_color.x, + .y = rgb_color.y, + .z = rgb_color.z, + }); + const hsv_color = ok_color.srgb_to_okhsv(.{ + .r = srgb.x, + .g = srgb.y, + .b = srgb.z, + }); + return Vec3.init(hsv_color.h, hsv_color.s, hsv_color.v); +} + +pub fn okhsv_to_rgb(hsv_color: Vec3) Vec3 { + const srgb_color = ok_color.okhsv_to_srgb(.{ + .h = hsv_color.x, + .s = hsv_color.y, + .v = hsv_color.z, + }); + const rgb_color = srgb_to_linear_rgb(.{ + .x = srgb_color.r, + .y = srgb_color.g, + .z = srgb_color.b, + }); + return Vec3.init(rgb_color.x, rgb_color.y, rgb_color.z); +} + +fn srgb_to_linear_rgb(a: Vec3) Vec3 { + return Vec3.init( + ok_color.srgb_transfer_function_inv(a.x), + ok_color.srgb_transfer_function_inv(a.y), + ok_color.srgb_transfer_function_inv(a.z) + ); +} + +fn linear_rgb_to_srgb(a: Vec3) Vec3 { + return Vec3.init( + ok_color.srgb_transfer_function(a.x), + ok_color.srgb_transfer_function(a.y), + ok_color.srgb_transfer_function(a.z), + ); +} + +pub fn ease_out_back(t: f32) f32 { + const c1 = 1.70158; + const c3 = c1 + 1.0; + + const pow = std.math.pow; + return 1.0 + c3 * pow(f32, t - 1.0, 3) + c1 * pow(f32, t - 1.0, 2); +} diff --git a/src/platform/root.zig b/src/platform/root.zig index a8af323..b0e0d33 100644 --- a/src/platform/root.zig +++ b/src/platform/root.zig @@ -105,10 +105,12 @@ fn PlatformType(App: type) type { _ = self.frame_arena.reset(.free_all); // TODO: make arena reset mode configurable - const now = Io.Timestamp.now(self.io, .awake); - self.input.now = @intCast(self.started_at.durationTo(now).nanoseconds); - self.input.delta_time = @intCast(self.last_frame_at.durationTo(now).nanoseconds); - self.last_frame_at = now; + { + const now = Io.Timestamp.now(self.io, .awake); + self.input.now = @intCast(self.started_at.durationTo(now).nanoseconds); + self.input.delta_time = @intCast(self.last_frame_at.durationTo(now).nanoseconds); + self.last_frame_at = now; + } if (self.input.keyboard.pressed.contains(.F4)) { self.show_imgui = !self.show_imgui; @@ -181,6 +183,14 @@ fn PlatformType(App: type) type { self.input.mouse_buttons.pressed = .empty; self.input.mouse_buttons.released = .empty; self.input.mouse_scroll = .init(0, 0); + + if (Gfx.getFrameIndex() == 1) { + const now = Io.Timestamp.now(self.io, .awake); + const time_to_first_frame = self.started_at.durationTo(now); + if (time_to_first_frame.nanoseconds > 100 * std.time.ns_per_ms) { + log.warn("Time to first frame is long, {f}", .{time_to_first_frame}); + } + } } fn sokolFrame(user_data: ?*anyopaque) callconv(.c) void { @@ -610,16 +620,12 @@ pub const Frame = struct { input_events: []Input.Event, request_quit: bool, - fn nanosecondsToSeconds(nanoseconds: Nanoseconds) f32 { - return @floatCast(@as(f64, @floatFromInt(nanoseconds)) / std.time.ns_per_s); - } - pub fn deltaTime(self: Frame) f32 { - return nanosecondsToSeconds(self.input.delta_time); + return self.input.deltaTime(); } pub fn time(self: Frame) f32 { - return nanosecondsToSeconds(self.input.now); + return self.input.time(); } }; diff --git a/src/platform/shader.glsl b/src/platform/shader.glsl index 82a15c6..ac67fd4 100644 --- a/src/platform/shader.glsl +++ b/src/platform/shader.glsl @@ -40,7 +40,8 @@ void main() { if (texture_mode == 0) { frag_color = tex_color * color; } else if (texture_mode == 1) { - frag_color = vec4(1, 1, 1, tex_color.r) * color; + float opacity = tex_color.r; + frag_color = vec4(opacity, opacity, opacity, opacity) * color; } } @end diff --git a/src/ui.zig b/src/ui.zig index caa999e..dc57fb6 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1,10 +1,13 @@ const std = @import("std"); const log = std.log; const assert = std.debug.assert; +const Allocator = std.mem.Allocator; const Font = @import("./font.zig"); +const NineRect = @import("./nine_rect.zig"); const Platform = @import("platform"); const Gfx = Platform.Gfx; +const Audio = Platform.Audio; const Math = Platform.Math; const Color = Platform.Color; const Vec2 = Platform.Vec2; @@ -169,29 +172,51 @@ pub const Sides = struct { } }; -const Box = struct { - margin: Sides, - padding: Sides, -}; - pub const Frame = struct { container: Rect = .zero, + button_normal_sprite: Gfx.Sprite.Id = .nil, + button_down_sprite: Gfx.Sprite.Id = .nil, + button_sound: []const Audio.BufferId = &.{}, key: Key = .nil, font: ?*Font = null, text_color: Color = .white, text_height: f32 = 64, margin: Sides = .empty, padding: Sides = .empty, + audio_bus: Audio.BusId = .nil +}; + +const Box = struct { + used: bool = false, + hot_t: f32 = 0, + hot_rising_edge_t: f32 = 0, + active_t: f32 = 0, }; pub const State = struct { hot: Key, active: Key, - pub const empty = State{ - .hot = .nil, - .active = .nil - }; + boxes: std.array_hash_map.Auto(Key, Box), + rng_state: std.Random.DefaultPrng, + + pub fn init(gpa: Allocator, seed: u64, max_boxes: usize) !State { + assert(max_boxes > 0); + + var self = State{ + .hot = .nil, + .active = .nil, + .boxes = .empty, + .rng_state = .init(seed) + }; + try self.boxes.ensureTotalCapacity(gpa, max_boxes); + self.boxes.putAssumeCapacity(.nil, .{}); + return self; + } + + pub fn deinit(self: *State, gpa: Allocator) void { + self.boxes.deinit(gpa); + } }; state: *State, @@ -212,6 +237,37 @@ pub fn init(state: *State, input: *Platform.Input) UI { } pub fn beginFrame(self: *UI) void { + { + var i: usize = 0; + while (i < self.state.boxes.count()) { + const entry = self.state.boxes.entries.get(i); + + if (!entry.value.used and entry.key != Key.nil) { + self.state.boxes.swapRemoveAt(i); + } else { + i += 1; + } + } + } + + const dt = self.input.deltaTime(); + const rate = 1 - std.math.pow(f32, 2, -45 * dt); + + for (self.state.boxes.keys()) |key| { + if (key == Key.nil) { + continue; + } + + const box = self.state.boxes.getEntry(key).?.value_ptr; + box.used = false; + + const is_hot_f32: f32 = @floatFromInt(@intFromBool(self.state.hot == key)); + const is_active_f32: f32 = @floatFromInt(@intFromBool(self.state.active == key)); + + box.hot_t += rate * (is_hot_f32 - box.hot_t ); + box.active_t += rate * (is_active_f32 - box.active_t); + } + if (self.state.active == Key.nil) { self.state.hot = .nil; } @@ -275,6 +331,21 @@ pub fn getMousePosition(self: *UI) ?Vec2 { return null; } +fn getBox(self: *UI, key: Key) *Box { + if (self.state.boxes.count() >= self.state.boxes.capacity()) { + log.warn("UI box limit reached!", .{}); + return self.state.boxes.getEntry(.nil).?.value_ptr; + } + + const gop = self.state.boxes.getOrPutAssumeCapacity(key); + const box = gop.value_ptr; + if (!gop.found_existing) { + box.* = .{}; + } + box.used = true; + return box; +} + const ButtonState = enum { normal, hot, @@ -308,29 +379,65 @@ fn buttonLogic(self: *UI, rect: Rect, key: Key) ButtonState { } } +fn squishRect(rect: Rect, sx: f32, sy: f32) Rect { + const amount = rect.size.multiply(.init(sx, sy)); + return Rect{ + .pos = rect.pos.sub(amount.divideScalar(2)), + .size = rect.size.add(amount) + }; +} + pub fn textButton(self: *UI, rect: Rect, text: []const u8) bool { const frame = self.top(); + const font = frame.font orelse return false; const text_bounds = self.measureText(text); - const button_state = self.buttonLogic(rect, Key.initString(@intFromEnum(frame.key), text)); + const key = Key.initString(@intFromEnum(frame.key), text); + const button_state = self.buttonLogic(rect, key); + const box = self.getBox(key); + var sprite: Gfx.Sprite.Id = .nil; if (button_state == .active) { - Gfx.drawRectangle(rect.pos, rect.size, .blue); - } else if (button_state == .hot) { - Gfx.drawRectangle(rect.pos, rect.size, .red); + sprite = frame.button_down_sprite; } else { - Gfx.drawRectangle(rect.pos, rect.size, .white); + sprite = frame.button_normal_sprite; } + const hot_t = Math.ease_out_back(box.hot_t); + const active_t = Math.ease_out_back(box.active_t); + + const animated_rect = squishRect( + rect, + 0.06 * hot_t - 0.1 * active_t, + -0.09 * hot_t + 0.16 * active_t, + ); + NineRect.draw( + sprite, + animated_rect.pos, + animated_rect.size, + Color.white.lerp_invert(hot_t * 0.2 - active_t * 0.2), + ); + const content_size = rect.size.sub(frame.padding.toVec2()); - 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 - ); + font.drawText(.{ + .pos = rect.pos + .sub(text_bounds.pos) + .add(.init(frame.padding.left, frame.padding.top)) + .add(content_size.sub(text_bounds.size).multiplyScalar(0.5)) + .add(.init(0, -5 * hot_t + 15 * active_t)), + .text = text, + .height = frame.text_height, + .color = frame.text_color.lerp_invert(hot_t * 0.9 + active_t * 0.1), + }); - return button_state == .released; + const result = button_state == .released; + if (result) { + var rng = self.state.rng_state.random(); + if (frame.button_sound.len > 0) { + _ = Audio.play(frame.button_sound[rng.uintLessThan(usize, frame.button_sound.len)], .{ .bus = frame.audio_bus }); + } + } + + return result; }