initial commit

This commit is contained in:
Rokas Puzonas 2026-01-18 07:06:01 +02:00
commit 69fed14d75
48 changed files with 5851 additions and 0 deletions

30
README.md Normal file
View File

@ -0,0 +1,30 @@
# Game template
## Run
Linux and Windows:
```sh
zig build run
```
Web:
```sh
zig build -Dtarget=wasm32-emscripten run
```
Cross-compile for Windows from Linux:
```sh
zig build -Dtarget=x86_64-windows
```
## TODO
* Use [Skribidi](https://github.com/memononen/Skribidi) instead of fontstash for text rendering
* Support for audio formats (Might not need all of them, haven't decided):
* QOA, maybe [qoa.h](https://github.com/phoboslab/qoa/blob/master/qoa.h)?
* Flac, maybe [dr_flac.h](https://github.com/mackron/dr_libs/blob/master/dr_flac.h)?
* Wav, maybe [dr_wav.h](https://github.com/mackron/dr_libs/blob/master/dr_wav.h)?
* Mp3, maybe [dr_mp3.h](https://github.com/mackron/dr_libs/blob/master/dr_mp3.h)?
* Gamepad support.
* WASM Support. Currently a build config isn't provided for this target.
* Update build config for other platforms to reduce binary size. All of the video and audio drivers aren't needed, only gamepads

304
build.zig Normal file
View File

@ -0,0 +1,304 @@
const std = @import("std");
const sokol = @import("sokol");
const builtin = @import("builtin");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const has_imgui = b.option(bool, "imgui", "ImGui integration") orelse (optimize == .Debug);
var has_tracy = b.option(bool, "tracy", "Tracy integration") orelse (optimize == .Debug);
const has_console = b.option(bool, "console", "Show console (Window only)") orelse (optimize == .Debug);
const isWasm = target.result.cpu.arch.isWasm();
if (isWasm) {
has_tracy = false;
}
const mod_main = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = true
});
const dep_sokol = b.dependency("sokol", .{
.target = target,
.optimize = optimize,
.with_sokol_imgui = has_imgui,
});
mod_main.linkLibrary(dep_sokol.artifact("sokol_clib"));
mod_main.addImport("sokol", dep_sokol.module("sokol"));
if (has_imgui) {
if (b.lazyDependency("cimgui", .{
.target = target,
.optimize = optimize,
})) |dep_cimgui| {
const cimgui = b.lazyImport(@This(), "cimgui").?;
const cimgui_conf = cimgui.getConfig(false);
mod_main.addImport("cimgui", dep_cimgui.module(cimgui_conf.module_name));
dep_sokol.artifact("sokol_clib").addIncludePath(dep_cimgui.path(cimgui_conf.include_dir));
}
}
const dep_tracy = b.dependency("tracy", .{
.target = target,
.optimize = optimize,
.tracy_enable = has_tracy,
.tracy_only_localhost = true
});
if (has_tracy) {
mod_main.linkLibrary(dep_tracy.artifact("tracy"));
}
mod_main.addImport("tracy", dep_tracy.module("tracy"));
const dep_tiled = b.dependency("tiled", .{});
mod_main.addImport("tiled", dep_tiled.module("tiled"));
const dep_stb = b.dependency("stb", .{});
mod_main.addImport("stb_image", dep_stb.module("stb_image"));
mod_main.addImport("stb_vorbis", dep_stb.module("stb_vorbis"));
const dep_fontstash_c = b.dependency("fontstash_c", .{});
mod_main.addIncludePath(dep_fontstash_c.path("src"));
const dep_sokol_c = b.dependency("sokol_c", .{});
{
var cflags_buffer: [64][]const u8 = undefined;
var cflags = std.ArrayListUnmanaged([]const u8).initBuffer(&cflags_buffer);
switch (sokol.resolveSokolBackend(.auto, target.result)) {
.d3d11 => try cflags.appendBounded("-DSOKOL_D3D11"),
.metal => try cflags.appendBounded("-DSOKOL_METAL"),
.gl => try cflags.appendBounded("-DSOKOL_GLCORE"),
.gles3 => try cflags.appendBounded("-DSOKOL_GLES3"),
.wgpu => try cflags.appendBounded("-DSOKOL_WGPU"),
else => @panic("unknown sokol backend"),
}
mod_main.addIncludePath(dep_sokol_c.path("util"));
mod_main.addCSourceFile(.{
.file = b.path("src/engine/fontstash/sokol_fontstash_impl.c"),
.flags = cflags.items
});
}
// TODO:
// const sdl = b.dependency("sdl", .{
// .optimize = optimize,
// .target = target,
// .linkage = .static,
// .default_target_config = !isWasm
// });
// mod_main.linkLibrary(sdl.artifact("SDL3"));
// if (isWasm) {
// // TODO: Define buid config for wasm
// }
var options = b.addOptions();
options.addOption(bool, "has_imgui", has_imgui);
options.addOption(bool, "has_tracy", has_tracy);
mod_main.addOptions("build_options", options);
// from here on different handling for native vs wasm builds
if (target.result.cpu.arch.isWasm()) {
try buildWasm(b, .{
.name = "sokol_template",
.mod_main = mod_main,
.dep_sokol = dep_sokol,
});
} else {
try buildNative(b, "sokol_template", mod_main, has_console);
}
}
fn buildNative(b: *std.Build, name: []const u8, mod: *std.Build.Module, has_console: bool) !void {
const exe = b.addExecutable(.{
.name = name,
.root_module = mod
});
const target = mod.resolved_target.?;
if (target.result.os.tag == .windows) {
exe.subsystem = if (has_console) .Console else .Windows;
const png_to_icon_tool = b.addExecutable(.{
.name = "png-to-icon",
.root_module = b.createModule(.{
.target = b.graph.host,
.root_source_file = b.path("tools/png-to-icon.zig"),
}),
});
const dep_stb_image = b.dependency("stb_image", .{});
png_to_icon_tool.root_module.addImport("stb_image", dep_stb_image.module("stb_image"));
const png_to_icon_step = b.addRunArtifact(png_to_icon_tool);
png_to_icon_step.addFileArg(b.path("src/assets/icon.png"));
const icon_file = png_to_icon_step.addOutputFileArg("icon.ico");
const add_icon_step = AddExecutableIcon.init(exe, icon_file);
exe.step.dependOn(&add_icon_step.step);
}
b.installArtifact(exe);
{
const run_step = b.step("run", "Run game");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
}
{
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_exe_tests.step);
}
}
const BuildWasmOptions = struct {
name: []const u8,
mod_main: *std.Build.Module,
dep_sokol: *std.Build.Dependency,
};
fn patchWasmIncludeDirs(
module: *std.Build.Module,
path: std.Build.LazyPath,
depend_step: *std.Build.Step
) void {
if (module.link_libc != null and module.link_libc.?) {
// need to inject the Emscripten system header include path into
// the cimgui C library otherwise the C/C++ code won't find
// C stdlib headers
module.addSystemIncludePath(path);
}
for (module.import_table.values()) |imported_module| {
patchWasmIncludeDirs(imported_module, path, depend_step);
}
for (module.link_objects.items) |link_object| {
if (link_object != .other_step) {
continue;
}
const lib = link_object.other_step;
if (&lib.step == depend_step) {
continue;
}
if (lib.root_module.link_libc != null and lib.root_module.link_libc.?) {
// need to inject the Emscripten system header include path into
// the cimgui C library otherwise the C/C++ code won't find
// C stdlib headers
lib.root_module.addSystemIncludePath(path);
// all C libraries need to depend on the sokol library, when building for
// WASM this makes sure that the Emscripten SDK has been setup before
// C compilation is attempted (since the sokol C library depends on the
// Emscripten SDK setup step)
lib.step.dependOn(depend_step);
}
patchWasmIncludeDirs(lib.root_module, path, depend_step);
}
}
fn buildWasm(b: *std.Build, opts: BuildWasmOptions) !void {
opts.mod_main.sanitize_c = .off;
// build the main file into a library, this is because the WASM 'exe'
// needs to be linked in a separate build step with the Emscripten linker
const main_lib = b.addLibrary(.{
.name = "index",
.root_module = opts.mod_main,
});
const dep_emsdk = opts.dep_sokol.builder.dependency("emsdk", .{});
patchWasmIncludeDirs(
opts.mod_main,
dep_emsdk.path("upstream/emscripten/cache/sysroot/include"),
&(opts.dep_sokol.artifact("sokol_clib").step)
);
// create a build step which invokes the Emscripten linker
const link_step = try sokol.emLinkStep(b, .{
.lib_main = main_lib,
.target = opts.mod_main.resolved_target.?,
.optimize = opts.mod_main.optimize.?,
.emsdk = dep_emsdk,
.use_webgl2 = true,
.use_emmalloc = true,
.use_filesystem = false,
.shell_file_path = b.path("src/engine/shell.html"),
});
// attach to default target
b.getInstallStep().dependOn(&link_step.step);
// ...and a special run step to start the web build output via 'emrun'
const run = sokol.emRunStep(b, .{ .name = "index", .emsdk = dep_emsdk });
run.step.dependOn(&link_step.step);
b.step("run", "Run game").dependOn(&run.step);
// TODO: Create a zip archive of all of the files. Would be useful for easier itch.io upload
}
const AddExecutableIcon = struct {
obj: *std.Build.Step.Compile,
step: std.Build.Step,
icon_file: std.Build.LazyPath,
resource_file: std.Build.LazyPath,
fn init(obj: *std.Build.Step.Compile, icon_file: std.Build.LazyPath) *AddExecutableIcon {
const b = obj.step.owner;
const self = b.allocator.create(AddExecutableIcon) catch @panic("OOM");
self.obj = obj;
self.step = std.Build.Step.init(.{
.id = .custom,
.name = "add executable icon",
.owner = b,
.makeFn = make
});
self.icon_file = icon_file;
icon_file.addStepDependencies(&self.step);
const write_files = b.addWriteFiles();
self.resource_file = write_files.add("resource-file.rc", "");
self.step.dependOn(&write_files.step);
self.obj.addWin32ResourceFile(.{
.file = self.resource_file,
});
return self;
}
fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void {
const b = step.owner;
const self: *AddExecutableIcon = @fieldParentPtr("step", step);
const resource_file = try std.fs.cwd().createFile(self.resource_file.getPath(b), .{ });
defer resource_file.close();
const relative_icon_path = try std.fs.path.relative(
b.allocator,
self.resource_file.dirname().getPath(b),
self.icon_file.getPath(b)
);
std.mem.replaceScalar(u8, relative_icon_path, '\\', '/');
try resource_file.writeAll("IDI_ICON ICON \"");
try resource_file.writeAll(relative_icon_path);
try resource_file.writeAll("\"");
}
};

44
build.zig.zon Normal file
View File

@ -0,0 +1,44 @@
.{
.name = .sokol_template,
.version = "0.0.0",
.fingerprint = 0x60a8e079a691c8d9, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2",
.dependencies = .{
.sokol = .{
.url = "git+https://github.com/floooh/sokol-zig.git#aaf291ca2d3d1cedc05d65f5a1cacae0f53d934a",
.hash = "sokol-0.1.0-pb1HK4iDNgCom5dkY66eUBm_bYBHEl8KWFDAiqwWgpEy",
},
.sokol_c = .{
.url = "git+https://github.com/floooh/sokol.git#c66a1f04e6495d635c5e913335ab2308281e0492",
.hash = "N-V-__8AAC3eYABB1DVLb4dkcEzq_xVeEZZugVfQ6DoNQBDN",
},
.fontstash_c = .{
.url = "git+https://github.com/memononen/fontstash.git#b5ddc9741061343740d85d636d782ed3e07cf7be",
.hash = "N-V-__8AAA9xHgAxdLYPmlNTy6qzv9IYqiIePEHQUOPWYQ_6",
},
.cimgui = .{
.url = "git+https://github.com/floooh/dcimgui.git#33c99ef426b68030412b5a4b11487a23da9d4f13",
.hash = "cimgui-0.1.0-44ClkQRJlABdFMKRqIG8KDD6jy1eQbgPO335NziPYjmL",
.lazy = true,
},
.tracy = .{
.url = "git+https://github.com/sagehane/zig-tracy.git#80933723efe9bf840fe749b0bfc0d610f1db1669",
.hash = "zig_tracy-0.0.5-aOIqsX1tAACKaRRB-sraMLuNiMASXi_y-4FtRuw4cTpx",
},
.tiled = .{
.path = "./libs/tiled",
},
.stb = .{
.path = "./libs/stb",
},
// .sdl = .{
// .url = "git+https://github.com/allyourcodebase/SDL3.git#f85824b0db782b7d01c60aaad8bcb537892394e8",
// .hash = "sdl-0.0.0-i4QD0UuFqADRQysNyJ1OvCOZnq-clcVhq3BfPcBOf9zr",
// },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

38
libs/stb/build.zig Normal file
View File

@ -0,0 +1,38 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const stb_dependency = b.dependency("stb", .{});
{
const mod_stb_image = b.addModule("stb_image", .{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/stb_image.zig"),
.link_libc = true,
});
mod_stb_image.addIncludePath(stb_dependency.path("."));
mod_stb_image.addCSourceFile(.{
.file = b.path("src/stb_image_impl.c"),
.flags = &.{}
});
}
{
const mod_stb_image = b.addModule("stb_vorbis", .{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/stb_vorbis.zig"),
.link_libc = true,
});
mod_stb_image.addIncludePath(stb_dependency.path("."));
mod_stb_image.addCSourceFile(.{
.file = b.path("src/stb_vorbis_impl.c"),
.flags = &.{}
});
}
}

17
libs/stb/build.zig.zon Normal file
View File

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

View File

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

View File

@ -0,0 +1,3 @@
#define STB_IMAGE_IMPLEMENTATION
#define STBI_NO_STDIO
#include "stb_image.h"

170
libs/stb/src/stb_vorbis.zig Normal file
View File

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

View File

@ -0,0 +1,3 @@
#define STB_VORBIS_NO_INTEGER_CONVERSION
#define STB_VORBIS_NO_STDIO
#include "stb_vorbis.c"

29
libs/tiled/build.zig Normal file
View File

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

View File

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

52
libs/tiled/src/color.zig Normal file
View File

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

View File

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

283
libs/tiled/src/layer.zig Normal file
View File

@ -0,0 +1,283 @@
const std = @import("std");
const Property = @import("./property.zig");
const xml = @import("./xml.zig");
const Color = @import("./color.zig");
const Object = @import("./object.zig");
const Position = @import("./position.zig");
const Layer = @This();
pub const TileVariant = struct {
width: u32,
height: u32,
data: []u32,
fn initDataFromXml(
arena: std.mem.Allocator,
scratch: *std.heap.ArenaAllocator,
lexer: *xml.Lexer,
) ![]u32 {
var iter = xml.TagParser.init(lexer);
const attrs = try iter.begin("data");
const encoding = attrs.get("encoding") orelse "csv";
// TODO: compression
var temp_tiles: std.ArrayList(u32) = .empty;
if (std.mem.eql(u8, encoding, "csv")) {
const text = try lexer.nextExpectText();
var split_iter = std.mem.splitScalar(u8, text, ',');
while (split_iter.next()) |raw_tile_id| {
const tile_id_str = std.mem.trim(u8, raw_tile_id, &std.ascii.whitespace);
const tile_id = try std.fmt.parseInt(u32, tile_id_str, 10);
try temp_tiles.append(scratch.allocator(), tile_id);
}
} else {
return error.UnknownEncodingType;
}
try iter.finish("data");
return try arena.dupe(u32, temp_tiles.items);
}
pub fn get(self: TileVariant, x: usize, y: usize) ?u32 {
if ((0 <= x and x < self.width) and (0 <= y and y < self.height)) {
return self.data[y * self.width + x];
}
return null;
}
};
pub const ImageVariant = struct {
pub const Image = struct {
// TODO: format
source: []const u8,
transparent_color: ?Color,
width: ?u32,
height: ?u32,
fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Image {
var iter = xml.TagParser.init(lexer);
const attrs = try iter.begin("image");
// TODO: format
const source = try attrs.getDupe(arena, "source") orelse return error.MissingSource;
const width = try attrs.getNumber(u32, "width") orelse null;
const height = try attrs.getNumber(u32, "height") orelse null;
const transparent_color = try attrs.getColor("trans", false);
try iter.finish("image");
return Image{
.source = source,
.transparent_color = transparent_color,
.width = width,
.height = height
};
}
};
repeat_x: bool,
repeat_y: bool,
image: ?Image
};
pub const ObjectVariant = struct {
pub const DrawOrder = enum {
top_down,
index,
const map: std.StaticStringMap(DrawOrder) = .initComptime(.{
.{ "topdown", .top_down },
.{ "index", .index },
});
};
color: ?Color,
draw_order: DrawOrder,
items: []Object
};
pub const GroupVariant = struct {
layers: []Layer
};
pub const Type = enum {
tile,
object,
image,
group,
const name_map: std.StaticStringMap(Type) = .initComptime(.{
.{ "layer", .tile },
.{ "objectgroup", .object },
.{ "imagelayer", .image },
.{ "group", .group }
});
fn toXmlName(self: Type) []const u8 {
return switch (self) {
.tile => "layer",
.object => "objectgroup",
.image => "imagelayer",
.group => "group",
};
}
};
pub const Variant = union(Type) {
tile: TileVariant,
object: ObjectVariant,
image: ImageVariant,
group: GroupVariant
};
id: u32,
name: []const u8,
class: []const u8,
opacity: f32,
visible: bool,
tint_color: ?Color,
offset_x: f32,
offset_y: f32,
parallax_x: f32,
parallax_y: f32,
properties: Property.List,
variant: Variant,
pub fn initFromXml(
arena: std.mem.Allocator,
scratch: *std.heap.ArenaAllocator,
lexer: *xml.Lexer,
) !Layer {
const value = try lexer.peek() orelse return error.MissingStartTag;
if (value != .start_tag) return error.MissingStartTag;
var layer_type = Type.name_map.get(value.start_tag.name) orelse return error.UnknownLayerType;
var iter = xml.TagParser.init(lexer);
const attrs = try iter.begin(layer_type.toXmlName());
const id = try attrs.getNumber(u32, "id") orelse return error.MissingId;
const name = try attrs.getDupe(arena, "name") orelse "";
const class = try attrs.getDupe(arena, "class") orelse "";
const opacity = try attrs.getNumber(f32, "opacity") orelse 1;
const visible = try attrs.getBool("visible", "1", "0") orelse true;
const offset_x = try attrs.getNumber(f32, "offsetx") orelse 0;
const offset_y = try attrs.getNumber(f32, "offsety") orelse 0;
const parallax_x = try attrs.getNumber(f32, "parallaxx") orelse 1;
const parallax_y = try attrs.getNumber(f32, "parallaxy") orelse 1;
const tint_color = try attrs.getColor("tintcolor", true);
var variant: Variant = undefined;
switch (layer_type) {
.tile => {
const width = try attrs.getNumber(u32, "width") orelse return error.MissingWidth;
const height = try attrs.getNumber(u32, "height") orelse return error.MissingHeight;
variant = .{
.tile = TileVariant{
.width = width,
.height = height,
.data = &[0]u32{}
}
};
},
.image => {
const repeat_x = try attrs.getBool("repeatx", "1", "0") orelse false;
const repeat_y = try attrs.getBool("repeaty", "1", "0") orelse false;
variant = .{
.image = ImageVariant{
.repeat_x = repeat_x,
.repeat_y = repeat_y,
.image = null
}
};
},
.object => {
const draw_order = try attrs.getEnum(ObjectVariant.DrawOrder, "draworder", ObjectVariant.DrawOrder.map) orelse .top_down;
const color = try attrs.getColor("color", true);
variant = .{
.object = ObjectVariant{
.color = color,
.draw_order = draw_order,
.items = &.{}
}
};
},
.group => {
variant = .{
.group = .{
.layers = &.{}
}
};
},
}
var properties: Property.List = .empty;
var objects: std.ArrayList(Object) = .empty;
var layers: std.ArrayList(Layer) = .empty;
while (try iter.next()) |node| {
if (node.isTag("properties")) {
properties = try Property.List.initFromXml(arena, scratch, lexer);
continue;
}
if (variant == .tile and node.isTag("data")) {
variant.tile.data = try TileVariant.initDataFromXml(arena, scratch, lexer);
continue;
}
if (variant == .image and node.isTag("image")) {
variant.image.image = try ImageVariant.Image.initFromXml(arena, lexer);
continue;
}
if (variant == .object and node.isTag("object")) {
const object = try Object.initFromXml(arena, scratch, lexer);
try objects.append(scratch.allocator(), object);
continue;
}
if (variant == .group and isLayerNode(node)) {
const layer = try initFromXml(arena, scratch, lexer);
try layers.append(scratch.allocator(), layer);
continue;
}
try iter.skip();
}
try iter.finish(layer_type.toXmlName());
if (variant == .object) {
variant.object.items = try arena.dupe(Object, objects.items);
}
if (variant == .group) {
variant.group.layers = try arena.dupe(Layer, layers.items);
}
return Layer{
.id = id,
.name = name,
.class = class,
.opacity = opacity,
.visible = visible,
.tint_color = tint_color,
.offset_x = offset_x,
.offset_y = offset_y,
.parallax_x = parallax_x,
.parallax_y = parallax_y,
.properties = properties,
.variant = variant,
};
}
pub fn isLayerNode(node: xml.TagParser.Node) bool {
return node.isTag("layer") or node.isTag("objectgroup") or node.isTag("imagelayer") or node.isTag("group");
}

421
libs/tiled/src/object.zig Normal file
View File

@ -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,
}
}
},
\\ <object id="10" name="rectangle" type="object class" 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
}
}
},
\\ <object id="3" name="point" type="foo" x="77.125" y="99.875">
\\ <point/>
\\ </object>
);
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
}
}
},
\\ <object id="4" name="ellipse" x="64.25" y="108.25" width="22.375" height="15.375">
\\ <ellipse/>
\\ </object>
);
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 },
}
}
}
},
\\ <object id="5" x="40.125" y="96.25">
\\ <polygon points="0,0 13.25,-4.25 10.125,18.625 2.25,17.375 -0.125,25.75 -3.875,20.75"/>
\\ </object>
);
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
}
}
},
\\ <object id="2" name="tile" gid="35" x="60.125" y="103.5" width="8" height="8"/>
);
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
},
}
}
},
\\ <object id="6" name="text" x="64.3906" y="92.8594" width="87.7188" height="21.7813">
\\ <text wrap="1" halign="center"> Hello World </text>
\\ </object>
);
}

