commit 69fed14d75f663a79917b24f45ea59167ffc9c25 Author: Rokas Puzonas Date: Sun Jan 18 07:06:01 2026 +0200 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..51fb2d6 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Game template + +## 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 +``` + +## TODO + +* Use [Skribidi](https://github.com/memononen/Skribidi) instead of fontstash for text rendering +* Support for audio formats (Might not need all of them, haven't decided): + * QOA, maybe [qoa.h](https://github.com/phoboslab/qoa/blob/master/qoa.h)? + * Flac, maybe [dr_flac.h](https://github.com/mackron/dr_libs/blob/master/dr_flac.h)? + * Wav, maybe [dr_wav.h](https://github.com/mackron/dr_libs/blob/master/dr_wav.h)? + * Mp3, maybe [dr_mp3.h](https://github.com/mackron/dr_libs/blob/master/dr_mp3.h)? +* Gamepad support. + * WASM Support. Currently a build config isn't provided for this target. + * Update build config for other platforms to reduce binary size. All of the video and audio drivers aren't needed, only gamepads diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..004e8b7 --- /dev/null +++ b/build.zig @@ -0,0 +1,304 @@ +const std = @import("std"); +const sokol = @import("sokol"); +const builtin = @import("builtin"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const has_imgui = b.option(bool, "imgui", "ImGui integration") orelse (optimize == .Debug); + var has_tracy = b.option(bool, "tracy", "Tracy integration") orelse (optimize == .Debug); + const has_console = b.option(bool, "console", "Show console (Window only)") orelse (optimize == .Debug); + + const isWasm = target.result.cpu.arch.isWasm(); + + if (isWasm) { + has_tracy = false; + } + + const mod_main = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true + }); + + const dep_sokol = b.dependency("sokol", .{ + .target = target, + .optimize = optimize, + .with_sokol_imgui = has_imgui, + }); + mod_main.linkLibrary(dep_sokol.artifact("sokol_clib")); + mod_main.addImport("sokol", dep_sokol.module("sokol")); + + if (has_imgui) { + if (b.lazyDependency("cimgui", .{ + .target = target, + .optimize = optimize, + })) |dep_cimgui| { + const cimgui = b.lazyImport(@This(), "cimgui").?; + const cimgui_conf = cimgui.getConfig(false); + mod_main.addImport("cimgui", dep_cimgui.module(cimgui_conf.module_name)); + dep_sokol.artifact("sokol_clib").addIncludePath(dep_cimgui.path(cimgui_conf.include_dir)); + } + } + + const dep_tracy = b.dependency("tracy", .{ + .target = target, + .optimize = optimize, + .tracy_enable = has_tracy, + .tracy_only_localhost = true + }); + if (has_tracy) { + mod_main.linkLibrary(dep_tracy.artifact("tracy")); + } + mod_main.addImport("tracy", dep_tracy.module("tracy")); + + const dep_tiled = b.dependency("tiled", .{}); + mod_main.addImport("tiled", dep_tiled.module("tiled")); + + const dep_stb = b.dependency("stb", .{}); + mod_main.addImport("stb_image", dep_stb.module("stb_image")); + mod_main.addImport("stb_vorbis", dep_stb.module("stb_vorbis")); + + const dep_fontstash_c = b.dependency("fontstash_c", .{}); + mod_main.addIncludePath(dep_fontstash_c.path("src")); + + const dep_sokol_c = b.dependency("sokol_c", .{}); + { + var cflags_buffer: [64][]const u8 = undefined; + var cflags = std.ArrayListUnmanaged([]const u8).initBuffer(&cflags_buffer); + switch (sokol.resolveSokolBackend(.auto, target.result)) { + .d3d11 => try cflags.appendBounded("-DSOKOL_D3D11"), + .metal => try cflags.appendBounded("-DSOKOL_METAL"), + .gl => try cflags.appendBounded("-DSOKOL_GLCORE"), + .gles3 => try cflags.appendBounded("-DSOKOL_GLES3"), + .wgpu => try cflags.appendBounded("-DSOKOL_WGPU"), + else => @panic("unknown sokol backend"), + } + + mod_main.addIncludePath(dep_sokol_c.path("util")); + mod_main.addCSourceFile(.{ + .file = b.path("src/engine/fontstash/sokol_fontstash_impl.c"), + .flags = cflags.items + }); + } + + // TODO: + // const sdl = b.dependency("sdl", .{ + // .optimize = optimize, + // .target = target, + // .linkage = .static, + // .default_target_config = !isWasm + // }); + // mod_main.linkLibrary(sdl.artifact("SDL3")); + // if (isWasm) { + // // TODO: Define buid config for wasm + // } + + var options = b.addOptions(); + options.addOption(bool, "has_imgui", has_imgui); + options.addOption(bool, "has_tracy", has_tracy); + mod_main.addOptions("build_options", options); + + // from here on different handling for native vs wasm builds + if (target.result.cpu.arch.isWasm()) { + try buildWasm(b, .{ + .name = "sokol_template", + .mod_main = mod_main, + .dep_sokol = dep_sokol, + }); + } else { + try buildNative(b, "sokol_template", mod_main, has_console); + } +} + +fn buildNative(b: *std.Build, name: []const u8, mod: *std.Build.Module, has_console: bool) !void { + const exe = b.addExecutable(.{ + .name = name, + .root_module = mod + }); + const target = mod.resolved_target.?; + if (target.result.os.tag == .windows) { + exe.subsystem = if (has_console) .Console else .Windows; + + const png_to_icon_tool = b.addExecutable(.{ + .name = "png-to-icon", + .root_module = b.createModule(.{ + .target = b.graph.host, + .root_source_file = b.path("tools/png-to-icon.zig"), + }), + }); + const dep_stb_image = b.dependency("stb_image", .{}); + png_to_icon_tool.root_module.addImport("stb_image", dep_stb_image.module("stb_image")); + + const png_to_icon_step = b.addRunArtifact(png_to_icon_tool); + png_to_icon_step.addFileArg(b.path("src/assets/icon.png")); + 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); + } + b.installArtifact(exe); + + { + const run_step = b.step("run", "Run game"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + } + + { + const exe_tests = b.addTest(.{ + .root_module = exe.root_module, + }); + + const run_exe_tests = b.addRunArtifact(exe_tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_exe_tests.step); + } +} + +const BuildWasmOptions = struct { + name: []const u8, + mod_main: *std.Build.Module, + dep_sokol: *std.Build.Dependency, +}; + +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); + } +} + +fn buildWasm(b: *std.Build, opts: BuildWasmOptions) !void { + opts.mod_main.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 = "index", + .root_module = opts.mod_main, + }); + + const dep_emsdk = opts.dep_sokol.builder.dependency("emsdk", .{}); + + patchWasmIncludeDirs( + opts.mod_main, + dep_emsdk.path("upstream/emscripten/cache/sysroot/include"), + &(opts.dep_sokol.artifact("sokol_clib").step) + ); + + // create a build step which invokes the Emscripten linker + const link_step = try sokol.emLinkStep(b, .{ + .lib_main = main_lib, + .target = opts.mod_main.resolved_target.?, + .optimize = opts.mod_main.optimize.?, + .emsdk = dep_emsdk, + .use_webgl2 = true, + .use_emmalloc = true, + .use_filesystem = false, + .shell_file_path = b.path("src/engine/shell.html"), + }); + // attach to default target + b.getInstallStep().dependOn(&link_step.step); + // ...and a special run step to start the web build output via 'emrun' + const run = sokol.emRunStep(b, .{ .name = "index", .emsdk = dep_emsdk }); + run.step.dependOn(&link_step.step); + b.step("run", "Run game").dependOn(&run.step); + + // TODO: Create a zip archive of all of the files. Would be useful for easier itch.io upload +} + +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.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 resource_file = try std.fs.cwd().createFile(self.resource_file.getPath(b), .{ }); + defer resource_file.close(); + + const relative_icon_path = try std.fs.path.relative( + b.allocator, + self.resource_file.dirname().getPath(b), + self.icon_file.getPath(b) + ); + std.mem.replaceScalar(u8, relative_icon_path, '\\', '/'); + + try resource_file.writeAll("IDI_ICON ICON \""); + try resource_file.writeAll(relative_icon_path); + try resource_file.writeAll("\""); + } +}; diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..0aee37a --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,44 @@ +.{ + .name = .sokol_template, + .version = "0.0.0", + .fingerprint = 0x60a8e079a691c8d9, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .sokol = .{ + .url = "git+https://github.com/floooh/sokol-zig.git#aaf291ca2d3d1cedc05d65f5a1cacae0f53d934a", + .hash = "sokol-0.1.0-pb1HK4iDNgCom5dkY66eUBm_bYBHEl8KWFDAiqwWgpEy", + }, + .sokol_c = .{ + .url = "git+https://github.com/floooh/sokol.git#c66a1f04e6495d635c5e913335ab2308281e0492", + .hash = "N-V-__8AAC3eYABB1DVLb4dkcEzq_xVeEZZugVfQ6DoNQBDN", + }, + .fontstash_c = .{ + .url = "git+https://github.com/memononen/fontstash.git#b5ddc9741061343740d85d636d782ed3e07cf7be", + .hash = "N-V-__8AAA9xHgAxdLYPmlNTy6qzv9IYqiIePEHQUOPWYQ_6", + }, + .cimgui = .{ + .url = "git+https://github.com/floooh/dcimgui.git#33c99ef426b68030412b5a4b11487a23da9d4f13", + .hash = "cimgui-0.1.0-44ClkQRJlABdFMKRqIG8KDD6jy1eQbgPO335NziPYjmL", + .lazy = true, + }, + .tracy = .{ + .url = "git+https://github.com/sagehane/zig-tracy.git#80933723efe9bf840fe749b0bfc0d610f1db1669", + .hash = "zig_tracy-0.0.5-aOIqsX1tAACKaRRB-sraMLuNiMASXi_y-4FtRuw4cTpx", + }, + .tiled = .{ + .path = "./libs/tiled", + }, + .stb = .{ + .path = "./libs/stb", + }, + // .sdl = .{ + // .url = "git+https://github.com/allyourcodebase/SDL3.git#f85824b0db782b7d01c60aaad8bcb537892394e8", + // .hash = "sdl-0.0.0-i4QD0UuFqADRQysNyJ1OvCOZnq-clcVhq3BfPcBOf9zr", + // }, + }, + .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..963b76f --- /dev/null +++ b/libs/stb/build.zig @@ -0,0 +1,38 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const stb_dependency = b.dependency("stb", .{}); + + { + const mod_stb_image = b.addModule("stb_image", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/stb_image.zig"), + .link_libc = true, + }); + + mod_stb_image.addIncludePath(stb_dependency.path(".")); + mod_stb_image.addCSourceFile(.{ + .file = b.path("src/stb_image_impl.c"), + .flags = &.{} + }); + } + + { + const mod_stb_image = b.addModule("stb_vorbis", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/stb_vorbis.zig"), + .link_libc = true, + }); + + mod_stb_image.addIncludePath(stb_dependency.path(".")); + mod_stb_image.addCSourceFile(.{ + .file = b.path("src/stb_vorbis_impl.c"), + .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_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/src/assets.zig b/src/assets.zig new file mode 100644 index 0000000..8ea3e31 --- /dev/null +++ b/src/assets.zig @@ -0,0 +1,44 @@ +const std = @import("std"); + +const Math = @import("./engine/math.zig"); +const Engine = @import("./engine/root.zig"); +const Gfx = Engine.Graphics; +const Audio = Engine.Audio; + +const Assets = @This(); + +const FontName = enum { + regular, + bold, + italic, + + const EnumArray = std.EnumArray(FontName, Gfx.Font.Id); +}; + +font_id: FontName.EnumArray, + +wood01: Audio.Data.Id, + +pub fn init(gpa: std.mem.Allocator) !Assets { + _ = gpa; // autofix + const font_id_array: FontName.EnumArray = .init(.{ + .regular = try Gfx.addFont("regular", @embedFile("assets/roboto-font/Roboto-Regular.ttf")), + .bold = try Gfx.addFont("bold", @embedFile("assets/roboto-font/Roboto-Bold.ttf")), + .italic = try Gfx.addFont("italic", @embedFile("assets/roboto-font/Roboto-Italic.ttf")), + }); + + const wood01 = try Audio.load(.{ + .format = .vorbis, + .data = @embedFile("assets/wood01.ogg"), + }); + + return Assets{ + .font_id = font_id_array, + .wood01 = wood01 + }; +} + +pub fn deinit(self: *Assets, gpa: std.mem.Allocator) void { + _ = self; // autofix + _ = gpa; // autofix +} diff --git a/src/assets/icon.png b/src/assets/icon.png new file mode 100644 index 0000000..12eeccc Binary files /dev/null and b/src/assets/icon.png differ diff --git a/src/assets/roboto-font/LICENSE.txt b/src/assets/roboto-font/LICENSE.txt new file mode 100644 index 0000000..9c48e05 --- /dev/null +++ b/src/assets/roboto-font/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/roboto-font/Roboto-Bold.ttf b/src/assets/roboto-font/Roboto-Bold.ttf new file mode 100644 index 0000000..9d7cf22 Binary files /dev/null and b/src/assets/roboto-font/Roboto-Bold.ttf differ diff --git a/src/assets/roboto-font/Roboto-Italic.ttf b/src/assets/roboto-font/Roboto-Italic.ttf new file mode 100644 index 0000000..c3abaef Binary files /dev/null and b/src/assets/roboto-font/Roboto-Italic.ttf differ diff --git a/src/assets/roboto-font/Roboto-Regular.ttf b/src/assets/roboto-font/Roboto-Regular.ttf new file mode 100644 index 0000000..7e3bb2f Binary files /dev/null and b/src/assets/roboto-font/Roboto-Regular.ttf differ diff --git a/src/assets/wood01.ogg b/src/assets/wood01.ogg new file mode 100644 index 0000000..be5a409 Binary files /dev/null and b/src/assets/wood01.ogg differ diff --git a/src/assets/wood02.ogg b/src/assets/wood02.ogg new file mode 100644 index 0000000..7132356 Binary files /dev/null and b/src/assets/wood02.ogg differ diff --git a/src/assets/wood03.ogg b/src/assets/wood03.ogg new file mode 100644 index 0000000..070b762 Binary files /dev/null and b/src/assets/wood03.ogg differ diff --git a/src/engine/audio/data.zig b/src/engine/audio/data.zig new file mode 100644 index 0000000..36c9d67 --- /dev/null +++ b/src/engine/audio/data.zig @@ -0,0 +1,64 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const STBVorbis = @import("stb_vorbis"); + +pub const Data = union(enum) { + raw: struct { + channels: [][*]f32, + sample_count: u32, + sample_rate: u32 + }, + vorbis: struct { + alloc_buffer: []u8, + stb_vorbis: STBVorbis, + }, + + pub fn streamChannel( + self: Data, + buffer: []f32, + cursor: u32, + channel_index: u32, + sample_rate: u32 + ) []f32 { + // var result: std.ArrayList(f32) = .initBuffer(buffer); + switch (self) { + .raw => |opts| { + if (opts.sample_rate == sample_rate) { + assert(channel_index < opts.channels.len); // TODO: + const channel = opts.channels[channel_index]; + + var memcpy_len: usize = 0; + if (cursor + buffer.len <= opts.sample_count) { + memcpy_len = buffer.len; + } else if (cursor < opts.sample_count) { + memcpy_len = opts.sample_count - cursor; + } + + @memcpy(buffer[0..memcpy_len], channel[cursor..][0..memcpy_len]); + return buffer[0..memcpy_len]; + } else { + // const in_sample_rate: f32 = @floatFromInt(opts.sample_rate); + // const out_sample_rate: f32 = @floatFromInt(sample_rate); + // const increment = in_sample_rate / out_sample_rate; + // _ = increment; // autofix + unreachable; + } + }, + .vorbis => |opts| { + _ = opts; // autofix + unreachable; + }, + } + // return result.items; + } + + pub fn getSampleCount(self: Data) u32 { + return switch (self) { + .raw => |opts| opts.sample_count, + .vorbis => |opts| opts.stb_vorbis.getStreamLengthInSamples() + }; + } + + pub const Id = enum (u16) { _ }; +}; diff --git a/src/engine/audio/mixer.zig b/src/engine/audio/mixer.zig new file mode 100644 index 0000000..31b0f89 --- /dev/null +++ b/src/engine/audio/mixer.zig @@ -0,0 +1,131 @@ +const std = @import("std"); +const log = std.log.scoped(.audio); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const sokol = @import("sokol"); +const saudio = sokol.audio; + +const AudioData = @import("./data.zig").Data; +pub const Store = @import("./store.zig"); + +const Mixer = @This(); + +pub const Instance = struct { + data_id: AudioData.Id, + cursor: u32 = 0, +}; + +pub const Command = union(enum) { + play: struct { + data_id: AudioData.Id, + }, + + pub const RingBuffer = struct { + // TODO: This ring buffer will work in a single producer single consumer configuration + // For my game this will be good enough + + items: []Command, + head: std.atomic.Value(usize) = .init(0), + tail: std.atomic.Value(usize) = .init(0), + + pub fn push(self: *RingBuffer, command: Command) error{OutOfMemory}!void { + const head = self.head.load(.monotonic); + const tail = self.tail.load(.monotonic); + const next_head = @mod(head + 1, self.items.len); + + // A single slot in the .items array will always not be used. + if (next_head == tail) { + return error.OutOfMemory; + } + + self.items[head] = command; + self.head.store(next_head, .monotonic); + } + + pub fn pop(self: *RingBuffer) ?Command { + const head = self.head.load(.monotonic); + const tail = self.tail.load(.monotonic); + + if (head == tail) { + return null; + } + + const result = self.items[tail]; + self.tail.store(@mod(tail + 1, self.items.len), .monotonic); + return result; + } + }; +}; + +// TODO: Tracks + +instances: std.ArrayList(Instance), +commands: Command.RingBuffer, + +pub fn init( + gpa: Allocator, + max_instances: u32, + max_commands: u32 +) !Mixer { + var instances = try std.ArrayList(Instance).initCapacity(gpa, max_instances); + errdefer instances.deinit(gpa); + + const commands = try gpa.alloc(Command, max_commands); + errdefer gpa.free(commands); + + return Mixer{ + .instances = instances, + .commands = .{ + .items = commands + } + }; +} + +pub fn deinit(self: *Mixer, gpa: Allocator) void { + self.instances.deinit(gpa); + gpa.free(self.commands.items); +} + +pub fn queue(self: *Mixer, command: Command) void { + self.commands.push(command) catch log.warn("Maximum number of audio commands reached!", .{}); +} + +pub fn stream(self: *Mixer, store: Store, buffer: []f32, num_frames: u32, num_channels: u32) !void { + while (self.commands.pop()) |command| { + switch (command) { + .play => |opts| { + self.instances.appendBounded(.{ + .data_id = opts.data_id + }) catch log.warn("Maximum number of audio instances reached!", .{}); + } + } + } + + assert(num_channels == 1); // TODO: + const sample_rate: u32 = @intCast(saudio.sampleRate()); + + @memset(buffer, 0); + + // var written: u32 = 0; + for (self.instances.items) |*instance| { + const audio_data = store.get(instance.data_id); + const samples = audio_data.streamChannel(buffer[0..num_frames], instance.cursor, 0, sample_rate); + instance.cursor += @intCast(samples.len); + } + + { + var i: usize = 0; + while (i < self.instances.items.len) { + const instance = self.instances.items[i]; + const audio_data = store.get(instance.data_id); + const is_complete = instance.cursor == audio_data.getSampleCount(); + + if (is_complete) { + _ = self.instances.swapRemove(i); + } else { + i += 1; + } + } + } +} diff --git a/src/engine/audio/root.zig b/src/engine/audio/root.zig new file mode 100644 index 0000000..c384a4d --- /dev/null +++ b/src/engine/audio/root.zig @@ -0,0 +1,100 @@ +const std = @import("std"); +const log = std.log.scoped(.audio); +const assert = std.debug.assert; + +const tracy = @import("tracy"); + +const Math = @import("../math.zig"); +const STBVorbis = @import("stb_vorbis"); + +pub const Data = @import("./data.zig").Data; +pub const Store = @import("./store.zig"); +pub const Mixer = @import("./mixer.zig"); + +const sokol = @import("sokol"); +const saudio = sokol.audio; + +var stopped: bool = true; +var gpa: std.mem.Allocator = undefined; +var store: Store = undefined; +pub var mixer: Mixer = undefined; + +const Options = struct { + allocator: std.mem.Allocator, + logger: saudio.Logger = .{}, + channels: u32 = 1, + max_vorbis_alloc_buffer_size: u32 = 1 * Math.bytes_per_mib, + buffer_frames: u32 = 2048, + max_instances: u32 = 64 +}; + +pub fn init(opts: Options) !void { + gpa = opts.allocator; + + store = try Store.init(.{ + .allocator = opts.allocator, + .max_vorbis_alloc_buffer_size = opts.max_vorbis_alloc_buffer_size, + }); + + mixer = try Mixer.init(gpa, opts.max_instances, opts.max_instances); + + saudio.setup(.{ + .logger = opts.logger, + .stream_cb = sokolStreamCallback, + .num_channels = @intCast(opts.channels), + .buffer_frames = @intCast(opts.buffer_frames) + }); + stopped = false; + + const sample_rate: f32 = @floatFromInt(saudio.sampleRate()); + const audio_latency: f32 = @as(f32, @floatFromInt(opts.buffer_frames)) / sample_rate; + log.debug("Audio latency = {D}", .{@as(u64, @intFromFloat(audio_latency * std.time.ns_per_s))}); +} + +pub fn deinit() void { + stopped = true; + saudio.shutdown(); + mixer.deinit(gpa); + store.deinit(); +} + +pub fn load(opts: Store.LoadOptions) !Data.Id { + return try store.load(opts); +} + +pub const PlayOptions = struct { + id: Data.Id, + delay: f32 = 0 +}; + +pub fn play(opts: PlayOptions) void { + mixer.queue(.{ + .play = .{ + .data_id = opts.id + } + }); +} + +fn sokolStreamCallback(buffer: [*c]f32, num_frames: i32, num_channels: i32) callconv(.c) void { + if (stopped) { + return; + } + + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + const num_frames_u32: u32 = @intCast(num_frames); + const num_channels_u32: u32 = @intCast(num_channels); + + mixer.stream( + store, + buffer[0..(num_frames_u32 * num_channels_u32)], + num_frames_u32, + num_channels_u32 + ) catch |e| { + log.err("mixer.stream() failed: {}", .{e}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + }; +} diff --git a/src/engine/audio/store.zig b/src/engine/audio/store.zig new file mode 100644 index 0000000..10e3b13 --- /dev/null +++ b/src/engine/audio/store.zig @@ -0,0 +1,106 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const Math = @import("../math.zig"); +const STBVorbis = @import("stb_vorbis"); + +const AudioData = @import("./data.zig").Data; + +const Store = @This(); + +arena: std.heap.ArenaAllocator, +list: std.ArrayList(AudioData), +temp_vorbis_alloc_buffer: []u8, + +const Options = struct { + allocator: std.mem.Allocator, + max_vorbis_alloc_buffer_size: u32, +}; + +pub fn init(opts: Options) !Store { + const gpa = opts.allocator; + + const temp_vorbis_alloc_buffer = try gpa.alloc(u8, opts.max_vorbis_alloc_buffer_size); + errdefer gpa.free(temp_vorbis_alloc_buffer); + + return Store{ + .arena = std.heap.ArenaAllocator.init(gpa), + .list = .empty, + .temp_vorbis_alloc_buffer = temp_vorbis_alloc_buffer + }; +} + +pub fn deinit(self: *Store) void { + const gpa = self.arena.child_allocator; + gpa.free(self.temp_vorbis_alloc_buffer); + self.list.deinit(gpa); + self.arena.deinit(); +} + +pub const LoadOptions = struct { + const PlaybackStyle = enum { + stream, + decode_once, + + // If the decoded size is less than `stream_threshold`, then .decode_once will by default be used. + const stream_threshold = 10 * Math.bytes_per_mib; + }; + + const Format = enum { + vorbis + }; + + format: Format, + data: []const u8, + playback_style: ?PlaybackStyle = null, +}; + +pub fn load(self: *Store, opts: LoadOptions) !AudioData.Id { + const gpa = self.arena.child_allocator; + + const id = self.list.items.len; + try self.list.ensureUnusedCapacity(gpa, 1); + + const PlaybackStyle = LoadOptions.PlaybackStyle; + const temp_stb_vorbis = try STBVorbis.init(opts.data, self.temp_vorbis_alloc_buffer); + const info = temp_stb_vorbis.getInfo(); + const duration_in_samples = temp_stb_vorbis.getStreamLengthInSamples(); + const decoded_size = info.channels * duration_in_samples * @sizeOf(f32); + const stream_threshold = PlaybackStyle.stream_threshold; + const default_playback_style: PlaybackStyle = if (decoded_size < stream_threshold) .decode_once else .stream; + + const arena_allocator = self.arena.allocator(); + const playback_style = opts.playback_style orelse default_playback_style; + if (playback_style == .decode_once) { + const channels = try arena_allocator.alloc([*]f32, info.channels); + for (channels) |*channel| { + channel.* = (try arena_allocator.alloc(f32, duration_in_samples)).ptr; + } + const samples_decoded = temp_stb_vorbis.getSamples(channels, duration_in_samples); + assert(samples_decoded == duration_in_samples); + + self.list.appendAssumeCapacity(AudioData{ + .raw = .{ + .channels = channels, + .sample_count = duration_in_samples, + .sample_rate = info.sample_rate + } + }); + } else { + const alloc_buffer = try arena_allocator.alloc(u8, temp_stb_vorbis.getMinimumAllocBufferSize()); + const stb_vorbis = STBVorbis.init(opts.data, alloc_buffer) catch unreachable; + + self.list.appendAssumeCapacity(AudioData{ + .vorbis = .{ + .alloc_buffer = alloc_buffer, + .stb_vorbis = stb_vorbis + } + }); + } + + return @enumFromInt(id); +} + +pub fn get(self: Store, id: AudioData.Id) AudioData { + return self.list.items[@intFromEnum(id)]; +} diff --git a/src/engine/fontstash/context.zig b/src/engine/fontstash/context.zig new file mode 100644 index 0000000..d360b31 --- /dev/null +++ b/src/engine/fontstash/context.zig @@ -0,0 +1,250 @@ +const std = @import("std"); + +const Font = @import("./font.zig"); +const Align = Font.Align; + +const Context = @This(); + +const c = @cImport({ + @cInclude("fontstash.h"); + + @cInclude("sokol/sokol_gfx.h"); + @cInclude("sokol/sokol_gl.h"); + @cInclude("sokol_fontstash.h"); +}); + +pub const FONScontext = c.FONScontext; + +pub const Size = struct { + width: u32, + height: u32, +}; + +pub const TextBounds = struct { + advance: f32, + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, +}; + +pub const LineBounds = struct { + min_y: f32, + max_y: f32, +}; + +pub const VertMetrics = struct { + ascender: f32, + descender: f32, + lineh: f32 +}; + +pub const Quad = struct { + x0: f32, + y0: f32, + s0: f32, + t0: f32, + + x1: f32, + y1: f32, + s1: f32, + t1: f32, +}; + +pub const TextIterator = struct { + context: Context, + iter: c.FONStextIter, + + pub fn init(ctx: Context, x: f32, y: f32, text: []const u8) TextIterator { + var self = TextIterator{ + .context = ctx, + .iter = undefined + }; + + const success = c.fonsTextIterInit( + self.context.ctx, + &self.iter, + x, + y, + text.ptr, + text.ptr + text.len + ); + if (success != 1) { + return error.fonsTextIterInit; + } + + return self; + } + + pub fn next(self: TextIterator) ?Quad { + var quad: c.FONSquad = undefined; + const success = c.fonsTextIterNext(self.context, &self.iter, &quad); + if (success != 1) { + return null; + } + + return Quad{ + .x0 = quad.x0, + .y0 = quad.y0, + .s0 = quad.s0, + .t0 = quad.t0, + + .x1 = quad.x0, + .y1 = quad.y0, + .s1 = quad.s0, + .t1 = quad.t0, + }; + } +}; + +ctx: *FONScontext, + +pub fn init(desc: c.sfons_desc_t) !Context { + const ctx = c.sfons_create(&desc); + if (ctx == null) { + return error.sfons_create; + } + + return Context{ + .ctx = ctx.? + }; +} + +pub fn deinit(self: Context) void { + c.sfons_destroy(self.ctx); +} + +pub fn flush(self: Context) void { + c.sfons_flush(self.ctx); +} + +pub fn addFont(self: Context, name: [*c]const u8, data: []const u8) !Font.Id { + const font_id = c.fonsAddFontMem( + self.ctx, + name, + @constCast(data.ptr), + @intCast(data.len), + 0 + ); + if (font_id == c.FONS_INVALID) { + return error.fonsAddFontMem; + } + return @enumFromInt(font_id); +} + +pub fn addFallbackFont(self: Context, base: Font.Id, fallback: Font.Id) void { + const success = c.fonsAddFallbackFont(self.ctx, @intFromEnum(base), @intFromEnum(fallback)); + if (success != 1) { + return error.fonsAddFallbackFont; + } +} + +pub fn getFontByName(self: Context, name: [*c]const u8) ?Font.Id { + const font_id = c.fonsGetFontByName(self.ctx, name); + if (font_id == c.FONS_INVALID) { + return null; + } + return @enumFromInt(font_id); +} + +// TODO: fonsSetErrorCallback + +pub fn getAtlasSize(self: Context) Size { + var result: Size = .{ + .width = 0, + .height = 0 + }; + c.fonsGetAtlasSize(self.ctx, &result.width, &result.height); + return result; +} + +pub fn expandAtlas(self: Context, width: u32, height: u32) !void { + const success = c.fonsExpandAtlas(self.ctx, @bitCast(width), @bitCast(height)); + if (success != 1) { + return error.fonsExpandAtlas; + } +} + +pub fn resetAtlas(self: Context) !void { + const success = c.fonsResetAtlas(self.ctx); + if (success != 1) { + return error.fonsResetAtlas; + } +} + +pub fn pushState(self: Context) void { + c.fonsPushState(self.ctx); +} + +pub fn popState(self: Context) void { + c.fonsPopState(self.ctx); +} + +pub fn clearState(self: Context) void { + c.fonsClearState(self.ctx); +} + +pub fn setSize(self: Context, size: f32) void { + c.fonsSetSize(self.ctx, size); +} + +pub fn setColor(self: Context, color: u32) void { + c.fonsSetColor(self.ctx, color); +} + +pub fn setSpacing(self: Context, spacing: f32) void { + c.fonsSetSpacing(self.ctx, spacing); +} + +pub fn setBlur(self: Context, blur: f32) void { + c.fonsSetSpacing(self.ctx, blur); +} + +pub fn setAlign(self: Context, alignment: Align) void { + c.fonsSetAlign(self.ctx, @intFromEnum(alignment.x) | @intFromEnum(alignment.y)); +} + +pub fn setFont(self: Context, id: Font.Id) void { + c.fonsSetFont(self.ctx, @intFromEnum(id)); +} + +pub fn drawText(self: Context, x: f32, y: f32, text: []const u8) void { + const advance = c.fonsDrawText(self.ctx, x, y, text.ptr, text.ptr + text.len); + _ = advance; +} + +pub fn textBounds(self: Context, x: f32, y: f32, text: []const u8) TextBounds { + var bounds: f32[4] = undefined; + const advance = c.fonsTextBounds(self.ctx, x, y, text.ptr, text.ptr + text.len, &bounds); + + return TextBounds{ + .advance = advance, + .min_x = bounds[0], + .max_x = bounds[1], + .min_y = bounds[2], + .max_y = bounds[3] + }; +} + +pub fn lineBounds(self: Context, y: f32) LineBounds { + var result: LineBounds = .{ + .max_y = 0, + .min_y = 0 + }; + c.fonsLineBounds(self.ctx, y, &result.min_y, &result.max_y); + return result; +} + +pub fn vertMetrics(self: Context) void { + var result: VertMetrics = .{ + .ascender = 0, + .descender = 0, + .lineh = 0 + }; + c.fonsVertMetrics(self.ctx, &result.ascender, &result.descender, &result.lineh); + return result; +} + +pub fn drawDebug(self: Context, x: f32, y: f32) void { + c.fonsDrawDebug(self.ctx, x, y); +} diff --git a/src/engine/fontstash/font.zig b/src/engine/fontstash/font.zig new file mode 100644 index 0000000..3b2032c --- /dev/null +++ b/src/engine/fontstash/font.zig @@ -0,0 +1,33 @@ +const std = @import("std"); + +const c = @cImport({ + @cInclude("fontstash.h"); +}); + +const Font = @This(); + +pub const Id = enum(c_int) { + _, + + pub const invalid: Id = @enumFromInt(c.FONS_INVALID); +}; + +pub const AlignX = enum(c_int) { + left = c.FONS_ALIGN_LEFT, + right = c.FONS_ALIGN_RIGHT, + center = c.FONS_ALIGN_CENTER, + _, +}; + +pub const AlignY = enum(c_int) { + top = c.FONS_ALIGN_TOP, + middle = c.FONS_ALIGN_MIDDLE, + bottom = c.FONS_ALIGN_BOTTOM, + baseline = c.FONS_ALIGN_BASELINE, + _, +}; + +pub const Align = struct { + x: AlignX, + y: AlignY, +}; diff --git a/src/engine/fontstash/root.zig b/src/engine/fontstash/root.zig new file mode 100644 index 0000000..2620597 --- /dev/null +++ b/src/engine/fontstash/root.zig @@ -0,0 +1,3 @@ + +pub const Font = @import("./font.zig"); +pub const Context = @import("./context.zig"); diff --git a/src/engine/fontstash/sokol_fontstash_impl.c b/src/engine/fontstash/sokol_fontstash_impl.c new file mode 100644 index 0000000..26aeca8 --- /dev/null +++ b/src/engine/fontstash/sokol_fontstash_impl.c @@ -0,0 +1,12 @@ +#include +#include +#ifdef _WIN32 +#include +#endif +#define FONTSTASH_IMPLEMENTATION +#include "fontstash.h" + +#include "sokol/sokol_gfx.h" +#include "sokol/sokol_gl.h" +#define SOKOL_FONTSTASH_IMPL +#include "sokol_fontstash.h" diff --git a/src/engine/graphics.zig b/src/engine/graphics.zig new file mode 100644 index 0000000..d996475 --- /dev/null +++ b/src/engine/graphics.zig @@ -0,0 +1,310 @@ +const sokol = @import("sokol"); +const sg = sokol.gfx; +const sglue = sokol.glue; +const slog = sokol.log; +const sapp = sokol.app; +const simgui = sokol.imgui; +const sgl = sokol.gl; + +const Math = @import("./math.zig"); +const Vec2 = Math.Vec2; +const Vec4 = Math.Vec4; +const rgb = Math.rgb; +const Rect = Math.Rect; + +const std = @import("std"); +const log = std.log.scoped(.graphics); +const assert = std.debug.assert; + +const imgui = @import("imgui.zig"); +const tracy = @import("tracy"); +const fontstash = @import("./fontstash/root.zig"); +pub const Font = fontstash.Font; + +// TODO: Seems that there is a vertical jitter bug when resizing a window in OpenGL. Seems like a driver bug. +// From other peoples research it seems that disabling vsync when a resize event occurs fixes it. +// Maybe a patch for sokol could be made? +// More info: +// * https://github.com/libsdl-org/SDL/issues/11618 +// * https://github.com/nimgl/nimgl/issues/59 + +const Options = struct { + const ImguiFont = struct { + ttf_data: []const u8, + size: f32 = 16 + }; + + allocator: std.mem.Allocator, + logger: sg.Logger = .{}, + imgui_font: ?ImguiFont = null +}; + +const DrawFrame = struct { + screen_size: Vec2 = Vec2.zero, + bg_color: Vec4 = rgb(0, 0, 0), + + scissor_stack_buffer: [32]Rect = undefined, + scissor_stack: std.ArrayListUnmanaged(Rect) = .empty, + + fn init(self: *DrawFrame) void { + self.* = DrawFrame{ + .scissor_stack = .initBuffer(&self.scissor_stack_buffer) + }; + } +}; + +var draw_frame: DrawFrame = undefined; +var main_pipeline: sgl.Pipeline = .{}; +var linear_sampler: sg.Sampler = .{}; +var nearest_sampler: sg.Sampler = .{}; +var font_context: fontstash.Context = undefined; +pub var font_resolution_scale: f32 = 1; + +pub fn init(options: Options) !void { + draw_frame.init(); + + sg.setup(.{ + .logger = options.logger, + .environment = sglue.environment(), + }); + + sgl.setup(.{ + .logger = .{ + .func = options.logger.func, + .user_data = options.logger.user_data + } + }); + + main_pipeline = sgl.makePipeline(.{ + .colors = init: { + var colors: [8]sg.ColorTargetState = @splat(.{}); + colors[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 colors; + }, + }); + + imgui.setup(options.allocator, .{ + .logger = .{ + .func = options.logger.func, + .user_data = options.logger.user_data + }, + .no_default_font = options.imgui_font != null, + + // TODO: Figure out a way to make imgui play nicely with UI + // Ideally when mouse is inside a Imgui window, then the imgui cursor should be used. + // Otherwise our own cursor should be used. + .disable_set_mouse_cursor = true + }); + + if (options.imgui_font) |imgui_font| { + imgui.addFont(imgui_font.ttf_data, imgui_font.size); + } + + linear_sampler = sg.makeSampler(.{ + .min_filter = .LINEAR, + .mag_filter = .LINEAR, + .mipmap_filter = .LINEAR, + .label = "linear-sampler", + }); + + nearest_sampler = sg.makeSampler(.{ + .min_filter = .NEAREST, + .mag_filter = .NEAREST, + .mipmap_filter = .NEAREST, + .label = "nearest-sampler", + }); + + const dpi_scale = sapp.dpiScale(); + const atlas_size = 512; + const atlas_dim = std.math.ceilPowerOfTwoAssert(u32, @intFromFloat(atlas_size * dpi_scale)); + font_context = try fontstash.Context.init(.{ + .width = @intCast(atlas_dim), + .height = @intCast(atlas_dim), + }); +} + +pub fn deinit() void { + imgui.shutdown(); + font_context.deinit(); + sgl.shutdown(); + sg.shutdown(); +} + +pub fn beginFrame() void { + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + draw_frame.init(); + draw_frame.screen_size = Vec2.init(sapp.widthf(), sapp.heightf()); + draw_frame.scissor_stack.appendAssumeCapacity(Rect.init(0, 0, sapp.widthf(), sapp.heightf())); + + imgui.newFrame(.{ + .width = @intFromFloat(draw_frame.screen_size.x), + .height = @intFromFloat(draw_frame.screen_size.y), + .delta_time = sapp.frameDuration(), + .dpi_scale = sapp.dpiScale() + }); + + font_context.clearState(); + sgl.defaults(); + sgl.matrixModeProjection(); + sgl.ortho(0, draw_frame.screen_size.x, draw_frame.screen_size.y, 0, -1, 1); + sgl.loadPipeline(main_pipeline); +} + +pub fn endFrame() void { + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + assert(draw_frame.scissor_stack.items.len == 1); + + var pass_action: sg.PassAction = .{}; + + pass_action.colors[0] = sg.ColorAttachmentAction{ + .load_action = .CLEAR, + .clear_value = .{ + .r = draw_frame.bg_color.x, + .g = draw_frame.bg_color.y, + .b = draw_frame.bg_color.z, + .a = draw_frame.bg_color.w + } + }; + + font_context.flush(); + + { + sg.beginPass(.{ + .action = pass_action, + .swapchain = sglue.swapchain() + }); + defer sg.endPass(); + + sgl.draw(); + + imgui.render(); + } + sg.commit(); +} + +pub fn pushTransform(translation: Vec2, scale: f32) void { + sgl.pushMatrix(); + sgl.translate(translation.x, translation.y, 0); + sgl.scale(scale, scale, 1); +} + +pub fn popTransform() void { + sgl.popMatrix(); +} + +pub fn drawQuad(quad: [4]Vec2, color: Vec4) void { + sgl.beginQuads(); + defer sgl.end(); + + sgl.c4f(color.x, color.y, color.z, color.w); + for (quad) |position| { + sgl.v2f(position.x, position.y); + } +} + +pub fn drawRectangle(pos: Vec2, size: Vec2, color: Vec4) void { + drawQuad( + .{ + // Top left + pos, + // Top right + pos.add(.{ .x = size.x, .y = 0 }), + // Bottom right + pos.add(size), + // Bottom left + pos.add(.{ .x = 0, .y = size.y }), + }, + color + ); +} + +pub fn drawTriangle(tri: [3]Vec2, color: Vec4) void { + sgl.beginTriangles(); + defer sgl.end(); + + sgl.c4f(color.x, color.y, color.z, color.w); + for (tri) |position| { + sgl.v2f(position.x, position.y); + } +} + +pub fn drawLine(from: Vec2, to: Vec2, color: Vec4, width: f32) void { + 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()); + + drawQuad( + .{ top_right, top_left, bottom_left, bottom_right }, + color + ); +} + +pub fn drawRectanglOutline(pos: Vec2, size: Vec2, color: Vec4, width: f32) void { + // TODO: Don't use line segments + drawLine(pos, pos.add(.{ .x = size.x, .y = 0 }), color, width); + drawLine(pos, pos.add(.{ .x = 0, .y = size.y }), color, width); + drawLine(pos.add(.{ .x = 0, .y = size.y }), pos.add(size), color, width); + drawLine(pos.add(.{ .x = size.x, .y = 0 }), pos.add(size), color, width); +} + +pub fn pushScissor(rect: Rect) void { + draw_frame.scissor_stack.appendAssumeCapacity(rect); + + sgl.scissorRectf(rect.pos.x, rect.pos.y, rect.size.x, rect.size.y, true); +} + +pub fn popScissor() void { + _ = draw_frame.scissor_stack.pop().?; + const rect = draw_frame.scissor_stack.getLast(); + + sgl.scissorRectf(rect.pos.x, rect.pos.y, rect.size.x, rect.size.y, true); +} + +pub fn addFont(name: [*c]const u8, data: []const u8) !Font.Id { + return try font_context.addFont(name, data); +} + +pub const DrawTextOptions = struct { + font: Font.Id, + size: f32 = 16, + color: Vec4 = rgb(255, 255, 255), +}; + +pub fn drawText(position: Vec2, text: []const u8, opts: DrawTextOptions) void { + pushTransform(.{ .x = 0, .y = 0}, 1/font_resolution_scale); + defer popTransform(); + + font_context.setFont(opts.font); + font_context.setSize(opts.size * font_resolution_scale); + font_context.setAlign(.{ .x = .left, .y = .top }); + font_context.setSpacing(0); + + const r: u8 = @intFromFloat(opts.color.x * 255); + const g: u8 = @intFromFloat(opts.color.y * 255); + const b: u8 = @intFromFloat(opts.color.z * 255); + const a: u8 = @intFromFloat(opts.color.w * 255); + const color: u32 = r | (@as(u32, g) << 8) | (@as(u32, b) << 16) | (@as(u32, a) << 24); + font_context.setColor(color); + font_context.drawText( + position.x * font_resolution_scale, + position.y * font_resolution_scale, + text + ); +} diff --git a/src/engine/imgui.zig b/src/engine/imgui.zig new file mode 100644 index 0000000..e92f419 --- /dev/null +++ b/src/engine/imgui.zig @@ -0,0 +1,504 @@ +const std = @import("std"); +const Math = @import("./math.zig"); +const build_options = @import("build_options"); +pub const ig = @import("cimgui"); +const Vec2 = Math.Vec2; +const Vec3 = Math.Vec3; +const Vec4 = Math.Vec4; + +const sokol = @import("sokol"); +const sapp = sokol.app; +const simgui = sokol.imgui; + +const enabled = build_options.has_imgui; + +var global_allocator: ?std.mem.Allocator = null; + +pub const WindowOptions = struct { + name: [*c]const u8, + pos: ?Vec2 = null, + size: ?Vec2 = null, + collapsed: ?bool = null, + open: ?*bool = null +}; + +pub const SliderOptions = struct { + label: [*c]const u8, + value: *f32, + min: f32, + max: f32, +}; + +fn toImVec2(vec2: Vec2) ig.ImVec2 { + return ig.ImVec2{ + .x = vec2.x, + .y = vec2.y, + }; +} + +inline fn structCast(T: type, value: anytype) T { + return @as(*T, @ptrFromInt(@intFromPtr(&value))).*; +} + +pub fn setup(gpa: std.mem.Allocator, desc: simgui.Desc) void { + if (!enabled) { + return; + } + + global_allocator = gpa; + simgui.setup(desc); +} + +pub fn addFont(ttf_data: []const u8, font_size: f32) void { + if (!enabled) { + return; + } + + var font_config: ig.ImFontConfig = .{}; + font_config.FontDataOwnedByAtlas = false; + font_config.OversampleH = 2; + font_config.OversampleV = 2; + font_config.GlyphMaxAdvanceX = std.math.floatMax(f32); + font_config.RasterizerMultiply = 1.0; + font_config.RasterizerDensity = 1.0; + font_config.EllipsisChar = 0; + + const io = ig.igGetIO(); + _ = ig.ImFontAtlas_AddFontFromMemoryTTF( + io.*.Fonts, + @constCast(@ptrCast(ttf_data.ptr)), + @intCast(ttf_data.len), + font_size, + &font_config, + null + ); +} + +pub fn shutdown() void { + if (!enabled) { + return; + } + + simgui.shutdown(); +} + +pub fn handleEvent(ev: sapp.Event) bool { + if (enabled) { + return simgui.handleEvent(ev); + } else { + return false; + } +} + +pub fn newFrame(desc: simgui.FrameDesc) void { + if (!enabled) { + return; + } + + simgui.newFrame(desc); +} + +pub fn render() void { + if (!enabled) { + return; + } + + simgui.render(); +} + +pub fn beginWindow(opts: WindowOptions) bool { + if (!enabled) { + 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); + + var open = ig.igBegin(opts.name, 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 (!enabled) { + return; + } + + ig.igEnd(); +} + +pub fn textFmt(comptime fmt: []const u8, args: anytype) void { + if (!enabled) { + return; + } + + const gpa = global_allocator orelse return; + + const formatted = std.fmt.allocPrintSentinel(gpa, fmt, args, 0) catch return; + defer gpa.free(formatted); + + text(formatted); +} + +pub fn text(text_z: [*c]const u8) void { + if (!enabled) { + return; + } + + ig.igText("%s", text_z); +} + +pub fn beginDisabled(disabled: bool) void { + if (!enabled) { + return; + } + + ig.igBeginDisabled(disabled); +} + +pub fn endDisabled() void { + if (!enabled) { + return; + } + + ig.igEndDisabled(); +} + +pub fn button(label: [*c]const u8) bool { + if (!enabled) { + return false; + } + + return ig.igButton(label); +} + +pub fn slider(opts: SliderOptions) bool { + if (!enabled) { + return false; + } + + return ig.igSliderFloat(opts.label, opts.value, opts.min, opts.max); +} + +pub fn checkbox(label: [*c]const u8, value: *bool) bool { + if (!enabled) { + return false; + } + + return ig.igCheckbox(label, value); +} + +pub fn beginTabBar(id: [*c]const u8) bool { + if (!enabled) { + return false; + } + + return ig.igBeginTabBar(id, ig.ImGuiTabBarFlags_None); +} + +pub fn endTabBar() void { + if (!enabled) { + return; + } + + ig.igEndTabBar(); +} + +pub fn beginTabItem(label: [*c]const u8) bool { + if (!enabled) { + return false; + } + + return ig.igBeginTabItem(label, null, ig.ImGuiTabItemFlags_None); +} + +pub fn endTabItem() void { + if (!enabled) { + return; + } + + return ig.igEndTabItem(); +} + +pub fn beginGroup() void { + if (!enabled) { + return; + } + + ig.igBeginGroup(); +} + +pub fn endGroup() void { + if (!enabled) { + return; + } + + ig.igEndGroup(); +} + +pub fn sameLine() void { + if (!enabled) { + return; + } + + ig.igSameLine(); +} + +pub fn beginTable(id: [*c]const u8, columns: u32, flags: ig.ImGuiTableFlags) bool { + if (!enabled) { + return false; + } + + return ig.igBeginTable(id, @intCast(columns), flags); +} + +pub fn endTable() void { + if (!enabled) { + return; + } + + ig.igEndTable(); +} + +pub fn tableNextColumn() void { + if (!enabled) { + return; + } + + _ = ig.igTableNextColumn(); +} + +pub fn tableNextRow() void { + if (!enabled) { + return; + } + + _ = ig.igTableNextRow(); +} + +pub fn tableSetColumnIndex(index: usize) void { + if (!enabled) { + return; + } + + _ = ig.igTableSetColumnIndex(@intCast(index)); +} + +pub fn tableSetupColumn(label: [*c]const u8, flags: ig.ImGuiTableColumnFlags) void { + if (!enabled) { + return; + } + + ig.igTableSetupColumn(label, flags); +} + +pub fn tableHeadersRow() void { + if (!enabled) { + return; + } + + ig.igTableHeadersRow(); +} + +pub const ID = union(enum) { + string: []const u8, + int: i32 +}; + +pub fn pushID(id: ID) void { + if (!enabled) { + return; + } + + switch (id) { + .string => |str| ig.igPushIDStr(str.ptr, str.ptr + str.len), + .int => |int| ig.igPushIDInt(int) + } +} + +pub fn popID() void { + if (!enabled) { + return; + } + + ig.igPopID(); +} + +pub const TreeNodeFlags = packed struct { + selected: bool = false, + framed: bool = false, + allow_overlap: bool = false, + no_tree_pushOnOpen: bool = false, + no_auto_open_on_log: bool = false, + default_open: bool = false, + open_on_double_click: bool = false, + open_on_arrow: bool = false, + leaf: bool = false, + bullet: bool = false, + frame_padding: bool = false, + span_avail_width: bool = false, + span_full_width: bool = false, + span_label_width: bool = false, + span_all_columns: bool = false, + label_span_all_columns: bool = false, + nav_left_jumps_back_here: bool = false, + collapsing_header: bool = false, + + fn toInt(self: TreeNodeFlags) u32 { + // TODO: Try using comptime to reduce this duplication. + // Would be great if `toInt()` could be replaced with just a @bitCast + // + // If the underlying C enum is exhaustive, maybe a bitcast could be performed? + // If the order of enums is correct + const flags = .{ + .{ self.selected, ig.ImGuiTreeNodeFlags_Selected }, + .{ self.framed, ig.ImGuiTreeNodeFlags_Framed }, + .{ self.allow_overlap, ig.ImGuiTreeNodeFlags_AllowOverlap }, + .{ self.no_tree_pushOnOpen, ig.ImGuiTreeNodeFlags_NoTreePushOnOpen }, + .{ self.no_auto_open_on_log, ig.ImGuiTreeNodeFlags_NoAutoOpenOnLog }, + .{ self.default_open, ig.ImGuiTreeNodeFlags_DefaultOpen }, + .{ self.open_on_double_click, ig.ImGuiTreeNodeFlags_OpenOnDoubleClick }, + .{ self.open_on_arrow, ig.ImGuiTreeNodeFlags_OpenOnArrow }, + .{ self.leaf, ig.ImGuiTreeNodeFlags_Leaf }, + .{ self.bullet, ig.ImGuiTreeNodeFlags_Bullet }, + .{ self.frame_padding, ig.ImGuiTreeNodeFlags_FramePadding }, + .{ self.span_avail_width, ig.ImGuiTreeNodeFlags_SpanAvailWidth }, + .{ self.span_full_width, ig.ImGuiTreeNodeFlags_SpanFullWidth }, + .{ self.span_label_width, ig.ImGuiTreeNodeFlags_SpanLabelWidth }, + .{ self.span_all_columns, ig.ImGuiTreeNodeFlags_SpanAllColumns }, + .{ self.label_span_all_columns, ig.ImGuiTreeNodeFlags_LabelSpanAllColumns }, + .{ self.nav_left_jumps_back_here, ig.ImGuiTreeNodeFlags_NavLeftJumpsBackHere }, + .{ self.collapsing_header, ig.ImGuiTreeNodeFlags_CollapsingHeader }, + }; + + var sum: u32 = 0; + inline for (flags) |flag_pair| { + if (flag_pair[0]) { + sum += flag_pair[1]; + } + } + return sum; + } +}; + +pub fn treeNode(label: [*c]const u8, flags: TreeNodeFlags) bool { + if (!enabled) { + return false; + } + + return ig.igTreeNodeEx(label, @intCast(flags.toInt())); +} + +pub fn treePop() void { + if (!enabled) { + return; + } + + ig.igTreePop(); +} + +pub fn isItemClicked() bool { + if (!enabled) { + return false; + } + + return ig.igIsItemClicked(); +} + +pub fn isItemToggledOpen() bool { + if (!enabled) { + return false; + } + + return ig.igIsItemToggledOpen(); +} + +pub fn colorPicker4(label: [*c]const u8, color: *Vec4) bool { + if (!enabled) { + return false; + } + + return ig.igColorPicker4(label, color.asArray().ptr, 0, null); +} + +pub fn colorEdit4(label: [*c]const u8, color: *Vec4) bool { + if (!enabled) { + return false; + } + + return ig.igColorEdit4(label, color.asArray().ptr, 0); +} + +pub fn beginCombo(label: [*c]const u8, preview_value: [*c]const u8) bool { + if (!enabled) { + return false; + } + + return ig.igBeginCombo(label, preview_value, 0); +} + +pub fn endCombo() void { + if (!enabled) { + return; + } + + ig.igEndCombo(); +} + +pub fn selectable(label: [*c]const u8, selected: bool) bool { + if (!enabled) { + return false; + } + + return ig.igSelectableEx(label, selected, 0, .{ }); +} + +pub fn setItemDefaultFocus() void { + if (!enabled) { + return; + } + + ig.igSetItemDefaultFocus(); +} + +pub fn combo(label: [*c]const u8, items: []const [*c]const u8, selected: *usize) void { + if (beginCombo(label, items[selected.*])) { + defer endCombo(); + + for (0.., items) |i, item| { + const is_selected = selected.* == i; + if (selectable(item, is_selected)) { + selected.* = i; + } + + if (is_selected) { + setItemDefaultFocus(); + } + } + } +} + +pub fn separator() void { + if (!enabled) { + return; + } + + ig.igSeparator(); +} diff --git a/src/engine/input.zig b/src/engine/input.zig new file mode 100644 index 0000000..0cf6518 --- /dev/null +++ b/src/engine/input.zig @@ -0,0 +1,239 @@ +const std = @import("std"); +const sokol = @import("sokol"); + +const Engine = @import("./root.zig"); +const Nanoseconds = Engine.Nanoseconds; + +const Math = @import("./math.zig"); +const Vec2 = Math.Vec2; + +const Input = @This(); + +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 KeyState = struct { + down: bool, + pressed: bool, + released: bool, + down_duration: ?f64, + + pub const RepeatOptions = struct { + first_at: f64 = 0, + period: f64 + }; + + pub fn repeat(self: KeyState, last_repeat_at: *?f64, opts: RepeatOptions) bool { + if (!self.down) { + last_repeat_at.* = null; + return false; + } + + const down_duration = self.down_duration.?; + if (last_repeat_at.* != null) { + if (down_duration >= last_repeat_at.*.? + opts.period) { + last_repeat_at.* = last_repeat_at.*.? + opts.period; + return true; + } + } else { + if (down_duration >= opts.first_at) { + last_repeat_at.* = opts.first_at; + return true; + } + } + + return false; + } +}; + +pub const Mouse = struct { + pub const Button = enum { + left, + right, + middle, + + pub fn fromSokol(mouse_button: sokol.app.Mousebutton) ?Button { + return switch(mouse_button) { + .LEFT => Button.left, + .RIGHT => Button.right, + .MIDDLE => Button.middle, + else => null + }; + } + }; + + position: ?Vec2, + buttons: std.EnumSet(Button), + + pub const empty = Mouse{ + .position = null, + .buttons = .initEmpty() + }; +}; + +down_keys: std.EnumSet(KeyCode), +pressed_keys: std.EnumSet(KeyCode), +released_keys: std.EnumSet(KeyCode), +pressed_keys_at: std.EnumMap(KeyCode, Nanoseconds), + +mouse: Mouse, + +pub const empty = Input{ + .down_keys = .initEmpty(), + .pressed_keys = .initEmpty(), + .released_keys = .initEmpty(), + .pressed_keys_at = .init(.{}), + .mouse = .empty +}; + +pub fn isKeyDown(self: *Input, key_code: KeyCode) bool { + return self.down_keys.contains(key_code); +} + +pub fn getKeyDownDuration(self: *Input, frame: Engine.Frame, key_code: KeyCode) ?f64 { + if (!self.isKeyDown(key_code)) { + return null; + } + + const pressed_at_ns = self.pressed_keys_at.get(key_code).?; + const duration_ns = frame.time_ns - pressed_at_ns; + + return @as(f64, @floatFromInt(duration_ns)) / std.time.ns_per_s; +} + +pub fn isKeyPressed(self: *Input, key_code: KeyCode) bool { + return self.pressed_keys.contains(key_code); +} + +pub fn isKeyReleased(self: *Input, key_code: KeyCode) bool { + return self.released_keys.contains(key_code); +} + +pub fn getKeyState(self: *Input, frame: Engine.Frame, key_code: KeyCode) KeyState { + return KeyState{ + .down = self.isKeyDown(key_code), + .released = self.isKeyReleased(key_code), + .pressed = self.isKeyPressed(key_code), + .down_duration = self.getKeyDownDuration(frame, key_code) + }; +} diff --git a/src/engine/math.zig b/src/engine/math.zig new file mode 100644 index 0000000..f620d92 --- /dev/null +++ b/src/engine/math.zig @@ -0,0 +1,413 @@ +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 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 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 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 length(self: Vec2) f32 { + return @sqrt(self.x*self.x + self.y*self.y); + } + + pub fn distance(self: Vec2, other: Vec2) f32 { + return self.sub(other).length(); + } + + pub fn limitLength(self: Vec2, max_length: f32) Vec2 { + const self_length = self.length(); + if (self_length > max_length) { + return Vec2.init(self.x / self_length * max_length, self.y / 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 Vec2.init(self.x / self_length, self.y / 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 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 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); +} + +pub fn rgba(r: u8, g: u8, b: u8, a: f32) Vec4 { + assert(0 <= a and a <= 1); + return Vec4.init( + @as(f32, @floatFromInt(r)) / 255, + @as(f32, @floatFromInt(g)) / 255, + @as(f32, @floatFromInt(b)) / 255, + a, + ); +} + +pub fn rgb(r: u8, g: u8, b: u8) Vec4 { + return rgba(r, g, b, 1); +} + +pub fn rgb_hex(text: []const u8) ?Vec4 { + 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/engine/root.zig b/src/engine/root.zig new file mode 100644 index 0000000..1a7bfe2 --- /dev/null +++ b/src/engine/root.zig @@ -0,0 +1,479 @@ +const std = @import("std"); +const log = std.log.scoped(.engine); +const assert = std.debug.assert; + +const sokol = @import("sokol"); +const sapp = sokol.app; + +pub const Math = @import("./math.zig"); +pub const Vec2 = Math.Vec2; + +pub const Input = @import("./input.zig"); + +const ScreenScalar = @import("./screen_scaler.zig"); +pub const imgui = @import("./imgui.zig"); +pub const Graphics = @import("./graphics.zig"); +pub const Audio = @import("./audio/root.zig"); +const tracy = @import("tracy"); +const builtin = @import("builtin"); +const STBImage = @import("stb_image"); + +const Gfx = Graphics; + +const Game = @import("../game.zig"); +const Assets = @import("../assets.zig"); + +const Engine = @This(); + +pub const Nanoseconds = u64; + +pub const Event = union(enum) { + mouse_pressed: struct { + button: Input.Mouse.Button, + position: Vec2, + }, + mouse_released: struct { + button: Input.Mouse.Button, + position: Vec2, + }, + mouse_move: Vec2, + mouse_enter: Vec2, + mouse_leave, + mouse_scroll: Vec2, + key_pressed: struct { + code: Input.KeyCode, + repeat: bool + }, + key_released: Input.KeyCode, + window_resize, + char: u21, +}; + +pub const Frame = struct { + time_ns: Nanoseconds, + dt_ns: Nanoseconds, + dt: f32, + input: *Input +}; + +allocator: std.mem.Allocator, +started_at: std.time.Instant, +mouse_inside: bool, +last_frame_at: Nanoseconds, +input: Input, + +game: Game, +assets: Assets, + +const RunOptions = struct { + window_title: [*:0]const u8 = "Game", + window_width: u31 = 640, + window_height: u31 = 480, +}; + +pub fn run(self: *Engine, opts: RunOptions) !void { + self.* = Engine{ + .allocator = undefined, + .started_at = std.time.Instant.now() catch @panic("Instant.now() unsupported"), + .input = .empty, + .mouse_inside = false, + .last_frame_at = 0, + .assets = undefined, + .game = undefined + }; + + var debug_allocator: std.heap.DebugAllocator(.{}) = .init; + defer _ = debug_allocator.deinit(); + + // TODO: Use tracy TracingAllocator + if (builtin.cpu.arch.isWasm()) { + self.allocator = std.heap.wasm_allocator; + } else if (builtin.mode == .Debug) { + self.allocator = debug_allocator.allocator(); + } else { + self.allocator = std.heap.smp_allocator; + } + + tracy.setThreadName("Main"); + + if (builtin.os.tag == .linux) { + 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); + } + + // 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(); + + var icon: sapp.IconDesc = .{}; + icon.sokol_default = false; + 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 + } + }; + + sapp.run(.{ + .init_userdata_cb = sokolInitCallback, + .frame_userdata_cb = sokolFrameCallback, + .cleanup_userdata_cb = sokolCleanupCallback, + .event_userdata_cb = sokolEventCallback, + .user_data = self, + .width = opts.window_width, + .height = opts.window_height, + .icon = icon, + .window_title = opts.window_title, + .logger = .{ .func = sokolLogCallback }, + .win32 = .{ + .console_utf8 = true + } + }); +} + +fn sokolInit(self: *Engine) !void { + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + try Gfx.init(.{ + .allocator = self.allocator, + .logger = .{ .func = sokolLogCallback }, + .imgui_font = .{ + .ttf_data = @embedFile("../assets/roboto-font/Roboto-Regular.ttf"), + } + }); + + try Audio.init(.{ + .allocator = self.allocator, + .logger = .{ .func = sokolLogCallback }, + }); + + self.assets = try Assets.init(self.allocator); + self.game = try Game.init(self.allocator, &self.assets); +} + +fn sokolCleanup(self: *Engine) void { + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + Audio.deinit(); + self.game.deinit(); + self.assets.deinit(self.allocator); + Gfx.deinit(); +} + +fn sokolFrame(self: *Engine) !void { + tracy.frameMark(); + + const time_passed = self.timePassed(); + defer self.last_frame_at = time_passed; + const dt_ns = time_passed - self.last_frame_at; + + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + Gfx.beginFrame(); + defer Gfx.endFrame(); + + { + const window_size: Vec2 = .init(sapp.widthf(), sapp.heightf()); + const ctx = ScreenScalar.push(window_size, self.game.canvas_size); + defer ctx.pop(); + + Graphics.font_resolution_scale = ctx.scale; + + try self.game.tick(Frame{ + .time_ns = time_passed, + .dt_ns = dt_ns, + .dt = @as(f32, @floatFromInt(dt_ns)) / std.time.ns_per_s, + .input = &self.input + }); + } + + try self.game.debug(); + + self.input.pressed_keys = .initEmpty(); + self.input.released_keys = .initEmpty(); +} + +fn timePassed(self: *Engine) Nanoseconds { + const now = std.time.Instant.now() catch @panic("Instant.now() unsupported"); + return now.since(self.started_at); +} + +fn event(self: *Engine, e: Event) !void { + const input = &self.input; + + switch (e) { + .key_pressed => |opts| { + if (!opts.repeat) { + input.pressed_keys_at.put(opts.code, self.timePassed()); + input.pressed_keys.insert(opts.code); + input.down_keys.insert(opts.code); + } + }, + .key_released => |key_code| { + input.down_keys.remove(key_code); + input.released_keys.insert(key_code); + input.pressed_keys_at.remove(key_code); + }, + .mouse_leave => { + var iter = input.down_keys.iterator(); + while (iter.next()) |key_code| { + input.released_keys.insert(key_code); + } + input.down_keys = .initEmpty(); + input.pressed_keys_at = .init(.{}); + + input.mouse = .empty; + }, + .mouse_enter => |pos| { + input.mouse.position = pos; + }, + .mouse_move => |pos| { + input.mouse.position = pos; + }, + .mouse_pressed => |opts| { + input.mouse.position = opts.position; + input.mouse.buttons.insert(opts.button); + }, + .mouse_released => |opts| { + input.mouse.position = opts.position; + input.mouse.buttons.remove(opts.button); + }, + else => {} + } +} + +fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool { + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + const e = e_ptr.*; + const MouseButton = Input.Mouse.Button; + + if (imgui.handleEvent(e)) { + if (self.mouse_inside) { + try self.event(Event{ + .mouse_leave = {} + }); + } + self.mouse_inside = false; + return true; + } + + blk: switch (e.type) { + .MOUSE_DOWN => { + const mouse_button = MouseButton.fromSokol(e.mouse_button) orelse break :blk; + + try self.event(Event{ + .mouse_pressed = .{ + .button = mouse_button, + .position = Vec2.init(e.mouse_x, e.mouse_y) + } + }); + + return true; + }, + .MOUSE_UP => { + const mouse_button = MouseButton.fromSokol(e.mouse_button) orelse break :blk; + + try self.event(Event{ + .mouse_released = .{ + .button = mouse_button, + .position = Vec2.init(e.mouse_x, e.mouse_y) + } + }); + + return true; + }, + .MOUSE_MOVE => { + if (!self.mouse_inside) { + try self.event(Event{ + .mouse_enter = Vec2.init(e.mouse_x, e.mouse_y) + }); + } else { + try self.event(Event{ + .mouse_move = Vec2.init(e.mouse_x, e.mouse_y) + }); + } + + self.mouse_inside = true; + return true; + }, + .MOUSE_ENTER => { + if (!self.mouse_inside) { + try self.event(Event{ + .mouse_enter = Vec2.init(e.mouse_x, e.mouse_y) + }); + } + + self.mouse_inside = true; + return true; + }, + .RESIZED => { + if (self.mouse_inside) { + try self.event(Event{ + .mouse_leave = {} + }); + } + + try self.event(Event{ + .window_resize = {} + }); + + self.mouse_inside = false; + return true; + }, + .MOUSE_LEAVE => { + if (self.mouse_inside) { + try self.event(Event{ + .mouse_leave = {} + }); + } + + self.mouse_inside = false; + return true; + }, + .MOUSE_SCROLL => { + try self.event(Event{ + .mouse_scroll = Vec2.init(e.scroll_x, e.scroll_y) + }); + + return true; + }, + .KEY_DOWN => { + try self.event(Event{ + .key_pressed = .{ + .code = @enumFromInt(@intFromEnum(e.key_code)), + .repeat = e.key_repeat + } + }); + + return true; + }, + .KEY_UP => { + try self.event(Event{ + .key_released = @enumFromInt(@intFromEnum(e.key_code)) + }); + + return true; + }, + .CHAR => { + try self.event(Event{ + .char = @intCast(e.char_code) + }); + + return true; + }, + .QUIT_REQUESTED => { + // TODO: handle quit request. Maybe show confirmation window in certain cases. + }, + else => {} + } + + return false; +} + +fn sokolEventCallback(e_ptr: [*c]const sapp.Event, userdata: ?*anyopaque) callconv(.c) void { + const engine: *Engine = @alignCast(@ptrCast(userdata)); + + const consume_event = engine.sokolEvent(e_ptr) catch |e| blk: { + log.err("sokolEvent() failed: {}", .{e}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + break :blk false; + }; + + if (consume_event) { + sapp.consumeEvent(); + } +} + +fn sokolCleanupCallback(userdata: ?*anyopaque) callconv(.c) void { + const engine: *Engine = @alignCast(@ptrCast(userdata)); + + engine.sokolCleanup(); +} + +fn sokolInitCallback(userdata: ?*anyopaque) callconv(.c) void { + const engine: *Engine = @alignCast(@ptrCast(userdata)); + + engine.sokolInit() catch |e| { + log.err("sokolInit() failed: {}", .{e}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + sapp.requestQuit(); + }; +} + +fn sokolFrameCallback(userdata: ?*anyopaque) callconv(.c) void { + const engine: *Engine = @alignCast(@ptrCast(userdata)); + + engine.sokolFrame() catch |e| { + log.err("sokolFrame() failed: {}", .{e}); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + sapp.requestQuit(); + }; +} + +fn sokolLogFmt(log_level: u32, comptime format: []const u8, args: anytype) void { + const log_sokol = std.log.scoped(.sokol); + + if (log_level == 0) { + log_sokol.err(format, args); + } else if (log_level == 1) { + log_sokol.err(format, args); + } else if (log_level == 2) { + log_sokol.warn(format, args); + } else { + log_sokol.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 "") + } + ); + } +} + +fn posixSignalHandler(sig: i32) callconv(.c) void { + _ = sig; + sapp.requestQuit(); +} diff --git a/src/engine/screen_scaler.zig b/src/engine/screen_scaler.zig new file mode 100644 index 0000000..e7860f3 --- /dev/null +++ b/src/engine/screen_scaler.zig @@ -0,0 +1,67 @@ +const Gfx = @import("./graphics.zig"); + +const Math = @import("./math.zig"); +const Vec2 = Math.Vec2; +const rgb = Math.rgb; + +const ScreenScalar = @This(); + +// TODO: Implement a fractional pixel perfect scalar +// Based on this video: https://www.youtube.com/watch?v=d6tp43wZqps +// And this blog: https://colececil.dev/blog/2017/scaling-pixel-art-without-destroying-it/ + +window_size: Vec2, +translation: Vec2, +scale: f32, + +pub fn push(window_size: Vec2, canvas_size: Vec2) ScreenScalar { + // TODO: Render to a lower resolution instead of scaling. + // To avoid pixel bleeding in spritesheet artifacts + const scale = @floor(@min( + window_size.x / canvas_size.x, + window_size.y / canvas_size.y, + )); + + var translation: Vec2 = Vec2.sub(window_size, canvas_size.multiplyScalar(scale)).multiplyScalar(0.5); + translation.x = @round(translation.x); + translation.y = @round(translation.y); + + Gfx.pushTransform(translation, scale); + + return ScreenScalar{ + .window_size = window_size, + .translation = translation, + .scale = scale + }; +} + +pub fn pop(self: ScreenScalar) void { + Gfx.popTransform(); + + const bg_color = rgb(0, 0, 0); + const filler_size = self.translation; + + Gfx.drawRectangle( + .init(0, 0), + .init(self.window_size.x, filler_size.y), + bg_color + ); + + Gfx.drawRectangle( + .init(0, self.window_size.y - filler_size.y), + .init(self.window_size.x, filler_size.y), + bg_color + ); + + Gfx.drawRectangle( + .init(0, 0), + .init(filler_size.x, self.window_size.y), + bg_color + ); + + Gfx.drawRectangle( + .init(self.window_size.x - filler_size.x, 0), + .init(filler_size.x, self.window_size.y), + bg_color + ); +} diff --git a/src/engine/shell.html b/src/engine/shell.html new file mode 100644 index 0000000..5b3aae6 --- /dev/null +++ b/src/engine/shell.html @@ -0,0 +1,53 @@ + + + + + + Sokol + + + + + + {{{ SCRIPT }}} + + diff --git a/src/game.zig b/src/game.zig new file mode 100644 index 0000000..a3a1836 --- /dev/null +++ b/src/game.zig @@ -0,0 +1,91 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +const Assets = @import("./assets.zig"); + +const Engine = @import("./engine/root.zig"); +const imgui = Engine.imgui; +const Vec2 = Engine.Vec2; +const rgb = Engine.Math.rgb; +const Gfx = Engine.Graphics; +const Audio = Engine.Audio; + +const Game = @This(); + +gpa: Allocator, +assets: *Assets, +canvas_size: Vec2, + +player: Vec2, + +pub fn init(gpa: Allocator, assets: *Assets) !Game { + return Game{ + .gpa = gpa, + .assets = assets, + .canvas_size = Vec2.init(100, 100), + .player = .init(50, 50) + }; +} + +pub fn deinit(self: *Game) void { + _ = self; // autofix +} + +pub fn tick(self: *Game, frame: Engine.Frame) !void { + + var dir = Vec2.init(0, 0); + if (frame.input.isKeyDown(.W)) { + dir.y -= 1; + } + if (frame.input.isKeyDown(.S)) { + dir.y += 1; + } + if (frame.input.isKeyDown(.A)) { + dir.x -= 1; + } + if (frame.input.isKeyDown(.D)) { + dir.x += 1; + } + dir = dir.normalized(); + + if (dir.x != 0 or dir.y != 0) { + Audio.play(.{ + .id = self.assets.wood01 + }); + } + + self.player = self.player.add(dir.multiplyScalar(50 * frame.dt)); + + const regular_font = self.assets.font_id.get(.regular); + + Gfx.drawRectangle(.init(0, 0), self.canvas_size, rgb(20, 20, 20)); + const size = Vec2.init(20, 20); + Gfx.drawRectangle(self.player.sub(size.divideScalar(2)), size, rgb(200, 20, 20)); + if (dir.x != 0 or dir.y != 0) { + Gfx.drawRectanglOutline(self.player.sub(size.divideScalar(2)), size, rgb(20, 200, 20), 3); + } + + Gfx.drawText(self.player, "Player", .{ + .font = regular_font, + .size = 10 + }); +} + +pub fn debug(self: *Game) !void { + _ = self; // autofix + if (!imgui.beginWindow(.{ + .name = "Debug", + .pos = Vec2.init(20, 20), + .size = Vec2.init(400, 200), + })) { + return; + } + defer imgui.endWindow(); + + imgui.text("Hello World!\n"); + imgui.textFmt("Audio: {}/{}\n", .{ + Audio.mixer.instances.items.len, + Audio.mixer.instances.capacity + }); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..9a6aadc --- /dev/null +++ b/src/main.zig @@ -0,0 +1,8 @@ +const Engine = @import("./engine/root.zig"); + +var engine: Engine = undefined; + +pub fn main() !void { + try engine.run(.{}); +} + diff --git a/tools/png-to-icon.zig b/tools/png-to-icon.zig new file mode 100644 index 0000000..424b4fc --- /dev/null +++ b/tools/png-to-icon.zig @@ -0,0 +1,56 @@ +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(); +}