From ca2940191113df13a2214aef056fe4bcc1002229 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sat, 18 Jul 2026 20:30:35 +0300 Subject: [PATCH] add shop panels --- src/app.zig | 636 ++++++++++++++++++++++++++++++++++++++++------- src/color.zig | 3 + src/graphics.zig | 546 +++++++++++++++++++++++++++++++--------- src/imgui.zig | 43 ++++ src/math.zig | 19 +- 5 files changed, 1050 insertions(+), 197 deletions(-) diff --git a/src/app.zig b/src/app.zig index 1f49ade..af24b6e 100644 --- a/src/app.zig +++ b/src/app.zig @@ -37,6 +37,7 @@ entities: EntitySlotMap, eternal_flame_id: EntityId, upgrades: Upgrades, +shop: Shop, font: Gfx.Font.Id, idle_animations: AnimationDirections, @@ -227,19 +228,29 @@ const Upgrades = struct { minion_walk: MinionWalkStats = .{}, tree_station_count: u32 = 0, - tree_health: u32 = 10, - tree_respawn_duration: Nanoseconds = std.time.ns_per_s, - tree_wood_amount: u32 = 1, + tree_level: u32 = 1, + tree_health: u32 = tree_levels[0].health, + tree_respawn_duration: Nanoseconds = tree_levels[0].respawn_duration, + tree_wood_amount: u32 = tree_levels[0].wood_amount, + tree_wood_health: f32 = tree_levels[0].wood_health, - burn_max_stack_size: u32 = 10, burn_speed: f32 = 10, - burn_wood_health: f32 = 20, burn_heat_per_wood: u32 = 1, + burn_max_stack_size: u32 = 10, burn_levels: [2]BurnLevel = .{ .{ .threshold = 10, .multiplier = 2 }, .{ .threshold = 20, .multiplier = 3 }, }, + const tree_levels = [_]TreeLevel{ + TreeLevel{ + .health = 10, + .respawn_duration = std.time.ns_per_s, + .wood_amount = 1, + .wood_health = 20, + } + }; + const MinionWalkStats = struct { friction: f32 = 0.998, max_velocity: f32 = 50, @@ -250,6 +261,133 @@ const Upgrades = struct { threshold: u32, multiplier: u32, }; + + const TreeLevel = struct { + health: u32, + respawn_duration: Nanoseconds, + wood_amount: u32, + wood_health: f32, + }; +}; + +const Shop = struct { + minion_upgrades: []Item, + tree_upgrades: []Item, + fire_upgrades: []Item, + + const Item = struct { + label: []const u8, + + value_ref: ValueRef, + value_scaling: Scaling, + + cost: u32, + cost_scaling: Scaling, + + unlocked: bool = false, + limit: ?f32 = null, + + const ValueType = enum { + u32, + f32, + }; + + const ValueRef = union(ValueType) { + u32: *u32, + f32: *f32, + + pub fn initU32(value: *u32) ValueRef { + return ValueRef{ + .u32 = value + }; + } + + pub fn initF32(value: *f32) ValueRef { + return ValueRef{ + .f32 = value + }; + } + + pub fn deref(self: ValueRef) Value { + return switch(self) { + .u32 => |value| Value{ .u32 = value.* }, + .f32 => |value| Value{ .f32 = value.* }, + }; + } + + pub fn set(self: *ValueRef, new_value: Value) void { + assert(@as(ValueType, self.*) == @as(ValueType, new_value)); + + switch (new_value) { + .u32 => |value| { + self.u32.* = value; + }, + .f32 => |value| { + self.f32.* = value; + } + } + } + }; + + const Value = union(ValueType) { + u32: u32, + f32: f32, + + pub fn format(self: Value, writer: *std.Io.Writer) std.Io.Writer.Error!void { + switch (self) { + .u32 => |v| try writer.print("{}", .{ v }), + .f32 => |v| try writer.print("{:.2}", .{ v }), + } + } + }; + + const Scaling = struct { + fixed: f32, + linear: f32, + quadratic: f32, + + pub fn initFixed(fixed: f32) Scaling { + return Scaling{ + .fixed = fixed, + .linear = 1, + .quadratic = 0, + }; + } + + pub fn initLinear(fixed: f32, linear: f32) Scaling { + return Scaling{ + .fixed = fixed, + .linear = linear, + .quadratic = 0, + }; + } + + pub fn initQuadratic(fixed: f32, linear: f32, quadratic: f32) Scaling { + return Scaling{ + .fixed = fixed, + .linear = linear, + .quadratic = quadratic, + }; + } + + pub fn apply(self: Scaling, value: Value) Value { + const value_f32: f32 = switch(value) { + .u32 => |v| @floatFromInt(v), + .f32 => |v| v + }; + + var new_value_f32: f32 = 0; + new_value_f32 += value_f32 * value_f32 * self.quadratic; + new_value_f32 += value_f32 * self.linear; + new_value_f32 += self.fixed; + + return switch(value) { + .u32 => Value{ .u32 = @ceil(new_value_f32) }, + .f32 => Value{ .f32 = new_value_f32 } + }; + } + }; + }; }; fn initImageDataFromSTB(img: STBImage) Gfx.ImageData { @@ -309,9 +447,12 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { .camera_pos = .init(0, 0), .font = font, .eternal_flame_id = undefined, - .upgrades = .{} + .upgrades = .{}, + .shop = undefined, }; + try self.initShop(plt.arena); + self.spawnBurnStation(100); self.spawnBurnStation(150); @@ -323,6 +464,95 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 { return null; } +fn initShop(self: *App, arena: Allocator) !void { + const minion_upgrades = try arena.dupe(Shop.Item, &.{ + Shop.Item{ + .label = "Minion count", + .limit = 50, + .unlocked = true, + + .value_ref = .initU32(&self.upgrades.minion_count), + .value_scaling = .initFixed(1), + + .cost = 1, + .cost_scaling = .initFixed(1), + }, + Shop.Item{ + .label = "Carry capacity", + .unlocked = true, + + .value_ref = .initU32(&self.upgrades.minion_carry_capacity), + .value_scaling = .initFixed(1), + + .cost = 1, + .cost_scaling = .initFixed(1), + }, + }); + + const tree_upgrades = try arena.dupe(Shop.Item, &.{ + Shop.Item{ + .label = "Tree count", + .unlocked = true, + + .value_ref = .initU32(&self.upgrades.tree_station_count), + .value_scaling = .initFixed(1), + + .cost = 1, + .cost_scaling = .initFixed(1), + }, + Shop.Item{ + .label = "Tree level", + .limit = Upgrades.tree_levels.len, + .unlocked = true, + + .value_ref = .initU32(&self.upgrades.tree_level), + .value_scaling = .initFixed(1), + + .cost = 1, + .cost_scaling = .initFixed(1), + }, + }); + + const fire_upgrades = try arena.dupe(Shop.Item, &.{ + Shop.Item{ + .label = "Burn speed", + .unlocked = true, + + .value_ref = .initF32(&self.upgrades.burn_speed), + .value_scaling = .initFixed(1), + + .cost = 1, + .cost_scaling = .initFixed(1), + }, + Shop.Item{ + .label = "Heat per wood", + .unlocked = true, + + .value_ref = .initU32(&self.upgrades.burn_heat_per_wood), + .value_scaling = .initFixed(1), + + .cost = 1, + .cost_scaling = .initFixed(1), + }, + Shop.Item{ + .label = "Max stack size", + .unlocked = true, + + .value_ref = .initU32(&self.upgrades.burn_max_stack_size), + .value_scaling = .initFixed(1), + + .cost = 1, + .cost_scaling = .initFixed(1), + }, + }); + + self.shop = .{ + .minion_upgrades = minion_upgrades, + .tree_upgrades = tree_upgrades, + .fire_upgrades = fire_upgrades, + }; +} + fn spawnEntity(self: *App, e: Entity) ?EntityId { return self.entities.insert(e) catch { log.warn("Failed to spawn entity {}, entity limit reached", .{e.type}); @@ -391,7 +621,7 @@ fn destroyTree(self: *App, tree_id: EntityId) void { } fn spawnWood(self: *App, pos: Vec2) void { - const health = self.upgrades.burn_wood_health; + const health = self.upgrades.tree_wood_health; _ = self.spawnEntity(Entity{ .type = .wood, @@ -773,6 +1003,318 @@ fn getBurnMultiplier(self: *App) u32 { return 1; } +fn buyUpgrade(self: *App, shop_item: *Shop.Item) void { + if (self.heat < shop_item.cost) { + return; + } + + self.heat -= shop_item.cost; + + shop_item.cost = shop_item.cost_scaling.apply(.{ .u32 = shop_item.cost }).u32; + + const value = shop_item.value_ref.deref(); + const new_value = shop_item.value_scaling.apply(value); + shop_item.value_ref.set(new_value); +} + +fn showShopPanel( + self: *App, + plt: Platform.Frame, + panel_label: []const u8, + panel_pos: Vec2, + shop_items: []Shop.Item +) !void { + var unlocked_count: f32 = 0; + for (shop_items) |*shop_item| { + if (shop_item.unlocked) { + unlocked_count += 1; + } + } + + if (unlocked_count == 0) { + return; + } + + const panel_size = Vec2.init(400, unlocked_count * 32 + 10); + const border_width = 5; + const border_color = Color.rgb(180, 180, 180); + + Gfx.transformPush(); + defer Gfx.transformPop(); + + Gfx.transformTranslate(panel_pos); + Gfx.transformTranslate(.init(-panel_size.x/2, -panel_size.y)); + Gfx.drawRectangleOutline(.init(0, 0), panel_size, border_color, border_width, 0); + + Gfx.transformTranslate(.init(0, panel_size.y)); + { + const panel_label_size = 40; + const panel_label_margin = Vec2.init(15, 10); + const panel_label_width = Gfx.measureText(self.font, panel_label_size, panel_label).size.x; + + Gfx.transformPush(); + defer Gfx.transformPop(); + + Gfx.drawText(self.font, .{ + .text = panel_label, + .pos = panel_label_margin.add(.init(0, 5)), + .height = panel_label_size + }); + Gfx.drawRectangleOutline( + .init(0, border_width), + Vec2.init(panel_label_width, panel_label_size).add(panel_label_margin.multiplyScalar(2)), + border_color, + border_width, + 0 + ); + } + + Gfx.transformTranslate(.init(10, -5)); + + var index: usize = 0; + for (shop_items) |*shop_item| { + if (!shop_item.unlocked) { + continue; + } + defer index += 1; + + Gfx.transformPush(); + defer Gfx.transformPop(); + Gfx.transformTranslate(.init(0, -32 * @as(f32, @floatFromInt(index + 1)))); + + const hitbox = Rect.init(-10, 0, panel_size.x, 32); + const cost = shop_item.cost; + + var limit_reached = false; + if (shop_item.limit) |limit| { + limit_reached = switch (shop_item.value_ref) { + .u32 => |value| @as(f32, @floatFromInt(value.*)) >= limit, + .f32 => |value| value.* >= limit + }; + } + + var text_color = Color.white; + var is_mouse_hovering = false; + if (limit_reached) { + text_color = .rgb(120, 120, 120); + } else if (plt.input.mouse_position) |screen_mouse| { + const transform = Gfx.getTransform(); + const world_mouse = screen_mouse.sub(transform.offset); + if (hitbox.isInside(world_mouse)) { + is_mouse_hovering = true; + } + } + + if (is_mouse_hovering) { + if (self.heat >= cost) { + if (plt.input.isMousePressed(.left)) { + self.buyUpgrade(shop_item); + } + + if (plt.input.isMouseDown(.left)) { + text_color = .rgb(100, 200, 100); + } else { + text_color = .rgb(180, 255, 180); + } + } else { + text_color = .rgb(255, 180, 180); + } + Gfx.drawLine( + .init(hitbox.left() + 5, hitbox.bottom() - 5), + .init(hitbox.right() - 5, hitbox.bottom() - 5), + text_color, + 2 + ); + } + + Gfx.drawText(self.font, .{ + .text = shop_item.label, + .pos = .init(0, 0), + .height = 24, + .color = text_color + }); + + const allocPrint = std.fmt.allocPrint; + Gfx.drawText(self.font, .{ + .text = try allocPrint(plt.arena, "{f}", .{shop_item.value_ref.deref()}), + .pos = .init(180, 0), + .height = 24, + .color = text_color + }); + + if (is_mouse_hovering) { + const new_value = shop_item.value_scaling.apply(shop_item.value_ref.deref()); + Gfx.drawText(self.font, .{ + .text = try allocPrint(plt.arena, "> {f}", .{new_value}), + .pos = .init(240, 0), + .height = 24, + .color = text_color + }); + } + + Gfx.drawText(self.font, .{ + .text = try std.fmt.allocPrint(plt.arena, "{}H", .{cost}), + .pos = .init(panel_size.x - 20, 0), + .height = 24, + .color = text_color, + .alignment = .init(1, 0) + }); + } +} + +fn showUI(self: *App, plt: Platform.Frame) !void { + var heat_pos = Vec2.init(0, -200); + + { // Draw heat counter + var builder = Gfx.TextBuilder.init(plt.arena); + + builder.font(self.font, 64); + builder.color = .rgb(200, 20, 20); + try builder.put(try std.fmt.allocPrint(plt.arena, "{}", .{self.heat})); + + builder.font(self.font, 32); + builder.color = .rgb(240, 100, 100); + builder.offset(.init(10, 0)); + try builder.put("Heat"); + + builder.draw(heat_pos.sub(builder.bounds.size.divideScalar(2))); + } + + try self.showShopPanel(plt, "Minion", Vec2.init(0, -600), self.shop.minion_upgrades); + try self.showShopPanel(plt, "Flame", Vec2.init(600, -600), self.shop.fire_upgrades); + try self.showShopPanel(plt, "Trees", Vec2.init(-600, -600), self.shop.tree_upgrades); +} + +fn showDebugScaling(label: []const u8, scaling: *Shop.Item.Scaling) void { + ImGUI.pushID(.{ .string = label }); + defer ImGUI.popID(); + + const field_width = 80; + + ImGUI.text("{s}", .{label}); + ImGUI.sameLine(); + ImGUI.setNextItemWidth(field_width); + _ = ImGUI.inputF32Drag(.{ .label = "fixed", .value = &scaling.fixed }); + ImGUI.sameLine(); + ImGUI.setNextItemWidth(field_width); + _ = ImGUI.inputF32Drag(.{ .label = "linear", .value = &scaling.linear }); + ImGUI.sameLine(); + ImGUI.setNextItemWidth(field_width); + _ = ImGUI.inputF32Drag(.{ .label = "quadratic", .value = &scaling.quadratic }); +} + +fn showDebugShopItems(self: *App, label: []const u8, shop_items: []Shop.Item) void { + _ = self; // autofix + ImGUI.pushID(.{ .string = label }); + defer ImGUI.popID(); + + _ = ImGUI.text("{s}", .{label}); + for (0.., shop_items) |i, *shop_item| { + ImGUI.pushID(.{ .int = @intCast(i) }); + defer ImGUI.popID(); + + if (ImGUI.collapsingHeader(shop_item.label)) { + _ = ImGUI.inputU32("cost", &shop_item.cost); + _ = ImGUI.checkbox("unlocked", &shop_item.unlocked); + + showDebugScaling("Cost scaling", &shop_item.cost_scaling); + showDebugScaling("Value scaling", &shop_item.value_scaling); + } + } +} + +fn showDebug(self: *App, plt: Platform.Frame) !void { + _ = plt; // autofix + if (!ImGUI.beginWindow(.{ .name = "Debug", .alpha = 0.8, .size = .init(500, 200), .collapsed = true })) { + return; + } + defer ImGUI.endWindow(); + + if (ImGUI.beginTabBar("tab bar")) { + defer ImGUI.endTabBar(); + + if (ImGUI.beginTabItem("Upgrades")) { + defer ImGUI.endTabItem(); + + var heat: u32 = @intCast(self.heat); + if (ImGUI.inputU32("heat", &heat)) { + self.heat = @intCast(heat); + } + + ImGUI.separator(); + _ = ImGUI.text("Minion upgrades", .{}); + _ = ImGUI.inputU32("count", &self.upgrades.minion_count); + _ = ImGUI.inputU32("carry_capacity", &self.upgrades.minion_carry_capacity); + + _ = ImGUI.inputU32("throw_strength", &self.upgrades.minion_throw_strength); + _ = ImGUI.inputDuration("throw_cooldown", &self.upgrades.minion_throw_cooldown); + + _ = ImGUI.inputU32("chop_damage", &self.upgrades.minion_chop_damage); + _ = ImGUI.inputF32Drag(.{ + .label = "chop_rate", + .value = &self.upgrades.minion_chop_rate + }); + + _ = ImGUI.inputF32Drag(.{ + .label = "walk_max_speed", + .value = &self.upgrades.minion_walk.max_speed + }); + _ = ImGUI.inputF32Drag(.{ + .label = "walk_max_vel", + .value = &self.upgrades.minion_walk.max_velocity + }); + _ = ImGUI.inputF32Drag(.{ + .label = "walk_friction", + .value = &self.upgrades.minion_walk.friction, + .ex = .{ .min = 0, .max = 1, .speed = 0.0001 } + }); + + ImGUI.separator(); + _ = ImGUI.text("Tree upgrades", .{}); + _ = ImGUI.inputU32("level", &self.upgrades.tree_level); + _ = ImGUI.inputU32("station_count", &self.upgrades.tree_station_count); + _ = ImGUI.inputU32("health", &self.upgrades.tree_health); + _ = ImGUI.inputU32("wood_amount", &self.upgrades.tree_wood_amount); + _ = ImGUI.inputF32(.{ + .label = "wood_health", + .value = &self.upgrades.tree_wood_health + }); + _ = ImGUI.inputDuration("respawn_duration", &self.upgrades.tree_respawn_duration); + + ImGUI.separator(); + _ = ImGUI.text("Burn upgrades", .{}); + _ = ImGUI.inputF32Drag(.{ + .label = "speed", + .value = &self.upgrades.burn_speed + }); + _ = ImGUI.inputU32("heat_per_wood", &self.upgrades.burn_heat_per_wood); + _ = ImGUI.inputU32("max_stack_size", &self.upgrades.burn_max_stack_size); + for (1.., &self.upgrades.burn_levels) |i, *level| { + ImGUI.pushID(.{ .int = @intCast(i) }); + defer ImGUI.popID(); + + _ = ImGUI.text("Level {}", .{i}); + ImGUI.sameLine(); + ImGUI.setNextItemWidth(150); + _ = ImGUI.inputU32("threshold", &level.threshold); + ImGUI.sameLine(); + ImGUI.setNextItemWidth(100); + _ = ImGUI.inputU32("multiplier", &level.multiplier); + } + } + + if (ImGUI.beginTabItem("Shop")) { + defer ImGUI.endTabItem(); + + self.showDebugShopItems("Minion", self.shop.minion_upgrades); + ImGUI.separator(); + self.showDebugShopItems("Tree", self.shop.tree_upgrades); + ImGUI.separator(); + self.showDebugShopItems("Fire", self.shop.fire_upgrades); + } + } +} + pub fn frame(self: *App, plt: Platform.Frame) !void { const input = plt.input; _ = input; // autofix @@ -790,8 +1332,8 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { self.camera_pos = self.camera_pos.sub(plt.input.mouse_delta); } - Gfx.viewTranslate(plt.input.window_size.x/2, plt.input.window_size.y/2); - Gfx.viewTranslate(-self.camera_pos.x, -self.camera_pos.y); + Gfx.transformTranslate(plt.input.window_size.divideScalar(2)); + Gfx.transformTranslate(self.camera_pos.multiplyScalar(-1)); Gfx.setClearColor(.rgb(40, 40, 40)); Gfx.drawLine(.init(-800, 0), .init(800, 0), .white, 5); @@ -1186,80 +1728,8 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { } } - Gfx.drawText(self.font, .{ - .height = 32, - .pos = .init(-50, -200), - .text = try std.fmt.allocPrint(plt.arena, "Heat: {}", .{self.heat}), - }); - - if (ImGUI.beginWindow(.{ .name = "Debug", .alpha = 0.8, .size = .init(500, 200) })) { - defer ImGUI.endWindow(); - - var heat: u32 = @intCast(self.heat); - if (ImGUI.inputU32("heat", &heat)) { - self.heat = @intCast(heat); - } - - ImGUI.separator(); - _ = ImGUI.text("Minion upgrades", .{}); - _ = ImGUI.inputU32("count", &self.upgrades.minion_count); - _ = ImGUI.inputU32("carry_capacity", &self.upgrades.minion_carry_capacity); - - _ = ImGUI.inputU32("throw_strength", &self.upgrades.minion_throw_strength); - _ = ImGUI.inputDuration("throw_cooldown", &self.upgrades.minion_throw_cooldown); - - _ = ImGUI.inputU32("chop_damage", &self.upgrades.minion_chop_damage); - _ = ImGUI.inputF32Drag(.{ - .label = "chop_rate", - .value = &self.upgrades.minion_chop_rate - }); - - _ = ImGUI.inputF32Drag(.{ - .label = "walk_max_speed", - .value = &self.upgrades.minion_walk.max_speed - }); - _ = ImGUI.inputF32Drag(.{ - .label = "walk_max_vel", - .value = &self.upgrades.minion_walk.max_velocity - }); - _ = ImGUI.inputF32Drag(.{ - .label = "walk_friction", - .value = &self.upgrades.minion_walk.friction, - .ex = .{ .min = 0, .max = 1, .speed = 0.0001 } - }); - - ImGUI.separator(); - _ = ImGUI.text("Tree upgrades", .{}); - _ = ImGUI.inputU32("station_count", &self.upgrades.tree_station_count); - _ = ImGUI.inputU32("health", &self.upgrades.tree_health); - _ = ImGUI.inputU32("wood_amount", &self.upgrades.tree_wood_amount); - _ = ImGUI.inputDuration("respawn_duration", &self.upgrades.tree_respawn_duration); - - ImGUI.separator(); - _ = ImGUI.text("Burn upgrades", .{}); - _ = ImGUI.inputF32Drag(.{ - .label = "speed", - .value = &self.upgrades.burn_speed - }); - _ = ImGUI.inputF32(.{ - .label = "wood_health", - .value = &self.upgrades.burn_wood_health - }); - _ = ImGUI.inputU32("heat_per_wood", &self.upgrades.burn_heat_per_wood); - _ = ImGUI.inputU32("max_stack_size", &self.upgrades.burn_max_stack_size); - for (1.., &self.upgrades.burn_levels) |i, *level| { - ImGUI.pushID(.{ .int = @intCast(i) }); - defer ImGUI.popID(); - - _ = ImGUI.text("Level {}", .{i}); - ImGUI.sameLine(); - ImGUI.setNextItemWidth(150); - _ = ImGUI.inputU32("threshold", &level.threshold); - ImGUI.sameLine(); - ImGUI.setNextItemWidth(100); - _ = ImGUI.inputU32("multiplier", &level.multiplier); - } - } + try self.showUI(plt); + try self.showDebug(plt); } pub fn deinit(self: *App, plt: Platform.Deinit) void { diff --git a/src/color.zig b/src/color.zig index 007f842..5feaa54 100644 --- a/src/color.zig +++ b/src/color.zig @@ -11,6 +11,9 @@ a: f32, pub const black = rgb(0, 0, 0); pub const white = rgb(255, 255, 255); pub const purple = rgb(255, 0, 255); +pub const red = rgb(255, 0, 0); +pub const blue = rgb(0, 0, 255); +pub const green = rgb(0, 255, 0); pub fn rgba(r: u8, g: u8, b: u8, a: f32) Color { assert(0 <= a and a <= 1); diff --git a/src/graphics.zig b/src/graphics.zig index 04986c3..907ce9a 100644 --- a/src/graphics.zig +++ b/src/graphics.zig @@ -26,7 +26,6 @@ const State = struct { frame_index: u64, - view: Mat4, shader: sg.Shader, pipeline: sg.Pipeline, bindings: sg.Bindings, @@ -45,6 +44,9 @@ const State = struct { clear_color: Color, + transforms_buffer: [32]TransformFrame, + transforms: std.ArrayList(TransformFrame) = .empty, + textures_buffer: [Texture.max_textures]Texture.SlotMap.Slot, textures: Texture.SlotMap, nil_texture: Texture.Id, @@ -151,7 +153,6 @@ pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { self.* = State{ .gpa = gpa, .frame_index = 0, - .view = .initIdentity(), .shader = shader, .pipeline = pipeline, .bindings = bindings, @@ -167,6 +168,9 @@ pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { .nearest_sampler = nearest_sampler, .default_sampler = .nearest, + .transforms_buffer = undefined, + .transforms = .initBuffer(&self.transforms_buffer), + .textures_buffer = undefined, .textures = .init(&self.textures_buffer), .nil_texture = undefined, @@ -240,7 +244,7 @@ pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { var pixels: [width * height * 4]u8 = undefined; @memset(&pixels, 0xFF); - self.default_sprite = initSprite(.{ }); + self.default_sprite = initSprite(.{ .padding = 0 }); setSprite(self.default_sprite, .{ .image = ImageData{ .width = width, @@ -330,23 +334,62 @@ fn toSokolColor(color: Color) sokol.gfx.Color { }; } -pub fn viewTranslate(x: f32, y: f32) void { - flush(.{}); - +fn getTransformPtr() ?*TransformFrame { const self = &g_state; - self.view.translate(x, y, 0); + if (self.transforms.items.len == 0) { + return null; + } + + return &self.transforms.items[self.transforms.items.len-1]; } -pub fn viewScale(x: f32, y: f32) void { - flush(.{}); +pub fn transformTranslate(offset: Vec2) void { + const top_frame = getTransformPtr() orelse return; + top_frame.offset = top_frame.offset.add(offset.multiply(top_frame.scale)); +} + +pub fn transformScale(scale: Vec2) void { + const top_frame = getTransformPtr() orelse return; + + top_frame.scale = top_frame.scale.multiply(scale); +} + +pub fn transformPush() void { const self = &g_state; - self.view.scale(x, y, 1); + + const top_frame = getTransformPtr() orelse return; + + self.transforms.appendBounded(top_frame.*) catch { + log.warn("Transform stack limit reached!", .{}); + }; +} + +pub fn transformPop() void { + const self = &g_state; + _ = self.transforms.pop(); +} + +pub fn getTransform() TransformFrame { + if (getTransformPtr()) |top_frame| { + return top_frame.*; + } else { + return TransformFrame{ + .offset = .init(0, 0), + .scale = .init(1, 1) + }; + } } pub fn beginFrame() void { const self = &g_state; + self.transforms.clearRetainingCapacity(); + self.transforms.appendAssumeCapacity(TransformFrame{ + .offset = .init(0, 0), + .scale = .init(1, 1), + }); + var pass_action: sg.PassAction = .{}; pass_action.colors[0] = .{ .load_action = .CLEAR, @@ -358,8 +401,6 @@ pub fn beginFrame() void { .swapchain = sglue.swapchain() }); - self.view = .initIdentity(); - const default_spritesheet = self.spritesheets.getAssumeExists(self.default_spritesheet); const spritesheet_texture = self.textures.getAssumeExists(default_spritesheet.texture); self.bindings.views[shd.VIEW_tex] = spritesheet_texture.view; @@ -448,7 +489,7 @@ pub fn flush(opts: FlushOptions) void { var vs_params: shd.VsParams = .{ // TODO: Maybe 'view' matrix is not needed. // We probably should only have a combined 'mvp' matrix - .view = self.view, + .view = .initIdentity(), .projection = createProjectionMatrix(window_size) }; @@ -480,6 +521,12 @@ pub fn endFrame() void { while (spritesheet_iter.next()) |spritesheet_id| { repackSpritesheetIfNeeded(spritesheet_id); } + + if (self.transforms.items.len > 1) { + log.warn("Too many calls to transformPush()", .{}); + } else if (self.transforms.items.len == 0) { + log.warn("Too many calls to transformPop()", .{}); + } } pub fn drawRectangle(pos: Vec2, size: Vec2, color: Color) void { @@ -488,6 +535,54 @@ pub fn drawRectangle(pos: Vec2, size: Vec2, color: Color) void { drawSprite(self.default_sprite, pos, size, color); } +pub fn drawRectangleOutline(pos: Vec2, size: Vec2, color: Color, width: f32, alignment: f32) void { + const self = &g_state; + + const outer_rect = (Rect{ .pos = pos, .size = size }).grow(Vec2.init(width, width).multiplyScalar(1-alignment)); + const inner_rect = (Rect{ .pos = pos, .size = size }).shrink(Vec2.init(width, width).multiplyScalar(alignment)); + + drawSpriteQuad( + self.default_sprite, + .{ + .init(outer_rect.right(), outer_rect.top()), + .init(outer_rect.left(), outer_rect.top()), + .init(inner_rect.right(), inner_rect.top()), + .init(inner_rect.left(), inner_rect.top()), + }, + color + ); + drawSpriteQuad( + self.default_sprite, + .{ + .init(outer_rect.right(), outer_rect.top()), + .init(inner_rect.right(), inner_rect.top()), + .init(outer_rect.right(), outer_rect.bottom()), + .init(inner_rect.right(), inner_rect.bottom()), + }, + color + ); + drawSpriteQuad( + self.default_sprite, + .{ + .init(outer_rect.left(), outer_rect.top()), + .init(inner_rect.left(), inner_rect.top()), + .init(outer_rect.left(), outer_rect.bottom()), + .init(inner_rect.left(), inner_rect.bottom()), + }, + color + ); + drawSpriteQuad( + self.default_sprite, + .{ + .init(outer_rect.right(), outer_rect.bottom()), + .init(outer_rect.left(), outer_rect.bottom()), + .init(inner_rect.right(), inner_rect.bottom()), + .init(inner_rect.left(), inner_rect.bottom()), + }, + color + ); +} + pub fn drawLine(from: Vec2, to: Vec2, color: Color, width: f32) void { const self = &g_state; @@ -749,6 +844,12 @@ pub fn draw(opts: DrawOptions) void { flush(.{}); } + if (getTransformPtr()) |transform| { + for (&quad.vertices) |*vertex| { + vertex.position = transform.apply(vertex.position); + } + } + self.quads.appendAssumeCapacity(quad); } @@ -1066,11 +1167,17 @@ pub fn getSpriteUVRect(id: Sprite.Id) ?Rect { assert(sprite.position != null); + const sprite_size = Vec2.initFromInt(u32, sprite_data.width, sprite_data.height); - return Rect{ + var uv_rect = Rect{ .pos = sprite.position.?.divide(spritesheet.size), .size = sprite_size.divide(spritesheet.size), }; + + const texel_size = Vec2.init(1, 1).divide(spritesheet.size); + uv_rect = uv_rect.shrink(texel_size.divideScalar(8)); + + return uv_rect; } fn drawSpriteQuad(id: Sprite.Id, points: [4]Vec2, color: Color) void { @@ -1086,19 +1193,10 @@ fn drawSpriteQuad(id: Sprite.Id, points: [4]Vec2, color: Color) void { var texture: ?Texture.Id = null; if (id != self.nil_sprite) { if (getSpriteUVRect(id)) |sprite_uv| { + quad.setUVRect(sprite_uv); + const sprite = self.sprites.getAssumeExists(id); const spritesheet = self.spritesheets.getAssumeExists(sprite.spritesheet); - const texel_size = Vec2.init(1, 1).divide(spritesheet.size); - _ = texel_size; // autofix - - // TODO: I hope this doesn't bite me in the ass. - // The shrink of the UV by half a texel - // This is to avoid linear sampling artifacts at edges - // - // Yes I did regret this. Fuck. - // - // quad.setUVRect(sprite_uv.shrink(texel_size.divideScalar(2))); - quad.setUVRect(sprite_uv); texture = spritesheet.texture; } } @@ -1215,119 +1313,327 @@ pub fn setFont(id: Font.Id, ttf_data: [:0]const u8) void { font.cache = .init(); } -const DrawTextOptions = struct { - text: []const u8, - pos: Vec2, - height: f32, - color: Color = .white -}; - -pub fn drawText(id: Font.Id, opts: DrawTextOptions) void { +fn getFont(id: Font.Id) ?*Font { const self = &g_state; if (id == self.nil_font) { - return; + return null; } - const font = self.fonts.get(id) orelse { - log.warn("Attempt to draw text with font that doesn't exist: {}", .{id}); - return; - }; - const stb_font = font.stb orelse { - log.warn("Attempt to draw text with font that isn't set: {}", .{id}); - return; - }; + return self.fonts.get(id); +} - const fallback_glyph = stb_font.findGlyphIndex('?') orelse { - log.warn("Failed to find fallback glyph '?'", .{}); - return; - }; +pub fn getGlyphIndex(id: Font.Id, codepoint: u21) ?GlyphIndex { + const font = getFont(id) orelse return null; + const stb_font = font.stb orelse return null; - const scale = stb_font.scaleForPixelHeight(opts.height); - const scale_x = scale; - const scale_y = scale; + if (stb_font.findGlyphIndex(codepoint)) |index| { + return index; + } else { + // Fallback + return getGlyphIndex(id, '?'); + } +} + +pub fn getFontPixelScale(id: Font.Id, height: f32) f32 { + const font = getFont(id) orelse return 0; + const stb_font = font.stb orelse return 0; + + return stb_font.scaleForPixelHeight(height); +} + +pub fn getFontAscent(id: Font.Id, height: f32) f32 { + const font = getFont(id) orelse return 0; + const stb_font = font.stb orelse return 0; const vmetrics = stb_font.getFontVMetrics(); - const baseline = @as(f32, @floatFromInt(vmetrics.ascent)) * scale; + const scale = stb_font.scaleForPixelHeight(height); + return @as(f32, @floatFromInt(vmetrics.ascent)) * scale; +} - var current_point = opts.pos; - current_point.y += baseline; +pub fn getGlyph(id: Font.Id, index: GlyphIndex, scale_x: f32, scale_y: f32) ?*Font.Glyph { + const self = &g_state; + const font = getFont(id) orelse return null; + const stb_font = font.stb orelse return null; - var prev_glyph: ?u32 = null; - var cursor: usize = 0; - while (cursor < opts.text.len) { - const codepoint_len = std.unicode.utf8ByteSequenceLength(opts.text[cursor]) catch |e| { - log.err("Failed to draw text, invalid codepoint at {} in '{s}': {}", .{cursor, opts.text, e}); - return; + // TODO: Allow specifying a different scale for x and y + const glyph_key = Font.Glyph.Key{ + .index = index, + .scale_x = scale_x, + .scale_y = scale_y + }; + + var glyph: *Font.Glyph = undefined; + if (font.cache.lookup(glyph_key)) |glyph_id| { + return font.cache.get(glyph_id); + } else { + const glyph_id = font.cache.insert(glyph_key) orelse { + @panic("TODO: render glyph lru_tail"); }; - const codepoint_bytes = opts.text[cursor..][0..codepoint_len]; - const codepoint = std.unicode.utf8Decode(codepoint_bytes) catch |e| { - log.err("Failed to draw text, invalid codepoint at {} in '{s}': {}", .{cursor, opts.text, e}); - return; - }; - cursor += codepoint_len; + glyph = font.cache.get(glyph_id); + glyph.box = stb_font.getGlyphBitmapBox(glyph_key.index, glyph_key.scale_x, glyph_key.scale_y); - const glyph_index = stb_font.findGlyphIndex(codepoint) orelse fallback_glyph; - const glyph_key = Font.Glyph.Key{ .index = glyph_index, .scale_x = scale_x, .scale_y = scale_y }; + const box_width: u32 = @intCast(glyph.box.x1 - glyph.box.x0); + const box_height: u32 = @intCast(glyph.box.y1 - glyph.box.y0); + if (box_width > 0 and box_height > 0) { + assert(glyph.sprite == self.nil_sprite); + glyph.sprite = initSprite(.{ .spritesheet = font.spritesheet, .padding = 1 }); - var glyph: *Font.Glyph = undefined; - if (font.cache.lookup(glyph_key)) |glyph_id| { - glyph = font.cache.get(glyph_id); - } else { - const glyph_id = font.cache.insert(glyph_key) orelse { - @panic("TODO: render glyph lru_tail"); + assert(box_width < Font.Glyph.max_width); + assert(box_height < Font.Glyph.max_height); + + var bitmap_buffer: [Font.Glyph.max_width * Font.Glyph.max_height]u8 = undefined; + const bitmap = ImageData{ + .pixels = .{ .r8 = (&bitmap_buffer).ptr }, + .width = box_width, + .height = box_height }; - glyph = font.cache.get(glyph_id); - glyph.box = stb_font.getGlyphBitmapBox(glyph_key.index, glyph_key.scale_x, glyph_key.scale_y); - - const width: u32 = @intCast(glyph.box.x1 - glyph.box.x0); - const height: u32 = @intCast(glyph.box.y1 - glyph.box.y0); - if (width > 0 and height > 0) { - assert(glyph.sprite == self.nil_sprite); - glyph.sprite = initSprite(.{ .spritesheet = font.spritesheet, .padding = 1 }); - - assert(width < Font.Glyph.max_width); - assert(height < Font.Glyph.max_height); - - var bitmap_buffer: [Font.Glyph.max_width * Font.Glyph.max_height]u8 = undefined; - const bitmap = ImageData{ - .pixels = .{ .r8 = (&bitmap_buffer).ptr }, - .width = width, - .height = height - }; - stb_font.makeGlyphBitmap( - bitmap.pixels.?.r8, - width, height, - width, - glyph_key.scale_x, glyph_key.scale_y, - glyph_key.index - ); - setSprite(glyph.sprite, .{ .image = bitmap }); - } else { - glyph.sprite = self.nil_sprite; - } + stb_font.makeGlyphBitmap( + bitmap.pixels.?.r8, + box_width, box_height, + box_width, + glyph_key.scale_x, glyph_key.scale_y, + glyph_key.index + ); + setSprite(glyph.sprite, .{ .image = bitmap }); + } else { + glyph.sprite = self.nil_sprite; } - glyph.used_this_frame = true; + } + glyph.used_this_frame = true; - if (prev_glyph != null) { - const kerning = stb_font.getGlyphKernAdvance(prev_glyph.?, glyph_index); - current_point.x += @as(f32, @floatFromInt(kerning)) * scale; + return glyph; +} + +pub const TextRunLayout = struct { + font: Font.Id, + scale_x: f32, + scale_y: f32, + + prev_glyph: ?GlyphIndex, + pen_pos: Vec2, + + const GlyphRect = struct { + rect: Rect, + sprite: Sprite.Id, + }; + + pub fn init(font: Font.Id, height: f32) TextRunLayout { + const scale = getFontPixelScale(font, height); + + return TextRunLayout{ + .font = font, + .scale_x = scale, + .scale_y = scale, + .prev_glyph = null, + .pen_pos = .init(0, 0) + }; + } + + pub fn next(self: *TextRunLayout, glyph_index: GlyphIndex) ?GlyphRect { + const glyph = getGlyph(self.font, glyph_index, self.scale_x, self.scale_y) orelse return null; + + const font = getFont(self.font) orelse return null; + const stb_font = font.stb orelse return null; + + if (self.prev_glyph) |prev_glyph| { + const kerning = stb_font.getGlyphKernAdvance(prev_glyph, glyph_index); + self.pen_pos.x += @as(f32, @floatFromInt(kerning)) * self.scale_x; } const hmetrics = stb_font.getGlyphHMetrics(glyph_index); const glyph_size = Vec2.initFromInt(i32, glyph.box.x1 - glyph.box.x0, glyph.box.y1 - glyph.box.y0); - if (glyph.sprite != self.nil_sprite) { - var draw_pos = current_point; - draw_pos.x -= @floatFromInt(glyph.box.x0); - draw_pos.x += @as(f32, @floatFromInt(hmetrics.left_side_bearing)) * scale; - draw_pos.y += @floatFromInt(glyph.box.y0); - drawSprite(glyph.sprite, draw_pos, glyph_size, opts.color); + + var draw_pos = self.pen_pos; + draw_pos.x -= @floatFromInt(glyph.box.x0); + draw_pos.x += @as(f32, @floatFromInt(hmetrics.left_side_bearing)) * self.scale_x; + draw_pos.y += @floatFromInt(glyph.box.y0); + const draw_rect = Rect{ + .pos = draw_pos, + .size = glyph_size + }; + + self.pen_pos.x += @as(f32, @floatFromInt(hmetrics.advance_width)) * self.scale_x; + self.prev_glyph = glyph_index; + + return GlyphRect{ + .rect = draw_rect, + .sprite = glyph.sprite + }; + } +}; + +pub fn measureText(id: Font.Id, height: f32, text: []const u8) Rect { + var bounds: Rect = .init(0, 0, 0, 0); + var first_glyph = false; + + var layout = TextRunLayout.init(id, height); + var iter = std.unicode.Utf8Iterator{ + .bytes = text, + .i = 0 + }; + while (iter.nextCodepoint()) |codepoint| { + const glyph_index = getGlyphIndex(id, codepoint) orelse continue; + const glyph_layout = layout.next(glyph_index) orelse continue; + + if (first_glyph) { + bounds = glyph_layout.rect; + first_glyph = false; + } else { + bounds = bounds.expand(glyph_layout.rect); + } + } + + return bounds; +} + +pub const DrawTextOptions = struct { + text: []const u8, + pos: Vec2, + height: f32, + color: Color = .white, + alignment: Vec2 = .init(0, 0) +}; + +pub fn drawText(id: Font.Id, opts: DrawTextOptions) void { + const self = &g_state; + + var layout = TextRunLayout.init(id, opts.height); + + var pos = opts.pos; + pos.y += getFontAscent(id, opts.height); + + if (!opts.alignment.eql(.zero)) { + const bounds = measureText(id, opts.height, opts.text); + pos = pos.sub(bounds.size.multiply(opts.alignment)); + } + + var iter = std.unicode.Utf8Iterator{ + .bytes = opts.text, + .i = 0 + }; + while (iter.nextCodepoint()) |codepoint| { + const glyph_index = getGlyphIndex(id, codepoint) orelse continue; + const glyph_layout = layout.next(glyph_index) orelse continue; + if (glyph_layout.sprite == self.nil_sprite) { + continue; } - current_point.x += @as(f32, @floatFromInt(hmetrics.advance_width)) * scale; - prev_glyph = glyph_index; + drawSprite( + glyph_layout.sprite, + glyph_layout.rect.pos.add(pos), + glyph_layout.rect.size, + opts.color + ); } } +pub const TextBuilder = struct { + gpa: std.mem.Allocator, + glyphs: std.ArrayList(Glyph), + + color: Color, + baseline: f32, + layout: ?TextRunLayout, + + bounds: Rect, + + const Glyph = struct { + sprite: Sprite.Id, + rect: Rect, + color: Color + }; + + pub fn init(gpa: std.mem.Allocator) TextBuilder { + return TextBuilder{ + .gpa = gpa, + .glyphs = .empty, + .color = .white, + .baseline = 0, + .layout = null, + .bounds = .init(0, 0, 0, 0) + }; + } + + pub fn font(self: *TextBuilder, id: Font.Id, height: f32) void { + var new_layout = TextRunLayout.init(id, height); + if (self.layout) |layout| { + if (new_layout.font == layout.font and + new_layout.scale_x == layout.scale_x and + new_layout.scale_y == layout.scale_y + ) { + return; + } + } + + const font_baseline = getFontAscent(id, height); + if (font_baseline > self.baseline) { + // Fixup previously drawn glyph to match the new larger baseline + const offset_y = font_baseline - self.baseline; + for (self.glyphs.items) |*glyph| { + glyph.rect.pos.y += offset_y; + } + self.bounds.pos.y += offset_y; + self.baseline = font_baseline; + } + + if (self.layout) |layout| { + new_layout.pen_pos = layout.pen_pos; + if (layout.font == new_layout.font) { + new_layout.prev_glyph = layout.prev_glyph; + } + } + + self.layout = new_layout; + } + + pub fn putCodepoint(self: *TextBuilder, codepoint: u21) !void { + if (self.layout == null) { + return; + } + const layout = &self.layout.?; + + const glyph_index = getGlyphIndex(layout.font, codepoint) orelse return; + const glyph_layout = layout.next(glyph_index) orelse return; + + var rect = glyph_layout.rect; + rect.pos.y += self.baseline; + + try self.glyphs.append(self.gpa, Glyph{ + .sprite = glyph_layout.sprite, + .rect = rect, + .color = self.color + }); + + if (self.bounds.size.x > 0 and self.bounds.size.y > 0) { + self.bounds = self.bounds.expand(rect); + } else { + self.bounds = rect; + } + } + + pub fn put(self: *TextBuilder, text: []const u8) !void { + var iter = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 }; + while (iter.nextCodepoint()) |codepoint| { + try self.putCodepoint(codepoint); + } + } + + pub fn draw(self: *TextBuilder, pos: Vec2) void { + for (self.glyphs.items) |glyph| { + if (glyph.sprite == g_state.nil_sprite) { + continue; + } + drawSprite(glyph.sprite, glyph.rect.pos.add(pos), glyph.rect.size, glyph.color); + } + } + + pub fn offset(self: *TextBuilder, vec2: Vec2) void { + if (self.layout) |*layout| { + layout.pen_pos = layout.pen_pos.add(vec2); + } + } +}; + pub const ImageData = struct { const Format = enum { rgba8, @@ -1947,7 +2253,10 @@ pub const Font = struct { const max_width: u32 = 512; const max_height: u32 = 512; + const Index = u32; + const Key = packed struct { + // TODO: Quantize `scale_x` and `scale_y`. index: u32, scale_x: f32, scale_y: f32, @@ -1979,6 +2288,8 @@ pub const Font = struct { }; }; +const GlyphIndex = Font.Glyph.Index; + pub const Vertex = extern struct { position: Vec2, texcoord: Vec2, @@ -2023,3 +2334,12 @@ pub const Quad = extern struct { self.vertices[3].position = pos.add(size); } }; + +pub const TransformFrame = struct { + offset: Vec2, + scale: Vec2, + + pub fn apply(self: TransformFrame, position: Vec2) Vec2 { + return position.multiply(self.scale).add(self.offset); + } +}; diff --git a/src/imgui.zig b/src/imgui.zig index 187ef51..54ed204 100644 --- a/src/imgui.zig +++ b/src/imgui.zig @@ -460,3 +460,46 @@ pub fn setNextItemWidth(width: f32) void { ig.igSetNextItemWidth(width); } + +pub fn beginTabBar(id: [*c]const u8) bool { + if (isDisabled()) { + return false; + } + + return ig.igBeginTabBar(id, ig.ImGuiTabBarFlags_None); +} + +pub fn endTabBar() void { + if (isDisabled()) { + return; + } + + ig.igEndTabBar(); +} + +pub fn beginTabItem(label: [*c]const u8) bool { + if (isDisabled()) { + return false; + } + + return ig.igBeginTabItem(label, null, ig.ImGuiTabItemFlags_None); +} + +pub fn endTabItem() void { + if (isDisabled()) { + return; + } + + return ig.igEndTabItem(); +} + +pub fn collapsingHeader(label: []const u8) bool { + if (isDisabled()) { + return false; + } + + return ig.igCollapsingHeader( + formatString("{s}", .{label}), + ig.ImGuiTreeNodeFlags_None + ); +} diff --git a/src/math.zig b/src/math.zig index 08cf77d..037b6fa 100644 --- a/src/math.zig +++ b/src/math.zig @@ -404,6 +404,19 @@ pub const Rect = struct { ); } + pub fn expand(self: Rect, other: Rect) Rect { + const left_edge = @min(self.left(), other.left()); + const right_edge = @max(self.right(), other.right()); + const top_edge = @min(self.top(), other.top()); + const bottom_edge = @max(self.bottom(), other.bottom()); + return Rect.init( + left_edge, + top_edge, + right_edge - left_edge, + bottom_edge - top_edge + ); + } + pub fn left(self: Rect) f32 { return self.pos.x; } @@ -447,9 +460,13 @@ pub const Rect = struct { pub fn shrink(self: Rect, margin: Vec2) Rect { return Rect{ .pos = self.pos.add(margin), - .size = self.pos.add(margin.multiplyScalar(2)) + .size = self.size.sub(margin.multiplyScalar(2)) }; } + + pub fn grow(self: Rect, margin: Vec2) Rect { + return self.shrink(margin.multiplyScalar(-1)); + } }; pub const Line = struct {