View File

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

153
libs/tiled/src/property.zig Normal file
View File

@ -0,0 +1,153 @@
const std = @import("std");
const xml = @import("./xml.zig");
const Property = @This();
pub const Type = enum {
string,
int,
bool,
const map: std.StaticStringMap(Type) = .initComptime(.{
.{ "string", .string },
.{ "int", .int },
.{ "bool", .bool },
});
};
pub const Value = union(Type) {
string: []const u8,
int: i32,
bool: bool
};
name: []const u8,
value: Value,
pub const List = struct {
items: []Property,
pub const empty = List{
.items = &[0]Property{}
};
pub fn initFromXml(
arena: std.mem.Allocator,
scratch: *std.heap.ArenaAllocator,
lexer: *xml.Lexer
) !Property.List {
var iter = xml.TagParser.init(lexer);
_ = try iter.begin("properties");
var temp_properties: std.ArrayList(Property) = .empty;
while (try iter.next()) |node| {
if (node.isTag("property")) {
const property = try Property.initFromXml(arena, lexer);
try temp_properties.append(scratch.allocator(), property);
continue;
}
try iter.skip();
}
try iter.finish("properties");
const properties = try arena.dupe(Property, temp_properties.items);
return List{
.items = properties
};
}
pub fn get(self: List, name: []const u8) ?Value {
for (self.items) |item| {
if (std.mem.eql(u8, item.name, name)) {
return item.value;
}
}
return null;
}
pub fn getString(self: List, name: []const u8) ?[]const u8 {
if (self.get(name)) |value| {
return value.string;
}
return null;
}
};
pub fn init(name: []const u8, value: Value) Property {
return Property{
.name = name,
.value = value
};
}
pub fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Property {
var iter = xml.TagParser.init(lexer);
const attrs = try iter.begin("property");
const name = try attrs.getDupe(arena, "name") orelse return error.MissingName;
const prop_type_str = attrs.get("type") orelse "string";
const value_str = attrs.get("value") orelse "";
const prop_type = Type.map.get(prop_type_str) orelse return error.UnknownPropertyType;
const value = switch(prop_type) {
.string => Value{
.string = try arena.dupe(u8, value_str)
},
.int => Value{
.int = try std.fmt.parseInt(i32, value_str, 10)
},
.bool => Value{
.bool = std.mem.eql(u8, value_str, "true")
}
};
try iter.finish("property");
return Property{
.name = name,
.value = value
};
}
fn expectParsedEquals(expected: Property, body: []const u8) !void {
const allocator = std.testing.allocator;
var ctx: xml.Lexer.TestingContext = undefined;
ctx.init(allocator, body);
defer ctx.deinit();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const parsed = try initFromXml(arena.allocator(), &ctx.lexer);
try std.testing.expectEqualDeep(expected, parsed);
}
test Property {
try expectParsedEquals(
Property.init("solid", .{ .string = "hello" }),
\\ <property name="solid" value="hello"/>
);
try expectParsedEquals(
Property.init("solid", .{ .string = "hello" }),
\\ <property name="solid" type="string" value="hello"/>
);
try expectParsedEquals(
Property.init("integer", .{ .int = 123 }),
\\ <property name="integer" type="int" value="123"/>
);
try expectParsedEquals(
Property.init("boolean", .{ .bool = true }),
\\ <property name="boolean" type="bool" value="true"/>
);
try expectParsedEquals(
Property.init("boolean", .{ .bool = false }),
\\ <property name="boolean" type="bool" value="false"/>
);
}

