Compare commits

...

5 Commits

Author SHA1 Message Date
fbf1d4c040 add bare bones level select 2026-10-07 02:44:20 +03:00
05698200d2 pause menu progress 2026-10-04 14:53:25 +03:00
dbae325cdb add stack size limit 2026-10-04 13:34:35 +03:00
576df80400 ui progress 2026-10-04 13:14:59 +03:00
fad9f779b6 remove coins 2026-10-03 17:58:22 +03:00
24 changed files with 1334 additions and 325 deletions

Binary file not shown.

View File

@ -0,0 +1,22 @@
UI Pack (2.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 12-06-2024
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
------------------------------
Donate: http://support.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:
http://twitter.com/KenneyNL

Binary file not shown.

View File

@ -1,93 +0,0 @@
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

View File

@ -128,7 +128,11 @@ fn getPackageSuffix(target: Build.ResolvedTarget) []const u8 {
};
}
const bytes_per_kib = 1024;
const bytes_per_mib = bytes_per_kib * 1024;
const ProjectOptions = struct {
stack_size: usize = 4 * bytes_per_mib,
imgui: ?bool = null,
assets_from_filesystem: ?bool = null,
tracy: ?bool = null,
@ -141,10 +145,15 @@ fn buildProject(ctx: Context, target: Build.ResolvedTarget, optimize: std.builti
const asset_files = [_][]const u8{
"sokoban/sokoban_tilesheet.png",
"sokoban/sokoban_tilesheet@2.png",
"roboto-font/Roboto-Regular.ttf",
"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();
@ -188,6 +197,9 @@ fn buildProject(ctx: Context, target: Build.ResolvedTarget, optimize: std.builti
if (assets_from_filesystem) {
build_options.addOptionPathDirectory("assets_dir", assets_dir);
}
if (is_debug and !is_wasm) {
build_options.addOption(usize, "stack_size_limit", opts.stack_size);
}
platform_mod.addOptions("build_options", build_options);
platform_mod.addImport("embedded_assets", try createAssetsModule(b, assets_dir, &asset_files));
@ -195,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", .{
@ -275,6 +289,18 @@ fn buildProject(ctx: Context, target: Build.ResolvedTarget, optimize: std.builti
&(dep_sokol.artifact("sokol_clib").step)
);
var extra_args: std.ArrayList([]const u8) = .empty;
try extra_args.append(b.allocator, "-sALLOW_MEMORY_GROWTH");
try extra_args.append(b.allocator, "-sENVIRONMENT=web");
try extra_args.append(b.allocator, b.fmt("-sSTACK_SIZE={}", .{opts.stack_size}));
if (is_debug) {
try extra_args.append(b.allocator, "-sASSERTIONS=2");
try extra_args.append(b.allocator, "-sSTACK_OVERFLOW_CHECK=2");
try extra_args.append(b.allocator, "-gsource-map");
try extra_args.append(b.allocator, "-g2");
}
const link_step = try sokol.emLinkStep(b, .{
.lib_main = main_lib,
.target = target,
@ -284,7 +310,7 @@ fn buildProject(ctx: Context, target: Build.ResolvedTarget, optimize: std.builti
.use_emmalloc = true,
.use_filesystem = true,
.shell_file_path = b.path("src/platform/shell.html"),
.extra_args = &.{ "-sALLOW_MEMORY_GROWTH", "-sENVIRONMENT=web" }
.extra_args = extra_args.items
});
return ProjectResult{

View File

@ -15,6 +15,9 @@
.stb = .{
.path = "./libs/stb",
},
.ok_color = .{
.path = "./libs/ok_color",
},
.tiled = .{
.path = "./libs/tiled",
},

37
libs/ok_color/build.zig Normal file
View File

@ -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());
}

View File

@ -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,
}

View File

@ -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;

View File

@ -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();
@ -96,7 +98,6 @@ const Entity = struct {
player,
box,
wall,
coin,
box_hole,
box_goal,
};
@ -333,37 +334,52 @@ const BoxSprites = struct {
};
};
world: World,
undo_stack: std.ArrayList(UndoFrame) = .empty,
current_level: u32 = 0,
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),
animation_timer: Platform.Nanoseconds,
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,
world: World,
undo_stack: std.ArrayList(UndoFrame) = .empty,
current_level: u32 = 0,
animation_timer: Platform.Nanoseconds = 0,
menu_state: ?MenuState = null,
pub fn init(self: *App, plt: Platform.Init) !?u8 {
const roboto_font = try Font.init(plt.gpa, .{
var roboto_font = try Font.init(plt.gpa, .{
.spritesheet = Gfx.initSpritesheet(.{ .format = .r8 }),
.ttf_data = plt.assets.readFile("roboto-font/Roboto-Regular.ttf")
.ttf_data = plt.assets.readFile("kenney-ui/Font/Kenney Future Narrow.ttf")
});
roboto_font.prepareASCII(80);
const tilesheet1_png = try STBImage.load(plt.assets.readFile("sokoban/sokoban_tilesheet.png"));
const tilesheet2_png = try STBImage.load(plt.assets.readFile("sokoban/sokoban_tilesheet@2.png"));
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,
@ -374,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
}
);
@ -399,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")
});
@ -410,48 +428,100 @@ pub fn init(self: *App, plt: Platform.Init) !?u8 {
self.* = App{
.player_sprites = player_sprites,
.roboto_font = roboto_font,
.animation_timer = 0,
.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" }),
.world = undefined,
.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);
@ -512,7 +582,7 @@ fn loadLevels(self: *App, gpa: Allocator, assets: *Platform.Assets) !std.ArrayLi
}
};
std.mem.sort(World, levels.items, Context{}, Context.lessThan);
std.sort.insertion(World, levels.items, Context{}, Context.lessThan);
return levels;
}
@ -545,7 +615,8 @@ fn loadLevel(
.x = @intCast(ox),
.y = @intCast(oy),
},
.sprite = self.tilesheet.get(pos.x, pos.y)
.visual_pos = .initFromInt(usize, ox, oy),
.sprite = self.sokoban_tilesheet.get(pos.x, pos.y)
});
}
@ -565,8 +636,6 @@ fn loadLevel(
color = BoxColor.string_mapping.get(props_color) orelse return error.UnknownBoxColor;
} else if (std.mem.eql(u8, props_type, "player")) {
entity_type = .player;
} else if (std.mem.eql(u8, props_type, "coin")) {
entity_type = .coin;
} else if (std.mem.eql(u8, props_type, "box_hole")) {
entity_type = .box_hole;
color = BoxColor.string_mapping.get(props_color) orelse .none;
@ -588,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
});
}
@ -632,42 +701,98 @@ 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);
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 = world.entities.iterator();
while (entity_iter.next()) |entity_id| {
const entity = world.entities.getAssumeExists(entity_id);
if (entity.visual_layer != layer) {
continue;
}
var sprite = entity.sprite;
const box_hole_margin = 0.1;
if (entity.type == .box) {
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 (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) {
is_correct = box_goal.color == entity.color;
}
if (is_correct) {
sprite = self.box_sprites_by_color.get(entity.color).darker;
}
}
}
} else if (entity.type == .box_hole) {
var box_iter = world.iterEntities(.{ .type = .box, .pos = entity.pos });
while (box_iter.next()) |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) {
Gfx.drawSprite(
sprite.?,
entity.visual_pos.multiply(tile_size),
tile_size,
.white
);
}
}
}
}
pub fn frame(self: *App, plt: *Platform.Frame) !void {
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();
var world = &self.world;
const menu_shown = self.menu_state != null;
if (!menu_shown) {
const input = getPlayerInput(plt.input);
if (input.undo) {
if (self.undo_stack.pop()) |undo_frame| {
undo_frame.apply(&self.world);
undo_frame.apply(world);
}
}
if (input.restart) {
try self.setWorldByLevel(self.current_level);
self.setWorldByLevel(self.current_level);
}
var tick_context: TickContext = .{
.world = &self.world
};
var tick_context: TickContext = .{ .world = world };
{
var entity_iter = self.world.entities.iterator();
var entity_iter = world.entities.iterator();
while (entity_iter.next()) |entity_id| {
var interpolate_pos = false;
const entity = self.world.entities.getAssumeExists(entity_id);
const entity = world.entities.getAssumeExists(entity_id);
if (entity.type == .player) {
interpolate_pos = true;
@ -677,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) {
@ -706,8 +831,6 @@ pub fn frame(self: *App, plt: Platform.Frame) !void {
self.animation_timer = 0;
entity.player_frame_index = 0;
}
entity.sprite = frames[entity.player_frame_index];
}
}
}
@ -716,15 +839,16 @@ pub fn frame(self: *App, plt: Platform.Frame) !void {
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 });
var goal_iter = world.iterEntities(.{ .type = .box_goal });
while (goal_iter.next()) |goal_id| {
const goal = self.world.entities.getAssumeExists(goal_id);
const goal = world.entities.getAssumeExists(goal_id);
goal_count += 1;
if (self.world.findEntity(.{ .pos = goal.pos, .type = .box })) |box| {
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;
@ -736,67 +860,103 @@ pub fn frame(self: *App, plt: Platform.Frame) !void {
}
}
}
}
inline for (.{ Entity.Layer.ground, Entity.Layer.on_ground, Entity.Layer.surface }) |layer| {
var entity_iter = self.world.entities.iterator();
while (entity_iter.next()) |entity_id| {
const entity = self.world.entities.getAssumeExists(entity_id);
if (entity.visual_layer != layer) {
continue;
}
var sprite = entity.sprite;
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| {
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 (box_goal.visual_pos.distance(entity.visual_pos) < 0.2) {
var is_correct = true;
if (box_goal.color != .none) {
is_correct = box_goal.color == entity.color;
}
if (is_correct) {
sprite = self.box_sprites_by_color.get(entity.color).darker;
}
}
}
} else if (entity.type == .box_hole) {
var box_iter = self.world.iterEntities(.{ .type = .box, .pos = entity.pos });
while (box_iter.next()) |box_id| {
const box = self.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;
}
}
}
if (sprite != null) {
Gfx.drawSprite(
sprite.?,
entity.visual_pos.multiply(tile_size),
tile_size,
.white
);
}
}
}
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);
self.setWorldByLevel(self.current_level);
}
}
}
pub fn setWorldByLevel(self: *App, index: u32) !void {
{
var canvas = CanvasScaler{
.size = Vec2.initFromInt(usize, world.width, world.height).multiply(tile_size)
};
canvas.push();
defer canvas.pop(clear_color);
try self.drawWorld(world);
if (menu_shown) {
var color = clear_color;
color.a = 0.8;
Gfx.drawRectangle(.init(0, 0), canvas.size, color);
}
}
if (self.menu_state) |menu_state| {
var canvas = CanvasScaler{
.size = Vec2.initFromInt(usize, 1920, 1080)
};
canvas.push();
defer canvas.pop(null);
var ui = UI.init(&self.ui_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 = 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;
if (menu_state == .main) {
ui_frame.padding.addHorizontal(150);
ui_frame.padding.addVertical(80);
var layout = UI.VerticalLayout{ .ui = &ui, .gap = 30 };
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: usize) void {
self.world.copy(self.levels.items[index]);
self.undo_stack.clearRetainingCapacity();
}
@ -807,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);
}

View File

@ -36,9 +36,10 @@ pub fn push(self: Canvas) void {
Gfx.transformScale(.init(scale, scale));
}
pub fn pop(self: Canvas, color: Color) void {
pub fn pop(self: Canvas, maybe_color: ?Color) void {
Gfx.transformPop();
if (maybe_color) |color| {
const scale = self.getScale();
const window_size = Gfx.getWindowSize();
const scaled_canvas = self.size.multiplyScalar(scale);
@ -68,3 +69,4 @@ pub fn pop(self: Canvas, color: Color) void {
);
}
}
}

View File

@ -46,6 +46,22 @@ pub fn deinit(self: *Font, gpa: Allocator) void {
self.cache.deinit(gpa);
}
pub fn prepareASCII(font: *Font, height: f32) void {
font.prepareGlyphs(height, 32, 127);
}
pub fn prepareGlyphs(font: *Font, height: f32, first_codepoint: u21, last_codepoint: u21) void {
const scale = font.stb.scaleForPixelHeight(height);
const scale_x = scale;
const scale_y = scale;
for (first_codepoint..last_codepoint) |i| {
const codepoint: u21 = @intCast(i);
const glyph_index = font.getGlyphIndex(codepoint) orelse continue;
_ = font.getGlyph(glyph_index, scale_x, scale_y);
}
}
pub fn getGlyphIndex(font: *Font, codepoint: u21) ?Glyph.Index {
if (font.stb.findGlyphIndex(codepoint)) |index| {
return index;
@ -93,19 +109,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 +142,23 @@ 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 getDescent(font: *Font, height: f32) f32 {
const vmetrics = font.stb.getFontVMetrics();
const scale = font.stb.scaleForPixelHeight(height);
return @as(f32, @floatFromInt(vmetrics.descent)) * scale;
}
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 +169,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 +187,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 +206,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 +344,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 +382,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 +399,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];

94
src/nine_rect.zig Normal file
View File

@ -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
});
}
}

