commit 052c3ad62474e5c733d796d87d552d13ae736415 Author: Rokas Puzonas Date: Fri Jan 30 23:28:18 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c2ffbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +zig-out +.zig-cache +*.tiled-session diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2e217c --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Gaem + +Weapons: +* Laser mask +* Bomb mask +* Piston mask + +Enemies: +* Singular pawns that keep distance +* Cluster of many enemies +* Snake-like row of enemies + +## Run + +Linux and Windows: +```sh +zig build run +``` + +Web: +```sh +zig build -Dtarget=wasm32-emscripten run +``` + +Cross-compile for Windows from Linux: +```sh +zig build -Dtarget=x86_64-windows +``` diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..c5b1acf --- /dev/null +++ b/build.zig @@ -0,0 +1,276 @@ +const std = @import("std"); +const sokol = @import("sokol"); +const builtin = @import("builtin"); + +const project_name = "game-2026-01-18"; + +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, .vulkan = true }); + 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")); + mod_main.addImport("stb_rect_pack", dep_stb.module("stb_rect_pack")); + + 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(.vulkan, 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"), + .vulkan => try cflags.appendBounded("-DSOKOL_VULKAN"), + 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 = project_name, + .mod_main = mod_main, + .dep_sokol = dep_sokol, + }); + } else { + try buildNative(b, project_name, 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..242addf --- /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#3f893819d1469cf96ab4d18ec1a54311c9c1c9c8", + .hash = "sokol-0.1.0-pb1HK_qcNgDWA4dFM6Lr-aUxnequltCKH87jDcYsV7t5", + }, + .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..54a79ed --- /dev/null +++ b/libs/stb/build.zig @@ -0,0 +1,53 @@ +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 = &.{} + }); + } + + { + const mod_stb_rect_pack = b.addModule("stb_rect_pack", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/stb_rect_pack.zig"), + .link_libc = true, + }); + + mod_stb_rect_pack.addIncludePath(stb_dependency.path(".")); + mod_stb_rect_pack.addCSourceFile(.{ + .file = b.path("src/stb_rect_pack_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_rect_pack.zig b/libs/stb/src/stb_rect_pack.zig new file mode 100644 index 0000000..77de6d1 --- /dev/null +++ b/libs/stb/src/stb_rect_pack.zig @@ -0,0 +1,52 @@ + +const c = @cImport({ + @cInclude("stb_rect_pack.h"); +}); + +const STBRectPack = @This(); + +pub const Node = c.stbrp_node; +pub const Rect = c.stbrp_rect; + +pub const Options = struct { + const Heuristic = enum(i32) { + bl_sort_height = c.STBRP_HEURISTIC_Skyline_BL_sortHeight, + bf_sort_height = c.STBRP_HEURISTIC_Skyline_BF_sortHeight, + + pub const default: Heuristic = @enumFromInt(c.STBRP_HEURISTIC_Skyline_default); + }; + + width: u31, + height: u31, + nodes: []Node, + allow_out_of_memory: bool = false, + heuristic: Heuristic = .default, +}; + +ctx: c.stbrp_context, + +pub fn init(options: Options) STBRectPack { + var ctx: c.stbrp_context = undefined; + c.stbrp_init_target( + &ctx, + options.width, + options.height, + options.nodes.ptr, + options.nodes.len + ); + + if (options.allow_out_of_memory) { + c.stbrp_setup_allow_out_of_mem(&ctx, 1); + } + + if (options.heuristic != .default) { + c.stbrp_setup_heuristic(&ctx, options.heuristic); + } + + return STBRectPack{ .ctx = ctx }; +} + +pub fn packRects(self: *STBRectPack, rects: []Rect) bool { + const success = c.stbrp_pack_rects(&self.ctx, rects.ptr, rects.len); + return success != 0; +} diff --git a/libs/stb/src/stb_rect_pack_impl.c b/libs/stb/src/stb_rect_pack_impl.c new file mode 100644 index 0000000..31c4106 --- /dev/null +++ b/libs/stb/src/stb_rect_pack_impl.c @@ -0,0 +1,2 @@ +#define STB_RECT_PACK_IMPLEMENTATION +#include "stb_rect_pack.h" diff --git a/libs/stb/src/stb_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..184380a --- /dev/null +++ b/libs/tiled/src/layer.zig @@ -0,0 +1,464 @@ +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 Bounds = struct { + left: i32, + right: i32, + top: i32, + bottom: i32, + + pub const zero = Bounds{ + .left = 0, + .right = 0, + .top = 0, + .bottom = 0, + }; + + pub fn initFromRect(x: i32, y: i32, width: i32, height: i32) Bounds { + return Bounds{ + .left = x, + .right = x + width, + .top = y, + .bottom = y + height, + }; + } + + pub fn getWidth(self: Bounds) u32 { + return @intCast(self.right - self.left + 1); + } + + pub fn getHeight(self: Bounds) u32 { + return @intCast(self.bottom - self.top + 1); + } + + pub fn combine(lhs: Bounds, rhs: Bounds) Bounds { + return Bounds{ + .left = @min(lhs.left, rhs.left), + .right = @max(lhs.right, rhs.right), + .top = @min(lhs.top, rhs.top), + .bottom = @max(lhs.bottom, rhs.bottom) + }; + } +}; + +pub const TileVariant = struct { + pub const Chunk = struct { + x: i32, + y: i32, + width: u32, + height: u32, + data: []u32, + + pub fn getBounds(self: Chunk) Bounds { + return Bounds.initFromRect(self.x, self.y, @intCast(self.width), @intCast(self.height)); + } + }; + + pub const Data = union(enum) { + fixed: []u32, + chunks: []Chunk + }; + + const Encoding = enum { + csv, + + const map: std.StaticStringMap(Encoding) = .initComptime(.{ + .{ "csv", .csv }, + }); + }; + + width: u32, + height: u32, + data: Data, + + fn initChunkDataFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, + encoding: Encoding + ) !Chunk { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("chunk"); + + const x = try attrs.getNumber(i32, "x") orelse return error.MissingAttribute; + const y = try attrs.getNumber(i32, "y") orelse return error.MissingAttribute; + const width = try attrs.getNumber(u32, "width") orelse return error.MissingAttribute; + const height = try attrs.getNumber(u32, "height") orelse return error.MissingAttribute; + + var temp_tiles: std.ArrayList(u32) = .empty; + + while (try iter.next()) |node| { + if (node == .text) { + const encoded_tiles = try lexer.nextExpectText(); + const tiles = try parseEncoding(encoding, scratch.allocator(), encoded_tiles); + try temp_tiles.appendSlice(scratch.allocator(), tiles.items); + + continue; + } + + try iter.skip(); + } + + try iter.finish("chunk"); + + return Chunk{ + .x = x, + .y = y, + .width = width, + .height = height, + .data = try arena.dupe(u32, temp_tiles.items) + }; + } + + fn initDataFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, + ) !Data { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("data"); + + const encoding = try attrs.getEnum(Encoding, "encoding", Encoding.map) orelse .csv; + // TODO: compression + + var temp_chunks: std.ArrayList(Chunk) = .empty; + var temp_tiles: std.ArrayList(u32) = .empty; + + while (try iter.next()) |node| { + if (node == .text) { + const encoded_tiles = try lexer.nextExpectText(); + const tiles = try parseEncoding(encoding, scratch.allocator(), encoded_tiles); + try temp_tiles.appendSlice(scratch.allocator(), tiles.items); + + } else if (node.isTag("chunk")) { + const chunk = try initChunkDataFromXml(arena, scratch, lexer, encoding); + try temp_chunks.append(scratch.allocator(), chunk); + + continue; + } + + try iter.skip(); + } + + try iter.finish("data"); + + if (temp_chunks.items.len > 0) { + return .{ + .chunks = try arena.dupe(Chunk, temp_chunks.items) + }; + } else { + return .{ + .fixed = try arena.dupe(u32, temp_tiles.items) + }; + } + + } + + fn parseEncoding( + encoding: Encoding, + allocator: std.mem.Allocator, + text: []const u8 + ) !std.ArrayList(u32) { + return switch (encoding) { + .csv => try parseCSVEncoding(allocator, text) + }; + } + + fn parseCSVEncoding( + allocator: std.mem.Allocator, + text: []const u8 + ) !std.ArrayList(u32) { + var result: std.ArrayList(u32) = .empty; + + 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 result.append(allocator, tile_id); + } + + return result; + } + + pub fn getBounds(self: TileVariant) Bounds { + if (self.data == .fixed) { + return Bounds.initFromRect(0, 0, @intCast(self.width), @intCast(self.height)); + + } else if (self.data == .chunks) { + const chunks = self.data.chunks; + + var result: Bounds = .zero; + if (chunks.len > 0) { + result = chunks[0].getBounds(); + for (chunks[1..]) |chunk| { + result = result.combine(chunk.getBounds()); + } + } + + return result; + } else { + unreachable; + } + } + + pub fn get(self: TileVariant, x: i32, y: i32) ?u32 { + if (self.data == .fixed) { + if ((0 <= x and x < self.width) and (0 <= y and y < self.height)) { + const x_u32: u32 = @intCast(x); + const y_u32: u32 = @intCast(y); + return self.data.fixed[y_u32 * self.width + x_u32]; + } + } else if (self.data == .chunks) { + for (self.data.chunks) |chunk| { + const ox = x - chunk.x; + const oy = y - chunk.y; + if ((0 <= ox and ox < chunk.width) and (0 <= oy and oy < chunk.height)) { + const ox_u32: u32 = @intCast(ox); + const oy_u32: u32 = @intCast(oy); + return chunk.data[oy_u32 * chunk.width + ox_u32]; + } + } + } else { + unreachable; + } + 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 = .{ .fixed = &[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..19af15b --- /dev/null +++ b/libs/tiled/src/property.zig @@ -0,0 +1,162 @@ +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 getBool(self: List, name: []const u8) ?bool { + if (self.get(name)) |value| { + if (value == .bool) { + return value.bool; + } + } + 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..3bf4e47 --- /dev/null +++ b/libs/tiled/src/tilemap.zig @@ -0,0 +1,266 @@ +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, tilesets: Tileset.List, gid: u32) ?Tile { + 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 getTileByPosition(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 getTileBounds(self: *const Tilemap) Layer.Bounds { + var result: ?Layer.Bounds = null; + + for (self.layers) |layer| { + if (layer.variant != .tile) { + continue; + } + + const layer_bounds = layer.variant.tile.getBounds(); + if (result == null) { + result = layer_bounds; + } else { + result = layer_bounds.combine(result.?); + } + } + + + return result orelse .zero; +} + +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..119f1f0 --- /dev/null +++ b/src/assets.zig @@ -0,0 +1,155 @@ +const std = @import("std"); + +const tiled = @import("tiled"); + +const Math = @import("./engine/math.zig"); +const Engine = @import("./engine/root.zig"); +const STBImage = @import("stb_image"); +const Gfx = Engine.Graphics; +const Audio = Engine.Audio; +const Vec2 = Engine.Vec2; +const Rect = Engine.Math.Rect; + +const Assets = @This(); + +const FontName = enum { + regular, + bold, + italic, + + const EnumArray = std.EnumArray(FontName, Gfx.Font.Id); +}; + +pub const Tilemap = struct { + texture: Gfx.TextureId, + tile_size: Engine.Vec2, + + + pub fn getTileUV(self: Tilemap, tile_x: f32, tile_y: f32) Rect { + const texture_info = Engine.Graphics.getTextureInfo(self.texture); + const tilemap_size = Vec2.initFromInt(u32, texture_info.width, texture_info.height); + + return .{ + .pos = Vec2.init(tile_x, tile_y).multiply(self.tile_size).divide(tilemap_size), + .size = self.tile_size.divide(tilemap_size), + }; + } +}; + +arena: std.heap.ArenaAllocator, + +font_id: FontName.EnumArray, +wood01: Audio.Data.Id, +map: tiled.Tilemap, +tilesets: tiled.Tileset.List, +move_sound: []Audio.Data.Id, + +terrain_tilemap: Tilemap, +players_tilemap: Tilemap, +weapons_tilemap: Tilemap, + +pub fn init(gpa: std.mem.Allocator) !Assets { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + + 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"), + }); + + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); + + var xml_buffers = tiled.xml.Lexer.Buffers.init(gpa); + defer xml_buffers.deinit(); + + const map = try tiled.Tilemap.initFromBuffer( + gpa, + &scratch, + &xml_buffers, + @embedFile("assets/map.tmx") + ); + + const tileset = try tiled.Tileset.initFromBuffer( + gpa, + &scratch, + &xml_buffers, + @embedFile("assets/tileset.tsx") + ); + + var tilesets: tiled.Tileset.List = .empty; + try tilesets.add(gpa, "tilemap.tsx", tileset); + + const players_image = try STBImage.load(@embedFile("assets/kenney_desert-shooter-pack_1.0/PNG/Players/tilemap_packed.png")); + defer players_image.deinit(); + const players_texture = try Gfx.addTexture(&.{ + .{ + .width = players_image.width, + .height = players_image.height, + .rgba = players_image.rgba8_pixels + } + }); + + const tileset_image = try STBImage.load(@embedFile("assets/kenney_desert-shooter-pack_1.0/PNG/Tiles/tilemap_packed.png")); + defer tileset_image.deinit(); + const tileset_texture = try Gfx.addTexture(&.{ + .{ + .width = tileset_image.width, + .height = tileset_image.height, + .rgba = tileset_image.rgba8_pixels + } + }); + + const weapons_tileset = try STBImage.load(@embedFile("assets/kenney_desert-shooter-pack_1.0/PNG/Weapons/tilemap_packed.png")); + defer weapons_tileset.deinit(); + const weapons_texture = try Gfx.addTexture(&.{ + .{ + .width = weapons_tileset.width, + .height = weapons_tileset.height, + .rgba = weapons_tileset.rgba8_pixels + } + }); + + const move_c = try Audio.load(.{ + .data = @embedFile("assets/kenney_desert-shooter-pack_1.0/Sounds/move-c.ogg"), + .format = .vorbis, + }); + const move_d = try Audio.load(.{ + .data = @embedFile("assets/kenney_desert-shooter-pack_1.0/Sounds/move-d.ogg"), + .format = .vorbis, + }); + const move_sound = try arena.allocator().dupe(Audio.Data.Id, &.{ move_c, move_d }); + + return Assets{ + .arena = arena, + .font_id = font_id_array, + .wood01 = wood01, + .map = map, + .tilesets = tilesets, + .move_sound = move_sound, + .terrain_tilemap = .{ + .texture = tileset_texture, + .tile_size = .initFromInt(u32, tileset.tile_width, tileset.tile_height) + }, + .players_tilemap = .{ + .texture = players_texture, + .tile_size = .init(24, 24) + }, + .weapons_tilemap = .{ + .texture = weapons_texture, + .tile_size = .init(24, 24) + }, + }; +} + +pub fn deinit(self: *Assets, gpa: std.mem.Allocator) void { + self.map.deinit(); + self.tilesets.deinit(gpa); + self.arena.deinit(); +} diff --git a/src/assets/game-2026-01-18.tiled-project b/src/assets/game-2026-01-18.tiled-project new file mode 100644 index 0000000..d0eb592 --- /dev/null +++ b/src/assets/game-2026-01-18.tiled-project @@ -0,0 +1,14 @@ +{ + "automappingRulesFile": "", + "commands": [ + ], + "compatibilityVersion": 1100, + "extensionsPath": "extensions", + "folders": [ + "." + ], + "properties": [ + ], + "propertyTypes": [ + ] +} 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/kenney_desert-shooter-pack_1.0/License.txt b/src/assets/kenney_desert-shooter-pack_1.0/License.txt new file mode 100644 index 0000000..8355bd0 --- /dev/null +++ b/src/assets/kenney_desert-shooter-pack_1.0/License.txt @@ -0,0 +1,23 @@ + + + Desert Shooter Pack (1.0) + + Created/distributed by Kenney (www.kenney.nl) + Sponsored by: GameMaker (www.gamemaker.io) + Creation date: 24-04-2024 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Enemies/Tilesheet.txt b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Enemies/Tilesheet.txt new file mode 100644 index 0000000..964fd76 --- /dev/null +++ b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Enemies/Tilesheet.txt @@ -0,0 +1,9 @@ +Tilesheet information: + +Tile size • 24px × 24px +Space between tiles • 1px × 1px +--- +Total tiles (horizontal) • 4 tiles +Total tiles (vertical) • 4 tiles +--- +Total tiles in sheet • 16 tiles \ No newline at end of file diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Enemies/tilemap_packed.png b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Enemies/tilemap_packed.png new file mode 100644 index 0000000..d3e1177 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Enemies/tilemap_packed.png differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Interface/Tilesheet.txt b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Interface/Tilesheet.txt new file mode 100644 index 0000000..2e555b6 --- /dev/null +++ b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Interface/Tilesheet.txt @@ -0,0 +1,9 @@ +Tilesheet information: + +Tile size • 16px × 16px +Space between tiles • 1px × 1px +--- +Total tiles (horizontal) • 18 tiles +Total tiles (vertical) • 11 tiles +--- +Total tiles in sheet • 198 tiles \ No newline at end of file diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Interface/tilemap_packed.png b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Interface/tilemap_packed.png new file mode 100644 index 0000000..c42cf3a Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Interface/tilemap_packed.png differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Players/Tilesheet.txt b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Players/Tilesheet.txt new file mode 100644 index 0000000..964fd76 --- /dev/null +++ b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Players/Tilesheet.txt @@ -0,0 +1,9 @@ +Tilesheet information: + +Tile size • 24px × 24px +Space between tiles • 1px × 1px +--- +Total tiles (horizontal) • 4 tiles +Total tiles (vertical) • 4 tiles +--- +Total tiles in sheet • 16 tiles \ No newline at end of file diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Players/tilemap_packed.png b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Players/tilemap_packed.png new file mode 100644 index 0000000..ec1003f Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Players/tilemap_packed.png differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Tiles/Tilesheet.txt b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Tiles/Tilesheet.txt new file mode 100644 index 0000000..49d55e3 --- /dev/null +++ b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Tiles/Tilesheet.txt @@ -0,0 +1,9 @@ +Tilesheet information: + +Tile size • 16px × 16px +Space between tiles • 1px × 1px +--- +Total tiles (horizontal) • 18 tiles +Total tiles (vertical) • 13 tiles +--- +Total tiles in sheet • 234 tiles \ No newline at end of file diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Tiles/tilemap_packed.png b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Tiles/tilemap_packed.png new file mode 100644 index 0000000..7740ae5 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Tiles/tilemap_packed.png differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Weapons/Tilesheet.txt b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Weapons/Tilesheet.txt new file mode 100644 index 0000000..d2636d2 --- /dev/null +++ b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Weapons/Tilesheet.txt @@ -0,0 +1,9 @@ +Tilesheet information: + +Tile size • 24px × 24px +Space between tiles • 1px × 1px +--- +Total tiles (horizontal) • 10 tiles +Total tiles (vertical) • 4 tiles +--- +Total tiles in sheet • 40 tiles \ No newline at end of file diff --git a/src/assets/kenney_desert-shooter-pack_1.0/PNG/Weapons/tilemap_packed.png b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Weapons/tilemap_packed.png new file mode 100644 index 0000000..a38ac73 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/PNG/Weapons/tilemap_packed.png differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-a.ogg new file mode 100644 index 0000000..1e6e4a3 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-b.ogg new file mode 100644 index 0000000..520dc9a Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-c.ogg new file mode 100644 index 0000000..214a9b9 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-d.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-d.ogg new file mode 100644 index 0000000..94e563d Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/coin-d.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-a.ogg new file mode 100644 index 0000000..98f03dd Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-b.ogg new file mode 100644 index 0000000..a6ad06b Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-c.ogg new file mode 100644 index 0000000..1dc55bc Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/error-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-a.ogg new file mode 100644 index 0000000..bc374c0 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-b.ogg new file mode 100644 index 0000000..cfd7318 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-c.ogg new file mode 100644 index 0000000..9ae8fe6 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/explosion-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/fall-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/fall-a.ogg new file mode 100644 index 0000000..4eeca77 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/fall-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/fall-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/fall-b.ogg new file mode 100644 index 0000000..f6eee21 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/fall-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-a.ogg new file mode 100644 index 0000000..89a03de Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-b.ogg new file mode 100644 index 0000000..dab329e Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-c.ogg new file mode 100644 index 0000000..1101d20 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-d.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-d.ogg new file mode 100644 index 0000000..3052183 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-d.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-e.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-e.ogg new file mode 100644 index 0000000..8de52c2 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/hurt-e.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-a.ogg new file mode 100644 index 0000000..a03bdc2 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-b.ogg new file mode 100644 index 0000000..27dc8c9 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-c.ogg new file mode 100644 index 0000000..2156626 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-d.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-d.ogg new file mode 100644 index 0000000..1438aea Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-d.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-e.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-e.ogg new file mode 100644 index 0000000..fc5c600 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-e.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-f.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-f.ogg new file mode 100644 index 0000000..a23a3b2 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/jump-f.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-a.ogg new file mode 100644 index 0000000..62ce9c0 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-b.ogg new file mode 100644 index 0000000..9167456 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-c.ogg new file mode 100644 index 0000000..5fc7ae4 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-d.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-d.ogg new file mode 100644 index 0000000..3783f06 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/lose-d.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-a.ogg new file mode 100644 index 0000000..ce07651 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-b.ogg new file mode 100644 index 0000000..fdc8375 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-c.ogg new file mode 100644 index 0000000..a6f0da0 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-d.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-d.ogg new file mode 100644 index 0000000..26bdea2 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/move-d.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/select-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/select-a.ogg new file mode 100644 index 0000000..9bc26ec Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/select-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-a.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-a.ogg new file mode 100644 index 0000000..4f603aa Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-a.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-b.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-b.ogg new file mode 100644 index 0000000..59ff721 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-b.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-c.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-c.ogg new file mode 100644 index 0000000..a30bf3f Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-c.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-d.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-d.ogg new file mode 100644 index 0000000..6599afe Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-d.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-e.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-e.ogg new file mode 100644 index 0000000..7130770 Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-e.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-f.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-f.ogg new file mode 100644 index 0000000..c0c660b Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-f.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-g.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-g.ogg new file mode 100644 index 0000000..a8ab97a Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-g.ogg differ diff --git a/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-h.ogg b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-h.ogg new file mode 100644 index 0000000..dc676af Binary files /dev/null and b/src/assets/kenney_desert-shooter-pack_1.0/Sounds/shoot-h.ogg differ diff --git a/src/assets/map.tmx b/src/assets/map.tmx new file mode 100644 index 0000000..302035f --- /dev/null +++ b/src/assets/map.tmx @@ -0,0 +1,225 @@ + + + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,91, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,109, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,109 + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,65,65,0,0,0,0,0,0, +92,92,92,92,92,92,92,93,65,65,0,0,0,0,0,0, +110,20,110,110,110,110,110,112,110,65,0,0,0,0,0,0, +110,110,110,110,20,110,110,110,110,65,65,0,0,0,0,0 + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,109, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,109, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,127, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + +110,110,20,110,110,110,110,94,110,65,65,0,0,0,65,0, +110,20,110,110,110,110,20,111,65,65,65,0,0,0,65,0, +128,128,128,128,128,128,128,129,65,65,65,0,65,65,65,0, +0,0,0,65,65,65,65,65,65,65,65,65,195,65,65,0, +0,0,0,65,65,65,65,65,65,65,65,65,65,65,65,0, +0,0,65,65,65,65,65,65,65,65,65,65,66,65,65,65, +0,0,65,65,65,65,66,65,65,66,65,65,65,65,65,65, +0,0,0,65,65,65,65,65,65,65,66,65,65,65,65,65, +0,0,0,65,65,195,65,65,65,65,65,65,65,65,65,65, +0,0,0,65,65,65,65,66,65,65,65,65,65,65,65,65, +0,0,0,65,65,65,65,65,65,66,65,65,195,65,65,65, +0,0,0,65,65,65,65,65,65,65,65,65,65,65,66,65, +0,65,65,65,65,65,66,65,65,65,65,65,65,65,65,65, +0,0,65,65,65,65,65,65,65,65,65,65,65,65,65,65, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +65,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +65,65,65,65,65,65,0,0,0,0,0,0,0,0,0,0, +65,65,65,65,65,65,0,0,0,0,0,0,0,0,0,0, +65,65,65,65,65,65,65,0,0,0,0,0,0,0,0,0, +65,65,65,65,65,65,65,0,0,0,0,0,0,0,0,0, +65,65,65,65,65,65,0,0,0,0,0,0,0,0,0,0, +65,65,65,65,65,0,0,0,0,0,0,0,0,0,0,0, +65,65,65,0,0,0,0,0,0,0,0,0,0,0,0,0, +65,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + + + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,1,2,2,2,2,3,0,0, +0,0,0,0,0,0,0,0,19,110,20,110,20,21,0,0, +0,0,0,0,0,0,0,0,19,110,110,20,20,21,0,0, +0,0,0,0,0,0,0,0,37,38,38,38,38,39,0,0, +0,0,0,0,0,0,0,0,73,56,174,174,174,57,0,0, +0,0,0,0,0,0,0,0,73,174,174,56,174,57,0,0, +0,0,0,0,0,0,0,0,73,174,174,174,174,57,0,0 + + +0,0,0,0,0,0,0,0,73,174,56,56,174,57,0,0, +0,0,0,0,0,0,0,0,73,174,174,174,56,57,0,0, +0,0,0,0,0,0,0,0,73,74,74,74,74,75,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + + + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,2, +0,0,0,0,0,0,0,0,0,0,0,0,0,37,38,38 + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,3,0,0,0,0,0,0,0,0,0,0,0,0, +38,38,38,39,0,0,0,0,0,0,0,0,0,0,0,0 + + +0,0,0,0,0,0,0,0,0,0,0,0,0,73,174,174, +0,0,0,0,0,0,0,0,0,0,0,0,0,73,174,174, +0,0,0,0,0,0,0,0,0,0,0,0,0,73,56,174, +0,0,0,0,0,0,0,0,0,0,0,0,0,73,56,56, +0,0,0,0,0,0,0,0,0,0,0,0,0,73,74,74, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + +56,56,174,75,0,0,0,0,0,0,0,0,0,0,0,0, +174,174,174,75,0,0,0,0,0,0,0,0,0,0,0,0, +174,174,56,75,0,0,0,0,0,0,0,0,0,0,0,0, +174,56,56,75,0,0,0,0,0,0,0,0,0,0,0,0, +74,74,74,75,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + + + + + + 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/tilemap.tsx b/src/assets/tilemap.tsx new file mode 100644 index 0000000..9c9efde --- /dev/null +++ b/src/assets/tilemap.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/tileset.tsx b/src/assets/tileset.tsx new file mode 100644 index 0000000..b66a44d --- /dev/null +++ b/src/assets/tileset.tsx @@ -0,0 +1,4 @@ + + + + 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..0fd8bfe --- /dev/null +++ b/src/engine/audio/data.zig @@ -0,0 +1,74 @@ +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 fn getSampleRate(self: Data) u32 { + return switch (self) { + .raw => |opts| opts.sample_rate, + .vorbis => |opts| blk: { + const info = opts.stb_vorbis.getInfo(); + break :blk info.sample_rate; + } + }; + } + + 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..2cc6daf --- /dev/null +++ b/src/engine/audio/mixer.zig @@ -0,0 +1,152 @@ +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, + volume: f32 = 0, + cursor: u32 = 0, +}; + +pub const Command = union(enum) { + pub const Play = struct { + id: AudioData.Id, + volume: f32 = 1 + }; + + play: Play, + + 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, +working_buffer: []f32, + +pub fn init( + gpa: Allocator, + max_instances: u32, + max_commands: u32, + working_buffer_size: 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); + + const working_buffer = try gpa.alloc(f32, working_buffer_size); + errdefer gpa.free(working_buffer); + + return Mixer{ + .working_buffer = working_buffer, + .instances = instances, + .commands = .{ + .items = commands + } + }; +} + +pub fn deinit(self: *Mixer, gpa: Allocator) void { + self.instances.deinit(gpa); + gpa.free(self.commands.items); + gpa.free(self.working_buffer); +} + +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| { + const volume = @max(opts.volume, 0); + if (volume == 0) { + log.warn("Attempt to play audio with 0 volume", .{}); + continue; + } + + self.instances.appendBounded(.{ + .data_id = opts.id, + .volume = volume, + }) catch log.warn("Maximum number of audio instances reached!", .{}); + } + } + } + + assert(num_channels == 1); // TODO: + const sample_rate: u32 = @intCast(saudio.sampleRate()); + + @memset(buffer, 0); + assert(self.working_buffer.len >= num_frames); + + for (self.instances.items) |*instance| { + const audio_data = store.get(instance.data_id); + const samples = audio_data.streamChannel(self.working_buffer[0..num_frames], instance.cursor, 0, sample_rate); + for (0.., samples) |i, sample| { + buffer[i] += sample * instance.volume; + } + 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..a1d9365 --- /dev/null +++ b/src/engine/audio/root.zig @@ -0,0 +1,111 @@ +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"); + +pub const Command = Mixer.Command; + +const Nanoseconds = @import("../root.zig").Nanoseconds; +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, + opts.buffer_frames + ); + + 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); +} + +const Info = struct { + sample_count: u32, + sample_rate: u32, + + pub fn getDuration(self: Info) Nanoseconds { + return @as(Nanoseconds, self.sample_count) * std.time.ns_per_s / self.sample_rate; + } +}; + +pub fn getInfo(id: Data.Id) Info { + const data = store.get(id); + return Info{ + .sample_count = data.getSampleCount(), + .sample_rate = data.getSampleRate(), + }; +} + +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/frame.zig b/src/engine/frame.zig new file mode 100644 index 0000000..4b4f2bf --- /dev/null +++ b/src/engine/frame.zig @@ -0,0 +1,291 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const log = std.log.scoped(.engine); + +const InputSystem = @import("./input.zig"); +const KeyCode = InputSystem.KeyCode; +const MouseButton = InputSystem.MouseButton; + +const AudioSystem = @import("./audio/root.zig"); +const AudioData = AudioSystem.Data; +const AudioCommand = AudioSystem.Command; + +const GraphicsSystem = @import("./graphics.zig"); +const TextureId = GraphicsSystem.TextureId; +const GraphicsCommand = GraphicsSystem.Command; +const Font = GraphicsSystem.Font; +const Sprite = GraphicsSystem.Sprite; + +const Math = @import("./math.zig"); +const Rect = Math.Rect; +const Vec4 = Math.Vec4; +const Vec2 = Math.Vec2; +const rgb = Math.rgb; + +pub const Nanoseconds = u64; + +const Frame = @This(); + +pub const Input = struct { + keyboard: InputSystem.ButtonStateSet(KeyCode), + mouse_button: InputSystem.ButtonStateSet(MouseButton), + mouse_position: ?Vec2, + + pub const empty = Input{ + .keyboard = .empty, + .mouse_button = .empty, + .mouse_position = null, + }; +}; + +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 Audio = struct { + commands: std.ArrayList(AudioCommand), + + pub const empty = Audio{ + .commands = .empty + }; +}; + +pub const Graphics = struct { + clear_color: Vec4, + screen_size: Vec2, + canvas_size: ?Vec2, + + scissor_stack: std.ArrayList(Rect), + commands: std.ArrayList(GraphicsCommand), + + pub const empty = Graphics{ + .clear_color = rgb(0, 0, 0), + .screen_size = .init(0, 0), + .canvas_size = null, + .scissor_stack = .empty, + .commands = .empty + }; +}; + +arena: std.heap.ArenaAllocator, + +time_ns: Nanoseconds, +dt_ns: Nanoseconds, +input: Input, +audio: Audio, +graphics: Graphics, + +show_debug: bool, +hide_cursor: bool, + +pub fn init(self: *Frame, gpa: std.mem.Allocator) void { + self.* = Frame{ + .arena = std.heap.ArenaAllocator.init(gpa), + .time_ns = 0, + .dt_ns = 0, + .input = .empty, + .audio = .empty, + .graphics = .empty, + .show_debug = false, + .hide_cursor = false + }; +} + +pub fn deinit(self: *Frame) void { + self.arena.deinit(); +} + +pub fn deltaTime(self: Frame) f32 { + return @as(f32, @floatFromInt(self.dt_ns)) / std.time.ns_per_s; +} + +pub fn time(self: Frame) f64 { + return @as(f64, @floatFromInt(self.time_ns)) / std.time.ns_per_s; +} + +pub fn isKeyDown(self: Frame, key_code: KeyCode) bool { + const keyboard = &self.input.keyboard; + return keyboard.down.contains(key_code); +} + +pub fn getKeyDownDuration(self: Frame, key_code: KeyCode) ?f32 { + if (!self.isKeyDown(key_code)) { + return null; + } + + const keyboard = &self.input.keyboard; + const pressed_at_ns = keyboard.pressed_at.get(key_code).?; + const duration_ns = self.time_ns - pressed_at_ns; + + return @as(f32, @floatFromInt(duration_ns)) / std.time.ns_per_s; +} + +pub fn isKeyPressed(self: Frame, key_code: KeyCode) bool { + const keyboard = &self.input.keyboard; + return keyboard.pressed.contains(key_code); +} + +pub fn isKeyReleased(self: Frame, key_code: KeyCode) bool { + const keyboard = &self.input.keyboard; + return keyboard.released.contains(key_code); +} + +pub fn getKeyState(self: 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(key_code) + }; +} + +pub fn isMousePressed(self: Frame, button: MouseButton) bool { + return self.input.mouse_button.pressed.contains(button); +} + +pub fn isMouseDown(self: Frame, button: MouseButton) bool { + return self.input.mouse_button.down.contains(button); +} + +fn pushAudioCommand(self: *Frame, command: AudioCommand) void { + const arena = self.arena.allocator(); + + self.audio.commands.append(arena, command) catch |e| { + log.warn("Failed to play audio: {}", .{e}); + }; +} + +pub fn pushGraphicsCommand(self: *Frame, command: GraphicsCommand) void { + const arena = self.arena.allocator(); + + self.graphics.commands.append(arena, command) catch |e|{ + log.warn("Failed to push graphics command: {}", .{e}); + }; +} + +pub fn prependGraphicsCommand(self: *Frame, command: GraphicsCommand) void { + const arena = self.arena.allocator(); + + self.graphics.commands.insert(arena, 0, command) catch |e|{ + log.warn("Failed to push graphics command: {}", .{e}); + }; +} + +pub fn playAudio(self: *Frame, options: AudioCommand.Play) void { + self.pushAudioCommand(.{ + .play = options, + }); +} + +pub fn pushScissor(self: *Frame, rect: Rect) void { + const arena = self.arena.allocator(); + self.graphics.scissor_stack.append(arena, rect) catch |e| { + log.warn("Failed to push scissor region: {}", .{e}); + return; + }; + + self.pushGraphicsCommand(.{ + .set_scissor = rect + }); +} + +pub fn popScissor(self: *Frame) void { + _ = self.graphics.scissor_stack.pop().?; + const rect = self.graphics.scissor_stack.getLast(); + + self.pushGraphicsCommand(.{ + .set_scissor = rect + }); +} + +pub fn drawRectangle(self: *Frame, opts: GraphicsCommand.DrawRectangle) void { + self.pushGraphicsCommand(.{ .draw_rectangle = opts }); +} + +pub fn drawLine(self: *Frame, pos1: Vec2, pos2: Vec2, color: Vec4, width: f32) void { + self.pushGraphicsCommand(.{ + .draw_line = .{ + .pos1 = pos1, + .pos2 = pos2, + .width = width, + .color = color + } + }); +} + +pub fn drawRectanglOutline(self: *Frame, pos: Vec2, size: Vec2, color: Vec4, width: f32) void { + // TODO: Don't use line segments + self.drawLine(pos, pos.add(.{ .x = size.x, .y = 0 }), color, width); + self.drawLine(pos, pos.add(.{ .x = 0, .y = size.y }), color, width); + self.drawLine(pos.add(.{ .x = 0, .y = size.y }), pos.add(size), color, width); + self.drawLine(pos.add(.{ .x = size.x, .y = 0 }), pos.add(size), color, width); +} + +pub const DrawTextOptions = struct { + font: Font.Id, + size: f32 = 16, + color: Vec4 = rgb(255, 255, 255), +}; + +pub fn drawText(self: *Frame, position: Vec2, text: []const u8, opts: DrawTextOptions) void { + const arena = self.arena.allocator(); + const text_dupe = arena.dupe(u8, text) catch |e| { + log.warn("Failed to draw text: {}", .{e}); + return; + }; + + self.pushGraphicsCommand(.{ + .draw_text = .{ + .pos = position, + .text = text_dupe, + .size = opts.size, + .font = opts.font, + .color = opts.color, + } + }); +} + +pub fn pushTransform(self: *Frame, translation: Vec2, scale: Vec2) void { + self.pushGraphicsCommand(.{ + .push_transformation = .{ + .translation = translation, + .scale = scale + } + }); +} + +pub fn popTransform(self: *Frame) void { + self.pushGraphicsCommand(.{ + .pop_transformation = {} + }); +} diff --git a/src/engine/graphics.zig b/src/engine/graphics.zig new file mode 100644 index 0000000..59de1ba --- /dev/null +++ b/src/engine/graphics.zig @@ -0,0 +1,380 @@ +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; + +const GraphicsFrame = @import("./frame.zig").Graphics; + +// 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, +}; + +pub const Command = union(enum) { + pub const DrawRectangle = struct { + rect: Rect, + color: Vec4, + sprite: ?Sprite = null, + rotation: f32 = 0, + origin: Vec2 = .init(0, 0), + }; + + set_scissor: Rect, + draw_rectangle: DrawRectangle, + draw_line: struct { pos1: Vec2, pos2: Vec2, color: Vec4, width: f32 }, + draw_text: struct { + pos: Vec2, + text: []const u8, + font: Font.Id, + size: f32, + color: Vec4, + }, + push_transformation: struct { translation: Vec2, scale: Vec2 }, + pop_transformation: void, +}; + +const Texture = struct { + image: sg.Image, + view: sg.View, + info: Info, + + const Info = struct { + width: u32, + height: u32, + }; + + const Id = enum(u32) { _ }; + const Data = struct { width: u32, height: u32, rgba: [*]u8 }; +}; +pub const TextureId = Texture.Id; +pub const TextureInfo = Texture.Info; + +pub const Sprite = struct { + texture: TextureId, + uv: Rect, +}; + +var gpa: std.mem.Allocator = undefined; + +var main_pipeline: sgl.Pipeline = .{}; +var linear_sampler: sg.Sampler = .{}; +var nearest_sampler: sg.Sampler = .{}; +var font_context: fontstash.Context = undefined; +var textures: std.ArrayList(Texture) = .empty; + +var scale_stack_buffer: [32]Vec2 = undefined; +var scale_stack: std.ArrayList(Vec2) = .empty; + +pub fn init(options: Options) !void { + gpa = options.allocator; + + 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 { + textures.deinit(gpa); + imgui.shutdown(); + font_context.deinit(); + sgl.shutdown(); + sg.shutdown(); +} + +pub fn drawCommand(command: Command) void { + switch (command) { + .push_transformation => |opts| { + pushTransform(opts.translation, opts.scale); + // font_resolution_scale = font_resolution_scale.multiply(opts.scale); + }, + .pop_transformation => { + popTransform(); + }, + .draw_rectangle => |opts| { + drawRectangle(opts); + }, + .set_scissor => |opts| { + sgl.scissorRectf(opts.pos.x, opts.pos.y, opts.size.x, opts.size.y, true); + }, + .draw_line => |opts| { + drawLine(opts.pos1, opts.pos2, opts.color, opts.width); + }, + .draw_text => |opts| { + const font_resolution_scale = scale_stack.getLast(); + + sgl.pushMatrix(); + defer sgl.popMatrix(); + + sgl.scale(1 / font_resolution_scale.x, 1 / font_resolution_scale.y, 1); + + font_context.setFont(opts.font); + font_context.setSize(opts.size * font_resolution_scale.y); + 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(opts.pos.x * font_resolution_scale.x, opts.pos.y * font_resolution_scale.y, opts.text); + }, + } +} + +pub fn drawCommands(commands: []const Command) void { + for (commands) |command| { + drawCommand(command); + } +} + +pub fn beginFrame() void { + const zone = tracy.initZone(@src(), .{}); + defer zone.deinit(); + + imgui.newFrame(.{ .width = sapp.width(), .height = sapp.height(), .delta_time = sapp.frameDuration(), .dpi_scale = sapp.dpiScale() }); + + scale_stack = .initBuffer(&scale_stack_buffer); + scale_stack.appendAssumeCapacity(.init(1, 1)); + + font_context.clearState(); + sgl.defaults(); + sgl.matrixModeProjection(); + sgl.ortho(0, sapp.widthf(), sapp.heightf(), 0, -1, 1); + sgl.loadPipeline(main_pipeline); +} + +pub fn endFrame(clear_color: Vec4) void { + const zone = tracy.initZone(@src(), .{}); + defer zone.deinit(); + + var pass_action: sg.PassAction = .{}; + + pass_action.colors[0] = sg.ColorAttachmentAction{ .load_action = .CLEAR, .clear_value = .{ .r = clear_color.x, .g = clear_color.y, .b = clear_color.z, .a = clear_color.w } }; + + font_context.flush(); + + { + sg.beginPass(.{ .action = pass_action, .swapchain = sglue.swapchain() }); + defer sg.endPass(); + + sgl.draw(); + + imgui.render(); + } + sg.commit(); +} + +fn pushTransform(translation: Vec2, scale: Vec2) void { + sgl.pushMatrix(); + sgl.translate(translation.x, translation.y, 0); + sgl.scale(scale.x, scale.y, 1); + + scale_stack.appendAssumeCapacity(scale_stack.getLast().multiply(scale)); +} + +fn popTransform() void { + sgl.popMatrix(); + _ = scale_stack.pop().?; +} + +const Vertex = struct { pos: Vec2, uv: Vec2 }; + +fn drawQuad(quad: [4]Vertex, color: Vec4, texture_id: TextureId) void { + sgl.enableTexture(); + defer sgl.disableTexture(); + + const view = textures.items[@intFromEnum(texture_id)].view; + // TODO: Make sampler configurable + sgl.texture(view, nearest_sampler); + + sgl.beginQuads(); + defer sgl.end(); + + sgl.c4f(color.x, color.y, color.z, color.w); + for (quad) |vertex| { + const pos = vertex.pos; + const uv = vertex.uv; + sgl.v2fT2f(pos.x, pos.y, uv.x, uv.y); + } +} + +fn drawQuadNoUVs(quad: [4]Vec2, color: Vec4) void { + sgl.beginQuads(); + defer sgl.end(); + + sgl.c4f(color.x, color.y, color.z, color.w); + for (quad) |pos| { + sgl.v2f(pos.x, pos.y); + } +} + +fn drawRectangle(opts: Command.DrawRectangle) void { + const pos = opts.rect.pos; + const size = opts.rect.size; + + const top_left = Vec2.init(0, 0).rotateAround(opts.rotation, opts.origin); + const top_right = Vec2.init(size.x, 0).rotateAround(opts.rotation, opts.origin); + const bottom_right = size.rotateAround(opts.rotation, opts.origin); + const bottom_left = Vec2.init(0, size.y).rotateAround(opts.rotation, opts.origin); + + if (opts.sprite) |sprite| { + const uv = sprite.uv; + const quad = [4]Vertex{ .{ .pos = pos.add(top_left), .uv = .init(uv.left(), uv.top()) }, .{ .pos = pos.add(top_right), .uv = .init(uv.right(), uv.top()) }, .{ .pos = pos.add(bottom_right), .uv = .init(uv.right(), uv.bottom()) }, .{ .pos = pos.add(bottom_left), .uv = .init(uv.left(), uv.bottom()) } }; + drawQuad(quad, opts.color, sprite.texture); + } else { + const quad = .{ pos.add(top_left), pos.add(top_right), pos.add(bottom_right), pos.add(bottom_left) }; + drawQuadNoUVs(quad, opts.color); + } +} + +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()); + + drawQuadNoUVs(.{ top_right, top_left, bottom_left, bottom_right }, color); +} + +pub fn addFont(name: [*c]const u8, data: []const u8) !Font.Id { + return try font_context.addFont(name, data); +} + +fn makeView(image: sg.Image) !sg.View { + const image_view = sg.makeView(.{ .texture = .{ .image = image } }); + if (image_view.id == sg.invalid_id) { + return error.InvalidView; + } + return image_view; +} + +fn makeImageWithMipMaps(mipmaps: []const Texture.Data) !sg.Image { + if (mipmaps.len == 0) { + return error.NoMipMaps; + } + + var data: sg.ImageData = .{}; + var mip_levels: std.ArrayListUnmanaged(sg.Range) = .initBuffer(&data.mip_levels); + + for (mipmaps) |mipmap| { + try mip_levels.appendBounded(.{ .ptr = mipmap.rgba, .size = mipmap.width * mipmap.height * 4 }); + } + + const image = sg.makeImage(.{ .width = @intCast(mipmaps[0].width), .height = @intCast(mipmaps[0].height), .pixel_format = .RGBA8, .usage = .{ .immutable = true }, .num_mipmaps = @intCast(mip_levels.items.len), .data = data }); + if (image.id == sg.invalid_id) { + return error.InvalidImage; + } + + return image; +} + +pub fn addTexture(mipmaps: []const Texture.Data) !TextureId { + const image = try makeImageWithMipMaps(mipmaps); + errdefer sg.deallocImage(image); + + const view = try makeView(image); + errdefer sg.deallocView(view); + + assert(mipmaps.len > 0); + const index = textures.items.len; + try textures.append(gpa, .{ .image = image, .view = view, .info = .{ + .width = mipmaps[0].width, + .height = mipmaps[0].height, + } }); + + return @enumFromInt(index); +} + +pub fn getTextureInfo(id: TextureId) TextureInfo { + const texture = textures.items[@intFromEnum(id)]; + return texture.info; +} 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..ee7ed33 --- /dev/null +++ b/src/engine/input.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const sokol = @import("sokol"); + +const Frame = @import("./frame.zig"); +const Nanoseconds = Frame.Nanoseconds; + +const Math = @import("./math.zig"); +const Vec2 = Math.Vec2; + +const Input = @This(); + +const SokolKeyCodeInfo = @typeInfo(sokol.app.Keycode); +pub const KeyCode = @Type(.{ + .@"enum" = .{ + .tag_type = std.math.IntFittingRange(0, sokol.app.max_keycodes-1), + .decls = SokolKeyCodeInfo.@"enum".decls, + .fields = SokolKeyCodeInfo.@"enum".fields, + .is_exhaustive = false + } +}); + +pub const MouseButton = enum(i3) { + left = @intFromEnum(sokol.app.Mousebutton.LEFT), + right = @intFromEnum(sokol.app.Mousebutton.RIGHT), + middle = @intFromEnum(sokol.app.Mousebutton.MIDDLE), + _ +}; + +pub fn ButtonStateSet(E: type) type { + return struct { + const Self = @This(); + + down: std.EnumSet(E), + pressed: std.EnumSet(E), + released: std.EnumSet(E), + pressed_at: std.EnumMap(E, Nanoseconds), + + pub const empty = Self{ + .down = .initEmpty(), + .pressed = .initEmpty(), + .released = .initEmpty(), + .pressed_at = .init(.{}), + }; + + fn press(self: *Self, button: E, now: Nanoseconds) void { + self.pressed_at.put(button, now); + self.pressed.insert(button); + self.down.insert(button); + } + + fn release(self: *Self, button: E) void { + self.down.remove(button); + self.released.insert(button); + self.pressed_at.remove(button); + } + + fn releaseAll(self: *Self) void { + var iter = self.down.iterator(); + while (iter.next()) |key_code| { + self.released.insert(key_code); + } + self.down = .initEmpty(); + self.pressed_at = .init(.{}); + } + }; +} + +pub const Event = union(enum) { + mouse_pressed: struct { + button: MouseButton, + position: Vec2, + }, + mouse_released: struct { + button: MouseButton, + position: Vec2, + }, + mouse_move: Vec2, + mouse_enter: Vec2, + mouse_leave, + mouse_scroll: Vec2, + key_pressed: struct { + code: KeyCode, + repeat: bool + }, + key_released: Input.KeyCode, + window_resize, + char: u21, +}; + +pub fn processEvent(frame: *Frame, event: Event) void { + const input = &frame.input; + + switch (event) { + .key_pressed => |opts| { + if (!opts.repeat) { + input.keyboard.press(opts.code, frame.time_ns); + } + }, + .key_released => |key_code| { + input.keyboard.release(key_code); + }, + .mouse_leave => { + input.keyboard.releaseAll(); + + input.mouse_position = null; + input.mouse_button = .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_button.press(opts.button, frame.time_ns); + }, + .mouse_released => |opts| { + input.mouse_position = opts.position; + input.mouse_button.release(opts.button); + }, + else => {} + } +} diff --git a/src/engine/math.zig b/src/engine/math.zig new file mode 100644 index 0000000..95d0178 --- /dev/null +++ b/src/engine/math.zig @@ -0,0 +1,469 @@ +const std = @import("std"); +const assert = std.debug.assert; + +pub const bytes_per_kib = 1024; +pub const bytes_per_mib = bytes_per_kib * 1024; +pub const bytes_per_gib = bytes_per_mib * 1024; + +pub const bytes_per_kb = 1000; +pub const bytes_per_mb = bytes_per_kb * 1000; +pub const bytes_per_gb = bytes_per_mb * 1000; + +pub const Range = struct { + from: f32, + to: f32, + + pub const zero = init(0, 0); + + pub fn init(from: f32, to: f32) Range { + return Range{ + .from = from, + .to = to + }; + } + + pub fn getSize(self: Range) f32 { + return @abs(self.from - self.to); + } + + pub fn random(self: Range, rng: std.Random) f32 { + return self.from + rng.float(f32) * (self.to - self.from); + } +}; + +pub const Vec2 = extern struct { + x: f32, + y: f32, + + pub const zero = init(0, 0); + + pub fn init(x: f32, y: f32) Vec2 { + return Vec2{ + .x = x, + .y = y, + }; + } + + pub fn initFromInt(T: type, x: T, y: T) Vec2 { + return .init(@floatFromInt(x), @floatFromInt(y)); + } + + pub fn initAngle(angle: f32) Vec2 { + return Vec2{ + .x = @cos(angle), + .y = @sin(angle), + }; + } + + pub fn rotateLeft90(self: Vec2) Vec2 { + return Vec2.init(self.y, -self.x); + } + + pub fn rotateRight90(self: Vec2) Vec2 { + return Vec2.init(-self.y, self.x); + } + + pub fn rotate(self: Vec2, angle: f32) Vec2 { + return init( + @cos(angle) * self.x - @sin(angle) * self.y, + @sin(angle) * self.x + @cos(angle) * self.y, + ); + } + + pub fn rotateAround(self: Vec2, angle: f32, origin: Vec2) Vec2 { + return self.sub(origin).rotate(angle).add(origin); + } + + pub fn getAngle(self: Vec2) f32 { + return std.math.atan2(self.y, self.x); + } + + pub fn flip(self: Vec2) Vec2 { + return Vec2.init(-self.x, -self.y); + } + + pub fn add(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x + other.x, + self.y + other.y, + ); + } + + pub fn sub(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x - other.x, + self.y - other.y, + ); + } + + pub fn multiplyScalar(self: Vec2, value: f32) Vec2 { + return Vec2.init( + self.x * value, + self.y * value, + ); + } + + pub fn multiply(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x * other.x, + self.y * other.y, + ); + } + + pub fn divide(self: Vec2, other: Vec2) Vec2 { + return Vec2.init( + self.x / other.x, + self.y / other.y, + ); + } + + pub fn divideScalar(self: Vec2, value: f32) Vec2 { + return Vec2.init( + self.x / value, + self.y / value, + ); + } + + pub fn lengthSqr(self: Vec2) f32 { + return self.x*self.x + self.y*self.y; + } + + pub fn length(self: Vec2) f32 { + return @sqrt(self.lengthSqr()); + } + + pub fn distance(self: Vec2, other: Vec2) f32 { + return self.sub(other).length(); + } + + pub fn distanceSqr(self: Vec2, other: Vec2) f32 { + return self.sub(other).lengthSqr(); + } + + pub fn limitLength(self: Vec2, max_length: f32) Vec2 { + const self_length = self.length(); + if (self_length > max_length) { + if (self_length == 0) { + return Vec2.init(0, 0); + } + return self.divideScalar(self_length / max_length); + } else { + return self; + } + } + + pub fn normalized(self: Vec2) Vec2 { + const self_length = self.length(); + if (self_length == 0) { + return Vec2.init(0, 0); + } + return self.divideScalar(self_length); + } + + pub fn initScalar(value: f32) Vec2 { + return Vec2.init(value, value); + } + + pub fn eql(self: Vec2, other: Vec2) bool { + return self.x == other.x and self.y == other.y; + } + + pub fn format(self: Vec2, writer: *std.io.Writer) std.io.Writer.Error!void { + try writer.print("Vec2{{ {d}, {d} }}", .{ self.x, self.y }); + } +}; + +pub const Vec3 = extern struct { + x: f32, y: f32, z: f32, + + pub const zero = init(0, 0, 0); + + pub fn init(x: f32, y: f32, z: f32) Vec3 { + return Vec3{ + .x = x, + .y = y, + .z = z, + }; + } + + pub fn initScalar(value: f32) Vec3 { + return Vec3.init(value, value, value); + } + + pub fn asArray(self: *Vec3) []f32 { + const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(self)))); + return ptr[0..3]; + } + + pub fn lerp(a: Vec3, b: Vec3, t: f32) Vec3 { + return Vec3.init( + std.math.lerp(a.x, b.x, t), + std.math.lerp(a.y, b.y, t), + std.math.lerp(a.z, b.z, t), + ); + } + + pub fn clamp(self: Vec3, min_value: f32, max_value: f32) Vec3 { + return Vec3.init( + std.math.clamp(self.x, min_value, max_value), + std.math.clamp(self.y, min_value, max_value), + std.math.clamp(self.z, min_value, max_value), + ); + } +}; + +pub const Vec4 = extern struct { + x: f32, y: f32, z: f32, w: f32, + + pub const zero = init(0, 0, 0, 0); + + pub fn init(x: f32, y: f32, z: f32, w: f32) Vec4 { + return Vec4{ + .x = x, + .y = y, + .z = z, + .w = w + }; + } + + pub fn initVec3XYZ(vec3: Vec3, w: f32) Vec4 { + return init(vec3.x, vec3.y, vec3.z, w); + } + + pub fn initScalar(value: f32) Vec4 { + return Vec4.init(value, value, value, value); + } + + pub fn multiplyMat4(left: Vec4, right: Mat4) Vec4 { + var result: Vec4 = undefined; + + // TODO: SIMD + + result.x = left.x * right.columns[0][0]; + result.y = left.x * right.columns[0][1]; + result.z = left.x * right.columns[0][2]; + result.w = left.x * right.columns[0][3]; + + result.x += left.y * right.columns[1][0]; + result.y += left.y * right.columns[1][1]; + result.z += left.y * right.columns[1][2]; + result.w += left.y * right.columns[1][3]; + + result.x += left.z * right.columns[2][0]; + result.y += left.z * right.columns[2][1]; + result.z += left.z * right.columns[2][2]; + result.w += left.z * right.columns[2][3]; + + result.x += left.w * right.columns[3][0]; + result.y += left.w * right.columns[3][1]; + result.z += left.w * right.columns[3][2]; + result.w += left.w * right.columns[3][3]; + + return result; + } + + pub fn multiply(left: Vec4, right: Vec4) Vec4 { + return init( + left.x * right.x, + left.y * right.y, + left.z * right.z, + left.w * right.w + ); + } + + pub fn asArray(self: *Vec4) []f32 { + const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(self)))); + return ptr[0..4]; + } + + pub fn initArray(array: []const f32) Vec4 { + return Vec4.init(array[0], array[1], array[2], array[3]); + } + + pub fn lerp(a: Vec4, b: Vec4, t: f32) Vec4 { + return Vec4.init( + std.math.lerp(a.x, b.x, t), + std.math.lerp(a.y, b.y, t), + std.math.lerp(a.z, b.z, t), + std.math.lerp(a.w, b.w, t), + ); + } + + pub fn clamp(self: Vec4, min_value: f32, max_value: f32) Vec4 { + return Vec4.init( + std.math.clamp(self.x, min_value, max_value), + std.math.clamp(self.y, min_value, max_value), + std.math.clamp(self.z, min_value, max_value), + std.math.clamp(self.w, min_value, max_value), + ); + } + + pub fn toVec3XYZ(self: Vec4) Vec3 { + return Vec3.init(self.x, self.y, self.z); + } +}; + +pub const Mat4 = extern struct { + columns: [4][4]f32, + + pub fn initZero() Mat4 { + var self: Mat4 = undefined; + @memset(self.asArray(), 0); + return self; + } + + pub fn initIdentity() Mat4 { + return Mat4.initDiagonal(1); + } + + pub fn initDiagonal(value: f32) Mat4 { + var self = Mat4.initZero(); + self.columns[0][0] = value; + self.columns[1][1] = value; + self.columns[2][2] = value; + self.columns[3][3] = value; + return self; + } + + pub fn multiply(left: Mat4, right: Mat4) Mat4 { + var self: Mat4 = undefined; + + inline for (.{ 0, 1, 2, 3 }) |i| { + var column = Vec4.initArray(&right.columns[i]).multiplyMat4(left); + @memcpy(&self.columns[i], column.asArray()); + } + + return self; + } + + pub fn initScale(scale: Vec3) Mat4 { + var self = Mat4.initIdentity(); + self.columns[0][0] = scale.x; + self.columns[1][1] = scale.y; + self.columns[2][2] = scale.z; + return self; + } + + pub fn initTranslate(offset: Vec3) Mat4 { + var self = Mat4.initIdentity(); + self.columns[3][0] = offset.x; + self.columns[3][1] = offset.y; + self.columns[3][2] = offset.z; + return self; + } + + pub fn asArray(self: *Mat4) []f32 { + const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(&self.columns)))); + return ptr[0..16]; + } +}; + +pub const Rect = struct { + pos: Vec2, + size: Vec2, + + pub const zero = Rect{ + .pos = Vec2.zero, + .size = Vec2.zero + }; + + pub fn init(x: f32, y: f32, width: f32, height: f32) Rect { + return Rect{ + .pos = Vec2.init(x, y), + .size = Vec2.init(width, height) + }; + } + + pub fn clip(self: Rect, other: Rect) Rect { + const left_edge = @max(self.left(), other.left()); + const right_edge = @min(self.right(), other.right()); + const top_edge = @max(self.top(), other.top()); + const bottom_edge = @min(self.bottom(), other.bottom()); + return Rect.init( + left_edge, + top_edge, + right_edge - left_edge, + bottom_edge - top_edge + ); + } + + pub fn left(self: Rect) f32 { + return self.pos.x; + } + + pub fn right(self: Rect) f32 { + return self.pos.x + self.size.x; + } + + pub fn top(self: Rect) f32 { + return self.pos.y; + } + + pub fn bottom(self: Rect) f32 { + return self.pos.y + self.size.y; + } + + pub fn center(self: Rect) Vec2 { + return self.pos.add(self.size.multiplyScalar(0.5)); + } + + pub fn multiply(self: Rect, xy: Vec2) Rect { + return Rect{ + .pos = self.pos.multiply(xy), + .size = self.size.multiply(xy), + }; + } + + pub fn divide(self: Rect, xy: Vec2) Rect { + return Rect{ + .pos = self.pos.divide(xy), + .size = self.size.divide(xy), + }; + } + + pub fn isInside(self: Rect, pos: Vec2) bool { + const x_overlap = self.pos.x <= pos.x and pos.x < self.pos.x + self.size.x; + const y_overlap = self.pos.y <= pos.y and pos.y < self.pos.y + self.size.y; + return x_overlap and y_overlap; + } +}; + +pub const Line = struct { + p0: Vec2, + p1: Vec2 +}; + +pub fn isInsideRect(rect_pos: Vec2, rect_size: Vec2, pos: Vec2) bool { + const rect = Rect{ + .pos = rect_pos, + .size = rect_size + }; + return rect.isInside(pos); +} + +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..3ae7a79 --- /dev/null +++ b/src/engine/root.zig @@ -0,0 +1,486 @@ +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; +const rgb = Math.rgb; + +pub const Input = @import("./input.zig"); + +pub const Frame = @import("./frame.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; + +allocator: std.mem.Allocator, +started_at: std.time.Instant, +mouse_inside: bool, +last_frame_at: Nanoseconds, + +game: Game, +assets: Assets, +frame: Frame, + +canvas_size: ?Vec2 = null, + +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"), + .mouse_inside = false, + .last_frame_at = 0, + .assets = undefined, + .game = undefined, + .frame = 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; + } + + self.frame.init(self.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, + .gl = .{ + .major_version = 3, + .minor_version = 3 + }, + .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 }, + }); + + const seed: u64 = @bitCast(std.time.milliTimestamp()); + + self.assets = try Assets.init(self.allocator); + self.game = try Game.init(self.allocator, seed, &self.assets); +} + +fn sokolCleanup(self: *Engine) void { + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + self.frame.deinit(); + Audio.deinit(); + self.game.deinit(); + self.assets.deinit(self.allocator); + Gfx.deinit(); +} + +fn sokolFrame(self: *Engine) !void { + tracy.frameMark(); + + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + const now = std.time.Instant.now() catch @panic("Instant.now() unsupported"); + const time_passed = now.since(self.started_at); + defer self.last_frame_at = time_passed; + + const frame = &self.frame; + + const screen_size = Vec2.init(sapp.widthf(), sapp.heightf()); + + var revert_mouse_position: ?Vec2 = null; + if (self.canvas_size) |canvas_size| { + if (self.frame.input.mouse_position) |mouse| { + const transform = ScreenScalar.init(screen_size, canvas_size); + + revert_mouse_position = mouse; + self.frame.input.mouse_position = mouse.sub(transform.translation).divideScalar(transform.scale); + } + } + + { + _ = frame.arena.reset(.retain_capacity); + const arena = frame.arena.allocator(); + + const audio_commands_capacity = frame.audio.commands.capacity; + frame.audio = .empty; + try frame.audio.commands.ensureTotalCapacity(arena, audio_commands_capacity); + + const graphics_commands_capacity = frame.graphics.commands.capacity; + const scissor_stack_capacity = frame.graphics.scissor_stack.capacity; + frame.graphics = .empty; + frame.graphics.screen_size = screen_size; + try frame.graphics.commands.ensureTotalCapacity(arena, graphics_commands_capacity); + try frame.graphics.scissor_stack.ensureTotalCapacity(arena, scissor_stack_capacity); + frame.pushScissor(.init(0, 0, sapp.widthf(), sapp.heightf())); + + frame.time_ns = time_passed; + frame.dt_ns = time_passed - self.last_frame_at; + frame.hide_cursor = false; + + try self.game.tick(&self.frame); + + frame.input.keyboard.pressed = .initEmpty(); + frame.input.keyboard.released = .initEmpty(); + frame.input.mouse_button.pressed = .initEmpty(); + frame.input.mouse_button.released = .initEmpty(); + } + + if (self.canvas_size) |canvas_size| { + const transform = ScreenScalar.init( + screen_size, + canvas_size + ); + transform.apply( + screen_size, + &self.frame, + rgb(0, 0, 0) + ); + } + + sapp.showMouse(!self.frame.hide_cursor); + + // Canvas size modification must always be applied a frame later. + // So that mouse coordinate transformations are consistent. + self.canvas_size = self.frame.graphics.canvas_size; + + { + Gfx.beginFrame(); + defer Gfx.endFrame(frame.graphics.clear_color); + + Gfx.drawCommands(frame.graphics.commands.items); + + if (frame.show_debug) { + try self.game.debug(); + try showDebugWindow(&self.frame); + } + } + + for (frame.audio.commands.items) |command| { + try Audio.mixer.commands.push(command); + } + + if (revert_mouse_position) |pos| { + self.frame.input.mouse_position = pos; + } +} + +fn showDebugWindow(frame: *Frame) !void { + if (!imgui.beginWindow(.{ + .name = "Engine", + .pos = Vec2.init(240, 20), + .size = Vec2.init(200, 200), + })) { + return; + } + defer imgui.endWindow(); + + imgui.textFmt("Draw commands: {}\n", .{ + frame.graphics.commands.items.len, + }); + imgui.textFmt("Audio instances: {}/{}\n", .{ + Audio.mixer.instances.items.len, + Audio.mixer.instances.capacity + }); +} + +fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool { + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + const e = e_ptr.*; + + if (imgui.handleEvent(e)) { + if (self.mouse_inside) { + Input.processEvent(&self.frame, .{ + .mouse_leave = {} + }); + } + self.mouse_inside = false; + return true; + } + + switch (e.type) { + .MOUSE_DOWN => { + Input.processEvent(&self.frame, .{ + .mouse_pressed = .{ + .button = @enumFromInt(@intFromEnum(e.mouse_button)), + .position = Vec2.init(e.mouse_x, e.mouse_y) + } + }); + + return true; + }, + .MOUSE_UP => { + Input.processEvent(&self.frame, .{ + .mouse_released = .{ + .button = @enumFromInt(@intFromEnum(e.mouse_button)), + .position = Vec2.init(e.mouse_x, e.mouse_y) + } + }); + + return true; + }, + .MOUSE_MOVE => { + if (!self.mouse_inside) { + Input.processEvent(&self.frame, .{ + .mouse_enter = Vec2.init(e.mouse_x, e.mouse_y) + }); + } else { + Input.processEvent(&self.frame, .{ + .mouse_move = Vec2.init(e.mouse_x, e.mouse_y) + }); + } + + self.mouse_inside = true; + return true; + }, + .MOUSE_ENTER => { + if (!self.mouse_inside) { + Input.processEvent(&self.frame, .{ + .mouse_enter = Vec2.init(e.mouse_x, e.mouse_y) + }); + } + + self.mouse_inside = true; + return true; + }, + .RESIZED => { + if (self.mouse_inside) { + Input.processEvent(&self.frame, .{ + .mouse_leave = {} + }); + } + + Input.processEvent(&self.frame, .{ + .window_resize = {} + }); + + self.mouse_inside = false; + return true; + }, + .MOUSE_LEAVE => { + if (self.mouse_inside) { + Input.processEvent(&self.frame, .{ + .mouse_leave = {} + }); + } + + self.mouse_inside = false; + return true; + }, + .MOUSE_SCROLL => { + Input.processEvent(&self.frame, .{ + .mouse_scroll = Vec2.init(e.scroll_x, e.scroll_y) + }); + + return true; + }, + .KEY_DOWN => { + Input.processEvent(&self.frame, .{ + .key_pressed = .{ + .code = @enumFromInt(@intFromEnum(e.key_code)), + .repeat = e.key_repeat + } + }); + + return true; + }, + .KEY_UP => { + Input.processEvent(&self.frame, .{ + .key_released = @enumFromInt(@intFromEnum(e.key_code)) + }); + + return true; + }, + .CHAR => { + Input.processEvent(&self.frame, .{ + .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..08a57ea --- /dev/null +++ b/src/engine/screen_scaler.zig @@ -0,0 +1,80 @@ +const Gfx = @import("./graphics.zig"); + +const Math = @import("./math.zig"); +const Vec2 = Math.Vec2; +const Vec4 = Math.Vec4; +const rgb = Math.rgb; + +const Frame = @import("./frame.zig"); + +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/ + +translation: Vec2, +scale: f32, + +pub fn init(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); + + return ScreenScalar{ + .translation = translation, + .scale = scale + }; +} + +pub fn apply(self: ScreenScalar, window_size: Vec2, frame: *Frame, color: Vec4) void { + const scale = self.scale; + const translation = self.translation; + + frame.prependGraphicsCommand(.{ + .push_transformation = .{ + .translation = translation, + .scale = .init(scale, scale) + } + }); + frame.popTransform(); + + frame.drawRectangle(.{ + .rect = .{ + .pos = .init(0, 0), + .size = .init(window_size.x, translation.y), + }, + .color = color + }); + + frame.drawRectangle(.{ + .rect = .{ + .pos = .init(0, window_size.y - translation.y), + .size = .init(window_size.x, translation.y), + }, + .color = color + }); + + frame.drawRectangle(.{ + .rect = .{ + .pos = .init(0, 0), + .size = .init(translation.x, window_size.y), + }, + .color = color + }); + + frame.drawRectangle(.{ + .rect = .{ + .pos = .init(window_size.x - translation.x, 0), + .size = .init(translation.x, window_size.y), + }, + .color = 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..b4eecb8 --- /dev/null +++ b/src/game.zig @@ -0,0 +1,130 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const clamp = std.math.clamp; +const log = std.log.scoped(.game); + +const sokol = @import("sokol"); +const sapp = sokol.app; + +const Assets = @import("./assets.zig"); +const Tilemap = Assets.Tilemap; + +const Engine = @import("./engine/root.zig"); +const Nanoseconds = Engine.Nanoseconds; +const imgui = Engine.imgui; +const Vec2 = Engine.Vec2; +const Vec4 = Engine.Math.Vec4; +const Rect = Engine.Math.Rect; +const rgb = Engine.Math.rgb; +const rgba = Engine.Math.rgba; +const Range = Engine.Math.Range; +const TextureId = Engine.Graphics.TextureId; +const AudioId = Engine.Audio.Data.Id; +const Sprite = Engine.Graphics.Sprite; + +const RaycastTileIterator = @import("./raycast_tile_iterator.zig"); + +const Game = @This(); + +const RNGState = std.Random.DefaultPrng; + +const Player = struct { + pos: Vec2 = .zero, + vel: Vec2 = .zero, + acc: Vec2 = .zero, +}; + +arena: std.heap.ArenaAllocator, +gpa: Allocator, +rng: RNGState, +assets: *Assets, + +player: Player = .{}, + +pub fn init(gpa: Allocator, seed: u64, assets: *Assets) !Game { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + + return Game{ + .arena = arena, + .gpa = gpa, + .assets = assets, + .player = .{ + .pos = .init(50, 50), + }, + .rng = RNGState.init(seed), + }; +} + +pub fn deinit(self: *Game) void { + self.arena.deinit(); +} + +pub fn tick(self: *Game, frame: *Engine.Frame) !void { + const dt = frame.deltaTime(); + + const canvas_size = Vec2.init(20 * 16, 15 * 16); + frame.graphics.canvas_size = canvas_size; + + if (frame.isKeyPressed(.F3)) { + frame.show_debug = !frame.show_debug; + } + + if (frame.isKeyPressed(.ESCAPE)) { + sapp.requestQuit(); + } + + frame.drawRectangle(.{ + .rect = .init(0, 0, canvas_size.x, canvas_size.y), + .color = rgb(20, 20, 20) + }); + + var dir = Vec2.init(0, 0); + if (frame.isKeyDown(.W)) { + dir.y -= 1; + } + if (frame.isKeyDown(.S)) { + dir.y += 1; + } + if (frame.isKeyDown(.A)) { + dir.x -= 1; + } + if (frame.isKeyDown(.D)) { + dir.x += 1; + } + dir = dir.normalized(); + + const max_speed = 400; + const acceleration = 1500; + const friction_coef = 0.99988; + + self.player.acc = dir.multiplyScalar(acceleration); + self.player.vel = self.player.vel.add(self.player.acc.multiplyScalar(dt)); + self.player.vel = self.player.vel.limitLength(max_speed); + const friction_force = std.math.pow(f32, 1 - friction_coef, dt); + self.player.vel = self.player.vel.multiplyScalar(friction_force); + self.player.pos = self.player.pos.add(self.player.vel.multiplyScalar(dt)); + + var size = self.assets.players_tilemap.tile_size; + frame.drawRectangle(.{ + .rect = .{ + .pos = self.player.pos.sub(size.divideScalar(2)), + .size = size, + }, + .color = rgb(255, 255, 255) + }); +} + +pub fn debug(self: *Game) !void { + if (!imgui.beginWindow(.{ + .name = "Game", + .pos = Vec2.init(20, 20), + .size = Vec2.init(200, 200), + })) { + return; + } + defer imgui.endWindow(); + + _ = self; +} diff --git a/src/game_original.zig b/src/game_original.zig new file mode 100644 index 0000000..12e0628 --- /dev/null +++ b/src/game_original.zig @@ -0,0 +1,719 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const clamp = std.math.clamp; +const log = std.log.scoped(.game); + +const Assets = @import("./assets.zig"); +const Tilemap = Assets.Tilemap; + +const Engine = @import("./engine/root.zig"); +const Nanoseconds = Engine.Nanoseconds; +const imgui = Engine.imgui; +const Vec2 = Engine.Vec2; +const Vec4 = Engine.Math.Vec4; +const Rect = Engine.Math.Rect; +const rgb = Engine.Math.rgb; +const rgba = Engine.Math.rgba; +const Range = Engine.Math.Range; +const TextureId = Engine.Graphics.TextureId; +const AudioId = Engine.Audio.Data.Id; +const Sprite = Engine.Graphics.Sprite; + +const RaycastTileIterator = @import("./raycast_tile_iterator.zig"); + +const Game = @This(); + +const RNGState = std.Random.DefaultPrng; + +const Animation = struct { + texture: TextureId, + frames: []Frame, + + const Frame = struct { + uv: Rect, + duration: f32, + }; + + const State = struct { + frame_index: usize, + timer: f32, + + const default = State{ + .frame_index = 0, + .timer = 0 + }; + + pub fn update(self: *State, animation: Animation, dt: f32) void { + self.timer += dt; + while (true) { + self.frame_index = @mod(self.frame_index, animation.frames.len); + const frame = animation.frames[self.frame_index]; + if (self.timer < frame.duration) { + break; + } + self.timer -= frame.duration; + self.frame_index += 1; + } + } + }; +}; + +const AudioBundle = struct { + cooldown_until: ?Engine.Nanoseconds, + + const empty = AudioBundle{ + .cooldown_until = null, + }; + + const PlayOptions = struct { + sounds: []const AudioId, + volume: Range = .init(1, 1), + fixed_delay: Range = .zero, + rng: std.Random + }; + + pub fn play(self: *AudioBundle, frame: *Engine.Frame, opts: PlayOptions) void { + if (opts.sounds.len == 0) { + return; + } + if (self.cooldown_until) |cooldown_until| { + if (cooldown_until > frame.time_ns) { + return; + } + } + + const sound_index = opts.rng.uintLessThan(usize, opts.sounds.len); + const sound = opts.sounds[sound_index]; + + frame.playAudio(.{ + .id = sound, + .volume = opts.volume.random(opts.rng) + }); + + const sound_info = Engine.Audio.getInfo(sound); + var duration = sound_info.getDuration(); + duration += @intFromFloat(opts.fixed_delay.random(opts.rng) * std.time.ns_per_s); + self.cooldown_until = frame.time_ns + duration; + } +}; + +const Bullet = struct { + position: Vec2, + velocity: Vec2 +}; + +const Tile = struct { + sprites_buffer: [4]Sprite, + sprites_len: usize, + + solid: bool, + + const empty = Tile{ + .sprites_buffer = undefined, + .sprites_len = 0, + .solid = false + }; + + fn sprites(self: *Tile) []Sprite { + return self.sprites_buffer[0..self.sprites_len]; + } + + fn appendSprite(self: *Tile, sprite: Sprite) void { + var list: std.ArrayList(Sprite) = .{ + .items = self.sprites(), + .capacity = self.sprites_buffer.len + }; + + if (list.items.len == list.capacity) { + _ = list.orderedRemove(0); + log.warn("Too many sprites on a single tile", .{}); + } + list.appendAssumeCapacity(sprite); + + self.sprites_len = list.items.len; + } +}; + +const TileGrid = struct { + origin: Vec2, + width: usize, + height: usize, + tile_size: Vec2, + tiles: []Tile, + + pub fn get(self: TileGrid, x: usize, y: usize) *Tile { + assert(0 <= x and x < self.width); + assert(0 <= y and y < self.height); + return &self.tiles[y * self.width + x]; + } + + pub fn isInBounds(self: TileGrid, x: i32, y: i32) bool { + return (0 <= x and x < @as(i32, @intCast(self.width))) and (0 <= y and y < @as(i32, @intCast(self.height))); + } + + pub fn toTileSpace(self: TileGrid, pos: Vec2) Vec2 { + return pos.sub(self.origin).divide(self.tile_size); + } + + pub fn fromTileSpace(self: TileGrid, pos: Vec2) Vec2 { + return pos.multiply(self.tile_size).add(self.origin); + } +}; + +arena: std.heap.ArenaAllocator, +gpa: Allocator, +rng: RNGState, +assets: *Assets, + +player: Vec2, +player_anim_state: Animation.State = .default, +last_faced_left: bool = false, +player_walk_sound: AudioBundle = .empty, +hand_offset: Vec2 = .zero, + +bullets: std.ArrayList(Bullet) = .empty, +tilegrid: TileGrid, + +player_anim: Animation, + +pub fn init(gpa: Allocator, seed: u64, assets: *Assets) !Game { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + + const player_anim = Animation{ + .texture = assets.players_tilemap.texture, + .frames = try arena.allocator().dupe(Animation.Frame, &.{ + .{ + .uv = assets.players_tilemap.getTileUV(0, 0), + .duration = 0.1, + }, + .{ + .uv = assets.players_tilemap.getTileUV(1, 0), + .duration = 0.2, + } + }), + }; + + const tilemap = assets.terrain_tilemap; + const map_bounds = assets.map.getTileBounds(); + + const map_width = map_bounds.getWidth(); + const map_height = map_bounds.getHeight(); + const tilegrid = TileGrid{ + .origin = Vec2.initFromInt(i32, map_bounds.left, map_bounds.top).multiply(tilemap.tile_size), + .width = map_width, + .height = map_height, + .tile_size = tilemap.tile_size, + .tiles = try arena.allocator().alloc(Tile, map_width * map_height), + }; + + @memset(tilegrid.tiles, .empty); + + { + const texture_info = Engine.Graphics.getTextureInfo(tilemap.texture); + const tilemap_size = Vec2.initFromInt(u32, texture_info.width, texture_info.height); + + for (assets.map.layers) |layer| { + if (layer.variant != .tile) { + continue; + } + + const is_layer_solid = layer.properties.getBool("solid") orelse false; + + const tile_layer = layer.variant.tile; + const layer_bounds = tile_layer.getBounds(); + for (0..layer_bounds.getHeight()) |oy| { + const y = layer_bounds.top + @as(i32, @intCast(oy)); + + for (0..layer_bounds.getWidth()) |ox| { + const x = layer_bounds.left + @as(i32, @intCast(ox)); + const tile_gid = tile_layer.get(x, y) orelse continue; + if (tile_gid == 0) continue; + const tile = assets.map.getTile(assets.tilesets, tile_gid) orelse continue; + + const tile_id_f32: f32 = @floatFromInt(tile.id); + const width_in_tiles = tilemap_size.x / tilemap.tile_size.x; + const tile_x = @rem(tile_id_f32, width_in_tiles); + const tile_y =@divFloor(tile_id_f32, width_in_tiles); + + const tilegrid_tile = tilegrid.get( + @intCast(x - map_bounds.left), + @intCast(y - map_bounds.top) + ); + tilegrid_tile.solid |= is_layer_solid; + tilegrid_tile.appendSprite(.{ + .texture = tilemap.texture, + .uv = tilemap.getTileUV(tile_x, tile_y) + }); + } + } + } + } + + return Game{ + .arena = arena, + .gpa = gpa, + .assets = assets, + .player = findSpawnpoint(assets) orelse .init(0, 0), + .player_anim = player_anim, + .rng = RNGState.init(seed), + .tilegrid = tilegrid + }; +} + +pub fn deinit(self: *Game) void { + self.arena.deinit(); + self.bullets.deinit(self.gpa); +} + +fn findSpawnpoint(assets: *Assets) ?Vec2 { + const map = assets.map; + for (map.layers) |layer| { + if (layer.variant != .object) { + continue; + } + const object_layer = layer.variant.object; + for (object_layer.items) |object| { + if (object.shape != .point) { + continue; + } + const point = object.shape.point; + if (std.mem.eql(u8, object.name, "spawnpoint")) { + return .init(point.x, point.y); + } + } + } + return null; +} + +const DrawTileOptions = struct { + pos: Vec2, + scale: Vec2 = .init(1, 1), + color: Vec4 = rgb(255, 255, 255), + rotation: f32 = 0, + origin: Vec2 = .init(0, 0), + + tilemap: Tilemap, + tile: Vec2 +}; + +fn drawTile(frame: *Engine.Frame, opts: DrawTileOptions) void { + frame.drawRectangle(.{ + .rect = .{ + .pos = opts.pos, + .size = opts.tilemap.tile_size.multiply(opts.scale) + }, + .color = opts.color, + .rotation = opts.rotation, + .origin = opts.origin, + .sprite = .{ + .texture = opts.tilemap.texture, + .uv = opts.tilemap.getTileUV(opts.tile.x, opts.tile.y) + } + }); +} + +fn drawTilemap(self: *Game, frame: *Engine.Frame) void { + for (0..self.tilegrid.height) |y| { + for (0..self.tilegrid.width) |x| { + const tile = self.tilegrid.get(x, y); + if (tile.sprites_len == 0) continue; + + const tile_pos = Vec2.initFromInt(usize, x, y).multiply(self.tilegrid.tile_size); + const tile_rect = Rect{ + .pos = self.tilegrid.origin.add(tile_pos), + .size = self.tilegrid.tile_size + }; + + const color = rgb(255, 255, 255); + for (tile.sprites()) |sprite| { + frame.drawRectangle(.{ + .rect = tile_rect, + .color = color, + .sprite = sprite + }); + } + } + } +} + +const CollisionResult = struct { + time: f32, + normal: Vec2, +}; + +fn sweptAABB(b1: Rect, v1: Vec2, b2: Rect) ?CollisionResult { + var entry_x: f32 = undefined; + var exit_x: f32 = undefined; + if (v1.x > 0) { + entry_x = b2.left() - b1.right(); + exit_x = b2.right() - b1.left(); + } else { + entry_x = b2.right() - b1.left(); + exit_x = b2.left() - b1.right(); + } + + var entry_y: f32 = undefined; + var exit_y: f32 = undefined; + if (v1.y > 0) { + entry_y = b2.top() - b1.bottom(); + exit_y = b2.bottom() - b1.top(); + } else { + entry_y = b2.bottom() - b1.top(); + exit_y = b2.top() - b1.bottom(); + } + + const inf = std.math.inf(f32); + + var entry_x_time: f32 = undefined; + var exit_x_time: f32 = undefined; + if (v1.x == 0) { + entry_x_time = -inf; + exit_x_time = inf; + } else { + entry_x_time = entry_x / v1.x; + exit_x_time = exit_x / v1.x; + } + + var entry_y_time: f32 = undefined; + var exit_y_time: f32 = undefined; + if (v1.y == 0) { + entry_y_time = -inf; + exit_y_time = inf; + } else { + entry_y_time = entry_y / v1.y; + exit_y_time = exit_y / v1.y; + } + + const entry_time = @max(entry_x_time, entry_y_time); + const exit_time = @min(exit_x_time, exit_y_time); + + if (entry_time > exit_time or + (entry_x_time < 0 and entry_y_time < 0) or + entry_x_time > 1.0 or + entry_y_time > 1.0 + ) { + return null; + } + + var result = CollisionResult{ + .time = entry_time, + .normal = .zero + }; + + if (entry_x_time > entry_y_time) { + result.normal.x = if ((exit_x - entry_x) < 0.0) 1 else -1; + } else { + result.normal.y = if ((exit_y - entry_y) < 0.0) 1 else -1; + } + + return result; +} + +fn getStepBounds(rect: Rect, step: Vec2) Rect { + var result = rect; + + if (step.x >= 0) { + result.size.x += step.x; + } else { + result.pos.x += step.x; + result.size.x -= step.x; + } + + if (step.y >= 0) { + result.size.y += step.y; + } else { + result.pos.y += step.y; + result.size.y -= step.y; + } + + return result; +} + +fn collisionResponseSlide(step: *Vec2, collision: CollisionResult) void { + var new_step = step.multiplyScalar(collision.time); + + const remaining_time = 1 - collision.time; + const dotprod = (step.x * collision.normal.y + step.y * collision.normal.x) * remaining_time; + new_step.x += dotprod * collision.normal.y; + new_step.y += dotprod * collision.normal.x; + + step.* = new_step; +} + +fn listNearestSolids(self: *Game, gpa: Allocator, bounds: Rect) ![]Rect { + var result: std.ArrayList(Rect) = .empty; + errdefer result.deinit(gpa); + + var distances: std.ArrayList(f32) = .empty; + defer distances.deinit(gpa); + + const top_left = self.tilegrid.toTileSpace(Vec2.init(bounds.left(), bounds.top())); + const bottom_right = self.tilegrid.toTileSpace(Vec2.init(bounds.right(), bounds.bottom())); + + var y = @floor(top_left.y); + while (y < bottom_right.y) : (y += 1) { + var x = @floor(top_left.x); + while (x < bottom_right.x) : (x += 1) { + const x_i32: i32 = @intFromFloat(x); + const y_i32: i32 = @intFromFloat(y); + if (!self.tilegrid.isInBounds(x_i32, y_i32)) continue; + + const tile = self.tilegrid.get(@intCast(x_i32), @intCast(y_i32)); + if (tile.solid) { + const tile_collider = Rect{ + .pos = self.tilegrid.fromTileSpace(Vec2.init(x, y)), + .size = self.tilegrid.tile_size + }; + try result.append(gpa, tile_collider); + try distances.append(gpa, Vec2.distanceSqr(tile_collider.center(), bounds.center())); + } + } + } + + const Context = struct { + bounds_center: Vec2, + + fn lessThanFn(ctx: @This(), lhs: Rect, rhs: Rect) bool { + const lhs_distance = Vec2.distanceSqr(lhs.center(), ctx.bounds_center); + const rhs_distance = Vec2.distanceSqr(rhs.center(), ctx.bounds_center); + return lhs_distance < rhs_distance; + } + }; + + const ctx = Context{ + .bounds_center = bounds.center() + }; + std.mem.sort(Rect, result.items, ctx, Context.lessThanFn); + + return try result.toOwnedSlice(gpa); +} + +pub fn tick(self: *Game, frame: *Engine.Frame) !void { + const dt = frame.deltaTime(); + + const canvas_size = Vec2.init(20 * 16, 15 * 16); + frame.graphics.canvas_size = canvas_size; + + if (frame.isKeyPressed(.F3)) { + frame.show_debug = !frame.show_debug; + } + + frame.drawRectangle(.{ + .rect = .init(0, 0, canvas_size.x, canvas_size.y), + .color = rgb(20, 20, 20) + }); + + const camera_offset = canvas_size.divideScalar(2).sub(self.player); + frame.pushTransform(camera_offset, .init(1, 1)); + defer frame.popTransform(); + + self.drawTilemap(frame); + + var dir = Vec2.init(0, 0); + if (frame.isKeyDown(.W)) { + dir.y -= 1; + } + if (frame.isKeyDown(.S)) { + dir.y += 1; + } + if (frame.isKeyDown(.A)) { + dir.x -= 1; + } + if (frame.isKeyDown(.D)) { + dir.x += 1; + } + dir = dir.normalized(); + + if (dir.x != 0 or dir.y != 0) { + self.player_anim_state.update(self.player_anim, dt); + self.player_walk_sound.play(frame, .{ + .sounds = self.assets.move_sound, + .fixed_delay = .init(0.1, 0.15), + .volume = .init(0.025, 0.03), + .rng = self.rng.random() + }); + } else { + self.player_anim_state.frame_index = 0; + } + + const velocity = dir.multiplyScalar(50); + var step = velocity.multiplyScalar(dt); + + const player_collider_size = Vec2.init(20, 20); + const player_collider = Rect{ + .pos = self.player.sub(player_collider_size.divideScalar(2)), + .size = player_collider_size, + }; + + { + // const solids = try self.listNearestSolids(frame.arena.allocator(), getStepBounds(player_collider, step)); + // for (solids) |solid| { + // if (sweptAABB(player_collider, step, solid)) |collision| { + // collisionResponseSlide(&step, collision); + // } + // } + + inline for (.{ + // Vec2.init(0, 0), + // Vec2.init(1, 0), + Vec2.init(0, 1), + // Vec2.init(1, 1), + }) |corner_offset| { + const corner = player_collider.pos.add(player_collider.size.multiply(corner_offset)); + var iter = RaycastTileIterator.init( + self.tilegrid.toTileSpace(corner), + self.tilegrid.toTileSpace(corner.add(step)), + ); + while (iter.next()) |collision| { + if (!self.tilegrid.isInBounds(collision.tile_x, collision.tile_y)) break; + const tile = self.tilegrid.get(@intCast(collision.tile_x), @intCast(collision.tile_y)); + if (tile.solid) { + const time = collision.distance / step.divide(self.tilegrid.tile_size).length(); + collisionResponseSlide(&step, .{ + .normal = .initFromInt(i32, collision.normal_x, collision.normal_y), + .time = time + }); + + const pos = Vec2.initFromInt(i32, collision.tile_x, collision.tile_y).multiply(self.tilegrid.tile_size).add(self.tilegrid.origin); + frame.drawRectangle(.{ + .rect = Rect{ + .pos = pos, + .size = self.tilegrid.tile_size, + }, + .color = rgba(200, 20, 20, 0.5) + }); + break; + } + } + } + } + + self.player = self.player.add(step); + + if (dir.x < 0) { + self.last_faced_left = true; + } else if (dir.x > 0) { + self.last_faced_left = false; + } + + var size = self.assets.players_tilemap.tile_size; + if (self.last_faced_left) { + size.x *= -1; + } + frame.drawRectangle(.{ + .rect = .{ + .pos = self.player.sub(size.divideScalar(2)), + .size = size, + }, + .color = rgb(255, 255, 255), + .sprite = .{ + .texture = self.player_anim.texture, + .uv = self.player_anim.frames[self.player_anim_state.frame_index].uv + } + }); + + frame.drawRectangle(.{ + .rect = player_collider, + .color = rgba(20, 255, 255, 0.5) + }); + + + const max_hand_length = 32; + if (frame.input.mouse_position) |mouse_screen| { + const mouse = mouse_screen.sub(camera_offset); + const player_to_mouse = mouse.sub(self.player); + self.hand_offset = mouse.sub(self.player).limitLength(max_hand_length); + + const opacity = clamp((player_to_mouse.length() - max_hand_length) / 16, 0, 1); + drawTile(frame, .{ + .pos = mouse.sub(self.assets.weapons_tilemap.tile_size.multiplyScalar(0.5)), + .tilemap = self.assets.weapons_tilemap, + .color = rgba(255, 255, 255, opacity), + .tile = .init(4, 2) + }); + frame.hide_cursor = true; + + if (false) { + var iter = RaycastTileIterator.init( + self.tilegrid.toTileSpace(self.player), + self.tilegrid.toTileSpace(mouse), + ); + while (iter.next()) |item| { + if (!self.tilegrid.isInBounds(item.tile_x, item.tile_y)) break; + + const tile = self.tilegrid.get(@intCast(item.tile_x), @intCast(item.tile_y)); + const pos = Vec2.initFromInt(i32, item.tile_x, item.tile_y).multiply(self.tilegrid.tile_size).add(self.tilegrid.origin); + + frame.drawRectangle(.{ + .rect = Rect{ + .pos = pos, + .size = self.tilegrid.tile_size, + }, + .color = rgba(200, 20, 20, 0.5) + }); + frame.drawRectangle(.{ + .rect = Rect{ + .pos = pos.add(self.tilegrid.tile_size.multiplyScalar(0.25)).add(self.tilegrid.tile_size.multiplyScalar(0.25).multiply(.initFromInt(i32, item.normal_x, item.normal_y))), + .size = self.tilegrid.tile_size.divideScalar(2), + }, + .color = rgba(20, 200, 20, 0.5) + }); + + if (tile.solid) { + break; + } + } + } + } + + const hand = self.player.add(self.hand_offset); + var hand_flip_x: f32 = 1; + if (self.hand_offset.x < 0) { + hand_flip_x *= -1; + } + const hand_scale = Vec2.init(1, hand_flip_x); + const weapon_size = self.assets.weapons_tilemap.tile_size; + drawTile(frame, .{ + .pos = hand.add(weapon_size.multiplyScalar(-0.5).multiply(hand_scale)), + .scale = hand_scale, + .tilemap = self.assets.weapons_tilemap, + .tile = .init(0, 0), + .origin = weapon_size.multiplyScalar(0.5).multiply(hand_scale), + .rotation = self.hand_offset.getAngle() + }); + + if (frame.isMousePressed(.left)) { + try self.bullets.append(self.gpa, .{ + .position = hand, + .velocity = self.hand_offset.normalized().multiplyScalar(100) + }); + } + + for (self.bullets.items) |*bullet| { + bullet.position = bullet.position.add(bullet.velocity.multiplyScalar(dt)); + + const bullet_size = Vec2.init(16, 16); + frame.drawRectangle(.{ + .rect = .{ + .pos = bullet.position.sub(bullet_size.multiplyScalar(0.5)), + .size = bullet_size + }, + .color = rgb(200, 10, 10) + }); + } +} + +pub fn debug(self: *Game) !void { + if (!imgui.beginWindow(.{ + .name = "Game", + .pos = Vec2.init(20, 20), + .size = Vec2.init(200, 200), + })) { + return; + } + defer imgui.endWindow(); + + imgui.textFmt("Position: {}, {}", .{ self.player.x, self.player.y }); +} 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/src/raycast_tile_iterator.zig b/src/raycast_tile_iterator.zig new file mode 100644 index 0000000..f3257f9 --- /dev/null +++ b/src/raycast_tile_iterator.zig @@ -0,0 +1,97 @@ +const Engine = @import("./engine/root.zig"); +const Vec2 = Engine.Vec2; + +// DDA Algorithm: https://lodev.org/cgtutor/raycasting.html +const RaycastTileIterator = @This(); + +max_distance: f32, +ray_length_1d: Vec2, +check_x: i32, +check_y: i32, +step_x: i32, +step_y: i32, +unit_step_size: Vec2, + +const Item = struct { + tile_x: i32, + tile_y: i32, + normal_x: i32, + normal_y: i32, + distance: f32, +}; + +pub fn init(origin: Vec2, target: Vec2) RaycastTileIterator { + const target_origin = target.sub(origin); + const max_distance = target_origin.length(); + const dir = target_origin.normalized(); + + var unit_step_size_x: f32 = 0; + if (dir.x != 0) { + unit_step_size_x = dir.y / dir.x; + } + + var unit_step_size_y: f32= 0; + if (dir.y != 0) { + unit_step_size_y = dir.x / dir.y; + } + + const unit_step_size = Vec2{ + .x = Vec2.init(1, unit_step_size_x).length(), + .y = Vec2.init(unit_step_size_y, 1).length() + }; + + const step_x: i32 = if (dir.x < 0) -1 else 1; + const step_y: i32 = if (dir.y < 0) -1 else 1; + + const step_x_f32: f32 = @floatFromInt(step_x); + const step_y_f32: f32 = @floatFromInt(step_y); + + const ray_length_1d = (Vec2{ + .x = ((step_x_f32 + 1)/2 - step_x_f32 * @mod(origin.x, 1)), + .y = ((step_y_f32 + 1)/2 - step_y_f32 * @mod(origin.y, 1)) + }).multiply(unit_step_size); + + const check_x: i32 = @intFromFloat(origin.x); + const check_y: i32 = @intFromFloat(origin.y); + + return RaycastTileIterator{ + .max_distance = max_distance, + .ray_length_1d = ray_length_1d, + .check_x = check_x, + .check_y = check_y, + .step_x = step_x, + .step_y = step_y, + .unit_step_size = unit_step_size + }; +} + +pub fn next(self: *RaycastTileIterator) ?Item { + if (self.ray_length_1d.x > self.max_distance and self.ray_length_1d.y > self.max_distance) { + return null; + } + + var normal_x: i32 = 0; + var normal_y: i32 = 0; + var distance: f32 = undefined; + if (self.ray_length_1d.x < self.ray_length_1d.y) { + distance = self.ray_length_1d.x; + + normal_x = -self.step_x; + self.check_x += self.step_x; + self.ray_length_1d.x += self.unit_step_size.x; + } else { + distance = self.ray_length_1d.y; + + normal_y = -self.step_y; + self.check_y += self.step_y; + self.ray_length_1d.y += self.unit_step_size.y; + } + + return .{ + .distance = distance, + .tile_x = self.check_x, + .tile_y = self.check_y, + .normal_x = normal_x, + .normal_y = normal_y + }; +} 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(); +}