16
libs/tiled/src/root.zig Normal file
View File

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

235
libs/tiled/src/tilemap.zig Normal file
View File

@ -0,0 +1,235 @@
const std = @import("std");
const xml = @import("./xml.zig");
const Io = std.Io;
const assert = std.debug.assert;
const Property = @import("./property.zig");
const Layer = @import("./layer.zig");
const Object = @import("./object.zig");
const Position = @import("./position.zig");
const Tileset = @import("./tileset.zig");
const GlobalTileId = @import("./global_tile_id.zig");
const Tilemap = @This();
pub const Orientation = enum {
orthogonal,
staggered,
hexagonal,
isometric,
const map: std.StaticStringMap(Orientation) = .initComptime(.{
.{ "orthogonal", .orthogonal },
.{ "staggered", .staggered },
.{ "hexagonal", .hexagonal },
.{ "isometric", .isometric }
});
};
pub const RenderOrder = enum {
right_down,
right_up,
left_down,
left_up,
const map: std.StaticStringMap(RenderOrder) = .initComptime(.{
.{ "right-down", .right_down },
.{ "right-up", .right_up },
.{ "left-down", .left_down },
.{ "left-up", .left_up },
});
};
pub const StaggerAxis = enum {
x,
y,
const map: std.StaticStringMap(StaggerAxis) = .initComptime(.{
.{ "x", .x },
.{ "y", .y }
});
};
pub const TilesetReference = struct {
source: []const u8,
first_gid: u32,
pub fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !TilesetReference {
var iter = xml.TagParser.init(lexer);
const attrs = try iter.begin("tileset");
const source = try attrs.getDupe(arena, "source") orelse return error.MissingFirstGid;
const first_gid = try attrs.getNumber(u32, "firstgid") orelse return error.MissingFirstGid;
try iter.finish("tileset");
return TilesetReference{
.source = source,
.first_gid = first_gid
};
}
};
pub const Tile = struct {
tileset: *const Tileset,
id: u32,
pub fn getProperties(self: Tile) Property.List {
return self.tileset.getTileProperties(self.id) orelse .empty;
}
};
arena: std.heap.ArenaAllocator,
version: []const u8,
tiled_version: ?[]const u8,
orientation: Orientation,
render_order: RenderOrder,
width: u32,
height: u32,
tile_width: u32,
tile_height: u32,
infinite: bool,
stagger_axis: ?StaggerAxis,
next_layer_id: u32,
next_object_id: u32,
tilesets: []TilesetReference,
layers: []Layer,
pub fn initFromBuffer(
gpa: std.mem.Allocator,
scratch: *std.heap.ArenaAllocator,
xml_buffers: *xml.Lexer.Buffers,
buffer: []const u8
) !Tilemap {
var reader = Io.Reader.fixed(buffer);
return initFromReader(gpa, scratch, xml_buffers, &reader);
}
pub fn initFromReader(
gpa: std.mem.Allocator,
scratch: *std.heap.ArenaAllocator,
xml_buffers: *xml.Lexer.Buffers,
reader: *Io.Reader,
) !Tilemap {
var lexer = xml.Lexer.init(reader, xml_buffers);
return initFromXml(gpa, scratch, &lexer);
}
// Map specification:
// https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#map
pub fn initFromXml(
gpa: std.mem.Allocator,
scratch: *std.heap.ArenaAllocator,
lexer: *xml.Lexer,
) !Tilemap {
var arena_allocator = std.heap.ArenaAllocator.init(gpa);
errdefer arena_allocator.deinit();
const arena = arena_allocator.allocator();
var iter = xml.TagParser.init(lexer);
const map_attrs = try iter.begin("map");
const version = try map_attrs.getDupe(arena, "version") orelse return error.MissingVersion;
const tiled_version = try map_attrs.getDupe(arena, "tiledversion");
const orientation = try map_attrs.getEnum(Orientation, "orientation", Orientation.map) orelse return error.MissingOrientation;
const render_order = try map_attrs.getEnum(RenderOrder, "renderorder", RenderOrder.map) orelse return error.MissingRenderOrder;
// TODO: compressionlevel
const width = try map_attrs.getNumber(u32, "width") orelse return error.MissingWidth;
const height = try map_attrs.getNumber(u32, "height") orelse return error.MissingHeight;
const tile_width = try map_attrs.getNumber(u32, "tilewidth") orelse return error.MissingTileWidth;
const tile_height = try map_attrs.getNumber(u32, "tileheight") orelse return error.MissingTileHeight;
// TODO: hexidelength
const infinite_int = try map_attrs.getNumber(u32, "infinite") orelse 0;
const infinite = infinite_int != 0;
const next_layer_id = try map_attrs.getNumber(u32, "nextlayerid") orelse return error.MissingLayerId;
const next_object_id = try map_attrs.getNumber(u32, "nextobjectid") orelse return error.MissingObjectId;
// TODO: parallaxoriginx
// TODO: parallaxoriginy
// TODO: backgroundcolor
var stagger_axis: ?StaggerAxis = null;
if (orientation == .hexagonal or orientation == .staggered) {
stagger_axis = try map_attrs.getEnum(StaggerAxis, "staggeraxis", StaggerAxis.map) orelse return error.MissingRenderOrder;
// TODO: staggerindex
}
var tileset_list: std.ArrayList(TilesetReference) = .empty;
var layer_list: std.ArrayList(Layer) = .empty;
while (try iter.next()) |node| {
if (node.isTag("tileset")) {
try tileset_list.append(scratch.allocator(), try TilesetReference.initFromXml(arena, lexer));
continue;
} else if (Layer.isLayerNode(node)) {
const layer = try Layer.initFromXml(
arena,
scratch,
lexer,
);
try layer_list.append(scratch.allocator(), layer);
continue;
}
try iter.skip();
}
try iter.finish("map");
const tilesets = try arena.dupe(TilesetReference, tileset_list.items);
const layers = try arena.dupe(Layer, layer_list.items);
return Tilemap{
.arena = arena_allocator,
.version = version,
.tiled_version = tiled_version,
.orientation = orientation,
.render_order = render_order,
.width = width,
.height = height,
.tile_width = tile_width,
.tile_height = tile_height,
.infinite = infinite,
.stagger_axis = stagger_axis,
.next_object_id = next_object_id,
.next_layer_id = next_layer_id,
.tilesets = tilesets,
.layers = layers,
};
}
fn getTilesetByGid(self: *const Tilemap, gid: u32) ?TilesetReference {
var result: ?TilesetReference = null;
for (self.tilesets) |tileset| {
if (gid < tileset.first_gid) {
continue;
}
if (result != null and result.?.first_gid < tileset.first_gid) {
continue;
}
result = tileset;
}
return result;
}
pub fn getTile(self: *const Tilemap, layer: *const Layer, tilesets: Tileset.List, x: usize, y: usize) ?Tile {
assert(layer.variant == .tile);
const tile_variant = layer.variant.tile;
const gid = tile_variant.get(x, y) orelse return null;
const tileset_ref = self.getTilesetByGid(gid & GlobalTileId.Flag.clear) orelse return null;
const tileset = tilesets.get(tileset_ref.source) orelse return null;
const id = gid - tileset_ref.first_gid;
return Tile{
.tileset = tileset,
.id = id
};
}
pub fn deinit(self: *const Tilemap) void {
self.arena.deinit();
}