View File

@ -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];

View File

@ -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
};
}

View File

@ -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();
@ -743,9 +753,7 @@ pub fn setTexture(id: Texture.Id, opts: SetTextureOptions) void {
texture.image = new_image;
texture.updated_at = null;
log.debug("is dynamic {f}, {}", .{id, texture.dynamic});
if (texture.dynamic) {
log.debug("update texture {f}", .{id});
sg.updateImage(new_image, image_data);
texture.updated_at = self.frame.index;
}
@ -1170,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 {
@ -1253,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,
@ -1343,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 {
@ -1435,6 +1480,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 +1488,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 +1496,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 +1504,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 +1512,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 +1823,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);
}
};

View File

@ -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,

View File

@ -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;
@ -464,6 +466,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));
}
@ -481,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);
}

View File

@ -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;
}
if (self.input.keyboard.pressed.contains(.F4)) {
self.show_imgui = !self.show_imgui;
@ -126,16 +128,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();
@ -176,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 {
@ -603,21 +618,18 @@ pub const Frame = struct {
assets: *Assets,
input: *Input,
input_events: []Input.Event,
fn nanosecondsToSeconds(nanoseconds: Nanoseconds) f32 {
return @floatCast(@as(f64, @floatFromInt(nanoseconds)) / std.time.ns_per_s);
}
request_quit: bool,
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();
}
};
pub fn run(App: type, opts: RunOptions) void {
fn runThread(App: type, opts: RunOptions) void {
const gpa = opts.init.gpa;
const io = opts.init.io;
@ -700,6 +712,19 @@ pub fn run(App: type, opts: RunOptions) void {
});
}
pub fn run(App: type, opts: RunOptions) void {
if (@hasDecl(build_options, "stack_size_limit")) {
var t = std.Thread.spawn(
.{ .stack_size = build_options.stack_size_limit },
runThread,
.{App, opts}
) catch @panic("Failed to launch thread for limiting stack size");
t.join();
} else {
runThread(App, opts);
}
}
fn posixSignalHandler(sig: std.posix.SIG) callconv(.c) void {
_ = sig;
sapp.requestQuit();

View File

@ -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

443
src/ui.zig Normal file
View File

@ -0,0 +1,443 @@
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;
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 = Vec2{
.x = font.measureText(text, frame.text_height).size.x,
.y = font.getAscent(frame.text_height)
};
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());
}
};
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,
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,
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 {
{
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;
}
}
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;
}
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,
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;
}
}
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 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) {
sprite = frame.button_down_sprite;
} else {
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());
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),
});
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;
}