From fbf1d4c040ca1540e4aff85a6bc8d9b593d323d5 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Wed, 7 Oct 2026 02:44:20 +0300 Subject: [PATCH] add bare bones level select --- assets/kenney-ui/Sounds/click-b.ogg | Bin 0 -> 6157 bytes build.zig | 9 +- build.zig.zon | 3 + libs/ok_color/build.zig | 37 ++++++ libs/ok_color/build.zig.zon | 12 ++ libs/ok_color/src/root.zig | 14 ++ src/app.zig | 196 ++++++++++++++++++++-------- src/nine_rect.zig | 94 +++++++++++++ src/platform/audio.zig | 9 +- src/platform/color.zig | 38 ++++++ src/platform/graphics.zig | 51 +++++++- src/platform/input.zig | 12 ++ src/platform/math.zig | 102 +++++++++++++++ src/platform/root.zig | 26 ++-- src/platform/shader.glsl | 3 +- src/ui.zig | 151 +++++++++++++++++---- 16 files changed, 659 insertions(+), 98 deletions(-) create mode 100644 assets/kenney-ui/Sounds/click-b.ogg create mode 100644 libs/ok_color/build.zig create mode 100644 libs/ok_color/build.zig.zon create mode 100644 libs/ok_color/src/root.zig create mode 100644 src/nine_rect.zig diff --git a/assets/kenney-ui/Sounds/click-b.ogg b/assets/kenney-ui/Sounds/click-b.ogg new file mode 100644 index 0000000000000000000000000000000000000000..570102c3b5d7213de2f6fe74fd99bcf9a061a4d1 GIT binary patch literal 6157 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 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; }