226
libs/tiled/src/tileset.zig Normal file
View File

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

687
libs/tiled/src/xml.zig Normal file
View File

@ -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,
\\ <hello />
);
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,
\\ <hello></hello>
);
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,
\\ <?xml version="1.0" encoding="UTF-8"?>
\\ <hello></hello>
);
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> Hello World </hello>
);
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,
\\ <hello a='1' b='2'/>
);
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);
}
}
};

44
src/assets.zig Normal file
View File

@ -0,0 +1,44 @@
const std = @import("std");
const Math = @import("./engine/math.zig");
const Engine = @import("./engine/root.zig");
const Gfx = Engine.Graphics;
const Audio = Engine.Audio;
const Assets = @This();
const FontName = enum {
regular,
bold,
italic,
const EnumArray = std.EnumArray(FontName, Gfx.Font.Id);
};
font_id: FontName.EnumArray,
wood01: Audio.Data.Id,
pub fn init(gpa: std.mem.Allocator) !Assets {
_ = gpa; // autofix
const font_id_array: FontName.EnumArray = .init(.{
.regular = try Gfx.addFont("regular", @embedFile("assets/roboto-font/Roboto-Regular.ttf")),
.bold = try Gfx.addFont("bold", @embedFile("assets/roboto-font/Roboto-Bold.ttf")),
.italic = try Gfx.addFont("italic", @embedFile("assets/roboto-font/Roboto-Italic.ttf")),
});
const wood01 = try Audio.load(.{
.format = .vorbis,
.data = @embedFile("assets/wood01.ogg"),
});
return Assets{
.font_id = font_id_array,
.wood01 = wood01
};
}
pub fn deinit(self: *Assets, gpa: std.mem.Allocator) void {
_ = self; // autofix
_ = gpa; // autofix
}

BIN
src/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/assets/wood01.ogg Normal file

Binary file not shown.

BIN
src/assets/wood02.ogg Normal file

Binary file not shown.

BIN
src/assets/wood03.ogg Normal file

Binary file not shown.

64
src/engine/audio/data.zig Normal file
View File

@ -0,0 +1,64 @@
const std = @import("std");
const assert = std.debug.assert;
const STBVorbis = @import("stb_vorbis");
pub const Data = union(enum) {
raw: struct {
channels: [][*]f32,
sample_count: u32,
sample_rate: u32
},
vorbis: struct {
alloc_buffer: []u8,
stb_vorbis: STBVorbis,
},
pub fn streamChannel(
self: Data,
buffer: []f32,
cursor: u32,
channel_index: u32,
sample_rate: u32
) []f32 {
// var result: std.ArrayList(f32) = .initBuffer(buffer);
switch (self) {
.raw => |opts| {
if (opts.sample_rate == sample_rate) {
assert(channel_index < opts.channels.len); // TODO:
const channel = opts.channels[channel_index];
var memcpy_len: usize = 0;
if (cursor + buffer.len <= opts.sample_count) {
memcpy_len = buffer.len;
} else if (cursor < opts.sample_count) {
memcpy_len = opts.sample_count - cursor;
}
@memcpy(buffer[0..memcpy_len], channel[cursor..][0..memcpy_len]);
return buffer[0..memcpy_len];
} else {
// const in_sample_rate: f32 = @floatFromInt(opts.sample_rate);
// const out_sample_rate: f32 = @floatFromInt(sample_rate);
// const increment = in_sample_rate / out_sample_rate;
// _ = increment; // autofix
unreachable;
}
},
.vorbis => |opts| {
_ = opts; // autofix
unreachable;
},
}
// return result.items;
}
pub fn getSampleCount(self: Data) u32 {
return switch (self) {
.raw => |opts| opts.sample_count,
.vorbis => |opts| opts.stb_vorbis.getStreamLengthInSamples()
};
}
pub const Id = enum (u16) { _ };
};

131
src/engine/audio/mixer.zig Normal file
View File

@ -0,0 +1,131 @@
const std = @import("std");
const log = std.log.scoped(.audio);
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const sokol = @import("sokol");
const saudio = sokol.audio;
const AudioData = @import("./data.zig").Data;
pub const Store = @import("./store.zig");
const Mixer = @This();
pub const Instance = struct {
data_id: AudioData.Id,
cursor: u32 = 0,
};
pub const Command = union(enum) {
play: struct {
data_id: AudioData.Id,
},
pub const RingBuffer = struct {
// TODO: This ring buffer will work in a single producer single consumer configuration
// For my game this will be good enough
items: []Command,
head: std.atomic.Value(usize) = .init(0),
tail: std.atomic.Value(usize) = .init(0),
pub fn push(self: *RingBuffer, command: Command) error{OutOfMemory}!void {
const head = self.head.load(.monotonic);
const tail = self.tail.load(.monotonic);
const next_head = @mod(head + 1, self.items.len);
// A single slot in the .items array will always not be used.
if (next_head == tail) {
return error.OutOfMemory;
}
self.items[head] = command;
self.head.store(next_head, .monotonic);
}
pub fn pop(self: *RingBuffer) ?Command {
const head = self.head.load(.monotonic);
const tail = self.tail.load(.monotonic);
if (head == tail) {
return null;
}
const result = self.items[tail];
self.tail.store(@mod(tail + 1, self.items.len), .monotonic);
return result;
}
};
};
// TODO: Tracks
instances: std.ArrayList(Instance),
commands: Command.RingBuffer,
pub fn init(
gpa: Allocator,
max_instances: u32,
max_commands: u32
) !Mixer {
var instances = try std.ArrayList(Instance).initCapacity(gpa, max_instances);
errdefer instances.deinit(gpa);
const commands = try gpa.alloc(Command, max_commands);
errdefer gpa.free(commands);
return Mixer{
.instances = instances,
.commands = .{
.items = commands
}
};
}
pub fn deinit(self: *Mixer, gpa: Allocator) void {
self.instances.deinit(gpa);
gpa.free(self.commands.items);
}
pub fn queue(self: *Mixer, command: Command) void {
self.commands.push(command) catch log.warn("Maximum number of audio commands reached!", .{});
}
pub fn stream(self: *Mixer, store: Store, buffer: []f32, num_frames: u32, num_channels: u32) !void {
while (self.commands.pop()) |command| {
switch (command) {
.play => |opts| {
self.instances.appendBounded(.{
.data_id = opts.data_id
}) catch log.warn("Maximum number of audio instances reached!", .{});
}
}
}
assert(num_channels == 1); // TODO:
const sample_rate: u32 = @intCast(saudio.sampleRate());
@memset(buffer, 0);
// var written: u32 = 0;
for (self.instances.items) |*instance| {
const audio_data = store.get(instance.data_id);
const samples = audio_data.streamChannel(buffer[0..num_frames], instance.cursor, 0, sample_rate);
instance.cursor += @intCast(samples.len);
}
{
var i: usize = 0;
while (i < self.instances.items.len) {
const instance = self.instances.items[i];
const audio_data = store.get(instance.data_id);
const is_complete = instance.cursor == audio_data.getSampleCount();
if (is_complete) {
_ = self.instances.swapRemove(i);
} else {
i += 1;
}
}
}
}

100
src/engine/audio/root.zig Normal file
View File

@ -0,0 +1,100 @@
const std = @import("std");
const log = std.log.scoped(.audio);
const assert = std.debug.assert;
const tracy = @import("tracy");
const Math = @import("../math.zig");
const STBVorbis = @import("stb_vorbis");
pub const Data = @import("./data.zig").Data;
pub const Store = @import("./store.zig");
pub const Mixer = @import("./mixer.zig");
const sokol = @import("sokol");
const saudio = sokol.audio;
var stopped: bool = true;
var gpa: std.mem.Allocator = undefined;
var store: Store = undefined;
pub var mixer: Mixer = undefined;
const Options = struct {
allocator: std.mem.Allocator,
logger: saudio.Logger = .{},
channels: u32 = 1,
max_vorbis_alloc_buffer_size: u32 = 1 * Math.bytes_per_mib,
buffer_frames: u32 = 2048,
max_instances: u32 = 64
};
pub fn init(opts: Options) !void {
gpa = opts.allocator;
store = try Store.init(.{
.allocator = opts.allocator,
.max_vorbis_alloc_buffer_size = opts.max_vorbis_alloc_buffer_size,
});
mixer = try Mixer.init(gpa, opts.max_instances, opts.max_instances);
saudio.setup(.{
.logger = opts.logger,
.stream_cb = sokolStreamCallback,
.num_channels = @intCast(opts.channels),
.buffer_frames = @intCast(opts.buffer_frames)
});
stopped = false;
const sample_rate: f32 = @floatFromInt(saudio.sampleRate());
const audio_latency: f32 = @as(f32, @floatFromInt(opts.buffer_frames)) / sample_rate;
log.debug("Audio latency = {D}", .{@as(u64, @intFromFloat(audio_latency * std.time.ns_per_s))});
}
pub fn deinit() void {
stopped = true;
saudio.shutdown();
mixer.deinit(gpa);
store.deinit();
}
pub fn load(opts: Store.LoadOptions) !Data.Id {
return try store.load(opts);
}
pub const PlayOptions = struct {
id: Data.Id,
delay: f32 = 0
};
pub fn play(opts: PlayOptions) void {
mixer.queue(.{
.play = .{
.data_id = opts.id
}
});
}
fn sokolStreamCallback(buffer: [*c]f32, num_frames: i32, num_channels: i32) callconv(.c) void {
if (stopped) {
return;
}
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
const num_frames_u32: u32 = @intCast(num_frames);
const num_channels_u32: u32 = @intCast(num_channels);
mixer.stream(
store,
buffer[0..(num_frames_u32 * num_channels_u32)],
num_frames_u32,
num_channels_u32
) catch |e| {
log.err("mixer.stream() failed: {}", .{e});
if (@errorReturnTrace()) |trace| {
std.debug.dumpStackTrace(trace.*);
}
};
}

106
src/engine/audio/store.zig Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
pub const Font = @import("./font.zig");
pub const Context = @import("./context.zig");

View File

@ -0,0 +1,12 @@
#include <stdio.h>
#include <stdlib.h>
#ifdef _WIN32
#include <windows.h>
#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"

