commit 9332649a63e2f7b6e81b4dc72ff17c0764658953 Author: Rokas Puzonas Date: Mon Jul 13 22:52:29 2026 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e5c1f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +zig-out +zig-pkg +.zig-cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfd0eec --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Gaem + +## Run + +Linux and Windows: +```sh +zig build run +``` + +Web: +```sh +zig build -Dtarget=wasm32-emscripten run +``` + +Cross-compile for Windows from Linux: +```sh +zig build -Dtarget=x86_64-windows +``` diff --git a/assets/Prototype_Character/Blue/_aseprite/death.aseprite b/assets/Prototype_Character/Blue/_aseprite/death.aseprite new file mode 100644 index 0000000..739f737 Binary files /dev/null and b/assets/Prototype_Character/Blue/_aseprite/death.aseprite differ diff --git a/assets/Prototype_Character/Blue/_aseprite/hurt.aseprite b/assets/Prototype_Character/Blue/_aseprite/hurt.aseprite new file mode 100644 index 0000000..b60713d Binary files /dev/null and b/assets/Prototype_Character/Blue/_aseprite/hurt.aseprite differ diff --git a/assets/Prototype_Character/Blue/_aseprite/idle.aseprite b/assets/Prototype_Character/Blue/_aseprite/idle.aseprite new file mode 100644 index 0000000..89c42f3 Binary files /dev/null and b/assets/Prototype_Character/Blue/_aseprite/idle.aseprite differ diff --git a/assets/Prototype_Character/Blue/_aseprite/walk.aseprite b/assets/Prototype_Character/Blue/_aseprite/walk.aseprite new file mode 100644 index 0000000..00d4bbc Binary files /dev/null and b/assets/Prototype_Character/Blue/_aseprite/walk.aseprite differ diff --git a/assets/Prototype_Character/Blue/_shadow/shadow_death.png b/assets/Prototype_Character/Blue/_shadow/shadow_death.png new file mode 100644 index 0000000..15b229d Binary files /dev/null and b/assets/Prototype_Character/Blue/_shadow/shadow_death.png differ diff --git a/assets/Prototype_Character/Blue/_shadow/shadow_static.png b/assets/Prototype_Character/Blue/_shadow/shadow_static.png new file mode 100644 index 0000000..ef02877 Binary files /dev/null and b/assets/Prototype_Character/Blue/_shadow/shadow_static.png differ diff --git a/assets/Prototype_Character/Blue/death.png b/assets/Prototype_Character/Blue/death.png new file mode 100644 index 0000000..8d893ae Binary files /dev/null and b/assets/Prototype_Character/Blue/death.png differ diff --git a/assets/Prototype_Character/Blue/hurt.png b/assets/Prototype_Character/Blue/hurt.png new file mode 100644 index 0000000..6480e44 Binary files /dev/null and b/assets/Prototype_Character/Blue/hurt.png differ diff --git a/assets/Prototype_Character/Blue/idle.png b/assets/Prototype_Character/Blue/idle.png new file mode 100644 index 0000000..1f16c57 Binary files /dev/null and b/assets/Prototype_Character/Blue/idle.png differ diff --git a/assets/Prototype_Character/Blue/walk.png b/assets/Prototype_Character/Blue/walk.png new file mode 100644 index 0000000..b6a60c1 Binary files /dev/null and b/assets/Prototype_Character/Blue/walk.png differ diff --git a/assets/Prototype_Character/Default/_aseprite/death.aseprite b/assets/Prototype_Character/Default/_aseprite/death.aseprite new file mode 100644 index 0000000..9137698 Binary files /dev/null and b/assets/Prototype_Character/Default/_aseprite/death.aseprite differ diff --git a/assets/Prototype_Character/Default/_aseprite/hurt.aseprite b/assets/Prototype_Character/Default/_aseprite/hurt.aseprite new file mode 100644 index 0000000..5a96694 Binary files /dev/null and b/assets/Prototype_Character/Default/_aseprite/hurt.aseprite differ diff --git a/assets/Prototype_Character/Default/_aseprite/idle.aseprite b/assets/Prototype_Character/Default/_aseprite/idle.aseprite new file mode 100644 index 0000000..5060819 Binary files /dev/null and b/assets/Prototype_Character/Default/_aseprite/idle.aseprite differ diff --git a/assets/Prototype_Character/Default/_aseprite/walk.aseprite b/assets/Prototype_Character/Default/_aseprite/walk.aseprite new file mode 100644 index 0000000..20e608c Binary files /dev/null and b/assets/Prototype_Character/Default/_aseprite/walk.aseprite differ diff --git a/assets/Prototype_Character/Default/_shadow/shadow_death.png b/assets/Prototype_Character/Default/_shadow/shadow_death.png new file mode 100644 index 0000000..15b229d Binary files /dev/null and b/assets/Prototype_Character/Default/_shadow/shadow_death.png differ diff --git a/assets/Prototype_Character/Default/_shadow/shadow_static.png b/assets/Prototype_Character/Default/_shadow/shadow_static.png new file mode 100644 index 0000000..ef02877 Binary files /dev/null and b/assets/Prototype_Character/Default/_shadow/shadow_static.png differ diff --git a/assets/Prototype_Character/Default/death.png b/assets/Prototype_Character/Default/death.png new file mode 100644 index 0000000..9f53bd8 Binary files /dev/null and b/assets/Prototype_Character/Default/death.png differ diff --git a/assets/Prototype_Character/Default/hurt.png b/assets/Prototype_Character/Default/hurt.png new file mode 100644 index 0000000..b7f92a6 Binary files /dev/null and b/assets/Prototype_Character/Default/hurt.png differ diff --git a/assets/Prototype_Character/Default/idle.png b/assets/Prototype_Character/Default/idle.png new file mode 100644 index 0000000..6836cf3 Binary files /dev/null and b/assets/Prototype_Character/Default/idle.png differ diff --git a/assets/Prototype_Character/Default/walk.png b/assets/Prototype_Character/Default/walk.png new file mode 100644 index 0000000..a08623d Binary files /dev/null and b/assets/Prototype_Character/Default/walk.png differ diff --git a/assets/Prototype_Character/Green/_aseprite/death.aseprite b/assets/Prototype_Character/Green/_aseprite/death.aseprite new file mode 100644 index 0000000..7d49266 Binary files /dev/null and b/assets/Prototype_Character/Green/_aseprite/death.aseprite differ diff --git a/assets/Prototype_Character/Green/_aseprite/hurt.aseprite b/assets/Prototype_Character/Green/_aseprite/hurt.aseprite new file mode 100644 index 0000000..0fd7530 Binary files /dev/null and b/assets/Prototype_Character/Green/_aseprite/hurt.aseprite differ diff --git a/assets/Prototype_Character/Green/_aseprite/idle.aseprite b/assets/Prototype_Character/Green/_aseprite/idle.aseprite new file mode 100644 index 0000000..f207eba Binary files /dev/null and b/assets/Prototype_Character/Green/_aseprite/idle.aseprite differ diff --git a/assets/Prototype_Character/Green/_aseprite/walk.aseprite b/assets/Prototype_Character/Green/_aseprite/walk.aseprite new file mode 100644 index 0000000..bc0b79e Binary files /dev/null and b/assets/Prototype_Character/Green/_aseprite/walk.aseprite differ diff --git a/assets/Prototype_Character/Green/_shadow/shadow_death.png b/assets/Prototype_Character/Green/_shadow/shadow_death.png new file mode 100644 index 0000000..15b229d Binary files /dev/null and b/assets/Prototype_Character/Green/_shadow/shadow_death.png differ diff --git a/assets/Prototype_Character/Green/_shadow/shadow_static.png b/assets/Prototype_Character/Green/_shadow/shadow_static.png new file mode 100644 index 0000000..ef02877 Binary files /dev/null and b/assets/Prototype_Character/Green/_shadow/shadow_static.png differ diff --git a/assets/Prototype_Character/Green/death.png b/assets/Prototype_Character/Green/death.png new file mode 100644 index 0000000..3731692 Binary files /dev/null and b/assets/Prototype_Character/Green/death.png differ diff --git a/assets/Prototype_Character/Green/hurt.png b/assets/Prototype_Character/Green/hurt.png new file mode 100644 index 0000000..01d8d61 Binary files /dev/null and b/assets/Prototype_Character/Green/hurt.png differ diff --git a/assets/Prototype_Character/Green/idle.png b/assets/Prototype_Character/Green/idle.png new file mode 100644 index 0000000..ac79e88 Binary files /dev/null and b/assets/Prototype_Character/Green/idle.png differ diff --git a/assets/Prototype_Character/Green/walk.png b/assets/Prototype_Character/Green/walk.png new file mode 100644 index 0000000..aa3ed79 Binary files /dev/null and b/assets/Prototype_Character/Green/walk.png differ diff --git a/assets/Prototype_Character/Red/_aseprite/death.aseprite b/assets/Prototype_Character/Red/_aseprite/death.aseprite new file mode 100644 index 0000000..8b75ade Binary files /dev/null and b/assets/Prototype_Character/Red/_aseprite/death.aseprite differ diff --git a/assets/Prototype_Character/Red/_aseprite/hurt.aseprite b/assets/Prototype_Character/Red/_aseprite/hurt.aseprite new file mode 100644 index 0000000..825b8ad Binary files /dev/null and b/assets/Prototype_Character/Red/_aseprite/hurt.aseprite differ diff --git a/assets/Prototype_Character/Red/_aseprite/idle.aseprite b/assets/Prototype_Character/Red/_aseprite/idle.aseprite new file mode 100644 index 0000000..d653ea6 Binary files /dev/null and b/assets/Prototype_Character/Red/_aseprite/idle.aseprite differ diff --git a/assets/Prototype_Character/Red/_aseprite/walk.aseprite b/assets/Prototype_Character/Red/_aseprite/walk.aseprite new file mode 100644 index 0000000..71e253f Binary files /dev/null and b/assets/Prototype_Character/Red/_aseprite/walk.aseprite differ diff --git a/assets/Prototype_Character/Red/_shadow/shadow_death.png b/assets/Prototype_Character/Red/_shadow/shadow_death.png new file mode 100644 index 0000000..15b229d Binary files /dev/null and b/assets/Prototype_Character/Red/_shadow/shadow_death.png differ diff --git a/assets/Prototype_Character/Red/_shadow/shadow_static.png b/assets/Prototype_Character/Red/_shadow/shadow_static.png new file mode 100644 index 0000000..ef02877 Binary files /dev/null and b/assets/Prototype_Character/Red/_shadow/shadow_static.png differ diff --git a/assets/Prototype_Character/Red/death.png b/assets/Prototype_Character/Red/death.png new file mode 100644 index 0000000..8dfc03b Binary files /dev/null and b/assets/Prototype_Character/Red/death.png differ diff --git a/assets/Prototype_Character/Red/hurt.png b/assets/Prototype_Character/Red/hurt.png new file mode 100644 index 0000000..e40334b Binary files /dev/null and b/assets/Prototype_Character/Red/hurt.png differ diff --git a/assets/Prototype_Character/Red/idle.png b/assets/Prototype_Character/Red/idle.png new file mode 100644 index 0000000..2de5d21 Binary files /dev/null and b/assets/Prototype_Character/Red/idle.png differ diff --git a/assets/Prototype_Character/Red/walk.png b/assets/Prototype_Character/Red/walk.png new file mode 100644 index 0000000..c2b2eee Binary files /dev/null and b/assets/Prototype_Character/Red/walk.png differ diff --git a/assets/Prototype_Character/Yellow/_aseprite/death.aseprite b/assets/Prototype_Character/Yellow/_aseprite/death.aseprite new file mode 100644 index 0000000..b449e74 Binary files /dev/null and b/assets/Prototype_Character/Yellow/_aseprite/death.aseprite differ diff --git a/assets/Prototype_Character/Yellow/_aseprite/hurt.aseprite b/assets/Prototype_Character/Yellow/_aseprite/hurt.aseprite new file mode 100644 index 0000000..f0f5bd4 Binary files /dev/null and b/assets/Prototype_Character/Yellow/_aseprite/hurt.aseprite differ diff --git a/assets/Prototype_Character/Yellow/_aseprite/idle.aseprite b/assets/Prototype_Character/Yellow/_aseprite/idle.aseprite new file mode 100644 index 0000000..f2d3516 Binary files /dev/null and b/assets/Prototype_Character/Yellow/_aseprite/idle.aseprite differ diff --git a/assets/Prototype_Character/Yellow/_aseprite/walk.aseprite b/assets/Prototype_Character/Yellow/_aseprite/walk.aseprite new file mode 100644 index 0000000..9d921e1 Binary files /dev/null and b/assets/Prototype_Character/Yellow/_aseprite/walk.aseprite differ diff --git a/assets/Prototype_Character/Yellow/_shadow/shadow_death.png b/assets/Prototype_Character/Yellow/_shadow/shadow_death.png new file mode 100644 index 0000000..15b229d Binary files /dev/null and b/assets/Prototype_Character/Yellow/_shadow/shadow_death.png differ diff --git a/assets/Prototype_Character/Yellow/_shadow/shadow_static.png b/assets/Prototype_Character/Yellow/_shadow/shadow_static.png new file mode 100644 index 0000000..ef02877 Binary files /dev/null and b/assets/Prototype_Character/Yellow/_shadow/shadow_static.png differ diff --git a/assets/Prototype_Character/Yellow/death.png b/assets/Prototype_Character/Yellow/death.png new file mode 100644 index 0000000..cac1353 Binary files /dev/null and b/assets/Prototype_Character/Yellow/death.png differ diff --git a/assets/Prototype_Character/Yellow/hurt.png b/assets/Prototype_Character/Yellow/hurt.png new file mode 100644 index 0000000..f72aacc Binary files /dev/null and b/assets/Prototype_Character/Yellow/hurt.png differ diff --git a/assets/Prototype_Character/Yellow/idle.png b/assets/Prototype_Character/Yellow/idle.png new file mode 100644 index 0000000..5e36ca3 Binary files /dev/null and b/assets/Prototype_Character/Yellow/idle.png differ diff --git a/assets/Prototype_Character/Yellow/walk.png b/assets/Prototype_Character/Yellow/walk.png new file mode 100644 index 0000000..9885cb7 Binary files /dev/null and b/assets/Prototype_Character/Yellow/walk.png differ diff --git a/assets/tranquil_tunnels_transparent.png b/assets/tranquil_tunnels_transparent.png new file mode 100644 index 0000000..24c48a4 Binary files /dev/null and b/assets/tranquil_tunnels_transparent.png differ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..d39f4a2 --- /dev/null +++ b/build.zig @@ -0,0 +1,393 @@ +const std = @import("std"); +const sokol = @import("sokol"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const assets_dir = "assets"; + const asset_files = [_][]const u8{ + "tranquil_tunnels_transparent.png" + // "sokoban/sokoban_tilesheet.png", + // "roboto-font/Roboto-Regular.ttf", + // "tiled/map.tmx", + // "tiled/tileset.tsx" + }; + + const asset_dir_realpath = try b.build_root.join(b.graph.arena, &.{ assets_dir }); + + const is_wasm = target.result.cpu.arch.isWasm(); + const is_debug = (optimize == .Debug); + + const has_imgui = b.option(bool, "imgui", "ImGui integration") orelse is_debug; + const assets_from_filesystem = b.option(bool, "assets-filesystem", "Allow reading assets from filesystem") orelse is_debug; + + var has_tracy = false; + if (!is_wasm) { + has_tracy = b.option(bool, "tracy", "Tracy integration") orelse is_debug; + } + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const build_options = b.addOptions(); + build_options.addOption(bool, "has_imgui", has_imgui); + build_options.addOption(?[]const u8, "assets_dir", if (assets_from_filesystem) asset_dir_realpath else null); + exe_mod.addOptions("build_options", build_options); + + exe_mod.addImport("embedded_assets", try createEmbeddedAssets(b, assets_dir, &asset_files)); + + const math_mod = b.createModule(.{ + .root_source_file = b.path("src/math.zig") + }); + exe_mod.addImport("math", math_mod); + + const dep_tiled = b.dependency("tiled", .{}); + exe_mod.addImport("tiled", dep_tiled.module("tiled")); + + const dep_sokol = b.dependency("sokol", .{ + .target = target, + .optimize = optimize, + .with_sokol_imgui = has_imgui + }); + const mod_sokol = dep_sokol.module("sokol"); + exe_mod.linkLibrary(dep_sokol.artifact("sokol_clib")); + exe_mod.addImport("sokol", mod_sokol); + + const dep_shdc = dep_sokol.builder.dependency("shdc", .{}); + + var slang = sokol.shdc.Slang{}; + if (is_wasm) { + slang.wgsl = true; + } else { + slang.glsl410 = true; + slang.hlsl4 = true; + slang.metal_macos = true; + slang.glsl300es = true; + } + const mod_shader = try sokol.shdc.createModule(b, "shader", mod_sokol, .{ + .shdc_dep = dep_shdc, + .input = "src/shader.glsl", + .output = "src/shader.zig", + .slang = slang + }); + mod_shader.addImport("math", math_mod); + exe_mod.addImport("shader", mod_shader); + + if (has_imgui) { + if (b.lazyDependency("cimgui", .{ + .target = target, + .optimize = optimize, + })) |dep_cimgui| { + const cimgui = b.lazyImport(@This(), "cimgui").?; + const cimgui_conf = cimgui.getConfig(true); + exe_mod.addImport("cimgui", dep_cimgui.module(cimgui_conf.module_name)); + dep_sokol.artifact("sokol_clib").root_module.addIncludePath(dep_cimgui.path(cimgui_conf.include_dir)); + } + } + + const dep_stb = b.dependency("stb", .{}); + exe_mod.addImport("stb_image", dep_stb.module("stb_image")); + exe_mod.addImport("stb_vorbis", dep_stb.module("stb_vorbis")); + exe_mod.addImport("stb_rect_pack", dep_stb.module("stb_rect_pack")); + exe_mod.addImport("stb_truetype", dep_stb.module("stb_truetype")); + + const dep_tracy = b.dependency("tracy", .{ + .target = target, + .optimize = optimize, + .tracy_enable = has_tracy, + .tracy_only_localhost = true + }); + if (has_tracy) { + exe_mod.linkLibrary(dep_tracy.artifact("tracy")); + } + exe_mod.addImport("tracy", dep_tracy.module("tracy")); + + var run_cmd_step: *std.Build.Step = undefined; + if (is_wasm) { + if (true) { + @panic("TODO: test in 0.17"); + } + + const build_step = buildWasm(b, .{ + .name = "index", + .root_module = exe_mod, + .dep_sokol = dep_sokol, + }); + b.getInstallStep().dependOn(build_step); + + const dep_emsdk = dep_sokol.builder.dependency("emsdk", .{}); + const emrun_step = sokol.emRunStep(b, .{ .name = "index", .emsdk = dep_emsdk }); + emrun_step.step.dependOn(build_step); + run_cmd_step = &emrun_step.step; + + // TODO: Create a zip archive of all of the files. Would be useful for easier itch.io upload + } else { + const exe = buildNative(b, .{ + .name = "sokol_template_v2", + .root_module = exe_mod + }); + b.installArtifact(exe); + + { + const run_cmd = b.addRunArtifact(exe); + if (b.args) |args| { + run_cmd.addArgs(args); + } + run_cmd_step = &run_cmd.step; + } + + { + const exe_tests = b.addTest(.{ .root_module = exe_mod, }); + const run_exe_tests = b.addRunArtifact(exe_tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_exe_tests.step); + } + } + + const run_step = b.step("run", "Run game"); + run_step.dependOn(run_cmd_step); +} + +fn createEmbeddedAssets( + b: *std.Build, + assets_dir: []const u8, + asset_files: []const []const u8 +) !*std.Build.Module { + const write_files = b.addWriteFiles(); + + var embedded_assets: std.ArrayList(u8) = .empty; + defer embedded_assets.deinit(b.allocator); + + try embedded_assets.appendSlice(b.allocator, "pub const files = .{\n"); + const mod_embedded_assets = b.createModule(.{}); + for (asset_files) |asset_file| { + mod_embedded_assets.addAnonymousImport(asset_file, .{ + .root_source_file = b.path(b.pathJoin(&.{ assets_dir, asset_file })) + }); + + try embedded_assets.appendSlice(b.allocator, " .{ "); + + try embedded_assets.appendSlice(b.allocator, ".name = \""); + try embedded_assets.appendSlice(b.allocator, asset_file); + try embedded_assets.appendSlice(b.allocator, "\""); + + try embedded_assets.appendSlice(b.allocator, ", "); + + try embedded_assets.appendSlice(b.allocator, ".contents = @embedFile(\""); + try embedded_assets.appendSlice(b.allocator, asset_file); + try embedded_assets.appendSlice(b.allocator, "\")"); + + try embedded_assets.appendSlice(b.allocator, " },\n"); + } + try embedded_assets.appendSlice(b.allocator, "};\n"); + mod_embedded_assets.root_source_file = write_files.add("embedded_assets.zig", embedded_assets.items); + + return mod_embedded_assets; +} + +const BuildNativeOptions = struct { + name: []const u8, + root_module: *std.Build.Module, + win32_has_console: ?bool = null, + win32_png_icon: ?std.Build.LazyPath = null +}; + +fn buildNative( + b: *std.Build, + opts: BuildNativeOptions, +) *std.Build.Step.Compile { + const exe = b.addExecutable(.{ + .name = opts.name, + .root_module = opts.root_module, + }); + + const target = opts.root_module.resolved_target.?; + if (target.result.os.tag == .windows) { + const optimize = opts.root_module.optimize.?; + const has_console = opts.win32_has_console orelse (optimize == .Debug); + exe.subsystem = if (has_console) .Console else .Windows; + + if (opts.win32_png_icon) |win32_png_icon| { + const png_to_icon_tool = buildPngToIconTool(b, b.graph.host, .ReleaseSafe); + + const png_to_icon_step = b.addRunArtifact(png_to_icon_tool); + png_to_icon_step.addFileArg(win32_png_icon); + const icon_file = png_to_icon_step.addOutputFileArg("icon.ico"); + + const add_icon_step = AddExecutableIcon.init(exe, icon_file); + exe.step.dependOn(&add_icon_step.step); + } + } + + return exe; +} + +fn buildPngToIconTool( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +) *std.Build.Step.Compile { + const mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tools/png-to-icon.zig"), + }); + + const dep_stb = b.dependency("stb", .{}); + mod.addImport("stb_image", dep_stb.module("stb_image")); + + return b.addExecutable(.{ + .name = "png-to-icon", + .root_module = mod, + }); +} + +const AddExecutableIcon = struct { + obj: *std.Build.Step.Compile, + step: std.Build.Step, + icon_file: std.Build.LazyPath, + resource_file: std.Build.LazyPath, + + fn init(obj: *std.Build.Step.Compile, icon_file: std.Build.LazyPath) *AddExecutableIcon { + const b = obj.step.owner; + const self = b.allocator.create(AddExecutableIcon) catch @panic("OOM"); + + self.obj = obj; + self.step = std.Build.Step.init(.{ + .id = .custom, + .name = "add executable icon", + .owner = b, + .makeFn = make + }); + + self.icon_file = icon_file; + icon_file.addStepDependencies(&self.step); + + const write_files = b.addWriteFiles(); + self.resource_file = write_files.add("resource-file.rc", ""); + self.step.dependOn(&write_files.step); + + self.obj.root_module.addWin32ResourceFile(.{ + .file = self.resource_file, + }); + + return self; + } + + fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { + const b = step.owner; + const self: *AddExecutableIcon = @fieldParentPtr("step", step); + + const relative_icon_path = try std.fs.path.relative( + b.allocator, + "", + null, + self.resource_file.dirname().getPath(b), + self.icon_file.getPath(b) + ); + std.mem.replaceScalar(u8, relative_icon_path, '\\', '/'); + + { + const cwd = std.Io.Dir.cwd(); + const resource_file = try cwd.createFile(b.graph.io, self.resource_file.getPath(b), .{ }); + defer resource_file.close(b.graph.io); + + var buff: [4*4096]u8 = undefined; + var file_writer = resource_file.writer(b.graph.io, &buff); + const w = &file_writer.interface; + + try w.writeAll("IDI_ICON ICON \""); + try w.writeAll(relative_icon_path); + try w.writeAll("\""); + } + } +}; + +const BuildWasmOptions = struct { + name: []const u8, + root_module: *std.Build.Module, + dep_sokol: *std.Build.Dependency, +}; + +fn buildWasm(b: *std.Build, opts: BuildWasmOptions) *std.Build.Step { + opts.root_module.sanitize_c = .off; + + // build the main file into a library, this is because the WASM 'exe' + // needs to be linked in a separate build step with the Emscripten linker + const main_lib = b.addLibrary(.{ + .name = opts.name, + .root_module = opts.root_module, + }); + + const dep_sokol = opts.dep_sokol; + const dep_emsdk = dep_sokol.builder.dependency("emsdk", .{}); + + // TODO: + // const emsdk_install_step = sokol.emSdkInstallStep(b, dep_emsdk, .{}); + // main_lib.step.dependOn(emsdk_install_step); + + patchWasmIncludeDirs( + opts.root_module, + dep_emsdk.path("upstream/emscripten/cache/sysroot/include"), + &(dep_sokol.artifact("sokol_clib").step) + ); + + const link_step = sokol.emLinkStep(b, .{ + .lib_main = main_lib, + .target = opts.root_module.resolved_target.?, + .optimize = opts.root_module.optimize.?, + .emsdk = dep_emsdk, + .use_webgl2 = true, + .use_emmalloc = true, + .use_filesystem = false, + .shell_file_path = b.path("src/engine/shell.html"), + }) catch unreachable; + + return &link_step.step; +} + +fn patchWasmIncludeDirs( + module: *std.Build.Module, + path: std.Build.LazyPath, + depend_step: *std.Build.Step +) void { + if (module.link_libc != null and module.link_libc.?) { + // need to inject the Emscripten system header include path into + // the cimgui C library otherwise the C/C++ code won't find + // C stdlib headers + module.addSystemIncludePath(path); + } + + for (module.import_table.values()) |imported_module| { + patchWasmIncludeDirs(imported_module, path, depend_step); + } + + for (module.link_objects.items) |link_object| { + if (link_object != .other_step) { + continue; + } + const lib = link_object.other_step; + if (&lib.step == depend_step) { + continue; + } + + if (lib.root_module.link_libc != null and lib.root_module.link_libc.?) { + // need to inject the Emscripten system header include path into + // the cimgui C library otherwise the C/C++ code won't find + // C stdlib headers + lib.root_module.addSystemIncludePath(path); + + // all C libraries need to depend on the sokol library, when building for + // WASM this makes sure that the Emscripten SDK has been setup before + // C compilation is attempted (since the sokol C library depends on the + // Emscripten SDK setup step) + lib.step.dependOn(depend_step); + } + patchWasmIncludeDirs(lib.root_module, path, depend_step); + } +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..bf65b84 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,31 @@ +.{ + .name = .sokol_template_v2, + .version = "0.0.0", + .fingerprint = 0xf440b4e6e51f5206, // Changing this has security and trust implications. + .minimum_zig_version = "0.16.0", + .dependencies = .{ + .sokol = .{ + .url = "git+https://github.com/floooh/sokol-zig.git#ba97ac4e82e36a0f6c62f7fad5dc9c2d82beadd8", + .hash = "sokol-0.1.0-pb1HK4prNwDGx2RJFyv7VtYJubDahi19XKioPzPeMCxv", + }, + .stb = .{ + .path = "./libs/stb", + }, + .tiled = .{ + .path = "./libs/tiled", + }, + .tracy = .{ + .path = "./libs/tracy", + }, + .cimgui = .{ + .url = "git+https://github.com/floooh/dcimgui.git#3c2b41a9333b1195e2c2f8ce6a2d3e50660a73bf", + .hash = "cimgui-0.1.0-44ClkR8dmgDqqnfloyLE1bUAtl4zSKTT1wxaHSEC0ikV", + .lazy = true + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/libs/stb/build.zig b/libs/stb/build.zig new file mode 100644 index 0000000..a601475 --- /dev/null +++ b/libs/stb/build.zig @@ -0,0 +1,37 @@ +const std = @import("std"); +const Build = std.Build; +const LazyPath = Build.LazyPath; + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + buildSTBModule(b, target, optimize, "stb_image", b.path("src/stb_image.zig"), b.path("src/stb_image_impl.c")); + buildSTBModule(b, target, optimize, "stb_vorbis", b.path("src/stb_vorbis.zig"), b.path("src/stb_vorbis_impl.c")); + buildSTBModule(b, target, optimize, "stb_rect_pack", b.path("src/stb_rect_pack.zig"), b.path("src/stb_rect_pack_impl.c")); + buildSTBModule(b, target, optimize, "stb_truetype", b.path("src/stb_truetype.zig"), b.path("src/stb_truetype_impl.c")); +} + +fn buildSTBModule( + b: *std.Build, + target: Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + name: []const u8, + zig_file: LazyPath, + impl_file: LazyPath +) void { + const stb_dependency = b.dependency("stb", .{}); + + const mod = b.addModule(name, .{ + .target = target, + .optimize = optimize, + .root_source_file = zig_file, + .link_libc = true, + }); + + mod.addIncludePath(stb_dependency.path(".")); + mod.addCSourceFile(.{ + .file = impl_file, + .flags = &.{} + }); +} diff --git a/libs/stb/build.zig.zon b/libs/stb/build.zig.zon new file mode 100644 index 0000000..867deaa --- /dev/null +++ b/libs/stb/build.zig.zon @@ -0,0 +1,17 @@ +.{ + .name = .stb_image, + .version = "0.0.0", + .fingerprint = 0xe5d3607840482046, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .stb = .{ + .url = "git+https://github.com/nothings/stb.git#f1c79c02822848a9bed4315b12c8c8f3761e1296", + .hash = "N-V-__8AABQ7TgCnPlp8MP4YA8znrjd6E-ZjpF1rvrS8J_2I", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/libs/stb/src/stb_image.zig b/libs/stb/src/stb_image.zig new file mode 100644 index 0000000..1c357a8 --- /dev/null +++ b/libs/stb/src/stb_image.zig @@ -0,0 +1,31 @@ +const std = @import("std"); + +const c = @cImport({ + @cDefine("STBI_NO_STDIO", {}); + @cInclude("stb_image.h"); +}); + +const STBImage = @This(); + +rgba8_pixels: [*c]u8, +width: u32, +height: u32, + +pub fn load(png_data: []const u8) !STBImage { + var width: c_int = undefined; + var height: c_int = undefined; + const pixels = c.stbi_load_from_memory(png_data.ptr, @intCast(png_data.len), &width, &height, null, 4); + if (pixels == null) { + return error.InvalidPng; + } + + return STBImage{ + .rgba8_pixels = pixels, + .width = @intCast(width), + .height = @intCast(height) + }; +} + +pub fn deinit(self: *const STBImage) void { + c.stbi_image_free(self.rgba8_pixels); +} diff --git a/libs/stb/src/stb_image_impl.c b/libs/stb/src/stb_image_impl.c new file mode 100644 index 0000000..eeee84a --- /dev/null +++ b/libs/stb/src/stb_image_impl.c @@ -0,0 +1,3 @@ +#define STB_IMAGE_IMPLEMENTATION +#define STBI_NO_STDIO +#include "stb_image.h" diff --git a/libs/stb/src/stb_rect_pack.zig b/libs/stb/src/stb_rect_pack.zig new file mode 100644 index 0000000..18065eb --- /dev/null +++ b/libs/stb/src/stb_rect_pack.zig @@ -0,0 +1,36 @@ +const std = @import("std"); + +const c = @cImport({ + @cInclude("stb_rect_pack.h"); +}); + +const STBRectPack = @This(); + +ctx: c.stbrp_context, + +pub fn init(self: *STBRectPack, width: u32, height: u32, nodes: []Node) void { + c.stbrp_init_target(&self.ctx, @intCast(width), @intCast(height), nodes.ptr, @intCast(nodes.len)); +} + +pub fn setupAllowOutOfMem(self: *STBRectPack, enabled: bool) void { + c.stbrp_setup_heuristic(&self.ctx, if (enabled) 1 else 0); +} + +pub fn setupHeuristic(self: *STBRectPack, heuristic: Heuristic) void { + c.stbrp_setup_heuristic(&self.ctx, heuristic); +} + +pub fn packRects(self: *STBRectPack, rects: []Rect) bool { + return c.stbrp_pack_rects(&self.ctx, rects.ptr, @intCast(rects.len)) == 1; +} + +pub const Heuristic = enum(i32) { + Skyline_BL_sortHeight = c.STBRP_HEURISTIC_Skyline_BL_sortHeight, + Skyline_BF_sortHeight = c.STBRP_HEURISTIC_Skyline_BF_sortHeight, + _, + + pub const default = Heuristic.Skyline_BL_sortHeight; +}; + +pub const Node = c.stbrp_node; +pub const Rect = c.stbrp_rect; diff --git a/libs/stb/src/stb_rect_pack_impl.c b/libs/stb/src/stb_rect_pack_impl.c new file mode 100644 index 0000000..31c4106 --- /dev/null +++ b/libs/stb/src/stb_rect_pack_impl.c @@ -0,0 +1,2 @@ +#define STB_RECT_PACK_IMPLEMENTATION +#include "stb_rect_pack.h" diff --git a/libs/stb/src/stb_truetype.zig b/libs/stb/src/stb_truetype.zig new file mode 100644 index 0000000..a63cfe6 --- /dev/null +++ b/libs/stb/src/stb_truetype.zig @@ -0,0 +1,116 @@ +const std = @import("std"); + +const c = @cImport({ + @cInclude("stb_truetype.h"); +}); + +pub const Box = struct { + x0: i32, + y0: i32, + x1: i32, + y1: i32, +}; + +pub const HMetrics = struct { + advance_width: i32, + left_side_bearing: i32 +}; + +pub const VMetrics = struct { + ascent: i32, + descent: i32, + line_gap: i32 +}; + +pub const Font = struct { + info: c.stbtt_fontinfo, + + pub fn getNumberOfFonts(data: [*c]const u8) !u32 { + const result = c.stbtt_GetNumberOfFonts(data); + if (result == -1) { + return error.stbtt_GetNumberOfFonts; + } + return @intCast(result); + } + + pub fn getFontOffsetForIndex(data: [*c]const u8, index: u32) ?u32 { + const result = c.stbtt_GetFontOffsetForIndex(data, @intCast(index)); + if (result == -1) { + return null; + } + return @intCast(result); + } + + pub fn init(data: [*c]const u8, offset: u32) !Font { + var font: Font = .{ + .info = undefined + }; + if (c.stbtt_InitFont(&font.info, data, @intCast(offset)) == 0) { + return error.stbtt_InitFont; + } + return font; + } + + pub fn findGlyphIndex(self: Font, codepoint: u21) ?u32 { + const result = c.stbtt_FindGlyphIndex(&self.info, codepoint); + if (result == 0) { + return null; + } + return @intCast(result); + } + + pub fn scaleForPixelHeight(self: Font, pixel_height: f32) f32 { + return c.stbtt_ScaleForPixelHeight(&self.info, pixel_height); + } + + pub fn getGlyphBitmapBox(self: Font, glyph: u32, scale_x: f32, scale_y: f32) Box { + var result: Box = undefined; + c.stbtt_GetGlyphBitmapBox( + &self.info, + @intCast(glyph), + scale_x, scale_y, + &result.x0, &result.y0, + &result.x1, &result.y1, + ); + return result; + } + + pub fn makeGlyphBitmap( + self: Font, + output: [*]u8, + out_w: u32, + out_h: u32, + out_stride: u32, + scale_x: f32, + scale_y: f32, + glyph: u32 + ) void { + c.stbtt_MakeGlyphBitmap( + &self.info, + output, + @intCast(out_w), + @intCast(out_h), + @intCast(out_stride), + scale_x, + scale_y, + @intCast(glyph) + ); + } + + pub fn getGlyphHMetrics(self: Font, glyph: u32) HMetrics { + var result: HMetrics = undefined; + c.stbtt_GetGlyphHMetrics(&self.info, @intCast(glyph), &result.advance_width, &result.left_side_bearing); + return result; + } + + pub fn getFontVMetrics(self: Font) VMetrics { + var result: VMetrics = undefined; + c.stbtt_GetFontVMetrics(&self.info, &result.ascent, &result.descent, &result.line_gap); + return result; + } + + pub fn getGlyphKernAdvance(self: Font, prev_glyph: u32, next_glyph: u32) i32 { + return c.stbtt_GetGlyphKernAdvance(&self.info, @intCast(prev_glyph), @intCast(next_glyph)); + } +}; + diff --git a/libs/stb/src/stb_truetype_impl.c b/libs/stb/src/stb_truetype_impl.c new file mode 100644 index 0000000..9f0bf61 --- /dev/null +++ b/libs/stb/src/stb_truetype_impl.c @@ -0,0 +1,4 @@ +#define STB_TRUETYPE_IMPLEMENTATION +// TODO: #define STBTT_STATIC +#include "stb_truetype.h" + diff --git a/libs/stb/src/stb_vorbis.zig b/libs/stb/src/stb_vorbis.zig new file mode 100644 index 0000000..ea25583 --- /dev/null +++ b/libs/stb/src/stb_vorbis.zig @@ -0,0 +1,170 @@ +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.stb_vorbis); + +const c = @cImport({ + @cDefine("STB_VORBIS_NO_INTEGER_CONVERSION", {}); + @cDefine("STB_VORBIS_NO_STDIO", {}); + @cDefine("STB_VORBIS_HEADER_ONLY", {}); + @cInclude("stb_vorbis.c"); +}); + +const STBVorbis = @This(); + +pub const Error = error { + NeedMoreData, + InvalidApiMixing, + OutOfMemory, + FeatureNotSupported, + TooManyChannels, + FileOpenFailure, + SeekWithoutLength, + UnexpectedEof, + SeekInvalid, + InvalidSetup, + InvalidStream, + MissingCapturePattern, + InvalidStreamStructureVersion, + ContinuedPacketFlagInvalid, + IncorrectStreamSerialNumber, + InvalidFirstPage, + BadPacketType, + CantFindLastPage, + SeekFailed, + OggSkeletonNotSupported, + + Unknown +}; + +fn errorFromInt(err: c_int) ?Error { + return switch (err) { + c.VORBIS__no_error => null, + c.VORBIS_need_more_data => Error.NeedMoreData, + c.VORBIS_invalid_api_mixing => Error.InvalidApiMixing, + c.VORBIS_outofmem => Error.OutOfMemory, + c.VORBIS_feature_not_supported => Error.FeatureNotSupported, + c.VORBIS_too_many_channels => Error.TooManyChannels, + c.VORBIS_file_open_failure => Error.FileOpenFailure, + c.VORBIS_seek_without_length => Error.SeekWithoutLength, + c.VORBIS_unexpected_eof => Error.UnexpectedEof, + c.VORBIS_seek_invalid => Error.SeekInvalid, + c.VORBIS_invalid_setup => Error.InvalidSetup, + c.VORBIS_invalid_stream => Error.InvalidStream, + c.VORBIS_missing_capture_pattern => Error.MissingCapturePattern, + c.VORBIS_invalid_stream_structure_version => Error.InvalidStreamStructureVersion, + c.VORBIS_continued_packet_flag_invalid => Error.ContinuedPacketFlagInvalid, + c.VORBIS_incorrect_stream_serial_number => Error.IncorrectStreamSerialNumber, + c.VORBIS_invalid_first_page => Error.InvalidFirstPage, + c.VORBIS_bad_packet_type => Error.BadPacketType, + c.VORBIS_cant_find_last_page => Error.CantFindLastPage, + c.VORBIS_seek_failed => Error.SeekFailed, + c.VORBIS_ogg_skeleton_not_supported => Error.OggSkeletonNotSupported, + else => Error.Unknown + }; +} + +handle: *c.stb_vorbis, + +pub fn init(data: []const u8, alloc_buffer: []u8) Error!STBVorbis { + const stb_vorbis_alloc: c.stb_vorbis_alloc = .{ + .alloc_buffer = alloc_buffer.ptr, + .alloc_buffer_length_in_bytes = @intCast(alloc_buffer.len) + }; + var error_code: c_int = -1; + const handle = c.stb_vorbis_open_memory( + data.ptr, + @intCast(data.len), + &error_code, + &stb_vorbis_alloc + ); + if (handle == null) { + return errorFromInt(error_code) orelse Error.Unknown; + } + + return STBVorbis{ + .handle = handle.? + }; +} + +pub fn getMinimumAllocBufferSize(self: STBVorbis) u32 { + const info = self.getInfo(); + return info.setup_memory_required + @max(info.setup_temp_memory_required, info.temp_memory_required); +} + +fn getLastError(self: STBVorbis) ?Error { + const error_code = c.stb_vorbis_get_error(self.handle); + return errorFromInt(error_code); +} + +pub fn seek(self: STBVorbis, sample_number: u32) !void { + const success = c.stb_vorbis_seek(self.handle, sample_number); + if (success != 1) { + return self.getLastError() orelse Error.Unknown; + } +} + +pub fn getStreamLengthInSamples(self: STBVorbis) u32 { + return c.stb_vorbis_stream_length_in_samples(self.handle); +} + +pub fn getStreamLengthInSeconds(self: STBVorbis) f32 { + return c.stb_vorbis_stream_length_in_seconds(self.handle); +} + +pub fn getSamples( + self: STBVorbis, + channels: []const [*]f32, + max_samples_per_channel: u32 +) u32 { + const samples_per_channel = c.stb_vorbis_get_samples_float( + self.handle, + @intCast(channels.len), + @constCast(@ptrCast(channels.ptr)), + @intCast(max_samples_per_channel) + ); + return @intCast(samples_per_channel); +} + +const Frame = struct { + channels: []const [*c]const f32, + samples_per_channel: u32 +}; + +pub fn getFrame(self: STBVorbis) Frame { + var output: [*c][*c]f32 = null; + var channels: c_int = undefined; + const samples_per_channel = c.stb_vorbis_get_frame_float( + self.handle, + &channels, + &output + ); + return Frame{ + .channels = output[0..@intCast(channels)], + .samples_per_channel = @intCast(samples_per_channel) + }; +} + +pub const Info = struct { + sample_rate: u32, + channels: u32, + + setup_memory_required: u32, + setup_temp_memory_required: u32, + temp_memory_required: u32, + + max_frame_size: u32 +}; + +pub fn getInfo(self: STBVorbis) Info { + const info = c.stb_vorbis_get_info(self.handle); + return Info{ + .sample_rate = info.sample_rate, + .channels = @intCast(info.channels), + + .setup_memory_required = info.setup_memory_required, + .setup_temp_memory_required = info.setup_temp_memory_required, + .temp_memory_required = info.temp_memory_required, + + .max_frame_size = @intCast(info.max_frame_size), + }; +} diff --git a/libs/stb/src/stb_vorbis_impl.c b/libs/stb/src/stb_vorbis_impl.c new file mode 100644 index 0000000..4a53456 --- /dev/null +++ b/libs/stb/src/stb_vorbis_impl.c @@ -0,0 +1,3 @@ +#define STB_VORBIS_NO_INTEGER_CONVERSION +#define STB_VORBIS_NO_STDIO +#include "stb_vorbis.c" diff --git a/libs/tiled/build.zig b/libs/tiled/build.zig new file mode 100644 index 0000000..e717649 --- /dev/null +++ b/libs/tiled/build.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const mod = b.addModule("tiled", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/root.zig") + }); + + const lib = b.addLibrary(.{ + .name = "tiled", + .root_module = mod + }); + b.installArtifact(lib); + + { + const tests = b.addTest(.{ + .root_module = mod + }); + + const run_tests = b.addRunArtifact(tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_tests.step); + } +} diff --git a/libs/tiled/src/buffers.zig b/libs/tiled/src/buffers.zig new file mode 100644 index 0000000..b9ab166 --- /dev/null +++ b/libs/tiled/src/buffers.zig @@ -0,0 +1,18 @@ +const std = @import("std"); + +const Position = @import("./position.zig"); + +const Buffers = @This(); + +allocator: std.mem.Allocator, +points: std.ArrayList(Position) = .empty, + +pub fn init(gpa: std.mem.Allocator) Buffers { + return Buffers{ + .allocator = gpa + }; +} + +pub fn deinit(self: *Buffers) void { + self.points.deinit(self.allocator); +} diff --git a/libs/tiled/src/color.zig b/libs/tiled/src/color.zig new file mode 100644 index 0000000..d8cd764 --- /dev/null +++ b/libs/tiled/src/color.zig @@ -0,0 +1,52 @@ +const std = @import("std"); + +const Color = @This(); + +r: u8, +g: u8, +b: u8, +a: u8, + +pub const black = Color{ + .r = 0, + .g = 0, + .b = 0, + .a = 255, +}; + +pub fn parse(str: []const u8, hash_required: bool) !Color { + var color = Color{ + .r = undefined, + .g = undefined, + .b = undefined, + .a = 255, + }; + + if (str.len < 1) { + return error.InvalidColorFormat; + } + + const has_hash = str[0] == '#'; + if (hash_required and !has_hash) { + return error.InvalidColorFormat; + } + + const hex_str = if (has_hash) str[1..] else str; + + if (hex_str.len == 6) { + color.r = try std.fmt.parseInt(u8, hex_str[0..2], 16); + color.g = try std.fmt.parseInt(u8, hex_str[2..4], 16); + color.b = try std.fmt.parseInt(u8, hex_str[4..6], 16); + + } else if (hex_str.len == 8) { + color.a = try std.fmt.parseInt(u8, hex_str[0..2], 16); + color.r = try std.fmt.parseInt(u8, hex_str[2..4], 16); + color.g = try std.fmt.parseInt(u8, hex_str[4..6], 16); + color.b = try std.fmt.parseInt(u8, hex_str[6..8], 16); + + } else { + return error.InvalidColorFormat; + } + + return color; +} diff --git a/libs/tiled/src/global_tile_id.zig b/libs/tiled/src/global_tile_id.zig new file mode 100644 index 0000000..96d93ad --- /dev/null +++ b/libs/tiled/src/global_tile_id.zig @@ -0,0 +1,15 @@ + +pub const Flag = enum(u32) { + flipped_horizontally = 1 << 31, // bit 32 + flipped_vertically = 1 << 30, // bit 31 + flipped_diagonally = 1 << 29, // bit 30 + rotated_hexagonal_120 = 1 << 28, // bit 29 + _, + + pub const clear: u32 = ~( + @intFromEnum(Flag.flipped_horizontally) | + @intFromEnum(Flag.flipped_vertically) | + @intFromEnum(Flag.flipped_diagonally) | + @intFromEnum(Flag.rotated_hexagonal_120) + ); +}; diff --git a/libs/tiled/src/layer.zig b/libs/tiled/src/layer.zig new file mode 100644 index 0000000..4efc102 --- /dev/null +++ b/libs/tiled/src/layer.zig @@ -0,0 +1,283 @@ +const std = @import("std"); + +const Property = @import("./property.zig"); +const xml = @import("./xml.zig"); +const Color = @import("./color.zig"); +const Object = @import("./object.zig"); +const Position = @import("./position.zig"); + +const Layer = @This(); + +pub const TileVariant = struct { + width: u32, + height: u32, + data: []u32, + + fn initDataFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, + ) ![]u32 { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("data"); + + const encoding = attrs.get("encoding") orelse "csv"; + // TODO: compression + + var temp_tiles: std.ArrayList(u32) = .empty; + + if (std.mem.eql(u8, encoding, "csv")) { + const text = try lexer.nextExpectText(); + var split_iter = std.mem.splitScalar(u8, text, ','); + while (split_iter.next()) |raw_tile_id| { + const tile_id_str = std.mem.trim(u8, raw_tile_id, &std.ascii.whitespace); + const tile_id = try std.fmt.parseInt(u32, tile_id_str, 10); + try temp_tiles.append(scratch.allocator(), tile_id); + } + } else { + return error.UnknownEncodingType; + } + + try iter.finish("data"); + + return try arena.dupe(u32, temp_tiles.items); + } + + pub fn get(self: TileVariant, x: usize, y: usize) ?u32 { + if ((0 <= x and x < self.width) and (0 <= y and y < self.height)) { + return self.data[y * self.width + x]; + } + return null; + } +}; + +pub const ImageVariant = struct { + pub const Image = struct { + // TODO: format + source: []const u8, + transparent_color: ?Color, + width: ?u32, + height: ?u32, + + fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Image { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("image"); + + // TODO: format + const source = try attrs.getDupe(arena, "source") orelse return error.MissingSource; + const width = try attrs.getNumber(u32, "width") orelse null; + const height = try attrs.getNumber(u32, "height") orelse null; + const transparent_color = try attrs.getColor("trans", false); + + try iter.finish("image"); + + return Image{ + .source = source, + .transparent_color = transparent_color, + .width = width, + .height = height + }; + } + }; + + repeat_x: bool, + repeat_y: bool, + image: ?Image +}; + +pub const ObjectVariant = struct { + pub const DrawOrder = enum { + top_down, + index, + + const map: std.StaticStringMap(DrawOrder) = .initComptime(.{ + .{ "topdown", .top_down }, + .{ "index", .index }, + }); + }; + + color: ?Color, + draw_order: DrawOrder, + items: []Object +}; + +pub const GroupVariant = struct { + layers: []Layer +}; + +pub const Type = enum { + tile, + object, + image, + group, + + const name_map: std.StaticStringMap(Type) = .initComptime(.{ + .{ "layer", .tile }, + .{ "objectgroup", .object }, + .{ "imagelayer", .image }, + .{ "group", .group } + }); + + fn toXmlName(self: Type) []const u8 { + return switch (self) { + .tile => "layer", + .object => "objectgroup", + .image => "imagelayer", + .group => "group", + }; + } +}; + +pub const Variant = union(Type) { + tile: TileVariant, + object: ObjectVariant, + image: ImageVariant, + group: GroupVariant +}; + +id: u32, +name: []const u8, +class: []const u8, +opacity: f32, +visible: bool, +tint_color: ?Color, +offset_x: f32, +offset_y: f32, +parallax_x: f32, +parallax_y: f32, +properties: Property.List, +variant: Variant, + +pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, +) !Layer { + const value = try lexer.peek() orelse return error.MissingStartTag; + if (value != .start_tag) return error.MissingStartTag; + + var layer_type = Type.name_map.get(value.start_tag.name) orelse return error.UnknownLayerType; + + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin(layer_type.toXmlName()); + + const id = try attrs.getNumber(u32, "id") orelse return error.MissingId; + const name = try attrs.getDupe(arena, "name") orelse ""; + const class = try attrs.getDupe(arena, "class") orelse ""; + const opacity = try attrs.getNumber(f32, "opacity") orelse 1; + const visible = try attrs.getBool("visible", "1", "0") orelse true; + const offset_x = try attrs.getNumber(f32, "offsetx") orelse 0; + const offset_y = try attrs.getNumber(f32, "offsety") orelse 0; + const parallax_x = try attrs.getNumber(f32, "parallaxx") orelse 1; + const parallax_y = try attrs.getNumber(f32, "parallaxy") orelse 1; + const tint_color = try attrs.getColor("tintcolor", true); + + var variant: Variant = undefined; + switch (layer_type) { + .tile => { + const width = try attrs.getNumber(u32, "width") orelse return error.MissingWidth; + const height = try attrs.getNumber(u32, "height") orelse return error.MissingHeight; + variant = .{ + .tile = TileVariant{ + .width = width, + .height = height, + .data = &[0]u32{} + } + }; + }, + .image => { + const repeat_x = try attrs.getBool("repeatx", "1", "0") orelse false; + const repeat_y = try attrs.getBool("repeaty", "1", "0") orelse false; + variant = .{ + .image = ImageVariant{ + .repeat_x = repeat_x, + .repeat_y = repeat_y, + .image = null + } + }; + }, + .object => { + const draw_order = try attrs.getEnum(ObjectVariant.DrawOrder, "draworder", ObjectVariant.DrawOrder.map) orelse .top_down; + const color = try attrs.getColor("color", true); + + variant = .{ + .object = ObjectVariant{ + .color = color, + .draw_order = draw_order, + .items = &.{} + } + }; + }, + .group => { + variant = .{ + .group = .{ + .layers = &.{} + } + }; + }, + } + + var properties: Property.List = .empty; + var objects: std.ArrayList(Object) = .empty; + var layers: std.ArrayList(Layer) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("properties")) { + properties = try Property.List.initFromXml(arena, scratch, lexer); + continue; + } + + if (variant == .tile and node.isTag("data")) { + variant.tile.data = try TileVariant.initDataFromXml(arena, scratch, lexer); + continue; + } + + if (variant == .image and node.isTag("image")) { + variant.image.image = try ImageVariant.Image.initFromXml(arena, lexer); + continue; + } + + if (variant == .object and node.isTag("object")) { + const object = try Object.initFromXml(arena, scratch, lexer); + try objects.append(scratch.allocator(), object); + continue; + } + + if (variant == .group and isLayerNode(node)) { + const layer = try initFromXml(arena, scratch, lexer); + try layers.append(scratch.allocator(), layer); + continue; + } + + try iter.skip(); + } + + try iter.finish(layer_type.toXmlName()); + + if (variant == .object) { + variant.object.items = try arena.dupe(Object, objects.items); + } + + if (variant == .group) { + variant.group.layers = try arena.dupe(Layer, layers.items); + } + + return Layer{ + .id = id, + .name = name, + .class = class, + .opacity = opacity, + .visible = visible, + .tint_color = tint_color, + .offset_x = offset_x, + .offset_y = offset_y, + .parallax_x = parallax_x, + .parallax_y = parallax_y, + .properties = properties, + .variant = variant, + }; +} + +pub fn isLayerNode(node: xml.TagParser.Node) bool { + return node.isTag("layer") or node.isTag("objectgroup") or node.isTag("imagelayer") or node.isTag("group"); +} diff --git a/libs/tiled/src/object.zig b/libs/tiled/src/object.zig new file mode 100644 index 0000000..e52ba05 --- /dev/null +++ b/libs/tiled/src/object.zig @@ -0,0 +1,421 @@ +const std = @import("std"); + +const xml = @import("./xml.zig"); +const Position = @import("./position.zig"); +const Color = @import("./color.zig"); + +const Object = @This(); + +pub const Shape = union (Type) { + pub const Type = enum { + rectangle, + point, + ellipse, + polygon, + tile, + // TODO: template + text + }; + + pub const Rectangle = struct { + x: f32, + y: f32, + width: f32, + height: f32, + }; + + pub const Tile = struct { + x: f32, + y: f32, + width: f32, + height: f32, + gid: u32 + }; + + pub const Ellipse = struct { + x: f32, + y: f32, + width: f32, + height: f32, + }; + + pub const Polygon = struct { + x: f32, + y: f32, + points: []const Position + }; + + pub const Text = struct { + pub const Font = struct { + family: []const u8, + pixel_size: f32, + bold: bool, + italic: bool, + underline: bool, + strikeout: bool, + kerning: bool + }; + + pub const HorizontalAlign = enum { + left, + center, + right, + justify, + + const map: std.StaticStringMap(HorizontalAlign) = .initComptime(.{ + .{ "left", .left }, + .{ "center", .center }, + .{ "right", .right }, + .{ "justify", .justify }, + }); + }; + + pub const VerticalAlign = enum { + top, + center, + bottom, + + const map: std.StaticStringMap(VerticalAlign) = .initComptime(.{ + .{ "top", .top }, + .{ "center", .center }, + .{ "bottom", .bottom }, + }); + }; + + x: f32, + y: f32, + width: f32, + height: f32, + word_wrap: bool, + color: Color, + font: Font, + horizontal_align: HorizontalAlign, + vertical_align: VerticalAlign, + content: []const u8 + }; + + pub const Point = struct { + x: f32, + y: f32, + }; + + rectangle: Rectangle, + point: Point, + ellipse: Ellipse, + polygon: Polygon, + tile: Tile, + text: Text +}; + +id: u32, +name: []const u8, +class: []const u8, +rotation: f32, // TODO: maybe this field should be moved to Shape struct +visible: bool, +shape: Shape, + +pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, +) !Object { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("object"); + + const id = try attrs.getNumber(u32, "id") orelse return error.MissingId; + const name = try attrs.getDupe(arena, "name") orelse ""; + const class = try attrs.getDupe(arena, "type") orelse ""; + const x = try attrs.getNumber(f32, "x") orelse 0; + const y = try attrs.getNumber(f32, "y") orelse 0; + const width = try attrs.getNumber(f32, "width") orelse 0; + const height = try attrs.getNumber(f32, "height") orelse 0; + const rotation = try attrs.getNumber(f32, "rotation") orelse 0; + const visible = try attrs.getBool("visible", "1", "0") orelse true; + + var shape: ?Shape = null; + + while (try iter.next()) |node| { + if (shape == null) { + if (node.isTag("point")) { + shape = .{ + .point = Shape.Point{ + .x = x, + .y = y, + } + }; + + } else if (node.isTag("ellipse")) { + shape = .{ + .ellipse = Shape.Ellipse{ + .x = x, + .y = y, + .width = width, + .height = height + } + }; + + } else if (node.isTag("text")) { + const HorizontalShape = Shape.Text.HorizontalAlign; + const VerticalAlign = Shape.Text.VerticalAlign; + + const text_attrs = node.tag.attributes; + const word_wrap = try text_attrs.getBool("wrap", "1", "0") orelse false; + const color = try text_attrs.getColor("color", true) orelse Color.black; + const horizontal_align = try text_attrs.getEnum(HorizontalShape, "halign", HorizontalShape.map) orelse .left; + const vertical_align = try text_attrs.getEnum(VerticalAlign, "valign", VerticalAlign.map) orelse .top; + const bold = try text_attrs.getBool("bold", "1", "0") orelse false; + const italic = try text_attrs.getBool("italic", "1", "0") orelse false; + const strikeout = try text_attrs.getBool("strikeout", "1", "0") orelse false; + const underline = try text_attrs.getBool("underline", "1", "0") orelse false; + const kerning = try text_attrs.getBool("kerning", "1", "0") orelse true; + const pixel_size = try text_attrs.getNumber(f32, "pixelsize") orelse 16; + const font_family = try text_attrs.getDupe(arena, "fontfamily") orelse "sans-serif"; + + _ = try lexer.nextExpectStartTag("text"); + var content: []const u8 = ""; + const content_value = try lexer.peek(); + if (content_value != null and content_value.? == .text) { + content = try arena.dupe(u8, content_value.?.text); + } + try lexer.skipUntilMatchingEndTag("text"); + + shape = .{ + .text = Shape.Text{ + .x = x, + .y = y, + .width = width, + .height = height, + .word_wrap = word_wrap, + .color = color, + .horizontal_align = horizontal_align, + .vertical_align = vertical_align, + .content = try arena.dupe(u8, content), + .font = .{ + .bold = bold, + .italic = italic, + .strikeout = strikeout, + .underline = underline, + .pixel_size = pixel_size, + .kerning = kerning, + .family = font_family, + } + } + }; + continue; + + } else if (node.isTag("polygon")) { + const points_str = node.tag.attributes.get("points") orelse ""; + + var points: std.ArrayList(Position) = .empty; + var point_iter = std.mem.splitScalar(u8, points_str, ' '); + while (point_iter.next()) |point_str| { + const point = try Position.parseCommaDelimited(point_str); + try points.append(scratch.allocator(), point); + } + + shape = .{ + .polygon = Shape.Polygon{ + .x = x, + .y = y, + .points = try arena.dupe(Position, points.items) + } + }; + } + } + + try iter.skip(); + } + + if (shape == null) { + if (try attrs.getNumber(u32, "gid")) |gid| { + shape = .{ + .tile = Shape.Tile{ + .x = x, + .y = y, + .width = width, + .height = height, + .gid = gid + } + }; + } else { + shape = .{ + .rectangle = Shape.Rectangle{ + .x = x, + .y = y, + .width = width, + .height = height + } + }; + } + } + + try iter.finish("object"); + + return Object{ + .id = id, + .name = name, + .class = class, + .rotation = rotation, + .visible = visible, + .shape = shape orelse return error.UnknownShapeType + }; +} + +fn expectParsedEquals(expected: Object, body: []const u8) !void { + const allocator = std.testing.allocator; + var ctx: xml.Lexer.TestingContext = undefined; + ctx.init(allocator, body); + defer ctx.deinit(); + + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const parsed = try initFromXml(arena.allocator(), &scratch, &ctx.lexer); + try std.testing.expectEqualDeep(expected, parsed); +} + +test Object { + try expectParsedEquals( + Object{ + .id = 10, + .name = "rectangle", + .class = "object class", + .rotation = 0, + .visible = true, + .shape = .{ + .rectangle = .{ + .x = 12.34, + .y = 56.78, + .width = 31.5, + .height = 20.25, + } + } + }, + \\ + ); + + try expectParsedEquals( + Object{ + .id = 3, + .name = "point", + .class = "foo", + .rotation = 0, + .visible = true, + .shape = .{ + .point = .{ + .x = 77.125, + .y = 99.875 + } + } + }, + \\ + \\ + \\ + ); + + try expectParsedEquals( + Object{ + .id = 4, + .name = "ellipse", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .ellipse = .{ + .x = 64.25, + .y = 108.25, + .width = 22.375, + .height = 15.375 + } + } + }, + \\ + \\ + \\ + ); + + try expectParsedEquals( + Object{ + .id = 5, + .name = "", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .polygon = .{ + .x = 40.125, + .y = 96.25, + .points = &[_]Position{ + .{ .x = 0, .y = 0 }, + .{ .x = 13.25, .y = -4.25 }, + .{ .x = 10.125, .y = 18.625 }, + .{ .x = 2.25, .y = 17.375 }, + .{ .x = -0.125, .y = 25.75 }, + .{ .x = -3.875, .y = 20.75 }, + } + } + } + }, + \\ + \\ + \\ + ); + + try expectParsedEquals( + Object{ + .id = 2, + .name = "tile", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .tile = .{ + .x = 60.125, + .y = 103.5, + .width = 8, + .height = 8, + .gid = 35 + } + } + }, + \\ + ); + + try expectParsedEquals( + Object{ + .id = 6, + .name = "text", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .text = .{ + .x = 64.3906, + .y = 92.8594, + .width = 87.7188, + .height = 21.7813, + .content = "Hello World", + .word_wrap = true, + .color = .black, + .horizontal_align = .center, + .vertical_align = .top, + .font = .{ + .family = "sans-serif", + .pixel_size = 16, + .bold = false, + .italic = false, + .underline = false, + .strikeout = false, + .kerning = true + }, + } + } + }, + \\ + \\ Hello World + \\ + ); +} diff --git a/libs/tiled/src/position.zig b/libs/tiled/src/position.zig new file mode 100644 index 0000000..79179d4 --- /dev/null +++ b/libs/tiled/src/position.zig @@ -0,0 +1,20 @@ +const std = @import("std"); + +const Position = @This(); + +x: f32, +y: f32, + +pub fn parseCommaDelimited(str: []const u8) !Position { + const comma_index = std.mem.indexOfScalar(u8, str, ',') orelse return error.MissingComma; + const x_str = str[0..comma_index]; + const y_str = str[(comma_index+1)..]; + + const x = try std.fmt.parseFloat(f32, x_str); + const y = try std.fmt.parseFloat(f32, y_str); + + return Position{ + .x = x, + .y = y + }; +} diff --git a/libs/tiled/src/property.zig b/libs/tiled/src/property.zig new file mode 100644 index 0000000..1fa29df --- /dev/null +++ b/libs/tiled/src/property.zig @@ -0,0 +1,153 @@ +const std = @import("std"); +const xml = @import("./xml.zig"); + +const Property = @This(); + +pub const Type = enum { + string, + int, + bool, + + const map: std.StaticStringMap(Type) = .initComptime(.{ + .{ "string", .string }, + .{ "int", .int }, + .{ "bool", .bool }, + }); +}; + +pub const Value = union(Type) { + string: []const u8, + int: i32, + bool: bool +}; + +name: []const u8, +value: Value, + +pub const List = struct { + items: []Property, + + pub const empty = List{ + .items = &[0]Property{} + }; + + pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer + ) !Property.List { + var iter = xml.TagParser.init(lexer); + _ = try iter.begin("properties"); + + var temp_properties: std.ArrayList(Property) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("property")) { + const property = try Property.initFromXml(arena, lexer); + try temp_properties.append(scratch.allocator(), property); + continue; + } + + try iter.skip(); + } + try iter.finish("properties"); + + const properties = try arena.dupe(Property, temp_properties.items); + + return List{ + .items = properties + }; + } + + pub fn get(self: List, name: []const u8) ?Value { + for (self.items) |item| { + if (std.mem.eql(u8, item.name, name)) { + return item.value; + } + } + return null; + } + + pub fn getString(self: List, name: []const u8) ?[]const u8 { + if (self.get(name)) |value| { + return value.string; + } + return null; + } +}; + +pub fn init(name: []const u8, value: Value) Property { + return Property{ + .name = name, + .value = value + }; +} + +pub fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Property { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("property"); + + const name = try attrs.getDupe(arena, "name") orelse return error.MissingName; + const prop_type_str = attrs.get("type") orelse "string"; + const value_str = attrs.get("value") orelse ""; + + const prop_type = Type.map.get(prop_type_str) orelse return error.UnknownPropertyType; + const value = switch(prop_type) { + .string => Value{ + .string = try arena.dupe(u8, value_str) + }, + .int => Value{ + .int = try std.fmt.parseInt(i32, value_str, 10) + }, + .bool => Value{ + .bool = std.mem.eql(u8, value_str, "true") + } + }; + + try iter.finish("property"); + + return Property{ + .name = name, + .value = value + }; +} + +fn expectParsedEquals(expected: Property, body: []const u8) !void { + const allocator = std.testing.allocator; + var ctx: xml.Lexer.TestingContext = undefined; + ctx.init(allocator, body); + defer ctx.deinit(); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const parsed = try initFromXml(arena.allocator(), &ctx.lexer); + try std.testing.expectEqualDeep(expected, parsed); +} + +test Property { + try expectParsedEquals( + Property.init("solid", .{ .string = "hello" }), + \\ + ); + + try expectParsedEquals( + Property.init("solid", .{ .string = "hello" }), + \\ + ); + + try expectParsedEquals( + Property.init("integer", .{ .int = 123 }), + \\ + ); + + try expectParsedEquals( + Property.init("boolean", .{ .bool = true }), + \\ + ); + + try expectParsedEquals( + Property.init("boolean", .{ .bool = false }), + \\ + ); +} diff --git a/libs/tiled/src/root.zig b/libs/tiled/src/root.zig new file mode 100644 index 0000000..50a97ee --- /dev/null +++ b/libs/tiled/src/root.zig @@ -0,0 +1,16 @@ +const std = @import("std"); + +// Warning: +// This library is not complete, it does not cover all features that the specification provides. +// But there are enough features implemented so that I could use this for my games. + +// Map format specification: +// https://doc.mapeditor.org/en/stable/reference/tmx-map-format/# + +pub const xml = @import("./xml.zig"); +pub const Tileset = @import("./tileset.zig"); +pub const Tilemap = @import("./tilemap.zig"); + +test { + _ = std.testing.refAllDeclsRecursive(@This()); +} diff --git a/libs/tiled/src/tilemap.zig b/libs/tiled/src/tilemap.zig new file mode 100644 index 0000000..c6cde2e --- /dev/null +++ b/libs/tiled/src/tilemap.zig @@ -0,0 +1,235 @@ +const std = @import("std"); +const xml = @import("./xml.zig"); +const Io = std.Io; +const assert = std.debug.assert; + +const Property = @import("./property.zig"); +const Layer = @import("./layer.zig"); +const Object = @import("./object.zig"); +const Position = @import("./position.zig"); +const Tileset = @import("./tileset.zig"); +const GlobalTileId = @import("./global_tile_id.zig"); + +const Tilemap = @This(); + +pub const Orientation = enum { + orthogonal, + staggered, + hexagonal, + isometric, + + const map: std.StaticStringMap(Orientation) = .initComptime(.{ + .{ "orthogonal", .orthogonal }, + .{ "staggered", .staggered }, + .{ "hexagonal", .hexagonal }, + .{ "isometric", .isometric } + }); +}; + +pub const RenderOrder = enum { + right_down, + right_up, + left_down, + left_up, + + const map: std.StaticStringMap(RenderOrder) = .initComptime(.{ + .{ "right-down", .right_down }, + .{ "right-up", .right_up }, + .{ "left-down", .left_down }, + .{ "left-up", .left_up }, + }); +}; + +pub const StaggerAxis = enum { + x, + y, + + const map: std.StaticStringMap(StaggerAxis) = .initComptime(.{ + .{ "x", .x }, + .{ "y", .y } + }); +}; + +pub const TilesetReference = struct { + source: []const u8, + first_gid: u32, + + pub fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !TilesetReference { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("tileset"); + + const source = try attrs.getDupe(arena, "source") orelse return error.MissingFirstGid; + const first_gid = try attrs.getNumber(u32, "firstgid") orelse return error.MissingFirstGid; + + try iter.finish("tileset"); + + return TilesetReference{ + .source = source, + .first_gid = first_gid + }; + } +}; + +pub const Tile = struct { + tileset: *const Tileset, + id: u32, + + pub fn getProperties(self: Tile) Property.List { + return self.tileset.getTileProperties(self.id) orelse .empty; + } +}; + +arena: std.heap.ArenaAllocator, + +version: []const u8, +tiled_version: ?[]const u8, +orientation: Orientation, +render_order: RenderOrder, +width: u32, +height: u32, +tile_width: u32, +tile_height: u32, +infinite: bool, + +stagger_axis: ?StaggerAxis, +next_layer_id: u32, +next_object_id: u32, + +tilesets: []TilesetReference, +layers: []Layer, + +pub fn initFromBuffer( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + buffer: []const u8 +) !Tilemap { + var reader = Io.Reader.fixed(buffer); + return initFromReader(gpa, scratch, xml_buffers, &reader); +} + +pub fn initFromReader( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + reader: *Io.Reader, +) !Tilemap { + var lexer = xml.Lexer.init(reader, xml_buffers); + return initFromXml(gpa, scratch, &lexer); +} + +// Map specification: +// https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#map +pub fn initFromXml( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, +) !Tilemap { + var arena_allocator = std.heap.ArenaAllocator.init(gpa); + errdefer arena_allocator.deinit(); + + const arena = arena_allocator.allocator(); + + var iter = xml.TagParser.init(lexer); + const map_attrs = try iter.begin("map"); + + const version = try map_attrs.getDupe(arena, "version") orelse return error.MissingVersion; + const tiled_version = try map_attrs.getDupe(arena, "tiledversion"); + const orientation = try map_attrs.getEnum(Orientation, "orientation", Orientation.map) orelse return error.MissingOrientation; + const render_order = try map_attrs.getEnum(RenderOrder, "renderorder", RenderOrder.map) orelse return error.MissingRenderOrder; + // TODO: compressionlevel + const width = try map_attrs.getNumber(u32, "width") orelse return error.MissingWidth; + const height = try map_attrs.getNumber(u32, "height") orelse return error.MissingHeight; + const tile_width = try map_attrs.getNumber(u32, "tilewidth") orelse return error.MissingTileWidth; + const tile_height = try map_attrs.getNumber(u32, "tileheight") orelse return error.MissingTileHeight; + // TODO: hexidelength + const infinite_int = try map_attrs.getNumber(u32, "infinite") orelse 0; + const infinite = infinite_int != 0; + const next_layer_id = try map_attrs.getNumber(u32, "nextlayerid") orelse return error.MissingLayerId; + const next_object_id = try map_attrs.getNumber(u32, "nextobjectid") orelse return error.MissingObjectId; + // TODO: parallaxoriginx + // TODO: parallaxoriginy + // TODO: backgroundcolor + + var stagger_axis: ?StaggerAxis = null; + if (orientation == .hexagonal or orientation == .staggered) { + stagger_axis = try map_attrs.getEnum(StaggerAxis, "staggeraxis", StaggerAxis.map) orelse return error.MissingRenderOrder; + // TODO: staggerindex + } + + var tileset_list: std.ArrayList(TilesetReference) = .empty; + var layer_list: std.ArrayList(Layer) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("tileset")) { + try tileset_list.append(scratch.allocator(), try TilesetReference.initFromXml(arena, lexer)); + continue; + } else if (Layer.isLayerNode(node)) { + const layer = try Layer.initFromXml( + arena, + scratch, + lexer, + ); + try layer_list.append(scratch.allocator(), layer); + continue; + } + + try iter.skip(); + } + try iter.finish("map"); + + const tilesets = try arena.dupe(TilesetReference, tileset_list.items); + const layers = try arena.dupe(Layer, layer_list.items); + + return Tilemap{ + .arena = arena_allocator, + .version = version, + .tiled_version = tiled_version, + .orientation = orientation, + .render_order = render_order, + .width = width, + .height = height, + .tile_width = tile_width, + .tile_height = tile_height, + .infinite = infinite, + .stagger_axis = stagger_axis, + .next_object_id = next_object_id, + .next_layer_id = next_layer_id, + .tilesets = tilesets, + .layers = layers, + }; +} + +fn getTilesetByGid(self: *const Tilemap, gid: u32) ?TilesetReference { + var result: ?TilesetReference = null; + for (self.tilesets) |tileset| { + if (gid < tileset.first_gid) { + continue; + } + if (result != null and result.?.first_gid < tileset.first_gid) { + continue; + } + result = tileset; + } + + return result; +} + +pub fn getTile(self: *const Tilemap, layer: *const Layer, tilesets: Tileset.List, x: usize, y: usize) ?Tile { + assert(layer.variant == .tile); + const tile_variant = layer.variant.tile; + + const gid = tile_variant.get(x, y) orelse return null; + const tileset_ref = self.getTilesetByGid(gid & GlobalTileId.Flag.clear) orelse return null; + const tileset = tilesets.get(tileset_ref.source) orelse return null; + const id = gid - tileset_ref.first_gid; + + return Tile{ + .tileset = tileset, + .id = id + }; +} + +pub fn deinit(self: *const Tilemap) void { + self.arena.deinit(); +} diff --git a/libs/tiled/src/tileset.zig b/libs/tiled/src/tileset.zig new file mode 100644 index 0000000..04b9305 --- /dev/null +++ b/libs/tiled/src/tileset.zig @@ -0,0 +1,226 @@ +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const xml = @import("./xml.zig"); +const Property = @import("./property.zig"); +const Position = @import("./position.zig"); + +const Tileset = @This(); + +pub const Image = struct { + source: []const u8, + width: u32, + height: u32, + + pub fn intFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Image { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("image"); + + const source = try attrs.getDupe(arena, "width") orelse return error.MissingSource; + const width = try attrs.getNumber(u32, "width") orelse return error.MissingWidth; + const height = try attrs.getNumber(u32, "height") orelse return error.MissingHeight; + + try iter.finish("image"); + + return Image{ + .source = source, + .width = width, + .height = height + }; + } +}; + +pub const Tile = struct { + id: u32, + properties: Property.List, + + pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, + ) !Tile { + var iter = xml.TagParser.init(lexer); + const tile_attrs = try iter.begin("tile"); + + const id = try tile_attrs.getNumber(u32, "id") orelse return error.MissingId; + + var properties: Property.List = .empty; + + while (try iter.next()) |node| { + if (node.isTag("properties")) { + properties = try Property.List.initFromXml(arena, scratch, lexer); + continue; + } + + try iter.skip(); + } + + try iter.finish("tile"); + + return Tile{ + .id = id, + .properties = properties + }; + } +}; + +pub const List = struct { + const Entry = struct { + name: []const u8, + tileset: Tileset + }; + + list: std.ArrayList(Entry), + + pub const empty = List{ + .list = .empty + }; + + pub fn add(self: *List, gpa: Allocator, name: []const u8, tileset: Tileset) !void { + if (self.get(name) != null) { + return error.DuplicateName; + } + + const name_dupe = try gpa.dupe(u8, name); + errdefer gpa.free(name_dupe); + + try self.list.append(gpa, .{ + .name = name_dupe, + .tileset = tileset + }); + } + + pub fn get(self: *const List, name: []const u8) ?*const Tileset { + for (self.list.items) |*entry| { + if (std.mem.eql(u8, entry.name, name)) { + return &entry.tileset; + } + } + return null; + } + + pub fn deinit(self: *List, gpa: Allocator) void { + for (self.list.items) |entry| { + gpa.free(entry.name); + entry.tileset.deinit(); + } + self.list.deinit(gpa); + } +}; + +arena: std.heap.ArenaAllocator, + +version: []const u8, +tiled_version: []const u8, +name: []const u8, +tile_width: u32, +tile_height: u32, +tile_count: u32, +columns: u32, + +image: Image, + +tiles: []Tile, + +pub fn initFromBuffer( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + buffer: []const u8 +) !Tileset { + var reader = Io.Reader.fixed(buffer); + return initFromReader(gpa, scratch, xml_buffers, &reader); +} + +pub fn initFromReader( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + reader: *Io.Reader, +) !Tileset { + var lexer = xml.Lexer.init(reader, xml_buffers); + return initFromXml(gpa, scratch, &lexer); +} + +pub fn initFromXml( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer +) !Tileset { + var arena_state = std.heap.ArenaAllocator.init(gpa); + const arena = arena_state.allocator(); + + var iter = xml.TagParser.init(lexer); + const tileset_attrs = try iter.begin("tileset"); + + const version = try tileset_attrs.getDupe(arena, "version") orelse return error.MissingTilesetTag; + const tiled_version = try tileset_attrs.getDupe(arena, "tiledversion") orelse return error.MissingTilesetTag; + const name = try tileset_attrs.getDupe(arena, "name") orelse return error.MissingTilesetTag; + const tile_width = try tileset_attrs.getNumber(u32, "tilewidth") orelse return error.MissingTilesetTag; + const tile_height = try tileset_attrs.getNumber(u32, "tileheight") orelse return error.MissingTilesetTag; + const tile_count = try tileset_attrs.getNumber(u32, "tilecount") orelse return error.MissingTilesetTag; + const columns = try tileset_attrs.getNumber(u32, "columns") orelse return error.MissingTilesetTag; + + var image: ?Image = null; + var tiles_list: std.ArrayList(Tile) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("image")) { + image = try Image.intFromXml(arena, lexer); + continue; + } else if (node.isTag("tile")) { + const tile = try Tile.initFromXml(arena, scratch, lexer); + try tiles_list.append(scratch.allocator(), tile); + continue; + } + + try iter.skip(); + } + try iter.finish("tileset"); + + const tiles = try arena.dupe(Tile, tiles_list.items); + + return Tileset{ + .arena = arena_state, + .version = version, + .tiled_version = tiled_version, + .name = name, + .tile_width = tile_width, + .tile_height = tile_height, + .tile_count = tile_count, + .columns = columns, + .image = image orelse return error.MissingImageTag, + .tiles = tiles + }; +} + +pub fn getTileProperties(self: *const Tileset, id: u32) ?Property.List { + for (self.tiles) |tile| { + if (tile.id == id) { + return tile.properties; + } + } + return null; +} + +pub fn getTilePositionInImage(self: *const Tileset, id: u32) ?Position { + if (id >= self.tile_count) { + return null; + } + + const tileset_width = @divExact(self.image.width, self.tile_width); + + const tile_x = @mod(id, tileset_width); + const tile_y = @divFloor(id, tileset_width); + + return Position{ + .x = @floatFromInt(tile_x * self.tile_width), + .y = @floatFromInt(tile_y * self.tile_height), + }; + +} + +pub fn deinit(self: *const Tileset) void { + self.arena.deinit(); +} diff --git a/libs/tiled/src/xml.zig b/libs/tiled/src/xml.zig new file mode 100644 index 0000000..661c065 --- /dev/null +++ b/libs/tiled/src/xml.zig @@ -0,0 +1,687 @@ +const std = @import("std"); +const Io = std.Io; +const assert = std.debug.assert; + +const Color = @import("./color.zig"); + +pub const Attribute = struct { + name: []const u8, + value: []const u8, + + pub const List = struct { + items: []const Attribute, + + pub fn get(self: List, name: []const u8) ?[]const u8 { + for (self.items) |attr| { + if (std.mem.eql(u8, attr.name, name)) { + return attr.value; + } + } + return null; + } + + pub fn getDupe(self: List, gpa: std.mem.Allocator, name: []const u8) !?[]u8 { + if (self.get(name)) |value| { + return try gpa.dupe(u8, value); + } + return null; + } + + pub fn getNumber(self: List, T: type, name: []const u8) !?T { + if (self.get(name)) |value| { + if (@typeInfo(T) == .int) { + return try std.fmt.parseInt(T, value, 10); + } else if (@typeInfo(T) == .float) { + return try std.fmt.parseFloat(T, value); + } + } + return null; + } + + pub fn getBool(self: List, name: []const u8, true_value: []const u8, false_value: []const u8) !?bool { + if (self.get(name)) |value| { + if (std.mem.eql(u8, value, true_value)) { + return true; + } else if (std.mem.eql(u8, value, false_value)) { + return false; + } else { + return error.InvalidBoolean; + } + } + return null; + } + + pub fn getEnum(self: List, T: type, name: []const u8, map: std.StaticStringMap(T)) !?T { + if (self.get(name)) |value| { + return map.get(value) orelse return error.InvalidEnumValue; + } + return null; + } + + pub fn getColor(self: List, name: []const u8, hash_required: bool) !?Color { + if (self.get(name)) |value| { + return try Color.parse(value, hash_required); + } + return null; + } + + pub fn format(self: List, writer: *Io.Writer) Io.Writer.Error!void { + if (self.items.len > 0) { + try writer.writeAll("{ "); + for (self.items, 0..) |attribute, i| { + if (i > 0) { + try writer.writeAll(", "); + } + try writer.print("{f}", .{attribute}); + } + try writer.writeAll(" }"); + } else { + try writer.writeAll("{ }"); + } + } + }; + + pub fn format(self: *const Attribute, writer: *Io.Writer) Io.Writer.Error!void { + try writer.print("{s}{{ .name='{s}', .value='{s}' }}", .{ @typeName(Attribute), self.name, self.value }); + } + + pub fn formatSlice(data: []const Attribute, writer: *Io.Writer) Io.Writer.Error!void { + if (data.len > 0) { + try writer.writeAll("{ "); + for (data, 0..) |attribute, i| { + if (i > 0) { + try writer.writeAll(", "); + } + try writer.print("{f}", .{attribute}); + } + try writer.writeAll(" }"); + } else { + try writer.writeAll("{ }"); + } + } + + fn altSlice(data: []const Attribute) std.fmt.Alt(Attribute.List, Attribute.List.format) { + return .{ .data = data }; + } +}; + +pub const Tag = struct { + name: []const u8, + attributes: Attribute.List +}; + +pub const Lexer = struct { + pub const Buffers = struct { + scratch: std.heap.ArenaAllocator, + text: std.ArrayList(u8), + + pub fn init(allocator: std.mem.Allocator) Buffers { + return Buffers{ + .scratch = std.heap.ArenaAllocator.init(allocator), + .text = .empty + }; + } + + pub fn clear(self: *Buffers) void { + self.text.clearRetainingCapacity(); + _ = self.scratch.reset(.retain_capacity); + } + + pub fn deinit(self: *Buffers) void { + const allocator = self.scratch.child_allocator; + self.scratch.deinit(); + self.text.deinit(allocator); + } + }; + + pub const Token = union(enum) { + start_tag: Tag, + end_tag: []const u8, + text: []const u8, + + pub fn isStartTag(self: Token, name: []const u8) bool { + if (self == .start_tag) { + return std.mem.eql(u8, self.start_tag.name, name); + } + return false; + } + + pub fn isEndTag(self: Token, name: []const u8) bool { + if (self == .end_tag) { + return std.mem.eql(u8, self.end_tag, name); + } + return false; + } + }; + + pub const StepResult = struct { + token: ?Token, + self_closing: bool = false, + }; + + pub const TestingContext = struct { + io_reader: Io.Reader, + buffers: Buffers, + lexer: Lexer, + + pub fn init(self: *TestingContext, allocator: std.mem.Allocator, body: []const u8) void { + self.* = TestingContext{ + .lexer = undefined, + .io_reader = Io.Reader.fixed(body), + .buffers = Buffers.init(allocator) + }; + self.lexer = Lexer.init(&self.io_reader, &self.buffers); + } + + pub fn deinit(self: *TestingContext) void { + self.buffers.deinit(); + } + }; + + io_reader: *Io.Reader, + buffers: *Buffers, + + peeked_value: ?Token, + cursor: usize, + queued_end_tag: ?[]const u8, + + pub fn init(reader: *Io.Reader, buffers: *Buffers) Lexer { + buffers.clear(); + + return Lexer{ + .io_reader = reader, + .buffers = buffers, + .cursor = 0, + .queued_end_tag = null, + .peeked_value = null + }; + } + + fn step(self: *Lexer) !StepResult { + _ = self.buffers.scratch.reset(.retain_capacity); + + if (try self.peekByte() == '<') { + self.tossByte(); + + if (try self.peekByte() == '/') { + // End tag + self.tossByte(); + + const name = try self.parseName(); + try self.skipWhiteSpace(); + + if (!std.mem.eql(u8, try self.takeBytes(1), ">")) { + return error.InvalidEndTag; + } + + const token = Token{ .end_tag = name }; + return .{ .token = token }; + + } else if (try self.peekByte() == '?') { + // Prolog tag + self.tossByte(); + if (!std.mem.eql(u8, try self.takeBytes(4), "xml ")) { + return error.InvalidPrologTag; + } + + const attributes = try self.parseAttributes(); + try self.skipWhiteSpace(); + + if (!std.mem.eql(u8, try self.takeBytes(2), "?>")) { + return error.MissingPrologEnd; + } + + const version = attributes.get("version") orelse return error.InvalidProlog; + if (!std.mem.eql(u8, version, "1.0")) { + return error.InvalidPrologVersion; + } + const encoding = attributes.get("encoding") orelse return error.InvalidProlog; + if (!std.mem.eql(u8, encoding, "UTF-8")) { + return error.InvalidPrologEncoding; + } + + return .{ .token = null }; + + } else { + // Start tag + const name = try self.parseName(); + const attributes = try self.parseAttributes(); + try self.skipWhiteSpace(); + + const token = Token{ + .start_tag = .{ + .name = name, + .attributes = attributes + } + }; + + var self_closing = false; + if (std.mem.eql(u8, try self.peekBytes(1), ">")) { + self.tossBytes(1); + + } else if (std.mem.eql(u8, try self.peekBytes(2), "/>")) { + self.tossBytes(2); + + self_closing = true; + + } else { + return error.UnfinishedStartTag; + } + + return .{ + .token = token, + .self_closing = self_closing + }; + } + + } else { + try self.skipWhiteSpace(); + + const text_start = self.cursor; + while (try self.peekByte() != '<') { + self.tossByte(); + } + var text: []const u8 = self.buffers.text.items[text_start..self.cursor]; + text = std.mem.trimEnd(u8, text, &std.ascii.whitespace); + + var token: ?Token = null; + if (text.len > 0) { + token = Token{ .text = text }; + } + return .{ .token = token }; + } + } + + pub fn next(self: *Lexer) !?Token { + if (self.peeked_value) |value| { + self.peeked_value = null; + return value; + } + + if (self.queued_end_tag) |name| { + self.queued_end_tag = null; + return Token{ + .end_tag = name + }; + } + + while (true) { + if (self.buffers.text.items.len == 0) { + self.readIntoTextBuffer() catch |e| switch (e) { + error.EndOfStream => break, + else => return e + }; + } + + const saved_cursor = self.cursor; + const result = self.step() catch |e| switch(e) { + error.EndOfTextBuffer => { + self.cursor = saved_cursor; + + const unused_capacity = self.buffers.text.capacity - self.buffers.text.items.len; + if (unused_capacity == 0 and self.cursor > 0) { + self.rebaseBuffer(); + } else { + self.readIntoTextBuffer() catch |read_err| switch (read_err) { + error.EndOfStream => break, + else => return read_err + }; + } + + continue; + }, + else => return e + }; + + if (result.token) |token| { + if (token == .start_tag and result.self_closing) { + self.queued_end_tag = token.start_tag.name; + } + return token; + } + } + + return null; + } + + pub fn nextExpectEndTag(self: *Lexer, name: []const u8) !void { + const value = try self.next() orelse return error.MissingEndTag; + if (!value.isEndTag(name)) return error.MissingEndTag; + } + + pub fn nextExpectStartTag(self: *Lexer, name: []const u8) !Attribute.List { + const value = try self.next() orelse return error.MissingStartTag; + if (!value.isStartTag(name)) return error.MissingStartTag; + return value.start_tag.attributes; + } + + pub fn nextExpectText(self: *Lexer) ![]const u8 { + const value = try self.next() orelse return error.MissingTextTag; + if (value != .text) return error.MissingTextTag; + return value.text; + } + + pub fn skipUntilMatchingEndTag(self: *Lexer, name: ?[]const u8) !void { + var depth: usize = 0; + while (true) { + const value = try self.next() orelse return error.MissingEndTag; + + if (depth == 0 and value == .end_tag) { + if (name != null and !std.mem.eql(u8, value.end_tag, name.?)) { + return error.MismatchedEndTag; + } + break; + } + + if (value == .start_tag) { + depth += 1; + } else if (value == .end_tag) { + depth -= 1; + } + } + } + + pub fn peek(self: *Lexer) !?Token { + if (try self.next()) |value| { + self.peeked_value = value; + return value; + } + + return null; + } + + fn readIntoTextBuffer(self: *Lexer) !void { + const gpa = self.buffers.scratch.child_allocator; + const text = &self.buffers.text; + try text.ensureUnusedCapacity(gpa, 1); + + var writer = Io.Writer.fixed(text.allocatedSlice()); + writer.end = text.items.len; + + _ = self.io_reader.stream(&writer, .limited(text.capacity - text.items.len)) catch |e| switch (e) { + error.WriteFailed => unreachable, + else => |ee| return ee + }; + text.items.len = writer.end; + } + + fn rebaseBuffer(self: *Lexer) void { + if (self.cursor == 0) { + return; + } + + const text = &self.buffers.text; + @memmove( + text.items[0..(text.items.len - self.cursor)], + text.items[self.cursor..] + ); + + text.items.len -= self.cursor; + self.cursor = 0; + } + + fn isNameStartChar(c: u8) bool { + return c == ':' or c == '_' or std.ascii.isAlphabetic(c); + } + + fn isNameChar(c: u8) bool { + return isNameStartChar(c) or c == '-' or c == '.' or ('0' <= c and c <= '9'); + } + + fn hasBytes(self: *Lexer, n: usize) bool { + const text = self.buffers.text.items; + return self.cursor + n <= text.len; + } + + fn peekBytes(self: *Lexer, n: usize) ![]const u8 { + if (self.hasBytes(n)) { + const text = self.buffers.text.items; + return text[self.cursor..][0..n]; + } + return error.EndOfTextBuffer; + } + + fn tossBytes(self: *Lexer, n: usize) void { + assert(self.hasBytes(n)); + self.cursor += n; + } + + fn takeBytes(self: *Lexer, n: usize) ![]const u8 { + const result = try self.peekBytes(n); + self.tossBytes(n); + return result; + } + + fn peekByte(self: *Lexer) !u8 { + return (try self.peekBytes(1))[0]; + } + + fn tossByte(self: *Lexer) void { + self.tossBytes(1); + } + + fn takeByte(self: *Lexer) !u8 { + return (try self.takeBytes(1))[0]; + } + + fn parseName(self: *Lexer) ![]const u8 { + const name_start = self.cursor; + + if (isNameStartChar(try self.peekByte())) { + self.tossByte(); + + while (isNameChar(try self.peekByte())) { + self.tossByte(); + } + } + + return self.buffers.text.items[name_start..self.cursor]; + } + + fn skipWhiteSpace(self: *Lexer) !void { + while (std.ascii.isWhitespace(try self.peekByte())) { + self.tossByte(); + } + } + + fn parseAttributeValue(self: *Lexer) ![]const u8 { + const quote = try self.takeByte(); + if (quote != '"' and quote != '\'') { + return error.InvalidAttributeValue; + } + + const value_start: usize = self.cursor; + var value_len: usize = 0; + + while (true) { + const c = try self.takeByte(); + if (c == '<' or c == '&') { + return error.InvalidAttributeValue; + } + if (c == quote) { + break; + } + value_len += 1; + } + + return self.buffers.text.items[value_start..][0..value_len]; + } + + fn parseAttributes(self: *Lexer) !Attribute.List { + const arena = self.buffers.scratch.allocator(); + var attributes: std.ArrayList(Attribute) = .empty; + + while (true) { + try self.skipWhiteSpace(); + const name = try self.parseName(); + if (name.len == 0) { + break; + } + + try self.skipWhiteSpace(); + if (try self.takeByte() != '=') { + return error.MissingAttributeEquals; + } + try self.skipWhiteSpace(); + const value = try self.parseAttributeValue(); + + const list = Attribute.List{ .items = attributes.items }; + if (list.get(name) != null) { + return error.DuplicateAttribute; + } + + try attributes.append(arena, Attribute{ + .name = name, + .value = value + }); + } + + return Attribute.List{ + .items = attributes.items + }; + } + + test "self closing tag" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "tag" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "tag with prolog" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + \\ + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "text content" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ Hello World + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expectEqualStrings("Hello World", (try ctx.lexer.next()).?.text); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "attributes" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + ); + defer ctx.deinit(); + + const token = try ctx.lexer.next(); + const attrs = token.?.start_tag.attributes; + try std.testing.expectEqualStrings("1", attrs.get("a").?); + try std.testing.expectEqualStrings("2", attrs.get("b").?); + } +}; + +// TODO: The API for this is easy to misuse. +// Design a better API for using Reader +// As a compromise `assert` was used to guard against some of the ways this can be misused +pub const TagParser = struct { + lexer: *Lexer, + + begin_called: bool = false, + finish_called: bool = false, + + pub const Node = union(enum) { + tag: Tag, + text: []const u8, + + pub fn isTag(self: Node, name: []const u8) bool { + if (self == .tag) { + return std.mem.eql(u8, self.tag.name, name); + } + return false; + } + }; + + pub fn init(lexer: *Lexer) TagParser { + return TagParser{ + .lexer = lexer, + }; + } + + pub fn begin(self: *TagParser, name: []const u8) !Attribute.List { + assert(!self.begin_called); + self.begin_called = true; + + return try self.lexer.nextExpectStartTag(name); + } + + pub fn finish(self: *TagParser, name: []const u8) !void { + assert(self.begin_called); + assert(!self.finish_called); + self.finish_called = true; + + try self.lexer.skipUntilMatchingEndTag(name); + } + + pub fn next(self: *TagParser) !?Node { + assert(self.begin_called); + assert(!self.finish_called); + + const value = try self.lexer.peek() orelse return error.MissingEndTag; + if (value == .end_tag) { + return null; + } + + return switch (value) { + .text => |text| Node{ .text = text }, + .start_tag => |start_tag| Node{ .tag = start_tag }, + .end_tag => unreachable, + }; + } + + pub fn skip(self: *TagParser) !void { + assert(self.begin_called); + assert(!self.finish_called); + + const value = try self.lexer.next() orelse return error.MissingNode; + if (value == .end_tag) { + return error.UnexpectedEndTag; + } else if (value == .start_tag) { + // TODO: Make this configurable + var name_buffer: [64]u8 = undefined; + var name: std.ArrayList(u8) = .initBuffer(&name_buffer); + try name.appendSliceBounded(value.start_tag.name); + + try self.lexer.skipUntilMatchingEndTag(name.items); + } + } +}; diff --git a/libs/tracy/build.zig b/libs/tracy/build.zig new file mode 100644 index 0000000..53e8992 --- /dev/null +++ b/libs/tracy/build.zig @@ -0,0 +1,138 @@ +const std = @import("std"); +const digits2 = std.fmt.digits2; + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const tracy_enable = b.option(bool, "tracy_enable", "Enable profiling") orelse true; + const tracy_on_demand = b.option(bool, "tracy_on_demand", "On-demand profiling") orelse false; + const tracy_callstack: ?u8 = b.option(u8, "tracy_callstack", "Enforce callstack collection for tracy regions"); + const tracy_no_callstack = b.option(bool, "tracy_no_callstack", "Disable all callstack related functionality") orelse false; + const tracy_no_callstack_inlines = b.option(bool, "tracy_no_callstack_inlines", "Disables the inline functions in callstacks") orelse false; + const tracy_only_localhost = b.option(bool, "tracy_only_localhost", "Only listen on the localhost interface") orelse false; + const tracy_no_broadcast = b.option(bool, "tracy_no_broadcast", "Disable client discovery by broadcast to local network") orelse false; + const tracy_only_ipv4 = b.option(bool, "tracy_only_ipv4", "Tracy will only accept connections on IPv4 addresses (disable IPv6)") orelse false; + const tracy_no_code_transfer = b.option(bool, "tracy_no_code_transfer", "Disable collection of source code") orelse false; + const tracy_no_context_switch = b.option(bool, "tracy_no_context_switch", "Disable capture of context switches") orelse false; + const tracy_no_exit = b.option(bool, "tracy_no_exit", "Client executable does not exit until all profile data is sent to server") orelse false; + const tracy_no_sampling = b.option(bool, "tracy_no_sampling", "Disable call stack sampling") orelse false; + const tracy_no_verify = b.option(bool, "tracy_no_verify", "Disable zone validation for C API") orelse false; + const tracy_no_vsync_capture = b.option(bool, "tracy_no_vsync_capture", "Disable capture of hardware Vsync events") orelse false; + const tracy_no_frame_image = b.option(bool, "tracy_no_frame_image", "Disable the frame image support and its thread") orelse false; + // NOTE For some reason system tracing on zig projects crashes tracy, will need to investigate + const tracy_no_system_tracing = b.option(bool, "tracy_no_system_tracing", "Disable systrace sampling") orelse true; + const tracy_delayed_init = b.option(bool, "tracy_delayed_init", "Enable delayed initialization of the library (init on first call)") orelse false; + const tracy_manual_lifetime = b.option(bool, "tracy_manual_lifetime", "Enable the manual lifetime management of the profile") orelse false; + const tracy_fibers = b.option(bool, "tracy_fibers", "Enable fibers support") orelse false; + const tracy_no_crash_handler = b.option(bool, "tracy_no_crash_handler", "Disable crash handling") orelse false; + const tracy_timer_fallback = b.option(bool, "tracy_timer_fallback", "Use lower resolution timers") orelse false; + const shared = b.option(bool, "shared", "Build the tracy client as a shared libary") orelse false; + + const options = b.addOptions(); + options.addOption(bool, "tracy_enable", tracy_enable); + options.addOption(bool, "tracy_on_demand", tracy_on_demand); + options.addOption(?u8, "tracy_callstack", tracy_callstack); + options.addOption(bool, "tracy_no_callstack", tracy_no_callstack); + options.addOption(bool, "tracy_no_callstack_inlines", tracy_no_callstack_inlines); + options.addOption(bool, "tracy_only_localhost", tracy_only_localhost); + options.addOption(bool, "tracy_no_broadcast", tracy_no_broadcast); + options.addOption(bool, "tracy_only_ipv4", tracy_only_ipv4); + options.addOption(bool, "tracy_no_code_transfer", tracy_no_code_transfer); + options.addOption(bool, "tracy_no_context_switch", tracy_no_context_switch); + options.addOption(bool, "tracy_no_exit", tracy_no_exit); + options.addOption(bool, "tracy_no_sampling", tracy_no_sampling); + options.addOption(bool, "tracy_no_verify", tracy_no_verify); + options.addOption(bool, "tracy_no_vsync_capture", tracy_no_vsync_capture); + options.addOption(bool, "tracy_no_frame_image", tracy_no_frame_image); + options.addOption(bool, "tracy_no_system_tracing", tracy_no_system_tracing); + options.addOption(bool, "tracy_delayed_init", tracy_delayed_init); + options.addOption(bool, "tracy_manual_lifetime", tracy_manual_lifetime); + options.addOption(bool, "tracy_fibers", tracy_fibers); + options.addOption(bool, "tracy_no_crash_handler", tracy_no_crash_handler); + options.addOption(bool, "tracy_timer_fallback", tracy_timer_fallback); + options.addOption(bool, "shared", shared); + + const dep_src = b.dependency("src", .{}); + + const tracy_module = b.addModule("tracy", .{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); + + tracy_module.addImport("tracy-options", options.createModule()); + tracy_module.addIncludePath(dep_src.path("./public")); + + const tracy_client = b.addLibrary(.{ + .linkage = if (shared) .dynamic else .static, + .name = "tracy", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + }), + }); + + if (target.result.os.tag == .windows) { + tracy_client.root_module.linkSystemLibrary("dbghelp", .{}); + tracy_client.root_module.linkSystemLibrary("ws2_32", .{}); + } + if (target.result.abi != .msvc) { + tracy_client.root_module.link_libcpp = true; + } else { + tracy_client.root_module.link_libc = true; + } + tracy_client.root_module.addCSourceFile(.{ + .file = dep_src.path("./public/TracyClient.cpp"), + .flags = if (target.result.os.tag == .windows) &.{"-fms-extensions"} else &.{}, + }); + tracy_client.installHeadersDirectory(dep_src.path("public"), "", .{ + .include_extensions = &.{".h", ".hpp"}, + }); + if (tracy_enable) + tracy_client.root_module.addCMacro("TRACY_ENABLE", "1"); + if (tracy_on_demand) + tracy_client.root_module.addCMacro("TRACY_ON_DEMAND", "1"); + if (tracy_callstack) |depth| { + tracy_client.root_module.addCMacro("TRACY_CALLSTACK", "\"" ++ digits2(depth) ++ "\""); + } + if (tracy_no_callstack) + tracy_client.root_module.addCMacro("TRACY_NO_CALLSTACK", "1"); + if (tracy_no_callstack_inlines) + tracy_client.root_module.addCMacro("TRACY_NO_CALLSTACK_INLINES", "1"); + if (tracy_only_localhost) + tracy_client.root_module.addCMacro("TRACY_ONLY_LOCALHOST", "1"); + if (tracy_no_broadcast) + tracy_client.root_module.addCMacro("TRACY_NO_BROADCAST", "1"); + if (tracy_only_ipv4) + tracy_client.root_module.addCMacro("TRACY_ONLY_IPV4", "1"); + if (tracy_no_code_transfer) + tracy_client.root_module.addCMacro("TRACY_NO_CODE_TRANSFER", "1"); + if (tracy_no_context_switch) + tracy_client.root_module.addCMacro("TRACY_NO_CONTEXT_SWITCH", "1"); + if (tracy_no_exit) + tracy_client.root_module.addCMacro("TRACY_NO_EXIT", "1"); + if (tracy_no_sampling) + tracy_client.root_module.addCMacro("TRACY_NO_SAMPLING", "1"); + if (tracy_no_verify) + tracy_client.root_module.addCMacro("TRACY_NO_VERIFY", "1"); + if (tracy_no_vsync_capture) + tracy_client.root_module.addCMacro("TRACY_NO_VSYNC_CAPTURE", "1"); + if (tracy_no_frame_image) + tracy_client.root_module.addCMacro("TRACY_NO_FRAME_IMAGE", "1"); + if (tracy_no_system_tracing) + tracy_client.root_module.addCMacro("TRACY_NO_SYSTEM_TRACING", "1"); + if (tracy_delayed_init) + tracy_client.root_module.addCMacro("TRACY_DELAYED_INIT", "1"); + if (tracy_manual_lifetime) + tracy_client.root_module.addCMacro("TRACY_MANUAL_LIFETIME", "1"); + if (tracy_fibers) + tracy_client.root_module.addCMacro("TRACY_FIBERS", "1"); + if (tracy_no_crash_handler) + tracy_client.root_module.addCMacro("TRACY_NO_CRASH_HANDLER", "1"); + if (tracy_timer_fallback) + tracy_client.root_module.addCMacro("TRACY_TIMER_FALLBACK", "1"); + if (shared and target.result.os.tag == .windows) + tracy_client.root_module.addCMacro("TRACY_EXPORTS", "1"); + b.installArtifact(tracy_client); +} diff --git a/libs/tracy/build.zig.zon b/libs/tracy/build.zig.zon new file mode 100644 index 0000000..db2a6c3 --- /dev/null +++ b/libs/tracy/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .tracy, + .version = "0.0.1", + .dependencies = .{ + .src = .{ + .url = "https://github.com/wolfpld/tracy/archive/refs/tags/v0.13.1.tar.gz", + .hash = "N-V-__8AAOncKwEm1F9c5LrT7HMNmRMYX8-fAoqpc6YyTu9X", + }, + }, + .minimum_zig_version = "0.16.0", + .paths = .{""}, + .fingerprint = 0x255a89eeaaacb778, +} diff --git a/libs/tracy/src/root.zig b/libs/tracy/src/root.zig new file mode 100644 index 0000000..227867c --- /dev/null +++ b/libs/tracy/src/root.zig @@ -0,0 +1,376 @@ +const std = @import("std"); +const digits2 = std.fmt.digits2; +const builtin = @import("builtin"); + +const options = @import("tracy-options"); + +const c = @cImport({ + if (options.tracy_enable) @cDefine("TRACY_ENABLE", {}); + if (options.tracy_on_demand) @cDefine("TRACY_ON_DEMAND", {}); + if (options.tracy_callstack) |depth| @cDefine("TRACY_CALLSTACK", "\"" ++ digits2(depth) ++ "\""); + if (options.tracy_no_callstack) @cDefine("TRACY_NO_CALLSTACK", {}); + if (options.tracy_no_callstack_inlines) @cDefine("TRACY_NO_CALLSTACK_INLINES", {}); + if (options.tracy_only_localhost) @cDefine("TRACY_ONLY_LOCALHOST", {}); + if (options.tracy_no_broadcast) @cDefine("TRACY_NO_BROADCAST", {}); + if (options.tracy_only_ipv4) @cDefine("TRACY_ONLY_IPV4", {}); + if (options.tracy_no_code_transfer) @cDefine("TRACY_NO_CODE_TRANSFER", {}); + if (options.tracy_no_context_switch) @cDefine("TRACY_NO_CONTEXT_SWITCH", {}); + if (options.tracy_no_exit) @cDefine("TRACY_NO_EXIT", {}); + if (options.tracy_no_sampling) @cDefine("TRACY_NO_SAMPLING", {}); + if (options.tracy_no_verify) @cDefine("TRACY_NO_VERIFY", {}); + if (options.tracy_no_vsync_capture) @cDefine("TRACY_NO_VSYNC_CAPTURE", {}); + if (options.tracy_no_frame_image) @cDefine("TRACY_NO_FRAME_IMAGE", {}); + if (options.tracy_no_system_tracing) @cDefine("TRACY_NO_SYSTEM_TRACING", {}); + if (options.tracy_delayed_init) @cDefine("TRACY_DELAYED_INIT", {}); + if (options.tracy_manual_lifetime) @cDefine("TRACY_MANUAL_LIFETIME", {}); + if (options.tracy_fibers) @cDefine("TRACY_FIBERS", {}); + if (options.tracy_no_crash_handler) @cDefine("TRACY_NO_CRASH_HANDLER", {}); + if (options.tracy_timer_fallback) @cDefine("TRACY_TIMER_FALLBACK", {}); + if (options.shared and builtin.os.tag == .windows) @cDefine("TRACY_IMPORTS", {}); + + @cInclude("tracy/TracyC.h"); +}); + +pub inline fn setThreadName(comptime name: [:0]const u8) void { + if (!options.tracy_enable) return; + c.___tracy_set_thread_name(name); +} + +pub inline fn startupProfiler() void { + if (!options.tracy_enable) return; + if (!options.tracy_manual_lifetime) return; + c.___tracy_startup_profiler(); +} + +pub inline fn shutdownProfiler() void { + if (!options.tracy_enable) return; + if (!options.tracy_manual_lifetime) return; + c.___tracy_shutdown_profiler(); +} + +pub inline fn profilerStarted() bool { + if (!options.tracy_enable) return false; + if (!options.tracy_manual_lifetime) return true; + return c.___tracy_profiler_started() != 0; +} + +pub inline fn isConnected() bool { + if (!options.tracy_enable) return false; + return c.___tracy_connected() > 0; +} + +pub inline fn frameMark() void { + if (!options.tracy_enable) return; + c.___tracy_emit_frame_mark(null); +} + +pub inline fn frameMarkNamed(comptime name: [:0]const u8) void { + if (!options.tracy_enable) return; + c.___tracy_emit_frame_mark(name); +} + +const DiscontinuousFrame = struct { + name: [:0]const u8, + + pub inline fn deinit(frame: *const DiscontinuousFrame) void { + if (!options.tracy_enable) return; + c.___tracy_emit_frame_mark_end(frame.name); + } +}; + +pub inline fn initDiscontinuousFrame(comptime name: [:0]const u8) DiscontinuousFrame { + if (!options.tracy_enable) return .{ .name = name }; + c.___tracy_emit_frame_mark_start(name); + return .{ .name = name }; +} + +pub inline fn frameImage(image: *anyopaque, width: u16, height: u16, offset: u8, flip: bool) void { + if (!options.tracy_enable) return; + c.___tracy_emit_frame_mark_image(image, width, height, offset, @as(c_int, @intFromBool(flip))); +} + +pub const ZoneOptions = struct { + active: bool = true, + name: ?[]const u8 = null, + color: ?u32 = null, +}; + +const ZoneContext = if (options.tracy_enable) extern struct { + ctx: c.___tracy_c_zone_context, + + pub inline fn deinit(zone: *const ZoneContext) void { + if (!options.tracy_enable) return; + c.___tracy_emit_zone_end(zone.ctx); + } + + pub inline fn name(zone: *const ZoneContext, zone_name: []const u8) void { + if (!options.tracy_enable) return; + c.___tracy_emit_zone_name(zone.ctx, zone_name.ptr, zone_name.len); + } + + pub inline fn text(zone: *const ZoneContext, zone_text: []const u8) void { + if (!options.tracy_enable) return; + c.___tracy_emit_zone_text(zone.ctx, zone_text.ptr, zone_text.len); + } + + pub inline fn color(zone: *const ZoneContext, zone_color: u32) void { + if (!options.tracy_enable) return; + c.___tracy_emit_zone_color(zone.ctx, zone_color); + } + + pub inline fn value(zone: *const ZoneContext, zone_value: u64) void { + if (!options.tracy_enable) return; + c.___tracy_emit_zone_value(zone.ctx, zone_value); + } +} else struct { + pub inline fn deinit(_: *const ZoneContext) void {} + pub inline fn name(_: *const ZoneContext, _: []const u8) void {} + pub inline fn text(_: *const ZoneContext, _: []const u8) void {} + pub inline fn color(_: *const ZoneContext, _: u32) void {} + pub inline fn value(_: *const ZoneContext, _: u64) void {} +}; + +pub inline fn initZone(comptime src: std.builtin.SourceLocation, comptime opts: ZoneOptions) ZoneContext { + if (!options.tracy_enable) return .{}; + const active: c_int = @intFromBool(opts.active); + + const static = struct { + var src_loc = c.___tracy_source_location_data{ + .name = if (opts.name) |name| name.ptr else null, + .function = src.fn_name.ptr, + .file = src.file, + .line = 0, + .color = opts.color orelse 0, + }; + }; + + // src.line magically is not comptime https://github.com/ziglang/zig/pull/12016#issuecomment-1178092847 + static.src_loc.line = src.line; + + if (!options.tracy_no_callstack) { + if (options.tracy_callstack) |depth| { + return .{ + .ctx = c.___tracy_emit_zone_begin_callstack(&static.src_loc, depth, active), + }; + } + } + + return .{ + .ctx = c.___tracy_emit_zone_begin(&static.src_loc, active), + }; +} + +pub inline fn plot(comptime T: type, comptime name: [:0]const u8, value: T) void { + if (!options.tracy_enable) return; + + const type_info = @typeInfo(T); + switch (type_info) { + .int => |int_type| { + if (int_type.bits > 64) @compileError("Too large int to plot"); + if (int_type.signedness == .unsigned and int_type.bits > 63) @compileError("Too large unsigned int to plot"); + c.___tracy_emit_plot_int(name, value); + }, + .float => |float_type| { + if (float_type.bits <= 32) { + c.___tracy_emit_plot_float(name, value); + } else if (float_type.bits <= 64) { + c.___tracy_emit_plot(name, value); + } else { + @compileError("Too large float to plot"); + } + }, + else => @compileError("Unsupported plot value type"), + } +} + +pub const PlotType = enum(c_int) { + Number, + Memory, + Percentage, + Watt, +}; + +pub const PlotConfig = struct { + plot_type: PlotType, + step: c_int, + fill: c_int, + color: u32, +}; + +pub inline fn plotConfig(comptime name: [:0]const u8, comptime config: PlotConfig) void { + if (!options.tracy_enable) return; + c.___tracy_emit_plot_config( + name, + @intFromEnum(config.plot_type), + config.step, + config.fill, + config.color, + ); +} + +pub inline fn message(comptime msg: [:0]const u8) void { + if (!options.tracy_enable) return; + const depth = options.tracy_callstack orelse 0; + c.___tracy_emit_messageL(msg, depth); +} + +pub inline fn messageColor(comptime msg: [:0]const u8, color: u32) void { + if (!options.tracy_enable) return; + const depth = options.tracy_callstack orelse 0; + c.___tracy_emit_messageLC(msg, color, depth); +} + +const tracy_message_buffer_size = if (options.tracy_enable) 4096 else 0; +threadlocal var tracy_message_buffer: [tracy_message_buffer_size]u8 = undefined; + +pub inline fn print(comptime fmt: []const u8, args: anytype) void { + if (!options.tracy_enable) return; + const depth = options.tracy_callstack orelse 0; + + var stream = std.io.fixedBufferStream(&tracy_message_buffer); + stream.writer().print(fmt, args) catch {}; + + const written = stream.getWritten(); + c.___tracy_emit_message(written.ptr, written.len, depth); +} + +pub inline fn printColor(comptime fmt: []const u8, args: anytype, color: u32) void { + if (!options.tracy_enable) return; + const depth = options.tracy_callstack orelse 0; + + var stream = std.io.fixedBufferStream(&tracy_message_buffer); + stream.writer().print(fmt, args) catch {}; + + const written = stream.getWritten(); + c.___tracy_emit_messageC(written.ptr, written.len, color, depth); +} + +pub inline fn printAppInfo(comptime fmt: []const u8, args: anytype) void { + if (!options.tracy_enable) return; + + var stream = std.io.fixedBufferStream(&tracy_message_buffer); + stream.reset(); + stream.writer().print(fmt, args) catch {}; + + const written = stream.getWritten(); + c.___tracy_emit_message_appinfo(written.ptr, written.len); +} + +pub const TracingAllocator = struct { + parent_allocator: std.mem.Allocator, + pool_name: ?[:0]const u8, + + const Self = @This(); + + pub fn init(parent_allocator: std.mem.Allocator) Self { + return .{ + .parent_allocator = parent_allocator, + .pool_name = null, + }; + } + + pub fn initNamed(comptime pool_name: [:0]const u8, parent_allocator: std.mem.Allocator) Self { + return .{ + .parent_allocator = parent_allocator, + .pool_name = pool_name, + }; + } + + pub fn allocator(self: *Self) std.mem.Allocator { + return .{ + .ptr = self, + .vtable = &.{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + }, + }; + } + + fn alloc( + ctx: *anyopaque, + len: usize, + ptr_align: std.mem.Alignment, + ret_addr: usize, + ) ?[*]u8 { + const self: *Self = @ptrCast(@alignCast(ctx)); + const result = self.parent_allocator.rawAlloc(len, ptr_align, ret_addr); + if (!options.tracy_enable) return result; + + if (self.pool_name) |name| { + c.___tracy_emit_memory_alloc_named(result, len, 0, name.ptr); + } else { + c.___tracy_emit_memory_alloc(result, len, 0); + } + + return result; + } + + fn resize( + ctx: *anyopaque, + buf: []u8, + buf_align: std.mem.Alignment, + new_len: usize, + ret_addr: usize, + ) bool { + const self: *Self = @ptrCast(@alignCast(ctx)); + const result = self.parent_allocator.rawResize(buf, buf_align, new_len, ret_addr); + if (!result) return false; + + if (!options.tracy_enable) return true; + + if (self.pool_name) |name| { + c.___tracy_emit_memory_free_named(buf.ptr, 0, name.ptr); + c.___tracy_emit_memory_alloc_named(buf.ptr, new_len, 0, name.ptr); + } else { + c.___tracy_emit_memory_free(buf.ptr, 0); + c.___tracy_emit_memory_alloc(buf.ptr, new_len, 0); + } + + return true; + } + + fn remap( + ctx: *anyopaque, + buf: []u8, + buf_align: std.mem.Alignment, + new_len: usize, + ret_addr: usize, + ) ?[*]u8 { + const self: *Self = @ptrCast(@alignCast(ctx)); + const result = self.parent_allocator.rawRemap(buf, buf_align, new_len, ret_addr); + const new_buf = result orelse return null; + + if (!options.tracy_enable) return new_buf; + + if (self.pool_name) |name| { + c.___tracy_emit_memory_free_named(buf.ptr, 0, name.ptr); + c.___tracy_emit_memory_alloc_named(new_buf, new_len, 0, name.ptr); + } else { + c.___tracy_emit_memory_free(buf.ptr, 0); + c.___tracy_emit_memory_alloc(new_buf, new_len, 0); + } + + return new_buf; + } + + fn free( + ctx: *anyopaque, + buf: []u8, + buf_align: std.mem.Alignment, + ret_addr: usize, + ) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + + if (options.tracy_enable) { + if (self.pool_name) |name| { + c.___tracy_emit_memory_free_named(buf.ptr, 0, name.ptr); + } else { + c.___tracy_emit_memory_free(buf.ptr, 0); + } + } + + self.parent_allocator.rawFree(buf, buf_align, ret_addr); + } +}; diff --git a/scripts/png-to-icon.zig b/scripts/png-to-icon.zig new file mode 100644 index 0000000..0638586 --- /dev/null +++ b/scripts/png-to-icon.zig @@ -0,0 +1,57 @@ +const std = @import("std"); +const STBImage = @import("stb_image"); + +// https://en.wikipedia.org/wiki/ICO_(file_format)#Icon_file_structure + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + defer _ = gpa.deinit(); + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + if (args.len != 3) { + std.debug.print("Usage: ./png-to-icon ", .{}); + std.process.exit(1); + } + + const cwd = std.fs.cwd(); + const input_png_path = args[1]; + const output_ico_path = args[2]; + + const input_png_data = try cwd.readFileAlloc(allocator, input_png_path, 1024 * 1024 * 5); + defer allocator.free(input_png_data); + + const png_image = try STBImage.load(input_png_data); + defer png_image.deinit(); + + std.debug.assert(png_image.width > 0 and png_image.width <= 256); + std.debug.assert(png_image.height > 0 and png_image.height <= 256); + + const output_ico_file = try std.fs.cwd().createFile(output_ico_path, .{ }); + defer output_ico_file.close(); + + var buffer: [4096 * 4]u8 = undefined; + var writer = output_ico_file.writer(&buffer); + + // ICONDIR structure + try writer.interface.writeInt(u16, 0, .little); // Must always be zero + try writer.interface.writeInt(u16, 1, .little); // Image type. 1 for .ICO + try writer.interface.writeInt(u16, 1, .little); // Number of images + + // ICONDIRENTRY structure + try writer.interface.writeInt(u8, @truncate(png_image.width), .little); // Image width + try writer.interface.writeInt(u8, @truncate(png_image.height), .little); // Image height + try writer.interface.writeInt(u8, 0, .little); // Number of colors in color pallete. 0 means that color pallete is not used + try writer.interface.writeInt(u8, 0, .little); // Must always be zero + try writer.interface.writeInt(u16, 0, .little); // Color plane + try writer.interface.writeInt(u16, 32, .little); // Bits per pixel + try writer.interface.writeInt(u32, @intCast(input_png_data.len), .little); // Image size in bytes + try writer.interface.writeInt(u32, 22, .little); // Offset to image data from the start + + // PNG image data + try writer.interface.writeAll(input_png_data); + try writer.interface.flush(); +} + diff --git a/src/app.zig b/src/app.zig new file mode 100644 index 0000000..d0b0ac5 --- /dev/null +++ b/src/app.zig @@ -0,0 +1,115 @@ +const std = @import("std"); +const CLI = @import("./cli.zig"); +const log = std.log.scoped(.app); +const Allocator = std.mem.Allocator; + +const Math = @import("math"); +const Vec2 = Math.Vec2; + +const STBImage = @import("stb_image"); +const STBTrueType = @import("stb_truetype"); +const Tiled = @import("tiled"); + +const Platform = @import("./platform.zig"); +const Gfx = Platform.Gfx; +const ImGUI = Platform.ImGUI; +const Rect = Platform.Rect; + +const App = @This(); + +tilesheet: Tilesheet, + +const Tilesheet = struct { + image: Gfx.ImageData, + sprites: []Gfx.Sprite.Id, + tile_size: Vec2, + width: u32, + height: u32, + + pub fn init(gpa: Allocator, image: Gfx.ImageData, width: u32, height: u32, tile_size: Vec2) !Tilesheet { + const sprites = try gpa.alloc(Gfx.Sprite.Id, width * height); + errdefer gpa.free(sprites); + @memset(sprites, Gfx.getNilSprite()); + + return Tilesheet{ + .sprites = sprites, + .image = image, + .width = width, + .height = height, + .tile_size = tile_size + }; + } + + pub fn deinit(self: Tilesheet) void { + for (self.sprites) |sprite| { + Gfx.deinitSprite(sprite); + } + } + + pub fn get(self: Tilesheet, x: u32, y: u32) Gfx.Sprite.Id { + if (x >= self.width or y >= self.height) { + log.warn("Attempt to get tile which is out of bounds", .{}); + return Gfx.getNilSprite(); + } + + const sprite_index = y * self.width + x; + if (self.sprites[sprite_index] == Gfx.getNilSprite()) { + const sprite = Gfx.initSprite(.{ .padding = 1 }); + Gfx.setSprite(sprite, .{ + .image = self.image, + .rect = Rect.init(@floatFromInt(x), @floatFromInt(y), 1, 1).multiply(self.tile_size) + }); + self.sprites[sprite_index] = sprite; + } + + return self.sprites[sprite_index]; + } +}; + +pub fn init(self: *App, plt: Platform.Init) !?u8 { + // const robot_font = Gfx.initFont(); + // Gfx.setFont(robot_font, plt.assets.readFile("roboto-font/Roboto-Regular.ttf")); + + const tilesheet_png = try STBImage.load(plt.assets.readFile("tranquil_tunnels_transparent.png")); + const tilesheet = try Tilesheet.init( + plt.arena, + Gfx.ImageData{ + .width = tilesheet_png.width, + .height = tilesheet_png.height, + .pixels = .{ .rgba8 = tilesheet_png.rgba8_pixels } + }, + 13, + 8, + .init(64, 64) + ); + + self.* = App{ + .tilesheet = tilesheet + }; + + return null; +} + +pub fn frame(self: *App, plt: Platform.Frame) !void { + _ = self; // autofix + const input = plt.input; + _ = input; // autofix + + const dt = plt.deltaTime(); + _ = dt; // autofix + + Gfx.setClearColor(.rgb(40, 40, 40)); + Gfx.drawLine(.init(0, 200), .init(300, 200), .white, 5); + // Gfx.drawSprite(frames[self.player_frame_index], self.player_pos, .init(64, 64), .white); + // Gfx.drawSprite(self.tilesheet.get(11, 6), self.player_pos.add(.init(0, 200)), .init(64, 64), .white); + // Gfx.drawText(self.roboto_font, .{ + // .pos = .init(300, 100), + // .text = "Hello, World!", + // .height = 64, + // }); +} + +pub fn deinit(self: *App, plt: Platform.Deinit) void { + _ = plt; // autofix + _ = self; // autofix +} diff --git a/src/cli.zig b/src/cli.zig new file mode 100644 index 0000000..0f37f36 --- /dev/null +++ b/src/cli.zig @@ -0,0 +1,302 @@ +const std = @import("std"); +const Io = std.Io; +const log = std.log.scoped(.engine); + +const CLI = @This(); + +gpa: std.mem.Allocator, +arena: std.heap.ArenaAllocator, + +options: std.ArrayList(Option), +commands: std.ArrayList(Command), + +stdout: Channel, +stderr: Channel, + +pub const Command = struct { + name: []const u8, + description: []const u8, + + const Id = enum (u32) { _ }; +}; + +pub const Option = struct { + long_name: []const u8, + description: []const u8, + kind: Kind, + + pub const Kind = enum { + toggle, + single_argument + }; + + pub const Id = enum (u32) { _ }; + + const Value = struct { + argument: ?[]const u8 = null + }; +}; + +const Channel = struct { + file: Io.File, + buffer: [4 * 4096]u8, + writer: Io.File.Writer, + + pub fn init(self: *Channel, io: Io, file: Io.File) void { + self.* = Channel{ + .file = file, + .buffer = undefined, + .writer = file.writer(io, &self.buffer) + }; + } + + pub fn write(self: *Channel, comptime fmt: []const u8, args: anytype) void { + self.writer.interface.print(fmt, args) catch |e| { + log.err("Failed to write to channel: {}", .{e}); + }; + } + + pub fn flush(self: *Channel) void { + self.writer.interface.flush() catch |e| { + log.err("Failed to flush channel: {}", .{e}); + }; + } +}; + +pub fn init(self: *CLI, io: Io, gpa: std.mem.Allocator) void { + self.* = CLI{ + .gpa = gpa, + .arena = .init(gpa), + .options = .empty, + .commands = .empty, + .stdout = undefined, + .stderr = undefined + }; + self.stdout.init(io, Io.File.stdout()); + self.stderr.init(io, Io.File.stderr()); +} + +pub fn deinit(self: *CLI) void { + self.stderr.flush(); + self.stdout.flush(); + + self.arena.deinit(); + + self.options.deinit(self.gpa); + self.commands.deinit(self.gpa); +} + +pub const AddOptionOptions = struct { + long_name: []const u8, + description: ?[]const u8 = null, + kind: Option.Kind = .toggle, +}; + +pub fn addOption(self: *CLI, opts: AddOptionOptions) !Option.Id { + const arena = self.arena.allocator(); + + var owned_description: []const u8 = ""; + if (opts.description) |description| { + owned_description = try arena.dupe(u8, description); + } + + const index = self.options.items.len; + try self.options.append(self.gpa, .{ + .long_name = try arena.dupe(u8, opts.long_name), + .description = owned_description, + .kind = opts.kind + }); + + return @enumFromInt(index); +} + +fn getOptionByLongName(self: *CLI, long_name: []const u8) ?Option.Id { + for (0.., self.options.items) |i, option| { + if (std.mem.eql(u8, long_name, option.long_name)) { + return @enumFromInt(i); + } + } + return null; +} + +pub const AddCommandOptions = struct { + name: []const u8, + description: ?[]const u8 = null +}; + +pub fn addCommand(self: *CLI, opts: AddCommandOptions) !Command.Id { + const arena = self.arena.allocator(); + + var owned_description: []const u8 = ""; + if (opts.description) |description| { + owned_description = try arena.dupe(u8, description); + } + + const index = self.commands.items.len; + try self.commands.append(self.gpa, Command{ + .name = try arena.dupe(u8, opts.name), + .description = owned_description, + }); + + return @enumFromInt(index); +} + +pub fn showUsage(self: *CLI, program_name: []const u8) void { + self.stderr.write("Usage: {s}", .{program_name}); + if (self.options.items.len > 0) { + self.stderr.write(" [options]", .{}); + } + if (self.commands.items.len > 0) { + self.stderr.write(" ", .{}); + } + self.stderr.write("\n", .{}); + + if (self.commands.items.len > 0) { + self.stderr.write("\n", .{}); + self.stderr.write("Commands:\n", .{}); + for (self.commands.items) |command| { + self.stderr.write(" {s} {s}\n", .{command.name, command.description}); + } + } + + if (self.options.items.len > 0) { + self.stderr.write("\n", .{}); + self.stderr.write("Options:\n", .{}); + + for (0.., self.options.items) |i, option| { + if (i > 0) { + self.stderr.write("\n", .{}); + } + + self.stderr.write(" --{s}", .{option.long_name}); + if (option.kind == .single_argument) { + // TODO: Make this hint renameable + self.stderr.write(" ", .{}); + } + self.stderr.write("\n", .{}); + if (option.description.len > 0) { + self.stderr.write(" {s}\n", .{option.description}); + } + } + } + + self.stderr.flush(); +} + +pub const ParseResult = struct { + arena: std.heap.ArenaAllocator, + + command: ?Command.Id, + options: []?Option.Value, + + pub fn init(gpa: std.mem.Allocator, options_len: usize) !ParseResult { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + + const options = try arena.allocator().alloc(?Option.Value, options_len); + @memset(options, null); + + return ParseResult{ + .arena = arena, + .command = null, + .options = options + }; + } + + pub fn deinit(self: *ParseResult) void { + self.arena.deinit(); + } + + pub fn getOption(self: ParseResult, id: Option.Id) ?Option.Value { + return self.options[@intFromEnum(id)]; + } + + pub fn isSet(self: ParseResult, id: Option.Id) bool { + return self.getOption(id) != null; + } + + // TODO: I don't like this function name + pub fn getOptionArgument(self: ParseResult, id: Option.Id) ?[]const u8 { + if (self.getOption(id)) |option| { + return option.argument; + } + return null; + } +}; + +const Parser = struct { + args: []const []const u8, + cursor: u32, + + pub fn peek(self: *Parser) ?[]const u8 { + if (self.cursor < self.args.len) { + return self.args[self.cursor]; + } + return null; + } + + pub fn take(self: *Parser) ?[]const u8 { + if (self.peek()) |value| { + self.cursor += 1; + return value; + } + return null; + } +}; + +fn printError(self: *CLI, comptime fmt: []const u8, args: anytype) void { + self.stderr.write("ERROR: " ++ fmt ++ "\n", args); + self.stderr.flush(); +} + +pub fn parse(self: *CLI, gpa: std.mem.Allocator, args: []const []const u8) !ParseResult { + var result = try ParseResult.init(gpa, self.options.items.len); + errdefer result.deinit(); + + var parser = Parser{ + .args = args, + .cursor = 1 + }; + + next_arg: while (parser.take()) |arg| { + if (std.mem.startsWith(u8, arg, "--")) { + const long_option_name = arg[2..]; + if (self.getOptionByLongName(long_option_name)) |option_id| { + const option = self.options.items[@intFromEnum(option_id)]; + var result_value = Option.Value{}; + + if (option.kind == .single_argument) { + const argument = parser.take() orelse { + self.printError("Missing required argument for '{s}'", .{arg}); + return error.MissingArgument; + }; + + result_value.argument = argument; + } + + result.options[@intFromEnum(option_id)] = result_value; + continue :next_arg; + } + + self.printError("Unknown option '{s}'", .{arg}); + return error.UnknownOption; + } + + if (result.command == null) { + for (0.., self.commands.items) |i, command| { + if (std.mem.eql(u8, command.name, arg)) { + result.command = @enumFromInt(i); + continue :next_arg; + } + } + + self.printError("Unknown command '{s}'", .{arg}); + return error.UnknownCommand; + } else { + self.printError("Unknown argument '{s}'", .{arg}); + return error.UnknownArgument; + } + } + + return result; +} diff --git a/src/color.zig b/src/color.zig new file mode 100644 index 0000000..007f842 --- /dev/null +++ b/src/color.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const Color = @This(); + +r: f32, +g: f32, +b: f32, +a: f32, + +pub const black = rgb(0, 0, 0); +pub const white = rgb(255, 255, 255); +pub const purple = rgb(255, 0, 255); + +pub fn rgba(r: u8, g: u8, b: u8, a: f32) Color { + assert(0 <= a and a <= 1); + return .{ + .r = @as(f32, @floatFromInt(r)) / 255, + .g = @as(f32, @floatFromInt(g)) / 255, + .b = @as(f32, @floatFromInt(b)) / 255, + .a = a, + }; +} + +pub fn rgb(r: u8, g: u8, b: u8) Color { + return rgba(r, g, b, 1); +} + +pub fn rgb_hex(text: []const u8) ?Color { + if (text.len != 7) { + return null; + } + if (text[0] != '#') { + return null; + } + const r = std.fmt.parseInt(u8, text[1..3], 16) catch return null; + const g = std.fmt.parseInt(u8, text[3..5], 16) catch return null; + const b = std.fmt.parseInt(u8, text[5..7], 16) catch return null; + return rgb(r, g, b); +} diff --git a/src/file_watcher.zig b/src/file_watcher.zig new file mode 100644 index 0000000..c749daa --- /dev/null +++ b/src/file_watcher.zig @@ -0,0 +1,320 @@ +const std = @import("std"); +const INotify = @import("inotify.zig"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +const FileWatcher = @This(); + +root_dir: []u8, +inotify: INotify, +files: std.ArrayList(File), +watches: std.ArrayList(Watch), + +const File = struct { + path: []u8, + debounce: Io.Duration, + last_event_at: ?Io.Timestamp, + + pub fn deinit(self: File, gpa: Allocator) void { + gpa.free(self.path); + } +}; + +const Watch = struct { + wd: ?u32, + path: []u8, + + pub fn deinit(self: Watch, gpa: Allocator) void { + gpa.free(self.path); + } +}; + +pub fn init(gpa: Allocator, root_dir: []const u8) !FileWatcher { + if (!std.fs.path.isAbsolute(root_dir)) { + return error.PathIsNotAbsolute; + } + + const inotify_buf = try gpa.alloc(u8, INotify.max_event_size * 10); + errdefer gpa.free(inotify_buf); + + const root_dir_owned = try gpa.dupe(u8, root_dir); + errdefer gpa.free(root_dir_owned); + + return FileWatcher{ + .root_dir = root_dir_owned, + .inotify = try .init(inotify_buf), + .files = .empty, + .watches = .empty + }; +} + +pub fn deinit(self: *FileWatcher, gpa: Allocator) void { + gpa.free(self.root_dir); + + gpa.free(self.inotify.buf); + self.inotify.deinit(); + + for (self.files.items) |file| { + file.deinit(gpa); + } + self.files.deinit(gpa); + + for (self.watches.items) |watch| { + watch.deinit(gpa); + } + self.watches.deinit(gpa); +} + +fn findFileIndex(self: *FileWatcher, path: []const u8) ?usize { + for (0.., self.files.items) |i, file| { + if (std.mem.eql(u8, file.path, path)) { + return i; + } + } + return null; +} + +fn findFile(self: *FileWatcher, path: []const u8) ?*File { + if (self.findFileIndex(path)) |file_index| { + return &self.files.items[file_index]; + } else { + return null; + } +} + +fn findWatchByPath(self: *FileWatcher, path: []const u8) ?usize { + for (0.., self.watches.items) |i, watch| { + if (std.mem.eql(u8, watch.path, path)) { + return i; + } + } + return null; +} + +fn findWatchByDescriptor(self: *FileWatcher, wd: u32) ?usize { + for (0.., self.watches.items) |i, watch| { + if (watch.wd == wd) { + return i; + } + } + return null; +} + +fn bufJoinPaths(path_buff: []u8, paths: []const []const u8) ![:0]u8 { + return try std.fmt.bufPrintZ(path_buff, "{f}", .{ + std.fs.path.fmtJoin(paths) + }); +} + +fn attemptRegisteringWatch(self: *FileWatcher, watch: *Watch) !void { + if (watch.wd != null) { + return; + } + + var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; + const path = try bufJoinPaths(&path_buff, &.{ self.root_dir, watch.path }); + + watch.wd = self.inotify.add(path, INotify.Mask{ + .create = true, + .modify = true, + .delete_self = true, + .move_self = true, + .moved_to = true, + .moved_from = true, + }) catch |err| switch (err) { + error.FileNotFound => null, + else => return err + }; +} + +pub fn toRelativePath(outer_path: []const u8, inner_path: []const u8) ?[]const u8 { + if (inner_path.len == outer_path.len) { + if (std.mem.eql(u8, inner_path, outer_path)) { + return ""; + } + } else if (inner_path.len > outer_path.len) { + if (std.mem.startsWith(u8, inner_path, outer_path) and inner_path[outer_path.len] == std.fs.path.sep) { + return inner_path[(outer_path.len+1)..]; + } + } + + return null; +} + +fn isInside(outer_path: []const u8, inner_path: []const u8) bool { + return toRelativePath(outer_path, inner_path) != null; +} + +fn unregisterWatchesRecursively(self: *FileWatcher, dir: []const u8) !void { + for (self.watches.items) |*watch| { + const wd = watch.wd orelse continue; + + if (isInside(dir, watch.path)) { + try self.inotify.remove(wd); + watch.wd = null; + } + } +} + +fn registerWatchesRecursively(self: *FileWatcher, dir: []const u8) !void { + for (self.watches.items) |*watch| { + if (watch.wd == null and isInside(dir, watch.path)) { + try self.attemptRegisteringWatch(watch); + } + } +} + +fn ensureWatchExists(self: *FileWatcher, gpa: Allocator, path: []const u8) !usize { + var watch_index: usize = undefined; + if (self.findWatchByPath(path)) |found_watch_index| { + watch_index = found_watch_index; + } else { + try self.watches.ensureUnusedCapacity(gpa, 1); + + const path_owned = try gpa.dupe(u8, path); + errdefer gpa.free(path_owned); + + watch_index = self.watches.items.len; + self.watches.appendAssumeCapacity(Watch{ + .wd = null, + .path = path_owned + }); + } + + try self.attemptRegisteringWatch(&self.watches.items[watch_index]); + + return watch_index; +} + +fn removeWatch(self: *FileWatcher, gpa: Allocator, index: usize) void { + const watch = self.watches.swapRemove(index); + watch.deinit(gpa); +} + +pub fn add(self: *FileWatcher, gpa: Allocator, path: []const u8, debounce: Io.Duration) !void { + if (std.fs.path.isAbsolute(path)) { + return error.PathIsAbsolute; + } + + if (self.findFileIndex(path) != null) { + return; + } + + var dir_iter: ?[]const u8 = path; + while (dir_iter) |current| { + _ = try self.ensureWatchExists(gpa, current); + + dir_iter = std.fs.path.dirname(current); + } + + _ = try self.ensureWatchExists(gpa, ""); + + try self.files.ensureUnusedCapacity(gpa, 1); + + const path_owned = try gpa.dupe(u8, path); + errdefer gpa.free(path_owned); + + self.files.appendAssumeCapacity(File{ + .path = path_owned, + .debounce = debounce, + .last_event_at = null + }); +} + +pub fn remove(self: *FileWatcher, gpa: Allocator, path: []const u8) void { + const index = self.findFileIndex(path); + if (index == null) { + return; + } + + const file = self.files.items[index]; + self.files.swapRemove(index); + + file.deinit(gpa); +} + +fn nextEvents(self: *FileWatcher) !?*File { + while (try self.inotify.next()) |e| { + const watch_index = self.findWatchByDescriptor(e.wd) orelse continue; + var watch = &self.watches.items[watch_index]; + + if (e.mask.ignored) { + watch.wd = null; + + } else if (e.mask.q_overflow) { + return error.QueueOverflow; + + } else if (e.mask.delete_self or e.mask.modify) { + if (self.findFile(watch.path)) |file| { + return file; + } + + } else if (e.mask.create) { + assert(e.name != null); + var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; + const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) }); + if (self.findWatchByPath(path)) |child_watch_index| { + try self.attemptRegisteringWatch(&self.watches.items[child_watch_index]); + } + + if (self.findFile(path)) |file| { + return file; + } + + } else if (e.mask.moved_from) { + assert(e.name != null); + var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; + const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) }); + try self.unregisterWatchesRecursively(path); + + if (self.findFile(path)) |file| { + return file; + } + + } else if (e.mask.moved_to) { + assert(e.name != null); + var path_buff: [Io.Dir.max_path_bytes+1]u8 = undefined; + const path = try bufJoinPaths(&path_buff, &.{ watch.path, std.mem.span(e.name.?) }); + try self.registerWatchesRecursively(path); + + if (self.findFile(path)) |file| { + return file; + } + } + } + + return null; +} + +pub fn next(self: *FileWatcher, io: Io) !?[]const u8 { + const now = Io.Clock.real.now(io); + + var queue_overflow = false; + while (true) { + const file = (self.nextEvents() catch |err| switch (err) { + error.QueueOverflow => { + queue_overflow = true; + break; + }, + else => return err + }) orelse break; + file.last_event_at = now; + } + + if (queue_overflow) { + for (self.files.items) |*file| { + file.last_event_at = now; + } + } + + for (self.files.items) |*file| { + const last_event_at = file.last_event_at orelse continue; + if (now.nanoseconds > last_event_at.addDuration(file.debounce).nanoseconds) { + file.last_event_at = null; + return file.path; + } + } + + return null; +} diff --git a/src/graphics.zig b/src/graphics.zig new file mode 100644 index 0000000..8a29f1a --- /dev/null +++ b/src/graphics.zig @@ -0,0 +1,1996 @@ +const std = @import("std"); +const log = std.log.scoped(.graphics); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const sokol = @import("sokol"); +const sg = sokol.gfx; +const sapp = sokol.app; +const sglue = sokol.glue; + +const Math = @import("math"); +const Vec2 = Math.Vec2; +const Vec4 = Math.Vec4; +const Mat4 = Math.Mat4; +const Rect = Math.Rect; + +const STBRectPack = @import("stb_rect_pack"); +const STBTrueType = @import("stb_truetype"); +const ImGUI = @import("imgui.zig"); +const Color = @import("./color.zig"); +const shd = @import("shader"); +const SlotMapType = @import("slot_map.zig").SlotMapType; + +const State = struct { + gpa: std.mem.Allocator, + + frame_index: u64, + + shader: sg.Shader, + pipeline: sg.Pipeline, + bindings: sg.Bindings, + + default_sprite: Sprite.Id, + error_sprite: Sprite.Id, + default_spritesheet: Spritesheet.Id, + default_font_spritesheet: Spritesheet.Id, + + linear_sampler: sg.Sampler, + nearest_sampler: sg.Sampler, + default_sampler: Sampler, + + quads_buffer: [2048]Quad, + quads: std.ArrayList(Quad), + + clear_color: Color, + + textures_buffer: [Texture.max_textures]Texture.SlotMap.Slot, + textures: Texture.SlotMap, + nil_texture: Texture.Id, + + spritesheets_buffer: [Spritesheet.max_spritesheets]Spritesheet.SlotMap.Slot, + spritesheets: Spritesheet.SlotMap, + nil_spritesheet: Spritesheet.Id, + + sprites_buffer: [Sprite.max_sprites]Sprite.SlotMap.Slot, + sprites: Sprite.SlotMap, + nil_sprite: Sprite.Id, + + fonts_buffer: [Font.max_fonts]Font.SlotMap.Slot, + fonts: Font.SlotMap, + nil_font: Font.Id, +}; + +var g_state: State = undefined; + +pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { + const self = &g_state; + + sg.setup(.{ + .environment = sglue.environment(), + .logger = logger + }); + + const shader = sg.makeShader(shd.mainShaderDesc(sg.queryBackend())); + assert(shader.id != sg.invalid_id); + + const linear_sampler = sg.makeSampler(.{ + .min_filter = .LINEAR, + .mag_filter = .LINEAR, + .label = "linear-sampler" + }); + assert(linear_sampler.id != sg.invalid_id); + + const nearest_sampler = sg.makeSampler(.{ + .min_filter = .NEAREST, + .mag_filter = .NEAREST, + .label = "nearest-sampler" + }); + assert(linear_sampler.id != sg.invalid_id); + + const pipeline = sg.makePipeline(.{ + .primitive_type = .TRIANGLES, + .index_type = .UINT16, + .colors = init: { + var s: [8]sg.ColorTargetState = [_]sg.ColorTargetState{.{}} ** 8; + s[0] = .{ + .blend = .{ + .enabled = true, + .src_factor_rgb = .SRC_ALPHA, + .dst_factor_rgb = .ONE_MINUS_SRC_ALPHA, + .op_rgb = .ADD, + .src_factor_alpha = .ONE, + .dst_factor_alpha = .ONE_MINUS_SRC_ALPHA, + .op_alpha = .ADD, + } + }; + break :init s; + }, + .shader = shader, + .layout = init: { + var l = sg.VertexLayoutState{}; + l.attrs[shd.ATTR_main_position].format = .FLOAT2; + l.attrs[shd.ATTR_main_texcoord0].format = .FLOAT2; + l.attrs[shd.ATTR_main_color0].format = .FLOAT4; + break :init l; + }, + .label = "main-pipeline" + }); + assert(pipeline.id != sg.invalid_id); + + var bindings: sg.Bindings = .{}; + { + const max_quads = self.quads_buffer.len; + + bindings.vertex_buffers[0] = sg.makeBuffer(.{ + .size = @sizeOf(Quad) * max_quads, + .usage = .{ .vertex_buffer = true, .stream_update = true }, + .label = "quad-vertices" + }); + + const index_count = max_quads * 6; + var indices: [index_count]u16 = undefined; + for (0..max_quads) |i| { + indices[6*i + 0] = @intCast(4*i + 0); + indices[6*i + 1] = @intCast(4*i + 1); + indices[6*i + 2] = @intCast(4*i + 2); + + indices[6*i + 3] = @intCast(4*i + 1); + indices[6*i + 4] = @intCast(4*i + 3); + indices[6*i + 5] = @intCast(4*i + 2); + } + + bindings.index_buffer = sg.makeBuffer(.{ + .data = sg.asRange(&indices), + .usage = .{ .index_buffer = true, }, + .label = "indices" + }); + } + + self.* = State{ + .gpa = gpa, + .frame_index = 0, + .shader = shader, + .pipeline = pipeline, + .bindings = bindings, + .quads_buffer = undefined, + .quads = .initBuffer(&self.quads_buffer), + .clear_color = .black, + .default_sprite = undefined, + .error_sprite = undefined, + .default_spritesheet = undefined, + .default_font_spritesheet = undefined, + + .linear_sampler = linear_sampler, + .nearest_sampler = nearest_sampler, + .default_sampler = .linear, + + .textures_buffer = undefined, + .textures = .init(.initBuffer(&self.textures_buffer)), + .nil_texture = undefined, + + .sprites_buffer = undefined, + .sprites = .init(.initBuffer(&self.sprites_buffer)), + .nil_sprite = undefined, + + .spritesheets_buffer = undefined, + .spritesheets = .init(.initBuffer(&self.spritesheets_buffer)), + .nil_spritesheet = undefined, + + .fonts_buffer = undefined, + .fonts = .init(.initBuffer(&self.fonts_buffer)), + .nil_font = undefined, + }; + + self.nil_texture = self.textures.insertAssumeCapacity(); + { + const nil_texture = self.textures.getAssumeExists(self.nil_texture); + nil_texture.* = Texture{ + .dynamic = false, + .updated_at = null, + .image = .{ .id = sg.invalid_id }, + .view = .{ .id = sg.invalid_id }, + }; + } + + self.nil_spritesheet = self.spritesheets.insertAssumeCapacity(); + { + const nil_spritesheet = self.spritesheets.getAssumeExists(self.nil_spritesheet); + nil_spritesheet.* = Spritesheet{ + .texture = self.nil_texture, + .format = .rgba8, + .size = .init(0, 0), + .needs_repack = false, + .needs_texture_rebuild = false, + .nodes = undefined, + .packer = null, + }; + } + + self.nil_sprite = self.sprites.insertAssumeCapacity(); + { + const nil_sprite = self.sprites.getAssumeExists(self.nil_sprite); + nil_sprite.* = Sprite{ + .spritesheet = self.nil_spritesheet, + .data = .none, + .position = null, + .padding = 0 + }; + } + + self.nil_font = self.fonts.insertAssumeCapacity(); + { + const nil_font = self.fonts.getAssumeExists(self.nil_font); + nil_font.* = Font{ + .spritesheet = self.nil_spritesheet, + .stb = null, + .cache = .init(), + }; + } + + self.default_spritesheet = initSpritesheet(.{}); + self.default_font_spritesheet = initSpritesheet(.{ .format = .r8 }); + + { + const width = 8; + const height = 8; + + var pixels: [width * height * 4]u8 = undefined; + @memset(&pixels, 0xFF); + + self.default_sprite = initSprite(.{ .padding = 1 }); + setSprite(self.default_sprite, .{ + .image = ImageData{ + .width = width, + .height = height, + .pixels = .{ .rgba8 = &pixels } + } + }); + } + + { + const width = 8; + const height = 8; + + var pixels: [width * height * 4]u8 = undefined; + for (0..height) |y| { + for (0..width) |x| { + const pixel_index = y * width + x; + + var color: Color = undefined; + if ((pixel_index+y) % 2 == 0) { + color = .purple; + } else { + color = .black; + } + + const pixel = pixels[(4*pixel_index)..]; + pixel[0] = @intFromFloat(color.r*255); + pixel[1] = @intFromFloat(color.g*255); + pixel[2] = @intFromFloat(color.b*255); + pixel[3] = @intFromFloat(color.a*255); + } + } + + self.error_sprite = initSprite(.{ .padding = 1 }); + setSprite(self.error_sprite, .{ + .image = .{ + .width = width, + .height = height, + .pixels = .{ .rgba8 = &pixels } + } + }); + } + + repackSpritesheetIfNeeded(self.default_spritesheet); + rebuildSpritesheetTextureIfNeeded(self.default_spritesheet); +} + +pub fn deinit() void { + const self = &g_state; + + var sprite_iter = self.sprites.iterator(); + while (sprite_iter.next()) |sprite_id| { + const sprite = self.sprites.getAssumeExists(sprite_id); + if (sprite.data) |data| { + data.deinit(self.gpa); + } + } + + sg.shutdown(); +} + +fn createProjectionMatrix(screen_size: Vec2) Mat4 { + return Mat4.initIdentity() + // Convert normalized [-1; 1] coordinates to [0; 1] + .multiply(Mat4.initTranslate(.{ .x = -1, .y = -1, .z = 0 })) + .multiply(Mat4.initScale(.{ .x = 2, .y = 2, .z = 0 })) + + // Make the top-left corner (0,0) + .multiply(Mat4.initTranslate(.{ .x = 0, .y = 1, .z = 0 })) + .multiply(Mat4.initScale(.{ .x = 1, .y = -1, .z = 0 })) + + // Scretch [0; 1] so that they map 1-to-1 on to screen coordinates. + .multiply(Mat4.initScale(.{ + .x = 1/screen_size.x, + .y = 1/screen_size.y, + .z = 0 + })); +} + +fn toSokolColor(color: Color) sokol.gfx.Color { + // TODO: Assert and do cast + return sokol.gfx.Color{ + .r = color.r, + .g = color.g, + .b = color.b, + .a = color.a, + }; +} + +pub fn beginFrame() void { + const self = &g_state; + + var pass_action: sg.PassAction = .{}; + pass_action.colors[0] = .{ + .load_action = .CLEAR, + .clear_value = toSokolColor(self.clear_color) + }; + + sg.beginPass(.{ + .action = pass_action, + .swapchain = sglue.swapchain() + }); + + 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; + self.bindings.samplers[shd.SMP_smp] = self.linear_sampler; +} + +pub fn showDebug() void { + const self = &g_state; + _ = self; // autofix + + // TODO: Improve this by using tables or somekind of filtering + + // if (ImGUI.beginWindow(.{ + // .name = "graphics", + // .size = .init(200, 200) + // })) { + // defer ImGUI.endWindow(); + // + // { + // ImGUI.text("Spritesheets:", .{}); + // var spritesheet_iter = self.spritesheets.iterator(); + // while (spritesheet_iter.next()) |spritesheet_id| { + // const spritesheet = self.spritesheets.getAssumeExists(spritesheet_id); + // const texture = self.textures.get(spritesheet.texture); + // ImGUI.text("{f}:", .{spritesheet_id}); + // ImGUI.text(" - needs_texture_rebuild:{}", .{spritesheet.needs_texture_rebuild}); + // ImGUI.text(" - texture:{?}", .{texture}); + // } + // } + // + // { + // ImGUI.text("Sprites:", .{}); + // var sprite_iter = self.sprites.iterator(); + // while (sprite_iter.next()) |sprite_id| { + // const sprite = self.sprites.getAssumeExists(sprite_id); + // ImGUI.text("{f} - {?}", .{sprite_id, sprite.position}); + // } + // } + // } +} + +// TODO: This will always have a 1-frame delay. +// fix this. +pub fn setClearColor(color: Color) void { + const self = &g_state; + + self.clear_color = color; +} + +const FlushOptions = struct { + rebuild_spritesheets: bool = true +}; + +pub fn flush(opts: FlushOptions) void { + const self = &g_state; + + if (self.quads.items.len == 0) { + return; + } + defer self.quads.clearRetainingCapacity(); + + if (opts.rebuild_spritesheets) { + var spritesheet_iter = self.spritesheets.iterator(); + while (spritesheet_iter.next()) |spritesheet_id| { + const spritesheet = self.spritesheets.getAssumeExists(spritesheet_id); + const texture = self.textures.get(spritesheet.texture) orelse continue; + if (isViewBound(texture.view)) { + rebuildSpritesheetTextureIfNeeded(spritesheet_id); + } + } + } + + const vertex_buffer = self.bindings.vertex_buffers[0]; + + const data = sg.asRange(self.quads.items); + if (sg.queryBufferWillOverflow(vertex_buffer, data.size)) { + log.warn("Vertex buffer overflow", .{}); + return; + } + + self.bindings.vertex_buffer_offsets[0] = sg.appendBuffer(vertex_buffer, data); + + // TODO: Rectify `window_size` vs `gfx.window_size` + const window_size = Vec2.init(sapp.widthf(), sapp.heightf()); + + var vs_params: shd.VsParams = .{ + // TODO: Maybe 'view' matrix is not needed. + // We probably should only have a combined 'mvp' matrix + .view = .initIdentity(), + .projection = createProjectionMatrix(window_size) + }; + + var fs_params: shd.FsParams = .{ + .texture_mode = 0, + }; + const image = sg.queryViewImage(self.bindings.views[shd.VIEW_tex]); + if (sg.queryImagePixelformat(image) == .R8) { + fs_params.texture_mode = 1; + } + + sg.applyPipeline(self.pipeline); + sg.applyBindings(self.bindings); + sg.applyUniforms(shd.UB_vs_params, sg.asRange(&vs_params)); + sg.applyUniforms(shd.UB_fs_params, sg.asRange(&fs_params)); + sg.draw(0, @intCast(self.quads.items.len * 6), 1); +} + +pub fn endFrame() void { + const self = &g_state; + + flush(.{}); + + sg.endPass(); + sg.commit(); + self.frame_index += 1; + + var spritesheet_iter = self.spritesheets.iterator(); + while (spritesheet_iter.next()) |spritesheet_id| { + repackSpritesheetIfNeeded(spritesheet_id); + } +} + +pub fn drawRectangle(pos: Vec2, size: Vec2, color: Color) void { + const self = &g_state; + + drawSprite(self.default_sprite, pos, size, color); +} + +pub fn drawLine(from: Vec2, to: Vec2, color: Color, width: f32) void { + const self = &g_state; + + const step = to.sub(from).normalized().multiplyScalar(width/2); + + const top_left = from.add(step.rotateLeft90()); + const bottom_left = from.add(step.rotateRight90()); + const top_right = to.add(step.rotateLeft90()); + const bottom_right = to.add(step.rotateRight90()); + + drawSpriteQuad( + self.default_sprite, + .{ top_right, top_left, bottom_right, bottom_left }, + color + ); +} + +const TextureOptions = struct { + dynamic: bool = false +}; + +pub fn initTexture(opts: TextureOptions) Texture.Id { + const self = &g_state; + const id = self.textures.insertBounded() catch { + log.warn("Failed to create texture, limit reached! limit: {}", .{self.textures.slots.capacity}); + return self.nil_texture; + }; + + const view = sg.allocView(); + if (sg.queryViewState(view) != .ALLOC) { + log.warn("Failed to alloc texture view", .{}); + return self.nil_texture; + } + + const texture = self.textures.getAssumeExists(id); + texture.* = .{ + .dynamic = opts.dynamic, + .updated_at = null, + .image = .{ .id = sg.invalid_id }, + .view = view, + }; + return id; +} + +fn isViewBound(view: sg.View) bool { + const self = &g_state; + const bound_view = &self.bindings.views[shd.VIEW_tex]; + return bound_view.id == view.id; +} + +fn deinitTextureResources(texture: *Texture) void { + const self = &g_state; + + if (isViewBound(texture.view)) { + const default_sprite = self.sprites.getAssumeExists(self.default_sprite); + const default_spritesheet = self.spritesheets.getAssumeExists(default_sprite.spritesheet); + const default_texture = self.textures.getAssumeExists(default_spritesheet.texture); + bindView(default_texture.view, .{ .rebuild_spritesheets = false }); + } + + sg.destroyImage(texture.image); +} + +pub fn deinitTexture(id: Texture.Id) void { + const self = &g_state; + if (id == self.nil_texture) { + return; + } + + if (self.textures.get(id)) |texture| { + deinitTextureResources(texture); + sg.destroyView(texture.view); + self.textures.removeAssumeExists(id); + } +} + +pub fn setTexture(id: Texture.Id, texture_data: ImageData) void { + const self = &g_state; + if (id == self.nil_texture) { + return; + } + + const texture = self.textures.get(id) orelse { + log.warn("Attempt to set texture that doesn't exist: {}", .{id}); + return; + }; + + const pixel_count = texture_data.width*texture_data.height; + + var image_data: sg.ImageData = .{}; + var pixel_format: sg.PixelFormat = .DEFAULT; + + if (texture_data.pixels) |pixels| { + switch (pixels) { + .rgba8 => |rgba8| { + image_data.mip_levels[0] = sg.asRange(rgba8[0..(pixel_count*4)]); + pixel_format = .RGBA8; + }, + .r8 => |r8| { + image_data.mip_levels[0] = sg.asRange(r8[0..pixel_count]); + pixel_format = .R8; + } + } + } + + var new_image_desc = sg.ImageDesc{ + .type = ._2D, + .width = @intCast(texture_data.width), + .height = @intCast(texture_data.height), + .num_mipmaps = 1, + .pixel_format = pixel_format, + }; + if (texture.dynamic) { + new_image_desc.usage.dynamic_update = true; + } else { + new_image_desc.data = image_data; + } + + var texture_updated: bool = false; + if (sg.queryImageState(texture.image) == .VALID) { + // sg.queryImageInfo + const image_desc = sg.queryImageDesc(texture.image); + if (image_desc.width == new_image_desc.width and + image_desc.height == new_image_desc.height and + image_desc.num_mipmaps == new_image_desc.num_mipmaps and + image_desc.pixel_format == new_image_desc.pixel_format and + image_desc.usage.dynamic_update == new_image_desc.usage.dynamic_update and + image_desc.usage.immutable == new_image_desc.usage.immutable and + image_desc.usage.dynamic_update + ) { + log.debug("Update texture '{f}'", .{id}); + if (texture.updated_at == null or texture.updated_at.? != self.frame_index) { + sg.updateImage(texture.image, image_data); + texture_updated = true; + texture.updated_at = self.frame_index; + } else { + log.warn("Attempt to update the same texture multiple times per frame", .{}); + } + } + } + + if (!texture_updated) { + log.debug("Create image-view, texture={f}", .{id}); + const new_image = sg.makeImage(new_image_desc); + if (sg.queryImageState(new_image) != .VALID) { + log.warn("makeImage() failed", .{}); + return; + } + + if (sg.queryViewState(texture.view) == .VALID) { + sg.uninitView(texture.view); + } + sg.initView(texture.view, .{ + .texture = .{ .image = new_image } + }); + if (sg.queryViewState(texture.view) != .VALID) { + log.warn("makeView() failed", .{}); + return; + } + + deinitTextureResources(texture); + texture.image = new_image; + texture.updated_at = null; + + if (texture.dynamic) { + sg.updateImage(new_image, image_data); + texture.updated_at = self.frame_index; + } + } +} + +pub fn drawTexture(id: Texture.Id, pos: Vec2, size: Vec2, color: Color) void { + var quad: Quad = undefined; + quad.setColor(color); + quad.setRect(.{ .pos = pos, .size = size }); + quad.setUVRect(.init(0, 0, 1, 1)); + + draw(.{ + .texture = id, + .quad = quad + }); +} + +fn bindView(view: sg.View, opts: FlushOptions) void { + const self = &g_state; + + const view_state = sg.queryViewState(view); + if (view_state != .VALID and view_state != .ALLOC) { + log.warn("Attempt to bind non-valid view: state={}", .{view_state}); + return; + } + + const bound_view = &self.bindings.views[shd.VIEW_tex]; + if (bound_view.id != view.id) { + flush(opts); + bound_view.* = view; + } +} + +pub fn setDefaultSampler(sampler: Sampler) void { + const self = &g_state; + self.default_sampler = sampler; +} + +pub const Sampler = enum { + linear, + nearest, +}; + +const DrawOptions = struct { + sampler: ?Sampler = null, + texture: Texture.Id, + quad: Quad +}; + +pub fn draw(opts: DrawOptions) void { + const self = &g_state; + + var quad = opts.quad; + + var view: sg.View = .{ .id = sg.invalid_id }; + if (opts.texture != self.nil_texture and self.textures.exists(opts.texture)) { + const texture = self.textures.getAssumeExists(opts.texture); + view = texture.view; + } + + if (sg.queryViewState(view) != .VALID and sg.queryViewState(view) != .ALLOC) { + if (self.sprites.get(self.error_sprite)) |sprite| { + if (self.spritesheets.get(sprite.spritesheet)) |spritesheet| { + if (self.textures.get(spritesheet.texture)) |texture| { + if (getSpriteUVRect(self.error_sprite)) |uv| { + quad.setUVRect(uv); + quad.setColor(.white); + view = texture.view; + } + } + } + } + } + + if (sg.queryViewState(view) != .VALID and sg.queryViewState(view) != .ALLOC) { + log.warn("Failed to bind view", .{}); + return; + } + + bindView(view, .{}); + + const sampler = switch (opts.sampler orelse self.default_sampler) { + .linear => self.linear_sampler, + .nearest => self.nearest_sampler, + }; + const bound_sampler = &self.bindings.samplers[shd.SMP_smp]; + if (bound_sampler.id != sampler.id) { + flush(.{}); + bound_sampler.* = sampler; + } + + if (self.quads.items.len == self.quads.capacity) { + flush(.{}); + } + + self.quads.appendAssumeCapacity(quad); +} + +const SpriteOptions = struct { + spritesheet: ?Spritesheet.Id = null, + padding: u32 = 0, +}; + +// TODO: I don't like this getter, maybe nil_sprite should be a public global variable. +pub fn getNilSprite() Sprite.Id { + const self = &g_state; + return self.nil_sprite; +} + +pub fn initSprite(opts: SpriteOptions) Sprite.Id { + const self = &g_state; + const id = self.sprites.insertBounded() catch { + log.warn("Failed to create sprite, limit reached! limit: {}", .{self.sprites.slots.capacity}); + return self.nil_sprite; + }; + + const sprite = self.sprites.getAssumeExists(id); + sprite.* = .{ + .spritesheet = opts.spritesheet orelse self.default_spritesheet, + .padding = opts.padding, + .data = null, + .position = null + }; + + return id; +} + +pub fn deinitSprite(id: Sprite.Id) void { + const self = &g_state; + if (id == self.nil_sprite) { + return; + } + + if (self.sprites.exists(id)) { + const sprite = self.sprites.getAssumeExists(id); + if (sprite.data) |data| { + data.deinit(self.gpa); + } + self.sprites.removeAssumeExists(id); + + if (self.spritesheets.get(sprite.spritesheet)) |spritesheet| { + spritesheet.needs_repack = true; + } + } +} + +const SetSpriteOptions = struct { + image: ImageData, + rect: ?Rect = null +}; + +pub fn setSprite(id: Sprite.Id, opts: SetSpriteOptions) void { + const self = &g_state; + if (id == self.nil_sprite) { + return; + } + + const sprite = self.sprites.get(id) orelse { + log.warn("Attempt to set sprite that doesn't exist: {}", .{id}); + return; + }; + + const spritesheet = self.spritesheets.get(sprite.spritesheet) orelse { + log.warn("Attempt to set sprite which doesn't have spritesheet", .{}); + return; + }; + + var sprite_data: ImageData = .none; + errdefer sprite_data.deinit(self.gpa); + + if (opts.image.pixels) |pixels| { + const format = @as(ImageData.Format, pixels); + if (format != spritesheet.format) { + log.warn("Attempt to use image of format '{}' on a spritesheet with '{}' format", .{format, spritesheet.format}); + return; + } + } + + if (opts.rect) |rect| { + var format: ?ImageData.Format = null; + if (opts.image.pixels) |pixels| { + format = @as(ImageData.Format, pixels); + } + + sprite_data = ImageData.init(self.gpa, @trunc(rect.size.x), @trunc(rect.size.y), format) catch |e| { + log.warn("Failed to create texture data: {}", .{e}); + return; + }; + + sprite_data.copyFrom( + opts.image, + 0, 0, + @trunc(rect.pos.x), @trunc(rect.pos.y), + @trunc(rect.size.x), @trunc(rect.size.y) + ); + } else { + sprite_data = opts.image.clone(self.gpa) catch |e| { + log.warn("Failed to clone image: {}", .{e}); + return; + }; + } + + var old_size: ?Vec2 = null; + if (sprite.data) |old_sprite_data| { + old_size = Vec2.initFromInt(u32, old_sprite_data.width, old_sprite_data.height); + old_sprite_data.deinit(self.gpa); + } + sprite.data = sprite_data; + + const new_size = Vec2.initFromInt(u32, sprite_data.width, sprite_data.height); + + if (old_size != null and old_size.?.eql(new_size) and sprite.position != null) { + spritesheet.needs_texture_rebuild = true; + } else { + sprite.position = null; + spritesheet.needs_repack = true; + } +} + +// WARNING: This will change the UV coordinates of all sprites. +// It's only safe to do this operation between frames where it's guarenteed that sprites aren't being used. +fn repackSpritesheet(id: Spritesheet.Id) void { + const self = &g_state; + + const spritesheet = self.spritesheets.get(id) orelse { + log.warn("Attempt to repack spritesheet which doesn't exist", .{}); + return; + }; + + log.debug("Repack spritesheet: {f}", .{id}); + + // TODO: Add a smarter startegy for picking the initial texture size. + // One idea is to calculate the sum area of all sprites and pick a atlas size based on that. + const texture_sizes = [_]u32{ + 256, 512, 1024, 2048, 4096 + }; + + var sprites_buffer: [Sprite.max_sprites]*Sprite = undefined; + var sprites = std.ArrayList(*Sprite).initBuffer(&sprites_buffer); + + var sprite_iter = self.sprites.iterator(); + while (sprite_iter.next()) |sprite_id| { + const sprite = self.sprites.getAssumeExists(sprite_id); + if (sprite.data != null and sprite.spritesheet == id) { + sprites.appendAssumeCapacity(sprite); + } + } + + var rects: [Sprite.max_sprites]STBRectPack.Rect = undefined; + @memset(&rects, .{}); + for (0.., sprites.items) |i, sprite| { + const sprite_data = sprite.data.?; + rects[i] = .{ + .w = @intCast(sprite_data.width + sprite.padding * 2), + .h = @intCast(sprite_data.height + sprite.padding * 2), + }; + } + + spritesheet.packer = undefined; + const packer = &spritesheet.packer.?; + + for (&texture_sizes) |texture_size| { + packer.init(texture_size, texture_size, &spritesheet.nodes); + packer.setupAllowOutOfMem(true); + + if (!packer.packRects(&rects)) { + continue; + } + + for (0.., sprites.items) |i, sprite| { + assert(rects[i].was_packed == 1); + const x: u32 = @as(u32, @intCast(rects[i].x)) + sprite.padding; + const y: u32 = @as(u32, @intCast(rects[i].y)) + sprite.padding; + sprite.position = .initFromInt(u32, x, y); + } + + spritesheet.size = .initFromInt(u32, texture_size, texture_size); + spritesheet.needs_texture_rebuild = true; + spritesheet.needs_repack = false; + return; + } + + const max_texture_size = texture_sizes[texture_sizes.len-1]; + log.err("Failed to pack sprites, couldn't create a texture that is large enough. Max size tried: {}x{}", .{max_texture_size, max_texture_size}); + return; +} + +fn repackSpritesheetIfNeeded(id: Spritesheet.Id) void { + const self = &g_state; + if (id == self.nil_spritesheet) { + return; + } + + const spritesheet = self.spritesheets.get(id) orelse { + log.warn("Attempt to pack spritesheet that doesn't exist: {f}", .{id}); + return; + }; + + if (!spritesheet.needs_repack) { + return; + } + + repackSpritesheet(id); +} + +fn rebuildSpritesheetTextureIfNeeded(id: Spritesheet.Id) void { + const self = &g_state; + if (id == self.nil_spritesheet) { + return; + } + + const spritesheet = self.spritesheets.get(id) orelse return; + if (!spritesheet.needs_texture_rebuild) { + return; + } + + log.debug("Rebuild spritesheet texture: {f}", .{id}); + + const image_data = ImageData.init( + self.gpa, + @intFromFloat(spritesheet.size.x), + @intFromFloat(spritesheet.size.y), + spritesheet.format + ) catch |e| { + log.err("ImageData allocation failed: {}", .{e}); + return; + }; + defer image_data.deinit(self.gpa); + + var sprite_iter = self.sprites.iterator(); + while (sprite_iter.next()) |sprite_id| { + const sprite = self.sprites.getAssumeExists(sprite_id); + if (sprite.spritesheet != id) { + continue; + } + + const sprite_data = sprite.data orelse continue; + const sprite_position = sprite.position orelse continue; + const x: u32 = @intFromFloat(sprite_position.x); + const y: u32 = @intFromFloat(sprite_position.y); + image_data.setRectZero( + x - sprite.padding, + y - sprite.padding, + sprite_data.width + sprite.padding * 2, + sprite_data.height + sprite.padding * 2 + ); + image_data.copyFrom( + sprite_data, + x, + y, + 0, + 0, + sprite_data.width, + sprite_data.height + ); + } + + setTexture(spritesheet.texture, image_data); + + spritesheet.needs_texture_rebuild = false; +} + +fn ensureSpritePacked(id: Sprite.Id) bool { + const self = &g_state; + const sprite = self.sprites.get(id) orelse return false; + + if (sprite.position == null) { + const spritesheet = self.spritesheets.get(sprite.spritesheet) orelse return false; + const sprite_data = sprite.data orelse return false; + + if (spritesheet.packer) |*packer| { + var rects: [1]STBRectPack.Rect = .{ + .{ + .w = @intCast(sprite_data.width + sprite.padding * 2), + .h = @intCast(sprite_data.height + sprite.padding * 2), + } + }; + if (packer.packRects(&rects)) { + // Suboptimal sprite packing, will repack at end of frame + assert(rects[0].was_packed == 1); + const x: u32 = @as(u32, @intCast(rects[0].x)) + sprite.padding; + const y: u32 = @as(u32, @intCast(rects[0].y)) + sprite.padding; + sprite.position = .initFromInt(u32, x, y); + spritesheet.needs_repack = true; + } else { + log.debug("Failed to pack a single sprite to spritesheet", .{}); + } + } else { + repackSpritesheet(sprite.spritesheet); + } + } + + return sprite.position != null; +} + +pub fn getSpriteUVRect(id: Sprite.Id) ?Rect { + const self = &g_state; + if (id == self.nil_sprite) { + return null; + } + + const sprite = self.sprites.get(id) orelse return null; + const sprite_data = sprite.data orelse return null; + + const spritesheet = self.spritesheets.get(sprite.spritesheet) orelse return null; + + if (!ensureSpritePacked(id)) { + return null; + } + + assert(sprite.position != null); + + const sprite_size = Vec2.initFromInt(u32, sprite_data.width, sprite_data.height); + return Rect{ + .pos = sprite.position.?.divide(spritesheet.size), + .size = sprite_size.divide(spritesheet.size), + }; +} + +fn drawSpriteQuad(id: Sprite.Id, points: [4]Vec2, color: Color) void { + const self = &g_state; + + var quad: Quad = undefined; + quad.setColor(color); + quad.vertices[0].position = points[0]; + quad.vertices[1].position = points[1]; + quad.vertices[2].position = points[2]; + quad.vertices[3].position = points[3]; + + var texture: ?Texture.Id = null; + if (id != self.nil_sprite) { + if (getSpriteUVRect(id)) |sprite_uv| { + const sprite = self.sprites.getAssumeExists(id); + const spritesheet = self.spritesheets.getAssumeExists(sprite.spritesheet); + const texel_size = spritesheet.size; + + quad.setUVRect(sprite_uv); + texture = spritesheet.texture; + } + } + + draw(.{ + .texture = texture orelse self.nil_texture, + .quad = quad + }); +} + +pub fn drawSprite(id: Sprite.Id, pos: Vec2, size: Vec2, color: Color) void { + const rect = .{ + pos, + pos.add(.init(size.x, 0)), + pos.add(.init(0, size.y)), + pos.add(size), + }; + drawSpriteQuad(id, rect, color); +} + +pub const SpriteSheetOptions = struct { + format: ImageData.Format = .rgba8 +}; + +pub fn initSpritesheet(opts: SpriteSheetOptions) Spritesheet.Id { + const self = &g_state; + const id = self.spritesheets.insertBounded() catch { + log.warn("Failed to create spritesheet, limit reached! limit: {}", .{self.spritesheets.slots.capacity}); + return self.nil_spritesheet; + }; + + const spritesheet = self.spritesheets.getAssumeExists(id); + spritesheet.* = .{ + .texture = initTexture(.{ .dynamic = true }), + .size = .init(0, 0), + .format = opts.format, + .needs_repack = false, + .needs_texture_rebuild = false, + .nodes = undefined, + .packer = null + }; + + return id; +} + +pub fn deinitSpritesheet(id: Spritesheet.Id) void { + const self = &g_state; + if (id == self.nil_spritesheet) { + return; + } + + if (self.spritesheets.get(id)) { + const spritesheet = self.spritesheets.getAssumeExists(id); + deinitTexture(spritesheet.texture); + self.spritesheets.removeAssumeExists(id); + } +} + +pub fn initFont() Font.Id { + const self = &g_state; + const id = self.fonts.insertBounded() catch { + log.warn("Failed to create font, limit reached! limit: {}", .{self.fonts.slots.capacity}); + return self.nil_font; + }; + + const font = self.fonts.getAssumeExists(id); + font.* = .{ + .spritesheet = self.default_font_spritesheet, + .stb = null, + .cache = .init(), + }; + + return id; +} + +pub fn deinitFont(id: Font.Id) void { + const self = &g_state; + if (id == self.nil_font) { + return; + } + + if (self.fonts.get(id)) |font| { + font.cache.deinit(); + self.fonts.removeAssumeExists(id); + } +} + +pub fn setFont(id: Font.Id, ttf_data: [:0]const u8) void { + const self = &g_state; + if (id == self.nil_font) { + return; + } + + const font = self.fonts.get(id) orelse { + log.warn("Attempt to set font that doesn't exist: {}", .{id}); + return; + }; + + const offset = STBTrueType.Font.getFontOffsetForIndex(ttf_data, 0) orelse { + log.err("Failed to get offset for font at index 0", .{}); + return; + }; + + const stb_font = STBTrueType.Font.init(ttf_data, offset) catch { + log.err("Failed to initialize font info", .{}); + return; + }; + + // TODO: deinit previous glyphs + + font.stb = stb_font; + + font.cache.deinit(); + 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 { + const self = &g_state; + if (id == self.nil_font) { + return; + } + + 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; + }; + + const fallback_glyph = stb_font.findGlyphIndex('?') orelse { + log.warn("Failed to find fallback glyph '?'", .{}); + return; + }; + + const scale = stb_font.scaleForPixelHeight(opts.height); + const scale_x = scale; + const scale_y = scale; + + const vmetrics = stb_font.getFontVMetrics(); + const baseline = @as(f32, @floatFromInt(vmetrics.ascent)) * scale; + + var current_point = opts.pos; + current_point.y += baseline; + + 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; + }; + 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; + + 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 }; + + 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"); + }; + 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; + } + } + 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; + } + + if (glyph.sprite != self.nil_sprite) { + const glyph_size = Vec2.initFromInt(i32, glyph.box.x1 - glyph.box.x0, glyph.box.y1 - glyph.box.y0); + const glyph_pos = current_point.sub(.initFromInt(i32, glyph.box.x0, -glyph.box.y0)); + drawSprite(glyph.sprite, glyph_pos, glyph_size, opts.color); + } + + const hmetrics = stb_font.getGlyphHMetrics(glyph_index); + current_point.x += @as(f32, @floatFromInt(hmetrics.advance_width)) * scale; + prev_glyph = glyph_index; + } +} + +pub const ImageData = struct { + const Format = enum { + rgba8, + r8 + }; + + const Pixels = union(Format) { + rgba8: [*]u8, + r8: [*]u8, + }; + + pixels: ?Pixels, + width: u32, + height: u32, + + pub const none = ImageData{ + .pixels = null, + .width = 0, + .height = 0, + }; + + pub fn init(gpa: Allocator, width: u32, height: u32, format: ?Format) !ImageData { + const pixel_count = width * height; + var pixels: ?Pixels = null; + if (format != null) { + pixels = switch (format.?) { + .rgba8 => Pixels{ + .rgba8 = (try gpa.alloc(u8, pixel_count * 4)).ptr + }, + .r8 => Pixels{ + .r8 = (try gpa.alloc(u8, pixel_count)).ptr + } + }; + } + + return ImageData{ + .pixels = pixels, + .width = width, + .height = height + }; + } + + pub fn initRGBA8(gpa: Allocator, width: u32, height: u32) !ImageData { + return ImageData.init(gpa, width, height, .rgba8); + } + + pub fn initR8(gpa: Allocator, width: u32, height: u32) !ImageData { + return ImageData.init(gpa, width, height, .r8); + } + + pub fn clone(self: ImageData, gpa: Allocator) !ImageData { + const pixel_count = self.width * self.height; + + var pixels: ?Pixels = null; + if (self.pixels) |self_pixels| { + pixels = switch (self_pixels) { + .rgba8 => |rgba8| Pixels{ + .rgba8 = (try gpa.dupe(u8, rgba8[0..(pixel_count * 4)])).ptr + }, + .r8 => |r8| Pixels{ + .r8 = (try gpa.dupe(u8, r8[0..pixel_count])).ptr + } + }; + } + + return ImageData{ + .pixels = pixels, + .width = self.width, + .height = self.height + }; + } + + pub fn deinit(self: ImageData, gpa: Allocator) void { + const pixel_count = self.width * self.height; + if (self.pixels) |pixels| { + switch (pixels) { + .rgba8 => |rgba8| gpa.free(rgba8[0..(pixel_count*4)]), + .r8 => |r8| gpa.free(r8[0..pixel_count]) + } + } + } + + fn getPixel(self: ImageData, x: u32, y: u32) []u8 { + const pixel_index = y * self.width + x; + if (self.pixels) |pixels| { + return switch (pixels) { + .rgba8 => |rgba8| rgba8[(4*pixel_index)..][0..4], + .r8 => |r8| r8[pixel_index..][0..1] + }; + } else { + return &.{}; + } + } + + pub fn setRect(self: ImageData, x: u32, y: u32, width: u32, height: u32, pixel: []const u8) void { + if (self.pixels == null) { + return; + } + + for (0..height) |oy| { + const iy = y + oy; + if (iy >= self.height) { + continue; + } + + for (0..width) |ox| { + const ix = x + ox; + if (ix >= self.width) { + continue; + } + + const dest_pixel = self.getPixel(@intCast(ix), @intCast(iy)); + @memcpy(dest_pixel, pixel); + } + } + } + + pub fn setRectZero(self: ImageData, x: u32, y: u32, width: u32, height: u32) void { + if (self.pixels == null) { + return; + } + + const pixel = switch (self.pixels.?) { + .rgba8 => &[_]u8{ 0, 0, 0, 0 }, + .r8 => &[_]u8{ 0 } + }; + self.setRect(x, y, width, height, pixel); + } + + pub fn copyFrom( + self: ImageData, + source: ImageData, + cx: u32, + cy: u32, + source_cx: u32, + source_cy: u32, + source_width: u32, + source_height: u32, + ) void { + if (source.pixels == null or self.pixels == null) { + return; + } + + for (0..source_height) |oy| { + const source_y = source_cy+oy; + const dest_y = cy+oy; + if (dest_y >= self.height) { + continue; + } + if (source_y >= source.height) { + continue; + } + + for (0..source_width) |ox| { + const source_x = source_cx+ox; + const dest_x = cx+ox; + if (dest_x >= self.width) { + continue; + } + if (source_x >= source.width) { + continue; + } + + const source_pixel = source.getPixel(@intCast(source_x), @intCast(source_y)); + const dest_pixel = self.getPixel(@intCast(dest_x), @intCast(dest_y)); + + @memcpy(dest_pixel, source_pixel); + } + } + } +}; + +pub const Texture = struct { + dynamic: bool, + updated_at: ?u64, + image: sg.Image, + view: sg.View, + + const max_textures = 64; + const SlotMap = SlotMapType(std.math.IntFittingRange(0, max_textures-1), u8, Texture); + pub const Id = SlotMap.Id; +}; + +pub const Sprite = struct { + spritesheet: Spritesheet.Id, + data: ?ImageData, + position: ?Vec2, + padding: u32, + + const max_sprites = 128; + const SlotMap = SlotMapType(std.math.IntFittingRange(0, max_sprites-1), u8, Sprite); + pub const Id = SlotMap.Id; +}; + +pub const Spritesheet = struct { + texture: Texture.Id, + format: ImageData.Format, + size: Vec2, + + needs_repack: bool, + needs_texture_rebuild: bool, + + packer: ?STBRectPack, + nodes: [256]STBRectPack.Node, + + const max_spritesheets = 64; + const SlotMap = SlotMapType(std.math.IntFittingRange(0, max_spritesheets-1), u8, Spritesheet); + pub const Id = SlotMap.Id; +}; + +pub const Font = struct { + spritesheet: Spritesheet.Id, + stb: ?STBTrueType.Font, + + cache: GlyphCache, + + const max_fonts = 16; + const SlotMap = SlotMapType(std.math.IntFittingRange(0, max_fonts-1), u8, Font); + pub const Id = SlotMap.Id; + + const GlyphCache = struct { + glyphs: [GlyphSlot.limit]GlyphSlot, + glyphs_hashmap: [GlyphSlot.limit*2]GlyphSlot.Id, + lru_head: GlyphSlot.Id, + lru_tail: GlyphSlot.Id, + free_head: GlyphSlot.Id, + + fn init() GlyphCache { + var self: GlyphCache = .{ + .glyphs = undefined, + .glyphs_hashmap = undefined, + .lru_head = GlyphSlot.nil_id, + .lru_tail = GlyphSlot.nil_id, + .free_head = undefined + }; + @memset(&self.glyphs_hashmap, GlyphSlot.nil_id); + @memset(&self.glyphs, .{ + .glyph = undefined, + .lru_prev = GlyphSlot.nil_id, + .lru_next = GlyphSlot.nil_id, + .next_free = GlyphSlot.nil_id, + }); + + assert(self.glyphs_hashmap.len > self.glyphs.len); + + self.free_head = 1; + for (1.., self.glyphs[1..(self.glyphs.len-1)]) |i, *glyph| { + glyph.next_free = @intCast(i + 1); + } + + return self; + } + + fn deinit(self: *GlyphCache) void { + for (self.glyphs_hashmap) |glyph_id| { + if (glyph_id == GlyphSlot.nil_id) { + continue; + } + const slot = self.glyphs[glyph_id]; + slot.glyph.deinit(); + } + self.* = undefined; + } + + fn get(self: *GlyphCache, id: GlyphSlot.Id) *Glyph { + return &self.glyphs[id].glyph; + } + + fn lookup(self: *GlyphCache, key: Glyph.Key) ?GlyphSlot.Id { + const home_bucket = key.hash() % self.glyphs_hashmap.len; + for (0..self.glyphs_hashmap.len) |offset| { + const i = (home_bucket + offset) % self.glyphs_hashmap.len; + const id = self.glyphs_hashmap[i]; + if (id == GlyphSlot.nil_id) { + continue; + } + if (self.glyphs[id].glyph.key == key) { + self.removeFromLRU(id); + self.appendToLRUHead(id); + return id; + } + } + + return null; + } + + fn appendToLRUHead(self: *GlyphCache, id: GlyphSlot.Id) void { + if (self.lru_head != GlyphSlot.nil_id) { + self.glyphs[self.lru_head].lru_prev = id; + self.glyphs[id].lru_next = self.lru_head; + } else { + assert(self.lru_tail == GlyphSlot.nil_id); + self.lru_tail = id; + } + + self.glyphs[id].lru_prev = GlyphSlot.nil_id; + self.lru_head = id; + } + + fn removeFromLRU(self: *GlyphCache, id: GlyphSlot.Id) void { + assert(id != GlyphSlot.nil_id); + + const lru_prev = self.glyphs[id].lru_prev; + const lru_next = self.glyphs[id].lru_next; + + if (lru_prev != GlyphSlot.nil_id) { + self.glyphs[lru_prev].lru_next = lru_next; + } + + if (lru_next != GlyphSlot.nil_id) { + self.glyphs[lru_next].lru_prev = lru_prev; + } + + if (id == self.lru_head) { + self.lru_head = lru_next; + } + if (id == self.lru_tail) { + self.lru_tail = lru_prev; + } + } + + fn popFreeId(self: *GlyphCache) ?GlyphSlot.Id { + if (self.free_head != GlyphSlot.nil_id) { + const result = self.free_head; + self.free_head = self.glyphs[result].next_free; + return result; + } + return null; + } + + fn pushFreeId(self: *GlyphCache, id: GlyphSlot.Id) void { + assert(id != GlyphSlot.nil_id); + self.glyphs[id].next_free = self.free_head; + self.free_head = id; + } + + fn insert(self: *GlyphCache, key: Glyph.Key) ?GlyphSlot.Id { + const key_hash = key.hash(); + + for (0..self.glyphs_hashmap.len) |offset| { + const i = (key_hash + offset) % self.glyphs_hashmap.len; + const hashmap_id = self.glyphs_hashmap[i]; + if (hashmap_id == GlyphSlot.nil_id) { + const id = self.popFreeId() orelse return null; + self.glyphs_hashmap[i] = id; + + self.glyphs[id].glyph = .init(key); + self.glyphs[id].lru_next = GlyphSlot.nil_id; + self.glyphs[id].lru_prev = GlyphSlot.nil_id; + self.appendToLRUHead(id); + return id; + } else { + const glyph = &self.glyphs[hashmap_id].glyph; + if (glyph.key == key) { + // An entry for this key already exists + self.removeFromLRU(hashmap_id); + self.appendToLRUHead(hashmap_id); + return hashmap_id; + } + } + } + + // This can't be reached because the hasmap is guarenteed to have enough slots to store all of the glyph ids + unreachable; + } + + fn removeFromHashmap(self: *GlyphCache, id: GlyphSlot.Id) void { + var bucket = self.glyphs[id].glyph.key.hash() % self.glyphs_hashmap.len; + for (0..self.glyphs_hashmap.len) |_| { + assert(self.glyphs_hashmap[bucket] != GlyphSlot.nil_id); + if (self.glyphs_hashmap[bucket] == id) { + break; + } + + bucket = (bucket + 1) % self.glyphs_hashmap.len; + } + + assert(self.glyphs_hashmap[bucket] == id); + + var empty = bucket; + var current = (bucket + 1) % self.glyphs_hashmap.len; + + 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.hash() % self.glyphs_hashmap.len; + + if (self.hashmapDistance(home, current) > self.hashmapDistance(home, empty)) { + self.glyphs_hashmap[empty] = self.glyphs_hashmap[current]; + empty = current; + } + + current = (current + 1) % self.glyphs_hashmap.len; + } + + assert(self.glyphs_hashmap[empty] != GlyphSlot.nil_id); + self.glyphs_hashmap[empty] = GlyphSlot.nil_id; + } + + fn hashmapDistance(self: GlyphCache, from: usize, to: usize) usize { + if (to >= from) { + return to - from; + } else { + return self.glyphs_hashmap.len - from + to; + } + } + + fn remove(self: *GlyphCache, id: GlyphSlot.Id) void { + assert(id != GlyphSlot.nil_id); + self.pushFreeId(id); + self.removeFromHashmap(id); + self.removeFromLRU(id); + + const glyph = self.glyphs[id].glyph; + glyph.deinit(); + } + + fn getLRUItems(self: *GlyphCache, gpa: Allocator) ![]GlyphSlot.Id { + var lru: std.ArrayList(GlyphSlot.Id) = .empty; + defer lru.deinit(gpa); + + var lru_iter = self.lru_head; + while (lru_iter != GlyphSlot.nil_id) { + try lru.append(gpa, lru_iter); + lru_iter = self.glyphs[lru_iter].lru_next; + } + + return try lru.toOwnedSlice(gpa); + } + + test { + var cache = GlyphCache.init(); + const key = Glyph.Key{ .index = 10, .scale_x = 1, .scale_y = 1 }; + const id1 = cache.insert(key).?; + const id2 = cache.insert(key).?; + try std.testing.expectEqual(id1, id2); + } + + test { + var cache = GlyphCache.init(); + const key = Glyph.Key{ .index = 10, .scale_x = 1, .scale_y = 1 }; + const id1 = cache.insert(key).?; + cache.remove(id1); + const id2 = cache.insert(key).?; + try std.testing.expectEqual(id1, id2); + } + + test { + var cache = GlyphCache.init(); + const id1 = cache.insert(Glyph.Key{ .index = 10, .scale_x = 1, .scale_y = 1 }).?; + const id2 = cache.insert(Glyph.Key{ .index = 11, .scale_x = 1, .scale_y = 1 }).?; + try std.testing.expect(id1 != id2); + } + + test { + var cache = GlyphCache.init(); + for (0..(GlyphSlot.limit-1)) |i| { + const id = cache.insert(Glyph.Key{ .index = @intCast(i), .scale_x = 1, .scale_y = 1 }); + try std.testing.expect(id != null); + } + const id = cache.insert(Glyph.Key{ .index = GlyphSlot.limit, .scale_x = 1, .scale_y = 1 }); + try std.testing.expect(id == null); + } + + test { + const gpa = std.testing.allocator; + var cache = GlyphCache.init(); + for (0..10) |_| { + var ids: std.ArrayList(GlyphSlot.Id) = .empty; + defer ids.deinit(gpa); + + for (0..(GlyphSlot.limit-1)) |i| { + const id = cache.insert(Glyph.Key{ .index = @intCast(i), .scale_x = 1, .scale_y = 1 }); + try std.testing.expect(id != null); + try ids.append(gpa, id.?); + } + assert(cache.free_head == GlyphSlot.nil_id); + + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + + const expected_lru = try gpa.dupe(GlyphSlot.Id, ids.items); + defer gpa.free(expected_lru); + std.mem.reverse(GlyphSlot.Id, expected_lru); + + try std.testing.expectEqualSlices(GlyphSlot.Id, expected_lru, lru); + + for (ids.items) |id| { + cache.remove(id); + } + assert(cache.lru_head == GlyphSlot.nil_id); + assert(cache.lru_tail == GlyphSlot.nil_id); + } + } + + test { + var cache = GlyphCache.init(); + const id1 = cache.insert(.{ .index = 0, .scale_x = 1, .scale_y = 1 }).?; + const id2 = cache.insert(.{ .index = 1, .scale_x = 1, .scale_y = 1 }).?; + const id3 = cache.insert(.{ .index = 2, .scale_x = 1, .scale_y = 1 }).?; + + const gpa = std.testing.allocator; + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ id3, id2, id1 }, lru); + } + + test { + var cache = GlyphCache.init(); + const id1 = cache.insert(.{ .index = 0, .scale_x = 1, .scale_y = 1 }).?; + const id2 = cache.insert(.{ .index = 1, .scale_x = 1, .scale_y = 1 }).?; + const id3 = cache.insert(.{ .index = 2, .scale_x = 1, .scale_y = 1 }).?; + + const gpa = std.testing.allocator; + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ id3, id2, id1 }, lru); + } + + test { + var cache = GlyphCache.init(); + const id = cache.insert(.{ .index = 0, .scale_x = 1, .scale_y = 1 }).?; + try std.testing.expect(cache.lru_head != GlyphSlot.nil_id); + try std.testing.expect(cache.lru_tail != GlyphSlot.nil_id); + cache.remove(id); + try std.testing.expect(cache.lru_head == GlyphSlot.nil_id); + try std.testing.expect(cache.lru_tail == GlyphSlot.nil_id); + } + + test { + const gpa = std.testing.allocator; + var cache = GlyphCache.init(); + const key1 = Glyph.Key{ .index = 0, .scale_x = 1, .scale_y = 1 }; + const id1 = cache.insert(key1).?; + const key2 = Glyph.Key{ .index = 1, .scale_x = 1, .scale_y = 1 }; + const id2 = cache.insert(key2).?; + const key3 = Glyph.Key{ .index = 2, .scale_x = 1, .scale_y = 1 }; + const id3 = cache.insert(key3).?; + + _ = cache.lookup(key2); + + { + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ id2, id3, id1 }, lru); + } + + _ = cache.lookup(key1); + + { + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ id1, id2, id3 }, lru); + } + } + + test { + const gpa = std.testing.allocator; + var cache = GlyphCache.init(); + const id1 = cache.insert(.{ .index = 0, .scale_x = 1, .scale_y = 1 }).?; + const id2 = cache.insert(.{ .index = 1, .scale_x = 1, .scale_y = 1 }).?; + const id3 = cache.insert(.{ .index = 2, .scale_x = 1, .scale_y = 1 }).?; + + { + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ id3, id2, id1 }, lru); + } + + _ = cache.remove(id2); + + { + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ id3, id1 }, lru); + } + + _ = cache.remove(id3); + + { + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ id1 }, lru); + } + + _ = cache.remove(id1); + + { + const lru = try cache.getLRUItems(gpa); + defer gpa.free(lru); + try std.testing.expectEqualSlices(GlyphSlot.Id, &.{ }, lru); + } + } + }; + + const GlyphSlot = struct { + glyph: Glyph, + + lru_next: GlyphSlot.Id, + lru_prev: GlyphSlot.Id, + + next_free: GlyphSlot.Id, + + const limit = 256; + const Id = std.math.IntFittingRange(0, limit-1); + const nil_id: GlyphSlot.Id = 0; + }; + + const Glyph = struct { + key: Key, + + box: STBTrueType.Box, + sprite: Sprite.Id, + used_this_frame: bool, + + const max_width: u32 = 512; + const max_height: u32 = 512; + + const Key = packed struct { + index: u32, + scale_x: f32, + scale_y: f32, + + pub fn hash(self: Key) u64 { + var hasher = std.hash.Wyhash.init(0); + hasher.update(std.mem.asBytes(&self)); + return hasher.final(); + } + }; + + fn init(key: Key) Glyph { + return Glyph{ + .key = key, + .box = .{ + .x0 = 0, + .y0 = 0, + .x1 = 0, + .y1 = 0, + }, + .sprite = g_state.nil_sprite, + .used_this_frame = false + }; + } + + fn deinit(self: Glyph) void { + deinitSprite(self.sprite); + } + }; +}; + +pub const Vertex = extern struct { + position: Vec2, + texcoord: Vec2, + color: Vec4, +}; + +pub const Quad = extern struct { + vertices: [4]Vertex, + + pub fn init(vertices: [4]Vertex) Quad { + return Quad{ + .vertices = vertices + }; + } + + pub fn setColor(self: *Quad, color: Color) void { + for (&self.vertices) |*vertex| { + vertex.color = Vec4{ + .x = color.r, + .y = color.g, + .z = color.b, + .w = color.a, + }; + } + } + + pub fn setUVRect(self: *Quad, rect: Rect) void { + const pos = rect.pos; + const size = rect.size; + self.vertices[0].texcoord = pos; + self.vertices[1].texcoord = pos.add(.init(size.x, 0)); + self.vertices[2].texcoord = pos.add(.init(0, size.y)); + self.vertices[3].texcoord = pos.add(size); + } + + pub fn setRect(self: *Quad, rect: Rect) void { + const pos = rect.pos; + const size = rect.size; + self.vertices[0].position = pos; + self.vertices[1].position = pos.add(.init(size.x, 0)); + self.vertices[2].position = pos.add(.init(0, size.y)); + self.vertices[3].position = pos.add(size); + } +}; diff --git a/src/imgui.zig b/src/imgui.zig new file mode 100644 index 0000000..87fdb02 --- /dev/null +++ b/src/imgui.zig @@ -0,0 +1,339 @@ +const std = @import("std"); +const log = std.log.scoped(.imgui); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); + +const build_options = @import("build_options"); +const Color = @import("./color.zig"); +const Math = @import("math"); +const Vec2 = Math.Vec2; + +const sokol = @import("sokol"); +const sapp = sokol.app; +const simgui = sokol.imgui; + +const ig = @import("cimgui"); + +const ImGUI = @This(); + +const State = struct { + enabled: bool, + frame: ?Allocator +}; + +var g_state: State = undefined; + +pub fn init(logger: simgui.Logger) void { + if (!build_options.has_imgui) { + return; + } + + const self = &g_state; + + simgui.setup(.{ + .logger = logger + }); + + if (@hasDecl(ig, "ImGuiConfigFlags_DockingEnable")) { + ig.igGetIO().*.ConfigFlags |= ig.ImGuiConfigFlags_DockingEnable; + ig.igGetIO().*.ConfigFlags |= ig.ImGuiConfigFlags_ViewportsEnable; + } + + self.* = State{ + .enabled = true, + .frame = null + }; +} + +pub fn deinit() void { + if (!build_options.has_imgui) { + return; + } + + simgui.shutdown(); +} + +pub fn event(e: sapp.Event) bool { + if (!build_options.has_imgui) { + return false; + } + + const self = &g_state; + if (!self.enabled) { + return false; + } + + return simgui.handleEvent(e); +} + +pub fn beginFrame(enabled: bool, frame: Allocator) void { + if (!build_options.has_imgui) { + return; + } + + const self = &g_state; + + self.frame = frame; + self.enabled = enabled; + + if (!self.enabled) { + return; + } + + simgui.newFrame(.{ + .width = sapp.width(), + .height = sapp.height(), + .delta_time = sapp.frameDuration(), + .dpi_scale = sapp.dpiScale(), + }); + + const viewport = ig.igGetMainViewport(); + ig.igSetNextWindowPos(viewport[0].WorkPos, ig.ImGuiCond_Always); + ig.igSetNextWindowSize(viewport[0].WorkSize, ig.ImGuiCond_Always); + ig.igSetNextWindowViewport(viewport[0].ID); + + const window_flags = + ig.ImGuiWindowFlags_NoDocking | + ig.ImGuiWindowFlags_NoTitleBar | + ig.ImGuiWindowFlags_NoCollapse | + ig.ImGuiWindowFlags_NoResize | + ig.ImGuiWindowFlags_NoMove | + ig.ImGuiWindowFlags_NoBackground | + ig.ImGuiWindowFlags_NoBringToFrontOnFocus | + ig.ImGuiWindowFlags_NoNavFocus; + + ig.igPushStyleVar(ig.ImGuiStyleVar_WindowRounding, 0.0); + ig.igPushStyleVar(ig.ImGuiStyleVar_WindowBorderSize, 0.0); + ig.igPushStyleVarImVec2(ig.ImGuiStyleVar_WindowPadding, .{ .x = 0, .y = 0 }); + _ = ig.igBegin("DockSpace", null, window_flags); + ig.igPopStyleVarEx(3); + + const dockspace_id = ig.igGetID("MyDockSpace"); + _ = ig.igDockSpaceEx(dockspace_id, ig.ImVec2{ .x = 0, .y = 0 }, ig.ImGuiDockNodeFlags_PassthruCentralNode, null); +} + +pub fn endFrame() void { + if (!build_options.has_imgui) { + return; + } + const self = &g_state; + if (!self.enabled) { + return; + } + + // TODO: + // if (ig.igBegin("Game", null, ig.ImGuiWindowFlags_None)) { + // // std.debug.print("{}\n", .{ig.igGetContentRegionAvail()}); + // } + // ig.igEnd(); + + ig.igEnd(); + + // if (ig.igBeginMainMenuBar()) { + // defer ig.igEndMainMenuBar(); + // + // if (ig.igBeginMenu("foo")) { + // defer ig.igEndMenu(); + // + // if (ig.igMenuItem("bar")) { + // } + // } + // } + + simgui.render(); +} + +pub const WindowOptions = struct { + name: []const u8, + pos: ?Vec2 = null, + size: ?Vec2 = null, + collapsed: ?bool = null, + open: ?*bool = null, +}; + +pub fn beginWindow(opts: WindowOptions) bool { + if (!build_options.has_imgui) { + return false; + } + const self = &g_state; + if (!self.enabled) { + return false; + } + + if (opts.open != null and opts.open.?.* == false) { + return false; + } + + if (opts.pos) |pos| { + ig.igSetNextWindowPos(toImVec2(pos), ig.ImGuiCond_Once); + } + if (opts.size) |size| { + ig.igSetNextWindowSize(toImVec2(size), ig.ImGuiCond_Once); + } + if (opts.collapsed) |collapsed| { + ig.igSetNextWindowCollapsed(collapsed, ig.ImGuiCond_Once); + } + + ig.igSetNextWindowBgAlpha(1); + + const frame = g_state.frame.?; + const namez = frame.dupeSentinel(u8, opts.name, 0) catch blk: { + log.err("dupeSentinel() failed",.{}); + break :blk ""; + }; + + var open = ig.igBegin(namez, opts.open, ig.ImGuiWindowFlags_None); + if (opts.open) |opts_open| { + if (opts_open.* == false) { + open = false; + } + } + if (!open) { + endWindow(); + } + + return open; +} + +pub fn endWindow() void { + if (!build_options.has_imgui) { + return; + } + const self = &g_state; + if (!self.enabled) { + return; + } + + ig.igEnd(); +} + +fn formatString(comptime fmt: []const u8, args: anytype) [:0]const u8 { + const frame = g_state.frame.?; + return std.fmt.allocPrintSentinel(frame, fmt, args, 0) catch blk: { + log.err("allocPrintSentinel() failed", .{}); + break :blk ""; + }; +} + +pub fn text(comptime fmt: []const u8, args: anytype) void { + if (!build_options.has_imgui) { + return; + } + const self = &g_state; + if (!self.enabled) { + return; + } + + ig.igTextUnformatted(formatString(fmt, args)); +} + +pub fn button(label: []const u8) bool { + if (!build_options.has_imgui) { + return false; + } + const self = &g_state; + if (!self.enabled) { + return false; + } + + return ig.igButton(formatString("{s}", .{label})); +} + +pub const SliderOptions = struct { + label: []const u8, + value: *f32, + min: f32, + max: f32, +}; + +pub fn slider(opts: SliderOptions) bool { + if (!build_options.has_imgui) { + return false; + } + const self = &g_state; + if (!self.enabled) { + return false; + } + + return ig.igSliderFloat( + formatString("{s}", .{opts.label}), + opts.value, + opts.min, + opts.max + ); +} + +pub fn checkbox(label: []const u8, value: *bool) bool { + if (!build_options.has_imgui) { + return false; + } + const self = &g_state; + if (!self.enabled) { + return false; + } + + return ig.igCheckbox( + formatString("{s}", .{label}), + value, + ); +} + +pub fn separator() void { + if (!build_options.has_imgui) { + return; + } + const self = &g_state; + if (!self.enabled) { + return; + } + + ig.igSeparator(); +} + +const ColorEditOptions = struct { + label: []const u8, + value: *Color, + has_alpha: bool = false +}; + +pub fn colorEdit(opts: ColorEditOptions) bool { + if (!build_options.has_imgui) { + return false; + } + const self = &g_state; + if (!self.enabled) { + return false; + } + + const label = formatString("{s}", .{opts.label}); + var color: [4]f32 = .{ + opts.value.r, + opts.value.g, + opts.value.b, + opts.value.a, + }; + + var changed: bool = undefined; + if (opts.has_alpha) { + changed = ig.igColorEdit4(label, &color, ig.ImGuiColorEditFlags_None); + } else { + changed = ig.igColorEdit3(label, &color, ig.ImGuiColorEditFlags_None); + } + + if (changed) { + opts.value.r = color[0]; + opts.value.g = color[1]; + opts.value.b = color[2]; + opts.value.a = color[3]; + } + + return changed; +} + +fn toImVec2(vec2: Vec2) ig.ImVec2 { + return ig.ImVec2{ + .x = vec2.x, + .y = vec2.y, + }; +} diff --git a/src/inotify.zig b/src/inotify.zig new file mode 100644 index 0000000..cbc4c90 --- /dev/null +++ b/src/inotify.zig @@ -0,0 +1,186 @@ +const std = @import("std"); +const Io = std.Io; +const assert = std.debug.assert; +const linux = std.os.linux; + +const INotify = @This(); + +fd: i32, + +buf: []u8, +buf_offset: usize, +buf_used: usize, + +pub const Mask = packed struct(u32) { + access: bool = false, + modify: bool = false, + attrib: bool = false, + close_write: bool = false, + close_nowrite: bool = false, + open: bool = false, + moved_from: bool = false, + moved_to: bool = false, + create: bool = false, + delete: bool = false, + delete_self: bool = false, + move_self: bool = false, + + _unused1: u1 = 0, + + unmount: bool = false, + q_overflow: bool = false, + ignored: bool = false, + + _unused2: u8 = 0, + + onlydir: bool = false, + dont_follow: bool = false, + excl_unlink: bool = false, + + _unused3: u1 = 0, + + mask_create: bool = false, + mask_add: bool = false, + isdir: bool = false, + oneshot: bool = false, + + pub fn format(self: Mask, writer: *Io.Writer) Io.Writer.Error!void { + try writer.writeAll(".{"); + var first = true; + inline for (@typeInfo(Mask).@"struct".fields) |field| { + if (@typeInfo(field.type) == .bool and @field(self, field.name)) { + if (!first) { + try writer.writeAll(","); + } + try writer.print(" .{s} = true", .{field.name}); + first = false; + } + } + try writer.writeAll(" }"); + } + + fn testMask(expected: u32, actual: Mask) !void { + try std.testing.expectEqual(expected, @as(u32, @bitCast(actual))); + } + + test { + try testMask(linux.IN.ACCESS, Mask{ .access = true }); + try testMask(linux.IN.MODIFY, Mask{ .modify = true }); + try testMask(linux.IN.ATTRIB, Mask{ .attrib = true }); + try testMask(linux.IN.CLOSE_WRITE, Mask{ .close_write = true }); + try testMask(linux.IN.CLOSE_NOWRITE, Mask{ .close_nowrite = true }); + try testMask(linux.IN.OPEN, Mask{ .open = true }); + try testMask(linux.IN.MOVED_FROM, Mask{ .moved_from = true }); + try testMask(linux.IN.MOVED_TO, Mask{ .moved_to = true }); + try testMask(linux.IN.CREATE, Mask{ .create = true }); + try testMask(linux.IN.DELETE, Mask{ .delete = true }); + try testMask(linux.IN.DELETE_SELF, Mask{ .delete_self = true }); + try testMask(linux.IN.MOVE_SELF, Mask{ .move_self = true }); + try testMask(linux.IN.UNMOUNT, Mask{ .unmount = true }); + try testMask(linux.IN.Q_OVERFLOW, Mask{ .q_overflow = true }); + try testMask(linux.IN.IGNORED, Mask{ .ignored = true }); + try testMask(linux.IN.ONLYDIR, Mask{ .onlydir = true }); + try testMask(linux.IN.DONT_FOLLOW, Mask{ .dont_follow = true }); + try testMask(linux.IN.EXCL_UNLINK, Mask{ .excl_unlink = true }); + try testMask(linux.IN.MASK_CREATE, Mask{ .mask_create = true }); + try testMask(linux.IN.MASK_ADD, Mask{ .mask_add = true }); + try testMask(linux.IN.ISDIR, Mask{ .isdir = true }); + try testMask(linux.IN.ONESHOT, Mask{ .oneshot = true }); + } +}; + +pub const Event = struct { + wd: u32, + mask: Mask, + cookie: u32, + name: ?[*:0]const u8, +}; + +pub const max_event_size = @sizeOf(linux.inotify_event) + linux.NAME_MAX + 1; + +pub fn init(buf: []u8) !INotify { + const fd = linux.inotify_init1(linux.IN.NONBLOCK); + if (linux.errno(fd) != .SUCCESS) { + return error.InotifyInit; + } + return INotify{ + .fd = @intCast(fd), + .buf = buf, + .buf_offset = 0, + .buf_used = 0, + }; +} + +pub fn add(self: INotify, path: [*:0]const u8, mask: Mask) !u32 { + const wd = linux.inotify_add_watch( + self.fd, + path, + @bitCast(mask) + ); + + switch (linux.errno(wd)) { + .SUCCESS => return @intCast(wd), + .ACCES => return error.AccessDenied, + .BADF => unreachable, // The given file descriptor is not valid. + .EXIST => return error.MarkAlreadyExists, + .FAULT => unreachable, // path points outside of the process's accessible address space. + .INVAL => unreachable, // The given event mask contains no valid events; + .NAMETOOLONG => return error.NameTooLong, + .NOENT => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOSPC => return error.UserMarkQuotaExceeded, + .NOTDIR => return error.NotDir, + else => |err| return std.posix.unexpectedErrno(err), + } +} + +pub fn remove(self: INotify, wd: u32) !void { + const rc = linux.inotify_rm_watch(self.fd, @bitCast(wd)); + if (linux.errno(rc) != .SUCCESS) { + return error.InotifyRemoveWatch; + } +} + +pub fn next(self: *INotify) !?Event { + if (self.buf_used <= self.buf.len) { + const n = std.posix.read(self.fd, self.buf[self.buf_used..]) catch |err| switch(err) { + error.WouldBlock => 0, + else => return err + }; + if (n > 0) { + self.buf_used += n; + } + } + + if (self.buf_offset < self.buf_used) { + const event = std.mem.bytesAsValue( + linux.inotify_event, + self.buf[self.buf_offset..][0..@sizeOf(linux.inotify_event)] + ); + self.buf_offset += @sizeOf(linux.inotify_event) + event.len; + assert(self.buf_offset <= self.buf_used); + + if (self.buf_used == self.buf_offset) { + self.buf_offset = 0; + self.buf_used = 0; + } + + var name: ?[*:0]const u8 = null; + if (event.len > 0) { + name = std.mem.span(@as([*:0]const u8, @ptrCast(event)) + @sizeOf(linux.inotify_event)); + } + + return Event{ + .wd = @bitCast(event.wd), + .mask = @bitCast(event.mask), + .cookie = event.cookie, + .name = name + }; + } + + return null; +} + +pub fn deinit(self: INotify) void { + _ = linux.close(self.fd); +} diff --git a/src/input.zig b/src/input.zig new file mode 100644 index 0000000..dcd58c4 --- /dev/null +++ b/src/input.zig @@ -0,0 +1,238 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const sokol = @import("sokol"); + +const Math = @import("math"); +const Vec2 = Math.Vec2; + +const Input = @This(); + +key_code_mapping: std.EnumMap(KeyCode, u21), + +window_size: Vec2, +focused: bool, +keyboard: KeyStateType(KeyCode), +mouse_buttons: KeyStateType(MouseButton), +mouse_position: ?Vec2, +mouse_scroll: Vec2, + +pub fn init(window_size: Vec2) Input { + return Input{ + .key_code_mapping = .{}, + .focused = false, + .keyboard = .empty, + .mouse_buttons = .empty, + .mouse_position = null, + .mouse_scroll = .init(0, 0), + .window_size = window_size + }; +} + +pub fn isKeyPressed(self: Input, key: KeyCode) bool { + return self.keyboard.pressed.contains(key); +} + +pub fn isKeyReleased(self: Input, key: KeyCode) bool { + return self.keyboard.released.contains(key); +} + +pub fn isKeyDown(self: Input, key: KeyCode) bool { + return self.keyboard.down.contains(key); +} + +pub fn isMousePressed(self: Input, button: MouseButton) bool { + return self.mouse_buttons.pressed.contains(button); +} + +pub fn iMouseReleased(self: Input, button: MouseButton) bool { + return self.mouse_buttons.released.contains(button); +} + +pub fn isMouseDown(self: Input, button: MouseButton) bool { + return self.mouse_buttons.down.contains(button); +} + +pub const KeyCode = enum(std.math.IntFittingRange(0, sokol.app.max_keycodes-1)) { + SPACE = 32, + APOSTROPHE = 39, + COMMA = 44, + MINUS = 45, + PERIOD = 46, + SLASH = 47, + _0 = 48, + _1 = 49, + _2 = 50, + _3 = 51, + _4 = 52, + _5 = 53, + _6 = 54, + _7 = 55, + _8 = 56, + _9 = 57, + SEMICOLON = 59, + EQUAL = 61, + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + LEFT_BRACKET = 91, + BACKSLASH = 92, + RIGHT_BRACKET = 93, + GRAVE_ACCENT = 96, + WORLD_1 = 161, + WORLD_2 = 162, + ESCAPE = 256, + ENTER = 257, + TAB = 258, + BACKSPACE = 259, + INSERT = 260, + DELETE = 261, + RIGHT = 262, + LEFT = 263, + DOWN = 264, + UP = 265, + PAGE_UP = 266, + PAGE_DOWN = 267, + HOME = 268, + END = 269, + CAPS_LOCK = 280, + SCROLL_LOCK = 281, + NUM_LOCK = 282, + PRINT_SCREEN = 283, + PAUSE = 284, + F1 = 290, + F2 = 291, + F3 = 292, + F4 = 293, + F5 = 294, + F6 = 295, + F7 = 296, + F8 = 297, + F9 = 298, + F10 = 299, + F11 = 300, + F12 = 301, + F13 = 302, + F14 = 303, + F15 = 304, + F16 = 305, + F17 = 306, + F18 = 307, + F19 = 308, + F20 = 309, + F21 = 310, + F22 = 311, + F23 = 312, + F24 = 313, + F25 = 314, + KP_0 = 320, + KP_1 = 321, + KP_2 = 322, + KP_3 = 323, + KP_4 = 324, + KP_5 = 325, + KP_6 = 326, + KP_7 = 327, + KP_8 = 328, + KP_9 = 329, + KP_DECIMAL = 330, + KP_DIVIDE = 331, + KP_MULTIPLY = 332, + KP_SUBTRACT = 333, + KP_ADD = 334, + KP_ENTER = 335, + KP_EQUAL = 336, + LEFT_SHIFT = 340, + LEFT_CONTROL = 341, + LEFT_ALT = 342, + LEFT_SUPER = 343, + RIGHT_SHIFT = 344, + RIGHT_CONTROL = 345, + RIGHT_ALT = 346, + RIGHT_SUPER = 347, + MENU = 348, +}; + +pub const MouseButton = enum { + left, + right, + middle, + + pub fn fromSokol(mouse_button: sokol.app.Mousebutton) ?MouseButton { + return switch(mouse_button) { + .LEFT => .left, + .RIGHT => .right, + .MIDDLE => .middle, + else => null + }; + } +}; + +pub const Event = union(enum) { + mouse_pressed: MouseButton, + mouse_released: MouseButton, + mouse_move: Vec2, + mouse_enter: Vec2, + mouse_leave, + mouse_scroll: Vec2, + key_pressed: KeyCode, + key_released: KeyCode, + char: u21, + window_resize: Vec2, + focused, + unfocused, +}; + +fn KeyStateType(T: type) type { + return struct { + pressed: std.EnumSet(T), + released: std.EnumSet(T), + down: std.EnumSet(T), + + pub const empty = @This(){ + .pressed = .empty, + .released = .empty, + .down = .empty, + }; + + pub fn press(self: *@This(), key: T) void { + self.pressed.insert(key); + self.down.insert(key); + } + + pub fn release(self: *@This(), key: T) void { + self.released.insert(key); + self.down.remove(key); + } + + pub fn releaseAll(self: *@This()) void { + var iter = self.down.iterator(); + while (iter.next()) |key| { + self.release(key); + } + } + }; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..c1fb15c --- /dev/null +++ b/src/main.zig @@ -0,0 +1,25 @@ +const std = @import("std"); + +const Platform = @import("platform.zig"); +const App = @import("app.zig"); + +pub const std_options: std.Options = .{ + .log_scope_levels = &.{ + .{ .scope = .graphics, .level = .info } + } +}; + +pub fn main(init: std.process.Init) void { + Platform.run(App, .{ + .init = init, + + .window_title = "Gaem", + .width = 800, + .height = 600, + }); +} + +test { + std.testing.refAllDecls(@This()); + _ = @import("./slot_map.zig"); +} diff --git a/src/math.zig b/src/math.zig new file mode 100644 index 0000000..abe04e5 --- /dev/null +++ b/src/math.zig @@ -0,0 +1,447 @@ +const std = @import("std"); +const assert = std.debug.assert; + +pub const bytes_per_kib = 1024; +pub const bytes_per_mib = bytes_per_kib * 1024; +pub const bytes_per_gib = bytes_per_mib * 1024; + +pub const bytes_per_kb = 1000; +pub const bytes_per_mb = bytes_per_kb * 1000; +pub const bytes_per_gb = bytes_per_mb * 1000; + +pub const Range = struct { + from: f32, + to: f32, + + pub const zero = init(0, 0); + + pub fn init(from: f32, to: f32) Range { + return Range{ + .from = from, + .to = to + }; + } + + pub fn getSize(self: Range) f32 { + return @abs(self.from - self.to); + } + + pub fn random(self: Range, rng: std.Random) f32 { + return self.from + rng.float(f32) * (self.to - self.from); + } +}; + +pub const Vec2 = extern struct { + x: f32, + y: f32, + + pub const zero = init(0, 0); + + pub fn init(x: f32, y: f32) Vec2 { + return Vec2{ + .x = x, + .y = y, + }; + } + + pub fn initFromInt(T: type, x: T, y: T) Vec2 { + return .init(@floatFromInt(x), @floatFromInt(y)); + } + + pub fn initAngle(angle: f32) Vec2 { + return Vec2{ + .x = @cos(angle), + .y = @sin(angle), + }; + } + + pub fn rotateLeft90(self: Vec2) Vec2 { + return Vec2.init(self.y, -self.x); + } + + pub fn rotateRight90(self: Vec2) Vec2 { + return Vec2.init(-self.y, self.x); + } + + pub fn rotate(self: Vec2, angle: f32) Vec2 { + return init( + @cos(angle) * self.x - @sin(angle) * self.y, + @sin(angle) * self.x + @cos(angle) * self.y, + ); + } + + pub fn rotateAround(self: Vec2, angle: f32, origin: Vec2) Vec2 { + return self.sub(origin).rotate(angle).add(origin); + } + + pub fn getAngle(self: Vec2) f32 { + return std.math.atan2(self.y, self.x); + } + + pub fn flip(self: Vec2) Vec2 { + return Vec2.init(-self.x, -self.y); + } + + pub fn add(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x + other.x, + self.y + other.y, + ); + } + + pub fn sub(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x - other.x, + self.y - other.y, + ); + } + + pub fn multiplyScalar(self: Vec2, value: f32) Vec2 { + return Vec2.init( + self.x * value, + self.y * value, + ); + } + + pub fn multiply(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x * other.x, + self.y * other.y, + ); + } + + pub fn divide(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x / other.x, + self.y / other.y, + ); + } + + pub fn divideScalar(self: Vec2, value: f32) Vec2 { + return Vec2.init( + self.x / value, + self.y / value, + ); + } + + pub fn lengthSqr(self: Vec2) f32 { + return self.x*self.x + self.y*self.y; + } + + pub fn length(self: Vec2) f32 { + return @sqrt(self.lengthSqr()); + } + + pub fn distance(self: Vec2, other: Vec2) f32 { + return self.sub(other).length(); + } + + pub fn distanceSqr(self: Vec2, other: Vec2) f32 { + return self.sub(other).lengthSqr(); + } + + pub fn limitLength(self: Vec2, max_length: f32) Vec2 { + const self_length = self.length(); + if (self_length > max_length) { + if (self_length == 0) { + return Vec2.init(0, 0); + } + return self.divideScalar(self_length / max_length); + } else { + return self; + } + } + + pub fn normalized(self: Vec2) Vec2 { + const self_length = self.length(); + if (self_length == 0) { + return Vec2.init(0, 0); + } + return self.divideScalar(self_length); + } + + pub fn initScalar(value: f32) Vec2 { + return Vec2.init(value, value); + } + + pub fn eql(self: Vec2, other: Vec2) bool { + return self.x == other.x and self.y == other.y; + } + + pub fn format(self: Vec2, writer: *std.io.Writer) std.io.Writer.Error!void { + try writer.print("Vec2{{ {d}, {d} }}", .{ self.x, self.y }); + } +}; + +pub const Vec3 = extern struct { + x: f32, y: f32, z: f32, + + pub const zero = init(0, 0, 0); + + pub fn init(x: f32, y: f32, z: f32) Vec3 { + return Vec3{ + .x = x, + .y = y, + .z = z, + }; + } + + pub fn initScalar(value: f32) Vec3 { + return Vec3.init(value, value, value); + } + + pub fn asArray(self: *Vec3) []f32 { + const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(self)))); + return ptr[0..3]; + } + + pub fn lerp(a: Vec3, b: Vec3, t: f32) Vec3 { + return Vec3.init( + std.math.lerp(a.x, b.x, t), + std.math.lerp(a.y, b.y, t), + std.math.lerp(a.z, b.z, t), + ); + } + + pub fn clamp(self: Vec3, min_value: f32, max_value: f32) Vec3 { + return Vec3.init( + std.math.clamp(self.x, min_value, max_value), + std.math.clamp(self.y, min_value, max_value), + std.math.clamp(self.z, min_value, max_value), + ); + } +}; + +pub const Vec4 = extern struct { + x: f32, y: f32, z: f32, w: f32, + + pub const zero = init(0, 0, 0, 0); + + pub fn init(x: f32, y: f32, z: f32, w: f32) Vec4 { + return Vec4{ + .x = x, + .y = y, + .z = z, + .w = w + }; + } + + pub fn initVec3XYZ(vec3: Vec3, w: f32) Vec4 { + return init(vec3.x, vec3.y, vec3.z, w); + } + + pub fn initScalar(value: f32) Vec4 { + return Vec4.init(value, value, value, value); + } + + pub fn multiplyMat4(left: Vec4, right: Mat4) Vec4 { + var result: Vec4 = undefined; + + // TODO: SIMD + + result.x = left.x * right.columns[0][0]; + result.y = left.x * right.columns[0][1]; + result.z = left.x * right.columns[0][2]; + result.w = left.x * right.columns[0][3]; + + result.x += left.y * right.columns[1][0]; + result.y += left.y * right.columns[1][1]; + result.z += left.y * right.columns[1][2]; + result.w += left.y * right.columns[1][3]; + + result.x += left.z * right.columns[2][0]; + result.y += left.z * right.columns[2][1]; + result.z += left.z * right.columns[2][2]; + result.w += left.z * right.columns[2][3]; + + result.x += left.w * right.columns[3][0]; + result.y += left.w * right.columns[3][1]; + result.z += left.w * right.columns[3][2]; + result.w += left.w * right.columns[3][3]; + + return result; + } + + pub fn multiply(left: Vec4, right: Vec4) Vec4 { + return init( + left.x * right.x, + left.y * right.y, + left.z * right.z, + left.w * right.w + ); + } + + pub fn asArray(self: *Vec4) []f32 { + const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(self)))); + return ptr[0..4]; + } + + pub fn initArray(array: []const f32) Vec4 { + return Vec4.init(array[0], array[1], array[2], array[3]); + } + + pub fn lerp(a: Vec4, b: Vec4, t: f32) Vec4 { + return Vec4.init( + std.math.lerp(a.x, b.x, t), + std.math.lerp(a.y, b.y, t), + std.math.lerp(a.z, b.z, t), + std.math.lerp(a.w, b.w, t), + ); + } + + pub fn clamp(self: Vec4, min_value: f32, max_value: f32) Vec4 { + return Vec4.init( + std.math.clamp(self.x, min_value, max_value), + std.math.clamp(self.y, min_value, max_value), + std.math.clamp(self.z, min_value, max_value), + std.math.clamp(self.w, min_value, max_value), + ); + } + + pub fn toVec3XYZ(self: Vec4) Vec3 { + return Vec3.init(self.x, self.y, self.z); + } +}; + +pub const Mat4 = extern struct { + columns: [4][4]f32, + + pub fn initZero() Mat4 { + var self: Mat4 = undefined; + @memset(self.asArray(), 0); + return self; + } + + pub fn initIdentity() Mat4 { + return Mat4.initDiagonal(1); + } + + pub fn initDiagonal(value: f32) Mat4 { + var self = Mat4.initZero(); + self.columns[0][0] = value; + self.columns[1][1] = value; + self.columns[2][2] = value; + self.columns[3][3] = value; + return self; + } + + pub fn multiply(left: Mat4, right: Mat4) Mat4 { + var self: Mat4 = undefined; + + inline for (.{ 0, 1, 2, 3 }) |i| { + var column = Vec4.initArray(&right.columns[i]).multiplyMat4(left); + @memcpy(&self.columns[i], column.asArray()); + } + + return self; + } + + pub fn initScale(scale: Vec3) Mat4 { + var self = Mat4.initIdentity(); + self.columns[0][0] = scale.x; + self.columns[1][1] = scale.y; + self.columns[2][2] = scale.z; + return self; + } + + pub fn initTranslate(offset: Vec3) Mat4 { + var self = Mat4.initIdentity(); + self.columns[3][0] = offset.x; + self.columns[3][1] = offset.y; + self.columns[3][2] = offset.z; + return self; + } + + pub fn asArray(self: *Mat4) []f32 { + const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(&self.columns)))); + return ptr[0..16]; + } +}; + +pub const Rect = struct { + pos: Vec2, + size: Vec2, + + pub const zero = Rect{ + .pos = Vec2.zero, + .size = Vec2.zero + }; + + pub const unit = Rect{ + .pos = Vec2.zero, + .size = Vec2.init(1, 1) + }; + + pub fn init(x: f32, y: f32, width: f32, height: f32) Rect { + return Rect{ + .pos = Vec2.init(x, y), + .size = Vec2.init(width, height) + }; + } + + pub fn clip(self: Rect, other: Rect) Rect { + const left_edge = @max(self.left(), other.left()); + const right_edge = @min(self.right(), other.right()); + const top_edge = @max(self.top(), other.top()); + const bottom_edge = @min(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; + } + + pub fn right(self: Rect) f32 { + return self.pos.x + self.size.x; + } + + pub fn top(self: Rect) f32 { + return self.pos.y; + } + + pub fn bottom(self: Rect) f32 { + return self.pos.y + self.size.y; + } + + pub fn center(self: Rect) Vec2 { + return self.pos.add(self.size.multiplyScalar(0.5)); + } + + pub fn multiply(self: Rect, xy: Vec2) Rect { + return Rect{ + .pos = self.pos.multiply(xy), + .size = self.size.multiply(xy), + }; + } + + pub fn divide(self: Rect, xy: Vec2) Rect { + return Rect{ + .pos = self.pos.divide(xy), + .size = self.size.divide(xy), + }; + } + + pub fn isInside(self: Rect, pos: Vec2) bool { + const x_overlap = self.pos.x <= pos.x and pos.x < self.pos.x + self.size.x; + const y_overlap = self.pos.y <= pos.y and pos.y < self.pos.y + self.size.y; + return x_overlap and y_overlap; + } +}; + +pub const Line = struct { + p0: Vec2, + p1: Vec2 +}; + +pub fn isInsideRect(rect_pos: Vec2, rect_size: Vec2, pos: Vec2) bool { + const rect = Rect{ + .pos = rect_pos, + .size = rect_size + }; + return rect.isInside(pos); +} diff --git a/src/platform.zig b/src/platform.zig new file mode 100644 index 0000000..5d6b26f --- /dev/null +++ b/src/platform.zig @@ -0,0 +1,674 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const log = std.log.scoped(.platform); +const assert = std.debug.assert; +const build_options = @import("build_options"); + +const sokol = @import("sokol"); +const sapp = sokol.app; +const sg = sokol.gfx; +const sglue = sokol.glue; +const sgl = sokol.gl; + +pub const Math = @import("math"); +pub const Vec2 = Math.Vec2; +pub const Vec4 = Math.Vec4; +pub const Mat4 = Math.Mat4; +pub const Rect = Math.Rect; + +const tracy = @import("tracy"); + +pub const Color = @import("./color.zig"); +const shd = @import("shader"); + +pub const ImGUI = @import("./imgui.zig"); +pub const Gfx = @import("./graphics.zig"); +pub const Input = @import("./input.zig"); + +const EmbeddedAssets = @import("embedded_assets"); + +fn PlatformType(App: type) type { + return struct { + const Self = @This(); + + gpa: Allocator, + arena: *std.heap.ArenaAllocator, + io: Io, + cli_args: []const [:0]const u8, + + app: App, + + started_at: Io.Timestamp, + last_frame_at: Io.Timestamp, + + assets: Assets, + input: Input, + events_buffer: [256]Input.Event, + events_overflow: bool, + events: std.ArrayList(Input.Event), + last_key_press_is_repeat: bool, + last_key_pressed: ?Input.KeyCode, + + frame_arena: std.heap.ArenaAllocator, + + show_imgui: bool, + + fn init(self: *Self) !void { + const window_size = Vec2.init(sapp.widthf(), sapp.heightf()); + + self.started_at = Io.Timestamp.now(self.io, .awake); + self.last_frame_at = self.started_at; + self.input = Input.init(window_size); + + try Gfx.init(self.gpa, .{ + .func = sokolLogCallback + }); + ImGUI.init(.{ + .func = sokolLogCallback + }); + + if (@hasDecl(App, "init")) { + const plt = Init{ + .gpa = self.gpa, + .arena = self.arena.allocator(), + .io = self.io, + .assets = &self.assets + }; + if (try self.app.init(plt)) |exit_code| { + std.process.exit(exit_code); + } + } + } + + fn sokolInit(user_data: ?*anyopaque) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(user_data)); + + self.init() catch |err| { + log.err("init() error: {}", .{err}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpErrorReturnTrace(trace); + } + sapp.quit(); + }; + } + + fn frame(self: *Self) !void { + tracy.frameMark(); + + _ = self.frame_arena.reset(.free_all); // TODO: make arena reset mode configurable + + const now = Io.Timestamp.now(self.io, .awake); + const t = self.started_at.durationTo(now); + const dt = self.last_frame_at.durationTo(now); + self.last_frame_at = now; + + if (self.input.keyboard.pressed.contains(.F4)) { + self.show_imgui = !self.show_imgui; + } + + { + Gfx.beginFrame(); + ImGUI.beginFrame(self.show_imgui, self.frame_arena.allocator()); + + if (@hasDecl(App, "frame")) { + const plt = Frame{ + .t = t.nanoseconds, + .dt = dt.nanoseconds, + .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 + }; + try self.app.frame(plt); + } + + Gfx.flush(.{}); + Gfx.showDebug(); + + ImGUI.endFrame(); + Gfx.endFrame(); + } + + self.events_overflow = false; + self.events.clearRetainingCapacity(); + self.input.keyboard.pressed = .empty; + self.input.keyboard.released = .empty; + self.input.mouse_buttons.pressed = .empty; + self.input.mouse_buttons.released = .empty; + } + + fn sokolFrame(user_data: ?*anyopaque) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(user_data)); + self.frame() catch |err| { + log.err("frame() error: {}", .{err}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpErrorReturnTrace(trace); + } + sapp.quit(); + }; + } + + // TODO: I don't like the names of these 'event' related functions. + // All of them "append an event", but they do different things. + fn applyEventToState(self: *Self, e: Input.Event) void { + const input = &self.input; + + switch (e) { + .key_pressed => |key| { + assert(!input.isKeyDown(key)); + input.keyboard.press(key); + self.last_key_pressed = key; + }, + .key_released => |key| { + assert(input.isKeyDown(key)); + input.keyboard.release(key); + }, + .mouse_pressed => |button| { + assert(input.mouse_position != null); + assert(!input.isMouseDown(button)); + input.mouse_buttons.press(button); + }, + .mouse_released => |button| { + assert(input.mouse_position != null); + assert(input.isMouseDown(button)); + input.mouse_buttons.release(button); + }, + .mouse_enter => |pos| { + assert(input.mouse_position == null); + input.mouse_position = pos; + }, + .mouse_move => |pos| { + assert(input.mouse_position != null); + input.mouse_position = pos; + }, + .mouse_leave => { + assert(input.mouse_position != null); + input.mouse_position = null; + }, + .char => |char| { + // A 'key_pressed' event always occurs before a 'char' event. + // This allows us to differentiate between keycodes and scan codes. + // And how to map key codes to character codes. + assert(self.last_key_pressed != null); + input.key_code_mapping.put(self.last_key_pressed.?, char); + self.last_key_pressed = null; + }, + .mouse_scroll => |offset| { + assert(input.mouse_position != null); + input.mouse_scroll = input.mouse_scroll.add(offset); + }, + .window_resize => |window_size| { + self.input.window_size = window_size; + }, + .focused => { + assert(!input.focused); + input.focused = true; + }, + .unfocused => { + assert(input.focused); + assert(input.mouse_position == null); + assert(input.keyboard.down.eql(.empty)); + assert(input.mouse_buttons.down.eql(.empty)); + input.focused = false; + } + } + } + + fn appendEvent(self: *Self, e: Input.Event) void { + if (self.events.capacity == self.events.items.len) { + if (!self.events_overflow) { + log.warn("too many events, limit: {}", .{self.events.capacity}); + } + self.events_overflow = true; + return; + } + + self.events.appendAssumeCapacity(e); + self.applyEventToState(e); + } + + fn pushEvent(self: *Self, e: Input.Event) void { + const input = &self.input; + + if (input.focused) { + if (e == .focused) { + return; + } + + if (e == .key_pressed and input.isKeyDown(e.key_pressed)) { + return; + } + if (e == .key_released and !input.isKeyDown(e.key_released)) { + return; + } + if (e == .mouse_move and input.mouse_position.?.eql(e.mouse_move)) { + return; + } + + if (e == .unfocused) { + var key_iter = input.keyboard.down.iterator(); + while (key_iter.next()) |key| { + self.appendEvent(.{ .key_released = key }); + } + var mouse_buttons_iter = input.mouse_buttons.down.iterator(); + while (mouse_buttons_iter.next()) |button| { + self.appendEvent(.{ .mouse_released = button }); + } + if (input.mouse_position != null) { + self.appendEvent(.{ .mouse_leave = {} }); + } + } + + } else { + if (e == .unfocused) { + return; + } + + if (e != .focused) { + self.appendEvent(.focused); + + if (e == .mouse_move) { + self.appendEvent(.{ .mouse_enter = e.mouse_move }); + } + } + } + + self.appendEvent(e); + } + + fn event(self: *Self, e: sapp.Event) !bool { + if (self.show_imgui) { + if (ImGUI.event(e)) { + self.pushEvent(.{ + .unfocused = {} + }); + return true; + } + } + + const input = &self.input; + + switch (e.type) { + .MOUSE_DOWN => { + if (input.mouse_position == null) { + self.pushEvent(.{ + .mouse_enter = .init(e.mouse_x, e.mouse_y) + }); + } + + self.pushEvent(.{ + .mouse_pressed = Input.MouseButton.fromSokol(e.mouse_button) orelse return false, + }); + }, + .MOUSE_UP => { + if (input.mouse_position == null) { + self.pushEvent(.{ + .mouse_enter = .init(e.mouse_x, e.mouse_y) + }); + } + + self.pushEvent(.{ + .mouse_released = Input.MouseButton.fromSokol(e.mouse_button) orelse return false, + }); + }, + .MOUSE_MOVE => { + if (input.mouse_position == null) { + self.pushEvent(.{ + .mouse_enter = .init(e.mouse_x, e.mouse_y) + }); + } + + self.pushEvent(.{ + .mouse_move = .init(e.mouse_x, e.mouse_y) + }); + }, + .MOUSE_ENTER => { + self.pushEvent(.{ + .mouse_enter = .init(e.mouse_x, e.mouse_y) + }); + }, + .MOUSE_LEAVE => { + self.pushEvent(.{ + .mouse_leave = {} + }); + }, + .MOUSE_SCROLL => { + self.pushEvent(.{ + .mouse_scroll = .init(e.scroll_x, e.scroll_y) + }); + }, + .KEY_DOWN => { + self.last_key_press_is_repeat = e.key_repeat; + if (!e.key_repeat) { + self.pushEvent(.{ + .key_pressed = @enumFromInt(@intFromEnum(e.key_code)) + }); + } + }, + .KEY_UP => { + self.pushEvent(.{ + .key_released = @enumFromInt(@intFromEnum(e.key_code)), + }); + }, + .CHAR => { + if (!self.last_key_press_is_repeat) { + self.pushEvent(.{ + .char = @intCast(e.char_code) + }); + } + }, + .FOCUSED => { + self.pushEvent(.{ + .focused = {} + }); + }, + .UNFOCUSED => { + self.pushEvent(.{ + .unfocused = {} + }); + }, + else => {} + } + + return true; + } + + fn sokolEvent(ev: [*c]const sapp.Event, user_data: ?*anyopaque) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(user_data)); + + const consumed_event = self.event(ev.*) catch |err| blk: { + log.err("event() error: {}", .{err}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpErrorReturnTrace(trace); + } + sapp.quit(); + break :blk false; + }; + + if (consumed_event) { + sapp.consumeEvent(); + } + } + + fn sokolCleanup(user_data: ?*anyopaque) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(user_data)); + if (@hasDecl(App, "deinit")) { + const plt = Deinit{ + .gpa = self.gpa, + .io = self.io + }; + self.app.deinit(plt); + } + self.frame_arena.deinit(); + self.assets.deinit(); + + ImGUI.deinit(); + Gfx.deinit(); + } + }; +} + +pub const RunOptions = struct { + init: std.process.Init, + + window_title: [*c]const u8 = null, + width: i32 = 800, + height: i32 = 600, +}; + +pub const Assets = struct { + arena: std.heap.ArenaAllocator, + gpa: Allocator, + io: Io, + + dir: ?Io.Dir, + files: std.array_hash_map.String(File), + + fn init(gpa: Allocator, io: Io, dir: ?Io.Dir) Assets { + return Assets{ + .gpa = gpa, + .io = io, + .arena = .init(gpa), + .dir = dir, + .files = .empty + }; + } + + fn readFileFromAssetDir(self: *Assets, filename: []const u8) !?[:0]const u8 { + const dir = self.dir orelse return null; + + const arena = self.arena.allocator(); + const contents = try dir.readFileAllocOptions( + self.io, + filename, + arena, + .unlimited, + .of(u8), + 0 + ); + + const filename_owned = try arena.dupe(u8, filename); + try self.files.put(self.gpa, filename_owned, File{ + .contents = contents + }); + + return contents; + } + + pub fn readFile(self: *Assets, filename: []const u8) [:0]const u8 { + if (self.files.get(filename)) |file| { + return file.contents; + } else { + if (self.readFileFromAssetDir(filename)) |maybe_contents| { + if (maybe_contents) |contents| { + log.warn("Read file which isn't defined in assets: '{s}'", .{filename}); + return contents; + } else { + log.err("Attempt to read file '{s}', which doesn't exist", .{filename}); + return ""; + } + } else |err| { + log.err("Failed to read file from filesystem: {}", .{err}); + return ""; + } + } + } + + fn insertStaticFile(self: *Assets, filename: []const u8, contents: [:0]const u8) !void { + try self.files.put(self.gpa, filename, File{ + .contents = contents + }); + } + + fn deinit(self: *Assets) void { + self.arena.deinit(); + self.files.deinit(self.gpa); + } + + const File = struct { + contents: [:0]const u8 + }; +}; + +pub const Init = struct { + gpa: Allocator, + arena: Allocator, + io: Io, + assets: *Assets +}; + +pub const Deinit = struct { + gpa: Allocator, + io: Io, +}; + +pub const Nanoseconds = i96; + +pub const Frame = struct { + t: Nanoseconds, + dt: Nanoseconds, + + gpa: Allocator, + arena: Allocator, + frame: Allocator, + io: Io, + assets: *Assets, + input: *Input, + input_events: []Input.Event, + + fn nanosecondsToSeconds(nanoseconds: i96) f32 { + return @floatCast(@as(f64, @floatFromInt(nanoseconds)) / std.time.ns_per_s); + } + + pub fn deltaTime(self: Frame) f32 { + return nanosecondsToSeconds(self.dt); + } + + pub fn time(self: Frame) f32 { + return nanosecondsToSeconds(self.t); + } +}; + +pub fn run(App: type, opts: RunOptions) void { + const arena = opts.init.arena.allocator(); + const gpa = opts.init.gpa; + const io = opts.init.io; + + var assets_dir: ?Io.Dir = null; + if (build_options.assets_dir) |assets_dir_path| { + const cwd = Io.Dir.cwd(); + assets_dir = cwd.openDir(io, assets_dir_path, .{}) catch |e| blk: { + log.err("Failed to open assets directory: {}", .{e}); + break :blk null; + }; + } + + const PlatformApp = PlatformType(App); + const plt = arena.create(PlatformApp) catch @panic("OOM"); + plt.* = PlatformApp{ + .gpa = gpa, + .arena = opts.init.arena, + .io = io, + .cli_args = opts.init.minimal.args.toSlice(arena) catch @panic("OOM"), + .app = undefined, + .input = undefined, + .events_buffer = undefined, + .events_overflow = false, + .events = .initBuffer(&plt.events_buffer), + .started_at = undefined, + .last_frame_at = undefined, + .last_key_press_is_repeat = false, + .last_key_pressed = null, + .frame_arena = .init(gpa), + .show_imgui = builtin.mode == .Debug, + .assets = .init(gpa, io, assets_dir) + }; + + inline for (EmbeddedAssets.files) |file| { + plt.assets.insertStaticFile(file.name, file.contents) catch @panic("OOM"); + } + + var icon: sapp.IconDesc = .{}; + icon.sokol_default = true; + // TODO: Don't hard code icon path, allow changing through options + // var icon_data = try STBImage.load(@embedFile("../assets/icon.png")); + // defer icon_data.deinit(); + // TODO: + // icon.images[0] = .{ + // .width = @intCast(icon_data.width), + // .height = @intCast(icon_data.height), + // .pixels = .{ + // .ptr = icon_data.rgba8_pixels, + // .size = icon_data.width * icon_data.height * 4 + // } + // }; + + tracy.setThreadName("Main"); + + const is_wasm = builtin.cpu.arch.isWasm(); + if (builtin.os.tag == .linux and !is_wasm) { + var sa: std.posix.Sigaction = .{ + .handler = .{ .handler = posixSignalHandler }, + .mask = std.posix.sigemptyset(), + .flags = std.posix.SA.RESTART, + }; + std.posix.sigaction(std.posix.SIG.INT, &sa, null); + } + + sapp.run(.{ + .init_userdata_cb = PlatformApp.sokolInit, + .frame_userdata_cb = PlatformApp.sokolFrame, + .cleanup_userdata_cb = PlatformApp.sokolCleanup, + .event_userdata_cb = PlatformApp.sokolEvent, + .user_data = plt, + .window_title = opts.window_title, + .width = opts.width, + .height = opts.height, + .icon = icon, + .logger = .{ .func = sokolLogCallback }, + }); +} + +fn posixSignalHandler(sig: std.posix.SIG) callconv(.c) void { + _ = sig; + sapp.requestQuit(); +} + +fn toSokolColor(color: Color) sokol.gfx.Color { + // TODO: Assert and do cast + return sokol.gfx.Color{ + .r = color.r, + .g = color.g, + .b = color.b, + .a = color.a, + }; +} + +fn sokolLogFmt(log_level: u32, comptime format: []const u8, args: anytype) void { + if (log_level == 0) { + log.err(format, args); + } else if (log_level == 1) { + log.err(format, args); + } else if (log_level == 2) { + log.warn(format, args); + } else { + log.info(format, args); + } +} + +fn cStrToZig(c_str: [*c]const u8) [:0]const u8 { + return std.mem.span(c_str); +} + +fn sokolLogCallback(tag: [*c]const u8, log_level: u32, log_item: u32, message: [*c]const u8, line_nr: u32, filename: [*c]const u8, user_data: ?*anyopaque) callconv(.c) void { + _ = user_data; + + if (filename != null) { + sokolLogFmt( + log_level, + "[{s}][id:{}] {s}:{}: {s}", + .{ + cStrToZig(tag orelse "-"), + log_item, + std.fs.path.basename(cStrToZig(filename orelse "-")), + line_nr, + cStrToZig(message orelse "") + } + ); + } else { + sokolLogFmt( + log_level, + "[{s}][id:{}] {s}", + .{ + cStrToZig(tag orelse "-"), + log_item, + cStrToZig(message orelse "") + } + ); + } +} diff --git a/src/shader.glsl b/src/shader.glsl new file mode 100644 index 0000000..3dc847b --- /dev/null +++ b/src/shader.glsl @@ -0,0 +1,50 @@ +@header const m = @import("math") +@ctype mat4 m.Mat4 + +/* main vertex shader */ +@vs vs +in vec2 position; +in vec2 texcoord0; +in vec4 color0; +out vec4 color; +out vec4 uv; + +layout(binding = 0) uniform vs_params { + mat4 view; + mat4 projection; +}; + +void main() { + gl_Position = projection * view * vec4(position, 0, 1); + color = color0; + uv = vec4(texcoord0, 0.0, 1.0); +} +@end + +/* main fragment shader */ +@fs fs +layout(binding=0) uniform texture2D tex; +layout(binding=0) uniform sampler smp; +in vec4 color; +in vec4 uv; +out vec4 frag_color; + +layout(binding = 1) uniform fs_params { + // Textures modes: + // 0 - rgba8 + // 1 - r8 - use red channel as opacity, used for fonts. + int texture_mode; +}; + +void main() { + vec4 tex_color = texture(sampler2D(tex, smp), uv.xy); + if (texture_mode == 0) { + frag_color = tex_color * color; + } else if (texture_mode == 1) { + frag_color = vec4(1, 1, 1, tex_color.r) * color; + } +} +@end + +/* main shader program */ +@program main vs fs diff --git a/src/slot_map.zig b/src/slot_map.zig new file mode 100644 index 0000000..5eb1cd2 --- /dev/null +++ b/src/slot_map.zig @@ -0,0 +1,305 @@ +const std = @import("std"); +const tracy = @import("tracy"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +/// This array provides: +/// - O(1) insertion +/// - O(1) removal +/// - O(1) lookup +/// - Pointers and IDs stay stable when inserting and removing. +/// - Unique IDs for every inserted item (assuming that generation doesn't overflow) +/// +/// For more details about this data structure checkout this podcast episode from Wookash: +/// * https://www.youtube.com/watch?v=ShSGHb65f3M +pub fn SlotMapType(Index: type, Generation: type, Value: type) type { + assert(@typeInfo(Generation) == .int); + assert(@typeInfo(Index) == .int); + + return struct { + const Self = @This(); + + // TODO: This struct will probably have paddings bytes inserted. + // It's not ideal for a data structure like this which will be commonly used. + // + // The `used` field could be packed into a bitset. + // But that would introduce a second `std.ArrayList` which would make managing the memory a PITA. + // + // So in the end idk if it's worth it. + pub const Slot = struct { + value: Value, + generation: Generation, + next_hole: ?Index, + used: bool + }; + + slots: std.ArrayList(Slot), + first_hole: ?Index, + last_hole: ?Index, + hole_count: usize, + + const empty = Self{ + .slots = .empty, + .first_hole = null, + .last_hole = null, + .hole_count = 0, + }; + + pub const Id = packed struct { + generation: Generation, + index: Index, + + pub fn format(self: Id, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("Id({s}){{ {}, {} }}", .{ @typeName(Value), self.index, self.generation }); + } + }; + + pub const Iterator = struct { + slot_map: *Self, + index: Index, + + pub fn next(self: *Iterator) ?Id { + while (self.index < self.slot_map.slots.items.len) { + const index = self.index; + const slot = self.slot_map.slots.items[index]; + self.index += 1; + + if (slot.used) { + return Id{ + .index = @intCast(index), + .generation = slot.generation + }; + } + } + + return null; + } + }; + + pub fn clearRetainingCapacity(self: *Self) void { + var slots = self.slots; + slots.clearRetainingCapacity(); + + self.* = .init(slots); + } + + fn insertHole(self: *Self, index: Index) void { + if (self.last_hole) |last_hole| { + self.slots.items[index].next_hole = last_hole; + self.last_hole = index; + } else { + self.first_hole = index; + self.last_hole = index; + } + self.hole_count += 1; + } + + fn removeUnused(self: *Self) ?Index { + if (self.first_hole) |first_hole| { + self.first_hole = self.slots.items[first_hole].next_hole; + if (self.first_hole == null) { + self.last_hole = null; + } + self.hole_count -= 1; + return first_hole; + } + + const capacity = @min(self.slots.capacity, std.math.maxInt(Index)); + if (self.slots.items.len < capacity) { + const index: Index = @intCast(self.slots.items.len); + self.slots.items.len += 1; + self.slots.items[index] = Slot{ + .value = undefined, + .generation = 0, + .next_hole = null, + .used = false + }; + return index; + } + + return null; + } + + pub fn ensureUnusedCapacity(self: *Self, gpa: Allocator, additional_count: usize) Allocator.Error!void { + if (additional_count > self.hole_count) { + const new_capacity, const overflow = @addWithOverflow( + self.slots.capacity, + additional_count - self.hole_count + ); + if (overflow != 0) return error.OutOfMemory; + if (new_capacity >= std.math.maxInt(Index)) return error.OutOfMemory; + + try self.slots.ensureTotalCapacity(gpa, new_capacity); + } + } + + pub fn unusedCapacity(self: *Self) usize { + const capacity = @min(self.slots.capacity, std.math.maxInt(Index)); + return capacity - self.slots.items.len + self.hole_count; + } + + pub fn insertAssumeCapacity(self: *Self) Id { + const index = self.removeUnused().?; + const slot = &self.slots.items[index]; + assert(!slot.used); + slot.used = true; + + return Id{ + .index = @intCast(index), + .generation = slot.generation + }; + } + + pub fn insert(self: *Self, gpa: Allocator) Allocator.Error!Id { + try self.ensureUnusedCapacity(gpa, 1); + return self.insertAssumeCapacity(); + } + + pub fn insertBounded(self: *Self) Allocator.Error!Id { + if (self.unusedCapacity() == 0) { + return error.OutOfMemory; + } + return self.insertAssumeCapacity(); + } + + pub fn exists(self: *Self, id: Id) bool { + if (id.index >= self.slots.items.len) { + return false; + } + + const slot = self.slots.items[id.index]; + + return slot.used and slot.generation == id.generation; + } + + pub fn get(self: *Self, id: Id) ?*Value { + if (self.exists(id)) { + return &self.slots.items[id.index].value; + } + return null; + } + + pub fn getAssumeExists(self: *Self, id: Id) *Value { + assert(self.exists(id)); + return self.get(id).?; + } + + pub fn removeAssumeExists(self: *Self, id: Id) void { + assert(self.exists(id)); + + const slot = &self.slots.items[id.index]; + + slot.used = false; + slot.generation +%= 1; + self.insertHole(id.index); + } + + pub fn remove(self: *Self, id: Id) bool { + if (!self.exists(id)) { + return false; + } + + self.removeAssumeExists(id); + return true; + } + + pub fn iterator(self: *Self) Iterator { + return Iterator{ + .slot_map = self, + .index = 0 + }; + } + + pub fn init(slots: std.ArrayList(Slot)) Self { + var self: Self = .empty; + self.slots = slots; + return self; + } + + pub fn deinit(self: *Self, gpa: Allocator) void { + self.slots.deinit(gpa); + } + }; +} + +const TestMap = SlotMapType(u24, u8, void); + +// TODO: Add more rigorous test for check if generation usage is nicely distributed. + +test "insert & remove" { + const expect = std.testing.expect; + const gpa = std.testing.allocator; + + var map: TestMap = .empty; + defer map.deinit(gpa); + + const id1 = try map.insert(gpa); + try expect(map.exists(id1)); + try expect(map.remove(id1)); + try expect(!map.exists(id1)); + try expect(!map.remove(id1)); + + const id2 = try map.insert(gpa); + try expect(map.exists(id2)); + try expect(!map.exists(id1)); +} + +test "generation wrap around" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var map: TestMap = .empty; + defer map.deinit(gpa); + + // Grow array list so that at least 1 slot exists + const id1 = try map.insert(gpa); + map.removeAssumeExists(id1); + + // Artificially increase generation count + map.slots.items[id1.index].generation = std.math.maxInt(@FieldType(TestMap.Id, "generation")); + + // Check if generation wraps around + const id2 = try map.insert(gpa); + map.removeAssumeExists(id2); + try expectEqual(id1.index, id2.index); + try expectEqual(0, map.slots.items[id1.index].generation); +} + +test "iterator" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var map: TestMap = .empty; + defer map.deinit(gpa); + + // Create array which has a hole + const id1 = try map.insert(gpa); + const id2 = try map.insert(gpa); + const id3 = try map.insert(gpa); + + map.removeAssumeExists(id2); + + var iter = map.iterator(); + try expectEqual(id1, iter.next().?); + try expectEqual(id3, iter.next().?); + try expectEqual(null, iter.next()); +} + +test "clear retaining capacity" { + const expect = std.testing.expect; + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var map: TestMap = .empty; + defer map.deinit(gpa); + + const id1 = try map.insert(gpa); + try expect(map.exists(id1)); + map.clearRetainingCapacity(); + + const id2 = try map.insert(gpa); + try expect(map.exists(id2)); + + try expectEqual(id1, id2); +}