310
src/engine/graphics.zig Normal file
View File

@ -0,0 +1,310 @@
const sokol = @import("sokol");
const sg = sokol.gfx;
const sglue = sokol.glue;
const slog = sokol.log;
const sapp = sokol.app;
const simgui = sokol.imgui;
const sgl = sokol.gl;
const Math = @import("./math.zig");
const Vec2 = Math.Vec2;
const Vec4 = Math.Vec4;
const rgb = Math.rgb;
const Rect = Math.Rect;
const std = @import("std");
const log = std.log.scoped(.graphics);
const assert = std.debug.assert;
const imgui = @import("imgui.zig");
const tracy = @import("tracy");
const fontstash = @import("./fontstash/root.zig");
pub const Font = fontstash.Font;
// TODO: Seems that there is a vertical jitter bug when resizing a window in OpenGL. Seems like a driver bug.
// From other peoples research it seems that disabling vsync when a resize event occurs fixes it.
// Maybe a patch for sokol could be made?
// More info:
// * https://github.com/libsdl-org/SDL/issues/11618
// * https://github.com/nimgl/nimgl/issues/59
const Options = struct {
const ImguiFont = struct {
ttf_data: []const u8,
size: f32 = 16
};
allocator: std.mem.Allocator,
logger: sg.Logger = .{},
imgui_font: ?ImguiFont = null
};
const DrawFrame = struct {
screen_size: Vec2 = Vec2.zero,
bg_color: Vec4 = rgb(0, 0, 0),
scissor_stack_buffer: [32]Rect = undefined,
scissor_stack: std.ArrayListUnmanaged(Rect) = .empty,
fn init(self: *DrawFrame) void {
self.* = DrawFrame{
.scissor_stack = .initBuffer(&self.scissor_stack_buffer)
};
}
};
var draw_frame: DrawFrame = undefined;
var main_pipeline: sgl.Pipeline = .{};
var linear_sampler: sg.Sampler = .{};
var nearest_sampler: sg.Sampler = .{};
var font_context: fontstash.Context = undefined;
pub var font_resolution_scale: f32 = 1;
pub fn init(options: Options) !void {
draw_frame.init();
sg.setup(.{
.logger = options.logger,
.environment = sglue.environment(),
});
sgl.setup(.{
.logger = .{
.func = options.logger.func,
.user_data = options.logger.user_data
}
});
main_pipeline = sgl.makePipeline(.{
.colors = init: {
var colors: [8]sg.ColorTargetState = @splat(.{});
colors[0] = .{
.blend = .{
.enabled = true,
.src_factor_rgb = .SRC_ALPHA,
.dst_factor_rgb = .ONE_MINUS_SRC_ALPHA,
.op_rgb = .ADD,
.src_factor_alpha = .ONE,
.dst_factor_alpha = .ONE_MINUS_SRC_ALPHA,
.op_alpha = .ADD,
},
};
break :init colors;
},
});
imgui.setup(options.allocator, .{
.logger = .{
.func = options.logger.func,
.user_data = options.logger.user_data
},
.no_default_font = options.imgui_font != null,
// TODO: Figure out a way to make imgui play nicely with UI
// Ideally when mouse is inside a Imgui window, then the imgui cursor should be used.
// Otherwise our own cursor should be used.
.disable_set_mouse_cursor = true
});
if (options.imgui_font) |imgui_font| {
imgui.addFont(imgui_font.ttf_data, imgui_font.size);
}
linear_sampler = sg.makeSampler(.{
.min_filter = .LINEAR,
.mag_filter = .LINEAR,
.mipmap_filter = .LINEAR,
.label = "linear-sampler",
});
nearest_sampler = sg.makeSampler(.{
.min_filter = .NEAREST,
.mag_filter = .NEAREST,
.mipmap_filter = .NEAREST,
.label = "nearest-sampler",
});
const dpi_scale = sapp.dpiScale();
const atlas_size = 512;
const atlas_dim = std.math.ceilPowerOfTwoAssert(u32, @intFromFloat(atlas_size * dpi_scale));
font_context = try fontstash.Context.init(.{
.width = @intCast(atlas_dim),
.height = @intCast(atlas_dim),
});
}
pub fn deinit() void {
imgui.shutdown();
font_context.deinit();
sgl.shutdown();
sg.shutdown();
}
pub fn beginFrame() void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
draw_frame.init();
draw_frame.screen_size = Vec2.init(sapp.widthf(), sapp.heightf());
draw_frame.scissor_stack.appendAssumeCapacity(Rect.init(0, 0, sapp.widthf(), sapp.heightf()));
imgui.newFrame(.{
.width = @intFromFloat(draw_frame.screen_size.x),
.height = @intFromFloat(draw_frame.screen_size.y),
.delta_time = sapp.frameDuration(),
.dpi_scale = sapp.dpiScale()
});
font_context.clearState();
sgl.defaults();
sgl.matrixModeProjection();
sgl.ortho(0, draw_frame.screen_size.x, draw_frame.screen_size.y, 0, -1, 1);
sgl.loadPipeline(main_pipeline);
}
pub fn endFrame() void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
assert(draw_frame.scissor_stack.items.len == 1);
var pass_action: sg.PassAction = .{};
pass_action.colors[0] = sg.ColorAttachmentAction{
.load_action = .CLEAR,
.clear_value = .{
.r = draw_frame.bg_color.x,
.g = draw_frame.bg_color.y,
.b = draw_frame.bg_color.z,
.a = draw_frame.bg_color.w
}
};
font_context.flush();
{
sg.beginPass(.{
.action = pass_action,
.swapchain = sglue.swapchain()
});
defer sg.endPass();
sgl.draw();
imgui.render();
}
sg.commit();
}
pub fn pushTransform(translation: Vec2, scale: f32) void {
sgl.pushMatrix();
sgl.translate(translation.x, translation.y, 0);
sgl.scale(scale, scale, 1);
}
pub fn popTransform() void {
sgl.popMatrix();
}
pub fn drawQuad(quad: [4]Vec2, color: Vec4) void {
sgl.beginQuads();
defer sgl.end();
sgl.c4f(color.x, color.y, color.z, color.w);
for (quad) |position| {
sgl.v2f(position.x, position.y);
}
}
pub fn drawRectangle(pos: Vec2, size: Vec2, color: Vec4) void {
drawQuad(
.{
// Top left
pos,
// Top right
pos.add(.{ .x = size.x, .y = 0 }),
// Bottom right
pos.add(size),
// Bottom left
pos.add(.{ .x = 0, .y = size.y }),
},
color
);
}
pub fn drawTriangle(tri: [3]Vec2, color: Vec4) void {
sgl.beginTriangles();
defer sgl.end();
sgl.c4f(color.x, color.y, color.z, color.w);
for (tri) |position| {
sgl.v2f(position.x, position.y);
}
}
pub fn drawLine(from: Vec2, to: Vec2, color: Vec4, width: f32) void {
const step = to.sub(from).normalized().multiplyScalar(width/2);
const top_left = from.add(step.rotateLeft90());
const bottom_left = from.add(step.rotateRight90());
const top_right = to.add(step.rotateLeft90());
const bottom_right = to.add(step.rotateRight90());
drawQuad(
.{ top_right, top_left, bottom_left, bottom_right },
color
);
}
pub fn drawRectanglOutline(pos: Vec2, size: Vec2, color: Vec4, width: f32) void {
// TODO: Don't use line segments
drawLine(pos, pos.add(.{ .x = size.x, .y = 0 }), color, width);
drawLine(pos, pos.add(.{ .x = 0, .y = size.y }), color, width);
drawLine(pos.add(.{ .x = 0, .y = size.y }), pos.add(size), color, width);
drawLine(pos.add(.{ .x = size.x, .y = 0 }), pos.add(size), color, width);
}
pub fn pushScissor(rect: Rect) void {
draw_frame.scissor_stack.appendAssumeCapacity(rect);
sgl.scissorRectf(rect.pos.x, rect.pos.y, rect.size.x, rect.size.y, true);
}
pub fn popScissor() void {
_ = draw_frame.scissor_stack.pop().?;
const rect = draw_frame.scissor_stack.getLast();
sgl.scissorRectf(rect.pos.x, rect.pos.y, rect.size.x, rect.size.y, true);
}
pub fn addFont(name: [*c]const u8, data: []const u8) !Font.Id {
return try font_context.addFont(name, data);
}
pub const DrawTextOptions = struct {
font: Font.Id,
size: f32 = 16,
color: Vec4 = rgb(255, 255, 255),
};
pub fn drawText(position: Vec2, text: []const u8, opts: DrawTextOptions) void {
pushTransform(.{ .x = 0, .y = 0}, 1/font_resolution_scale);
defer popTransform();
font_context.setFont(opts.font);
font_context.setSize(opts.size * font_resolution_scale);
font_context.setAlign(.{ .x = .left, .y = .top });
font_context.setSpacing(0);
const r: u8 = @intFromFloat(opts.color.x * 255);
const g: u8 = @intFromFloat(opts.color.y * 255);
const b: u8 = @intFromFloat(opts.color.z * 255);
const a: u8 = @intFromFloat(opts.color.w * 255);
const color: u32 = r | (@as(u32, g) << 8) | (@as(u32, b) << 16) | (@as(u32, a) << 24);
font_context.setColor(color);
font_context.drawText(
position.x * font_resolution_scale,
position.y * font_resolution_scale,
text
);
}

504
src/engine/imgui.zig Normal file
View File

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

239
src/engine/input.zig Normal file
View File

@ -0,0 +1,239 @@
const std = @import("std");
const sokol = @import("sokol");
const Engine = @import("./root.zig");
const Nanoseconds = Engine.Nanoseconds;
const Math = @import("./math.zig");
const Vec2 = Math.Vec2;
const Input = @This();
pub const KeyCode = enum(std.math.IntFittingRange(0, sokol.app.max_keycodes-1)) {
SPACE = 32,
APOSTROPHE = 39,
COMMA = 44,
MINUS = 45,
PERIOD = 46,
SLASH = 47,
_0 = 48,
_1 = 49,
_2 = 50,
_3 = 51,
_4 = 52,
_5 = 53,
_6 = 54,
_7 = 55,
_8 = 56,
_9 = 57,
SEMICOLON = 59,
EQUAL = 61,
A = 65,
B = 66,
C = 67,
D = 68,
E = 69,
F = 70,
G = 71,
H = 72,
I = 73,
J = 74,
K = 75,
L = 76,
M = 77,
N = 78,
O = 79,
P = 80,
Q = 81,
R = 82,
S = 83,
T = 84,
U = 85,
V = 86,
W = 87,
X = 88,
Y = 89,
Z = 90,
LEFT_BRACKET = 91,
BACKSLASH = 92,
RIGHT_BRACKET = 93,
GRAVE_ACCENT = 96,
WORLD_1 = 161,
WORLD_2 = 162,
ESCAPE = 256,
ENTER = 257,
TAB = 258,
BACKSPACE = 259,
INSERT = 260,
DELETE = 261,
RIGHT = 262,
LEFT = 263,
DOWN = 264,
UP = 265,
PAGE_UP = 266,
PAGE_DOWN = 267,
HOME = 268,
END = 269,
CAPS_LOCK = 280,
SCROLL_LOCK = 281,
NUM_LOCK = 282,
PRINT_SCREEN = 283,
PAUSE = 284,
F1 = 290,
F2 = 291,
F3 = 292,
F4 = 293,
F5 = 294,
F6 = 295,
F7 = 296,
F8 = 297,
F9 = 298,
F10 = 299,
F11 = 300,
F12 = 301,
F13 = 302,
F14 = 303,
F15 = 304,
F16 = 305,
F17 = 306,
F18 = 307,
F19 = 308,
F20 = 309,
F21 = 310,
F22 = 311,
F23 = 312,
F24 = 313,
F25 = 314,
KP_0 = 320,
KP_1 = 321,
KP_2 = 322,
KP_3 = 323,
KP_4 = 324,
KP_5 = 325,
KP_6 = 326,
KP_7 = 327,
KP_8 = 328,
KP_9 = 329,
KP_DECIMAL = 330,
KP_DIVIDE = 331,
KP_MULTIPLY = 332,
KP_SUBTRACT = 333,
KP_ADD = 334,
KP_ENTER = 335,
KP_EQUAL = 336,
LEFT_SHIFT = 340,
LEFT_CONTROL = 341,
LEFT_ALT = 342,
LEFT_SUPER = 343,
RIGHT_SHIFT = 344,
RIGHT_CONTROL = 345,
RIGHT_ALT = 346,
RIGHT_SUPER = 347,
MENU = 348,
};
pub const KeyState = struct {
down: bool,
pressed: bool,
released: bool,
down_duration: ?f64,
pub const RepeatOptions = struct {
first_at: f64 = 0,
period: f64
};
pub fn repeat(self: KeyState, last_repeat_at: *?f64, opts: RepeatOptions) bool {
if (!self.down) {
last_repeat_at.* = null;
return false;
}
const down_duration = self.down_duration.?;
if (last_repeat_at.* != null) {
if (down_duration >= last_repeat_at.*.? + opts.period) {
last_repeat_at.* = last_repeat_at.*.? + opts.period;
return true;
}
} else {
if (down_duration >= opts.first_at) {
last_repeat_at.* = opts.first_at;
return true;
}
}
return false;
}
};
pub const Mouse = struct {
pub const Button = enum {
left,
right,
middle,
pub fn fromSokol(mouse_button: sokol.app.Mousebutton) ?Button {
return switch(mouse_button) {
.LEFT => Button.left,
.RIGHT => Button.right,
.MIDDLE => Button.middle,
else => null
};
}
};
position: ?Vec2,
buttons: std.EnumSet(Button),
pub const empty = Mouse{
.position = null,
.buttons = .initEmpty()
};
};
down_keys: std.EnumSet(KeyCode),
pressed_keys: std.EnumSet(KeyCode),
released_keys: std.EnumSet(KeyCode),
pressed_keys_at: std.EnumMap(KeyCode, Nanoseconds),
mouse: Mouse,
pub const empty = Input{
.down_keys = .initEmpty(),
.pressed_keys = .initEmpty(),
.released_keys = .initEmpty(),
.pressed_keys_at = .init(.{}),
.mouse = .empty
};
pub fn isKeyDown(self: *Input, key_code: KeyCode) bool {
return self.down_keys.contains(key_code);
}
pub fn getKeyDownDuration(self: *Input, frame: Engine.Frame, key_code: KeyCode) ?f64 {
if (!self.isKeyDown(key_code)) {
return null;
}
const pressed_at_ns = self.pressed_keys_at.get(key_code).?;
const duration_ns = frame.time_ns - pressed_at_ns;
return @as(f64, @floatFromInt(duration_ns)) / std.time.ns_per_s;
}
pub fn isKeyPressed(self: *Input, key_code: KeyCode) bool {
return self.pressed_keys.contains(key_code);
}
pub fn isKeyReleased(self: *Input, key_code: KeyCode) bool {
return self.released_keys.contains(key_code);
}
pub fn getKeyState(self: *Input, frame: Engine.Frame, key_code: KeyCode) KeyState {
return KeyState{
.down = self.isKeyDown(key_code),
.released = self.isKeyReleased(key_code),
.pressed = self.isKeyPressed(key_code),
.down_duration = self.getKeyDownDuration(frame, key_code)
};
}

413
src/engine/math.zig Normal file
View File

@ -0,0 +1,413 @@
const std = @import("std");
const assert = std.debug.assert;
pub const bytes_per_kib = 1024;
pub const bytes_per_mib = bytes_per_kib * 1024;
pub const bytes_per_gib = bytes_per_mib * 1024;
pub const bytes_per_kb = 1000;
pub const bytes_per_mb = bytes_per_kb * 1000;
pub const bytes_per_gb = bytes_per_mb * 1000;
pub const Vec2 = extern struct {
x: f32,
y: f32,
pub const zero = init(0, 0);
pub fn init(x: f32, y: f32) Vec2 {
return Vec2{
.x = x,
.y = y,
};
}
pub fn initAngle(angle: f32) Vec2 {
return Vec2{
.x = @cos(angle),
.y = @sin(angle),
};
}
pub fn rotateLeft90(self: Vec2) Vec2 {
return Vec2.init(self.y, -self.x);
}
pub fn rotateRight90(self: Vec2) Vec2 {
return Vec2.init(-self.y, self.x);
}
pub fn flip(self: Vec2) Vec2 {
return Vec2.init(-self.x, -self.y);
}
pub fn add(self: Vec2, other: Vec2) Vec2 {
return Vec2.init(
self.x + other.x,
self.y + other.y,
);
}
pub fn sub(self: Vec2, other: Vec2) Vec2 {
return Vec2.init(
self.x - other.x,
self.y - other.y,
);
}
pub fn multiplyScalar(self: Vec2, value: f32) Vec2 {
return Vec2.init(
self.x * value,
self.y * value,
);
}
pub fn multiply(self: Vec2, other: Vec2) Vec2 {
return Vec2.init(
self.x * other.x,
self.y * other.y,
);
}
pub fn divide(self: Vec2, other: Vec2) Vec2 {
return Vec2.init(
self.x / other.x,
self.y / other.y,
);
}
pub fn divideScalar(self: Vec2, value: f32) Vec2 {
return Vec2.init(
self.x / value,
self.y / value,
);
}
pub fn length(self: Vec2) f32 {
return @sqrt(self.x*self.x + self.y*self.y);
}
pub fn distance(self: Vec2, other: Vec2) f32 {
return self.sub(other).length();
}
pub fn limitLength(self: Vec2, max_length: f32) Vec2 {
const self_length = self.length();
if (self_length > max_length) {
return Vec2.init(self.x / self_length * max_length, self.y / self_length * max_length);
} else {
return self;
}
}
pub fn normalized(self: Vec2) Vec2 {
const self_length = self.length();
if (self_length == 0) {
return Vec2.init(0, 0);
}
return Vec2.init(self.x / self_length, self.y / self_length);
}
pub fn initScalar(value: f32) Vec2 {
return Vec2.init(value, value);
}
pub fn eql(self: Vec2, other: Vec2) bool {
return self.x == other.x and self.y == other.y;
}
pub fn format(self: Vec2, writer: *std.io.Writer) std.io.Writer.Error!void {
try writer.print("Vec2{{ {d}, {d} }}", .{ self.x, self.y });
}
};
pub const Vec3 = extern struct {
x: f32, y: f32, z: f32,
pub const zero = init(0, 0, 0);
pub fn init(x: f32, y: f32, z: f32) Vec3 {
return Vec3{
.x = x,
.y = y,
.z = z,
};
}
pub fn initScalar(value: f32) Vec3 {
return Vec3.init(value, value, value);
}
pub fn asArray(self: *Vec3) []f32 {
const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(self))));
return ptr[0..3];
}
pub fn lerp(a: Vec3, b: Vec3, t: f32) Vec3 {
return Vec3.init(
std.math.lerp(a.x, b.x, t),
std.math.lerp(a.y, b.y, t),
std.math.lerp(a.z, b.z, t),
);
}
pub fn clamp(self: Vec3, min_value: f32, max_value: f32) Vec3 {
return Vec3.init(
std.math.clamp(self.x, min_value, max_value),
std.math.clamp(self.y, min_value, max_value),
std.math.clamp(self.z, min_value, max_value),
);
}
};
pub const Vec4 = extern struct {
x: f32, y: f32, z: f32, w: f32,
pub const zero = init(0, 0, 0, 0);
pub fn init(x: f32, y: f32, z: f32, w: f32) Vec4 {
return Vec4{
.x = x,
.y = y,
.z = z,
.w = w
};
}
pub fn initVec3XYZ(vec3: Vec3, w: f32) Vec4 {
return init(vec3.x, vec3.y, vec3.z, w);
}
pub fn initScalar(value: f32) Vec4 {
return Vec4.init(value, value, value, value);
}
pub fn multiplyMat4(left: Vec4, right: Mat4) Vec4 {
var result: Vec4 = undefined;
// TODO: SIMD
result.x = left.x * right.columns[0][0];
result.y = left.x * right.columns[0][1];
result.z = left.x * right.columns[0][2];
result.w = left.x * right.columns[0][3];
result.x += left.y * right.columns[1][0];
result.y += left.y * right.columns[1][1];
result.z += left.y * right.columns[1][2];
result.w += left.y * right.columns[1][3];
result.x += left.z * right.columns[2][0];
result.y += left.z * right.columns[2][1];
result.z += left.z * right.columns[2][2];
result.w += left.z * right.columns[2][3];
result.x += left.w * right.columns[3][0];
result.y += left.w * right.columns[3][1];
result.z += left.w * right.columns[3][2];
result.w += left.w * right.columns[3][3];
return result;
}
pub fn multiply(left: Vec4, right: Vec4) Vec4 {
return init(
left.x * right.x,
left.y * right.y,
left.z * right.z,
left.w * right.w
);
}
pub fn asArray(self: *Vec4) []f32 {
const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(self))));
return ptr[0..4];
}
pub fn initArray(array: []const f32) Vec4 {
return Vec4.init(array[0], array[1], array[2], array[3]);
}
pub fn lerp(a: Vec4, b: Vec4, t: f32) Vec4 {
return Vec4.init(
std.math.lerp(a.x, b.x, t),
std.math.lerp(a.y, b.y, t),
std.math.lerp(a.z, b.z, t),
std.math.lerp(a.w, b.w, t),
);
}
pub fn clamp(self: Vec4, min_value: f32, max_value: f32) Vec4 {
return Vec4.init(
std.math.clamp(self.x, min_value, max_value),
std.math.clamp(self.y, min_value, max_value),
std.math.clamp(self.z, min_value, max_value),
std.math.clamp(self.w, min_value, max_value),
);
}
pub fn toVec3XYZ(self: Vec4) Vec3 {
return Vec3.init(self.x, self.y, self.z);
}
};
pub const Mat4 = extern struct {
columns: [4][4]f32,
pub fn initZero() Mat4 {
var self: Mat4 = undefined;
@memset(self.asArray(), 0);
return self;
}
pub fn initIdentity() Mat4 {
return Mat4.initDiagonal(1);
}
pub fn initDiagonal(value: f32) Mat4 {
var self = Mat4.initZero();
self.columns[0][0] = value;
self.columns[1][1] = value;
self.columns[2][2] = value;
self.columns[3][3] = value;
return self;
}
pub fn multiply(left: Mat4, right: Mat4) Mat4 {
var self: Mat4 = undefined;
inline for (.{ 0, 1, 2, 3 }) |i| {
var column = Vec4.initArray(&right.columns[i]).multiplyMat4(left);
@memcpy(&self.columns[i], column.asArray());
}
return self;
}
pub fn initScale(scale: Vec3) Mat4 {
var self = Mat4.initIdentity();
self.columns[0][0] = scale.x;
self.columns[1][1] = scale.y;
self.columns[2][2] = scale.z;
return self;
}
pub fn initTranslate(offset: Vec3) Mat4 {
var self = Mat4.initIdentity();
self.columns[3][0] = offset.x;
self.columns[3][1] = offset.y;
self.columns[3][2] = offset.z;
return self;
}
pub fn asArray(self: *Mat4) []f32 {
const ptr: [*]f32 = @alignCast(@ptrCast(@as(*anyopaque, @ptrCast(&self.columns))));
return ptr[0..16];
}
};
pub const Rect = struct {
pos: Vec2,
size: Vec2,
pub const zero = Rect{
.pos = Vec2.zero,
.size = Vec2.zero
};
pub fn init(x: f32, y: f32, width: f32, height: f32) Rect {
return Rect{
.pos = Vec2.init(x, y),
.size = Vec2.init(width, height)
};
}
pub fn clip(self: Rect, other: Rect) Rect {
const left_edge = @max(self.left(), other.left());
const right_edge = @min(self.right(), other.right());
const top_edge = @max(self.top(), other.top());
const bottom_edge = @min(self.bottom(), other.bottom());
return Rect.init(
left_edge,
top_edge,
right_edge - left_edge,
bottom_edge - top_edge
);
}
pub fn left(self: Rect) f32 {
return self.pos.x;
}
pub fn right(self: Rect) f32 {
return self.pos.x + self.size.x;
}
pub fn top(self: Rect) f32 {
return self.pos.y;
}
pub fn bottom(self: Rect) f32 {
return self.pos.y + self.size.y;
}
pub fn multiply(self: Rect, xy: Vec2) Rect {
return Rect{
.pos = self.pos.multiply(xy),
.size = self.size.multiply(xy),
};
}
pub fn divide(self: Rect, xy: Vec2) Rect {
return Rect{
.pos = self.pos.divide(xy),
.size = self.size.divide(xy),
};
}
pub fn isInside(self: Rect, pos: Vec2) bool {
const x_overlap = self.pos.x <= pos.x and pos.x < self.pos.x + self.size.x;
const y_overlap = self.pos.y <= pos.y and pos.y < self.pos.y + self.size.y;
return x_overlap and y_overlap;
}
};
pub const Line = struct {
p0: Vec2,
p1: Vec2
};
pub fn isInsideRect(rect_pos: Vec2, rect_size: Vec2, pos: Vec2) bool {
const rect = Rect{
.pos = rect_pos,
.size = rect_size
};
return rect.isInside(pos);
}
pub fn rgba(r: u8, g: u8, b: u8, a: f32) Vec4 {
assert(0 <= a and a <= 1);
return Vec4.init(
@as(f32, @floatFromInt(r)) / 255,
@as(f32, @floatFromInt(g)) / 255,
@as(f32, @floatFromInt(b)) / 255,
a,
);
}
pub fn rgb(r: u8, g: u8, b: u8) Vec4 {
return rgba(r, g, b, 1);
}
pub fn rgb_hex(text: []const u8) ?Vec4 {
if (text.len != 7) {
return null;
}
if (text[0] != '#') {
return null;
}
const r = std.fmt.parseInt(u8, text[1..3], 16) catch return null;
const g = std.fmt.parseInt(u8, text[3..5], 16) catch return null;
const b = std.fmt.parseInt(u8, text[5..7], 16) catch return null;
return rgb(r, g, b);
}

479
src/engine/root.zig Normal file
View File

@ -0,0 +1,479 @@
const std = @import("std");
const log = std.log.scoped(.engine);
const assert = std.debug.assert;
const sokol = @import("sokol");
const sapp = sokol.app;
pub const Math = @import("./math.zig");
pub const Vec2 = Math.Vec2;
pub const Input = @import("./input.zig");
const ScreenScalar = @import("./screen_scaler.zig");
pub const imgui = @import("./imgui.zig");
pub const Graphics = @import("./graphics.zig");
pub const Audio = @import("./audio/root.zig");
const tracy = @import("tracy");
const builtin = @import("builtin");
const STBImage = @import("stb_image");
const Gfx = Graphics;
const Game = @import("../game.zig");
const Assets = @import("../assets.zig");
const Engine = @This();
pub const Nanoseconds = u64;
pub const Event = union(enum) {
mouse_pressed: struct {
button: Input.Mouse.Button,
position: Vec2,
},
mouse_released: struct {
button: Input.Mouse.Button,
position: Vec2,
},
mouse_move: Vec2,
mouse_enter: Vec2,
mouse_leave,
mouse_scroll: Vec2,
key_pressed: struct {
code: Input.KeyCode,
repeat: bool
},
key_released: Input.KeyCode,
window_resize,
char: u21,
};
pub const Frame = struct {
time_ns: Nanoseconds,
dt_ns: Nanoseconds,
dt: f32,
input: *Input
};
allocator: std.mem.Allocator,
started_at: std.time.Instant,
mouse_inside: bool,
last_frame_at: Nanoseconds,
input: Input,
game: Game,
assets: Assets,
const RunOptions = struct {
window_title: [*:0]const u8 = "Game",
window_width: u31 = 640,
window_height: u31 = 480,
};
pub fn run(self: *Engine, opts: RunOptions) !void {
self.* = Engine{
.allocator = undefined,
.started_at = std.time.Instant.now() catch @panic("Instant.now() unsupported"),
.input = .empty,
.mouse_inside = false,
.last_frame_at = 0,
.assets = undefined,
.game = undefined
};
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
// TODO: Use tracy TracingAllocator
if (builtin.cpu.arch.isWasm()) {
self.allocator = std.heap.wasm_allocator;
} else if (builtin.mode == .Debug) {
self.allocator = debug_allocator.allocator();
} else {
self.allocator = std.heap.smp_allocator;
}
tracy.setThreadName("Main");
if (builtin.os.tag == .linux) {
var sa: std.posix.Sigaction = .{
.handler = .{ .handler = posixSignalHandler },
.mask = std.posix.sigemptyset(),
.flags = std.posix.SA.RESTART,
};
std.posix.sigaction(std.posix.SIG.INT, &sa, null);
}
// TODO: Don't hard code icon path, allow changing through options
var icon_data = try STBImage.load(@embedFile("../assets/icon.png"));
defer icon_data.deinit();
var icon: sapp.IconDesc = .{};
icon.sokol_default = false;
icon.images[0] = .{
.width = @intCast(icon_data.width),
.height = @intCast(icon_data.height),
.pixels = .{
.ptr = icon_data.rgba8_pixels,
.size = icon_data.width * icon_data.height * 4
}
};
sapp.run(.{
.init_userdata_cb = sokolInitCallback,
.frame_userdata_cb = sokolFrameCallback,
.cleanup_userdata_cb = sokolCleanupCallback,
.event_userdata_cb = sokolEventCallback,
.user_data = self,
.width = opts.window_width,
.height = opts.window_height,
.icon = icon,
.window_title = opts.window_title,
.logger = .{ .func = sokolLogCallback },
.win32 = .{
.console_utf8 = true
}
});
}
fn sokolInit(self: *Engine) !void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
try Gfx.init(.{
.allocator = self.allocator,
.logger = .{ .func = sokolLogCallback },
.imgui_font = .{
.ttf_data = @embedFile("../assets/roboto-font/Roboto-Regular.ttf"),
}
});
try Audio.init(.{
.allocator = self.allocator,
.logger = .{ .func = sokolLogCallback },
});
self.assets = try Assets.init(self.allocator);
self.game = try Game.init(self.allocator, &self.assets);
}
fn sokolCleanup(self: *Engine) void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
Audio.deinit();
self.game.deinit();
self.assets.deinit(self.allocator);
Gfx.deinit();
}
fn sokolFrame(self: *Engine) !void {
tracy.frameMark();
const time_passed = self.timePassed();
defer self.last_frame_at = time_passed;
const dt_ns = time_passed - self.last_frame_at;
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
Gfx.beginFrame();
defer Gfx.endFrame();
{
const window_size: Vec2 = .init(sapp.widthf(), sapp.heightf());
const ctx = ScreenScalar.push(window_size, self.game.canvas_size);
defer ctx.pop();
Graphics.font_resolution_scale = ctx.scale;
try self.game.tick(Frame{
.time_ns = time_passed,
.dt_ns = dt_ns,
.dt = @as(f32, @floatFromInt(dt_ns)) / std.time.ns_per_s,
.input = &self.input
});
}
try self.game.debug();
self.input.pressed_keys = .initEmpty();
self.input.released_keys = .initEmpty();
}
fn timePassed(self: *Engine) Nanoseconds {
const now = std.time.Instant.now() catch @panic("Instant.now() unsupported");
return now.since(self.started_at);
}
fn event(self: *Engine, e: Event) !void {
const input = &self.input;
switch (e) {
.key_pressed => |opts| {
if (!opts.repeat) {
input.pressed_keys_at.put(opts.code, self.timePassed());
input.pressed_keys.insert(opts.code);
input.down_keys.insert(opts.code);
}
},
.key_released => |key_code| {
input.down_keys.remove(key_code);
input.released_keys.insert(key_code);
input.pressed_keys_at.remove(key_code);
},
.mouse_leave => {
var iter = input.down_keys.iterator();
while (iter.next()) |key_code| {
input.released_keys.insert(key_code);
}
input.down_keys = .initEmpty();
input.pressed_keys_at = .init(.{});
input.mouse = .empty;
},
.mouse_enter => |pos| {
input.mouse.position = pos;
},
.mouse_move => |pos| {
input.mouse.position = pos;
},
.mouse_pressed => |opts| {
input.mouse.position = opts.position;
input.mouse.buttons.insert(opts.button);
},
.mouse_released => |opts| {
input.mouse.position = opts.position;
input.mouse.buttons.remove(opts.button);
},
else => {}
}
}
fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
const e = e_ptr.*;
const MouseButton = Input.Mouse.Button;
if (imgui.handleEvent(e)) {
if (self.mouse_inside) {
try self.event(Event{
.mouse_leave = {}
});
}
self.mouse_inside = false;
return true;
}
blk: switch (e.type) {
.MOUSE_DOWN => {
const mouse_button = MouseButton.fromSokol(e.mouse_button) orelse break :blk;
try self.event(Event{
.mouse_pressed = .{
.button = mouse_button,
.position = Vec2.init(e.mouse_x, e.mouse_y)
}
});
return true;
},
.MOUSE_UP => {
const mouse_button = MouseButton.fromSokol(e.mouse_button) orelse break :blk;
try self.event(Event{
.mouse_released = .{
.button = mouse_button,
.position = Vec2.init(e.mouse_x, e.mouse_y)
}
});
return true;
},
.MOUSE_MOVE => {
if (!self.mouse_inside) {
try self.event(Event{
.mouse_enter = Vec2.init(e.mouse_x, e.mouse_y)
});
} else {
try self.event(Event{
.mouse_move = Vec2.init(e.mouse_x, e.mouse_y)
});
}
self.mouse_inside = true;
return true;
},
.MOUSE_ENTER => {
if (!self.mouse_inside) {
try self.event(Event{
.mouse_enter = Vec2.init(e.mouse_x, e.mouse_y)
});
}
self.mouse_inside = true;
return true;
},
.RESIZED => {
if (self.mouse_inside) {
try self.event(Event{
.mouse_leave = {}
});
}
try self.event(Event{
.window_resize = {}
});
self.mouse_inside = false;
return true;
},
.MOUSE_LEAVE => {
if (self.mouse_inside) {
try self.event(Event{
.mouse_leave = {}
});
}
self.mouse_inside = false;
return true;
},
.MOUSE_SCROLL => {
try self.event(Event{
.mouse_scroll = Vec2.init(e.scroll_x, e.scroll_y)
});
return true;
},
.KEY_DOWN => {
try self.event(Event{
.key_pressed = .{
.code = @enumFromInt(@intFromEnum(e.key_code)),
.repeat = e.key_repeat
}
});
return true;
},
.KEY_UP => {
try self.event(Event{
.key_released = @enumFromInt(@intFromEnum(e.key_code))
});
return true;
},
.CHAR => {
try self.event(Event{
.char = @intCast(e.char_code)
});
return true;
},
.QUIT_REQUESTED => {
// TODO: handle quit request. Maybe show confirmation window in certain cases.
},
else => {}
}
return false;
}
fn sokolEventCallback(e_ptr: [*c]const sapp.Event, userdata: ?*anyopaque) callconv(.c) void {
const engine: *Engine = @alignCast(@ptrCast(userdata));
const consume_event = engine.sokolEvent(e_ptr) catch |e| blk: {
log.err("sokolEvent() failed: {}", .{e});
if (@errorReturnTrace()) |trace| {
std.debug.dumpStackTrace(trace.*);
}
break :blk false;
};
if (consume_event) {
sapp.consumeEvent();
}
}
fn sokolCleanupCallback(userdata: ?*anyopaque) callconv(.c) void {
const engine: *Engine = @alignCast(@ptrCast(userdata));
engine.sokolCleanup();
}
fn sokolInitCallback(userdata: ?*anyopaque) callconv(.c) void {
const engine: *Engine = @alignCast(@ptrCast(userdata));
engine.sokolInit() catch |e| {
log.err("sokolInit() failed: {}", .{e});
if (@errorReturnTrace()) |trace| {
std.debug.dumpStackTrace(trace.*);
}
sapp.requestQuit();
};
}
fn sokolFrameCallback(userdata: ?*anyopaque) callconv(.c) void {
const engine: *Engine = @alignCast(@ptrCast(userdata));
engine.sokolFrame() catch |e| {
log.err("sokolFrame() failed: {}", .{e});
if (@errorReturnTrace()) |trace| {
std.debug.dumpStackTrace(trace.*);
}
sapp.requestQuit();
};
}
fn sokolLogFmt(log_level: u32, comptime format: []const u8, args: anytype) void {
const log_sokol = std.log.scoped(.sokol);
if (log_level == 0) {
log_sokol.err(format, args);
} else if (log_level == 1) {
log_sokol.err(format, args);
} else if (log_level == 2) {
log_sokol.warn(format, args);
} else {
log_sokol.info(format, args);
}
}
fn cStrToZig(c_str: [*c]const u8) [:0]const u8 {
return std.mem.span(c_str);
}
fn sokolLogCallback(tag: [*c]const u8, log_level: u32, log_item: u32, message: [*c]const u8, line_nr: u32, filename: [*c]const u8, user_data: ?*anyopaque) callconv(.c) void {
_ = user_data;
if (filename != null) {
sokolLogFmt(
log_level,
"[{s}][id:{}] {s}:{}: {s}",
.{
cStrToZig(tag orelse "-"),
log_item,
std.fs.path.basename(cStrToZig(filename orelse "-")),
line_nr,
cStrToZig(message orelse "")
}
);
} else {
sokolLogFmt(
log_level,
"[{s}][id:{}] {s}",
.{
cStrToZig(tag orelse "-"),
log_item,
cStrToZig(message orelse "")
}
);
}
}
fn posixSignalHandler(sig: i32) callconv(.c) void {
_ = sig;
sapp.requestQuit();
}

View File

@ -0,0 +1,67 @@
const Gfx = @import("./graphics.zig");
const Math = @import("./math.zig");
const Vec2 = Math.Vec2;
const rgb = Math.rgb;
const ScreenScalar = @This();
// TODO: Implement a fractional pixel perfect scalar
// Based on this video: https://www.youtube.com/watch?v=d6tp43wZqps
// And this blog: https://colececil.dev/blog/2017/scaling-pixel-art-without-destroying-it/
window_size: Vec2,
translation: Vec2,
scale: f32,
pub fn push(window_size: Vec2, canvas_size: Vec2) ScreenScalar {
// TODO: Render to a lower resolution instead of scaling.
// To avoid pixel bleeding in spritesheet artifacts
const scale = @floor(@min(
window_size.x / canvas_size.x,
window_size.y / canvas_size.y,
));
var translation: Vec2 = Vec2.sub(window_size, canvas_size.multiplyScalar(scale)).multiplyScalar(0.5);
translation.x = @round(translation.x);
translation.y = @round(translation.y);
Gfx.pushTransform(translation, scale);
return ScreenScalar{
.window_size = window_size,
.translation = translation,
.scale = scale
};
}
pub fn pop(self: ScreenScalar) void {
Gfx.popTransform();
const bg_color = rgb(0, 0, 0);
const filler_size = self.translation;
Gfx.drawRectangle(
.init(0, 0),
.init(self.window_size.x, filler_size.y),
bg_color
);
Gfx.drawRectangle(
.init(0, self.window_size.y - filler_size.y),
.init(self.window_size.x, filler_size.y),
bg_color
);
Gfx.drawRectangle(
.init(0, 0),
.init(filler_size.x, self.window_size.y),
bg_color
);
Gfx.drawRectangle(
.init(self.window_size.x - filler_size.x, 0),
.init(filler_size.x, self.window_size.y),
bg_color
);
}

53
src/engine/shell.html Normal file
View File

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"/>
<title>Sokol</title>
<style>
body { margin: 0; background-color: black }
.game {
position: absolute;
top: 0px;
left: 0px;
margin: 0px;
border: 0;
width: 100%;
height: 100%;
overflow: hidden;
display: block;
image-rendering: optimizeSpeed;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
-ms-interpolation-mode: nearest-neighbor;
}
</style>
</head>
<body>
<canvas class="game" id="canvas" oncontextmenu="event.preventDefault()"></canvas>
<script type='text/javascript'>
var Module = {
preRun: [],
print: (function() {
return function(text) {
text = Array.prototype.slice.call(arguments).join(' ');
console.log(text);
};
})(),
printErr: function(text) {
text = Array.prototype.slice.call(arguments).join(' ');
console.error(text);
},
};
window.onerror = function() {
console.log("onerror: " + event.message);
};
window.addEventListener("click", () => window.focus())
</script>
{{{ SCRIPT }}}
</body>
</html>

91
src/game.zig Normal file
View File

@ -0,0 +1,91 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const Assets = @import("./assets.zig");
const Engine = @import("./engine/root.zig");
const imgui = Engine.imgui;
const Vec2 = Engine.Vec2;
const rgb = Engine.Math.rgb;
const Gfx = Engine.Graphics;
const Audio = Engine.Audio;
const Game = @This();
gpa: Allocator,
assets: *Assets,
canvas_size: Vec2,
player: Vec2,
pub fn init(gpa: Allocator, assets: *Assets) !Game {
return Game{
.gpa = gpa,
.assets = assets,
.canvas_size = Vec2.init(100, 100),
.player = .init(50, 50)
};
}
pub fn deinit(self: *Game) void {
_ = self; // autofix
}
pub fn tick(self: *Game, frame: Engine.Frame) !void {
var dir = Vec2.init(0, 0);
if (frame.input.isKeyDown(.W)) {
dir.y -= 1;
}
if (frame.input.isKeyDown(.S)) {
dir.y += 1;
}
if (frame.input.isKeyDown(.A)) {
dir.x -= 1;
}
if (frame.input.isKeyDown(.D)) {
dir.x += 1;
}
dir = dir.normalized();
if (dir.x != 0 or dir.y != 0) {
Audio.play(.{
.id = self.assets.wood01
});
}
self.player = self.player.add(dir.multiplyScalar(50 * frame.dt));
const regular_font = self.assets.font_id.get(.regular);
Gfx.drawRectangle(.init(0, 0), self.canvas_size, rgb(20, 20, 20));
const size = Vec2.init(20, 20);
Gfx.drawRectangle(self.player.sub(size.divideScalar(2)), size, rgb(200, 20, 20));
if (dir.x != 0 or dir.y != 0) {
Gfx.drawRectanglOutline(self.player.sub(size.divideScalar(2)), size, rgb(20, 200, 20), 3);
}
Gfx.drawText(self.player, "Player", .{
.font = regular_font,
.size = 10
});
}
pub fn debug(self: *Game) !void {
_ = self; // autofix
if (!imgui.beginWindow(.{
.name = "Debug",
.pos = Vec2.init(20, 20),
.size = Vec2.init(400, 200),
})) {
return;
}
defer imgui.endWindow();
imgui.text("Hello World!\n");
imgui.textFmt("Audio: {}/{}\n", .{
Audio.mixer.instances.items.len,
Audio.mixer.instances.capacity
});
}

8
src/main.zig Normal file
View File

@ -0,0 +1,8 @@
const Engine = @import("./engine/root.zig");
var engine: Engine = undefined;
pub fn main() !void {
try engine.run(.{});
}

56
tools/png-to-icon.zig Normal file
View File

@ -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 <png-file> <output-ico>", .{});
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();
}