diff options
author | sadbeast <sadbeast@sadbeast.com> | 2024-04-15 18:08:28 -0700 |
---|---|---|
committer | sadbeast <sadbeast@sadbeast.com> | 2024-05-10 15:44:51 -0700 |
commit | de200f57e9381c8338ddd909dd6199c4be8aac20 (patch) | |
tree | 51222d7aa676479152d71fa2c41b4a6fc6234cf7 | |
download | tmz-main.tar.gz tmz-main.tar.bz2 |
-rw-r--r-- | .envrc | 7 | ||||
-rw-r--r-- | .gitignore | 9 | ||||
-rw-r--r-- | README.md | 69 | ||||
-rw-r--r-- | build.zig | 101 | ||||
-rw-r--r-- | build.zig.zon | 61 | ||||
-rw-r--r-- | example/build.zig | 68 | ||||
-rw-r--r-- | example/build.zig.zon | 67 | ||||
-rw-r--r-- | example/game.zig | 19 | ||||
-rw-r--r-- | example/src/main.zig | 24 | ||||
-rw-r--r-- | example/src/map.tmj | 105 | ||||
-rw-r--r-- | example/src/tiles.png | bin | 0 -> 3572 bytes | |||
-rw-r--r-- | example/src/tiles.tsj | 51 | ||||
-rw-r--r-- | flake.lock | 162 | ||||
-rw-r--r-- | flake.nix | 59 | ||||
-rw-r--r-- | src/main.zig | 28 | ||||
-rw-r--r-- | src/test/map-base64-gzip.tmj | 35 | ||||
-rw-r--r-- | src/test/map-base64-none.tmj | 35 | ||||
-rw-r--r-- | src/test/map-base64-zlib.tmj | 35 | ||||
-rw-r--r-- | src/test/map-base64-zstd.tmj | 35 | ||||
-rw-r--r-- | src/test/map-csv.tmj | 37 | ||||
-rw-r--r-- | src/test/map.tmj | 107 | ||||
-rw-r--r-- | src/test/tiles.png | bin | 0 -> 3572 bytes | |||
-rw-r--r-- | src/test/tiles.tsj | 61 | ||||
-rw-r--r-- | src/tmz.zig | 514 | ||||
-rw-r--r-- | src/tmz_test.zig | 124 |
25 files changed, 1813 insertions, 0 deletions
@@ -0,0 +1,7 @@ +#dotenv + +if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4=" +fi + +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb45df7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# direnv +.direnv + +# Zig +zig-cache +zig-out + +# kcov +kcov-output diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fd44db --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# tmz + +A library for parsing and loading [Tiled](https://www.mapeditor.org/) [JSON Maps](https://doc.mapeditor.org/en/stable/reference/json-map-format/#map) and [JSON Tilesets](https://doc.mapeditor.org/en/stable/reference/json-map-format/#tileset) in [Zig](https://ziglang.org/). + +## Getting started + +```zig +const allocator = std.heap.page_allocator; + +const parsed_map = try tmz.parseJson(@embedFile("map.tmj"), allocator); +defer parsed_map.deinit(); + +const map = parsed_map.map; +std.debug.print("Map size: {}x{}\n", .{ map.height, map.width }); +``` + +## Use in your own projects + +1. Add tmz to your `build.zig.zon` + +``` +zig fetch --save https://git.sadbeast.com/tmz/snapshot/tmz-main.tar.gz +``` +or manually: + +```zig +.dependencies = .{ + .tmz = .{ + .url = "https://git.sadbeast.com/tmz/snapshot/tmz-main.tar.gz", + .hash = "122049a7810f7bd222fc2d65204c85d0cf867ab03063bb11c8086b452092d9f1a4b5", + }, +}, +``` + +2. Add tmz to your `build.zig` + +```zig +const tmz = b.dependency("tmz", .{ + .target = target, + .optimize = optimize, +}); + +exe.root_module.addImport("tmz", tmz.module("tmz")); +``` + +## Documentation + +[Generated docs](https://sadbeast.com/tmz/docs) + +## Building + +To build static library and tmj-viewer executable: + +``` +$ zig build install +``` + +### Dependencies + +* Zig 0.12.0 +* kcov (optional - for test coverage) + +### Nix + +If you have Nix, `nix develop` and/or if you have direnv, `direnv allow` to get a development shell with above dependencies + +## Contributing + +[Email patches](https://git-send-email.io/) to sadbeast@sadbeast.com diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..3108d45 --- /dev/null +++ b/build.zig @@ -0,0 +1,101 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + + const optimize = b.standardOptimizeOption(.{}); + + const lib_source_file = b.path("src/tmz.zig"); + const exe_source_file = b.path("src/main.zig"); + + const tmz_module = b.addModule("tmz", .{ .root_source_file = lib_source_file }); + + const lib = b.addStaticLibrary(.{ + .name = "tmz", + .root_source_file = lib_source_file, + .target = target, + .optimize = optimize, + }); + + // This declares intent for the library to be installed into the standard + // location when the user invokes the "install" step (the default step when + // running `zig build`). + b.installArtifact(lib); + + const exe = b.addExecutable(.{ + .name = "tmjinfo", + .root_source_file = exe_source_file, + .target = target, + .optimize = optimize, + }); + + exe.root_module.addImport("tmz", tmz_module); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const lib_unit_tests = b.addTest(.{ + .root_source_file = lib_source_file, + .target = target, + .optimize = optimize, + }); + + const coverage = b.option(bool, "cover", "Generate test coverage") orelse false; + + // TODO: this doesn't seem to work (zig build test -Dcover), but running this does: + // zig test src/tmz.zig --test-cmd kcov --test-cmd "--clean" --test-cmd kcov-output --test-cmd "--include-pattern=src/" --test-cmd "--exclude-pattern=_test" --test-cmd-bin + if (coverage) { + lib_unit_tests.setExecCmd(&[_]?[]const u8{ + "kcov", + "--clean", + "--include-pattern=src/", + "--exclude-pattern=_test", + "kcov-output", // output dir for kcov + null, // to get zig to use the --test-cmd-bin flag + }); + } + + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = exe_source_file, + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_unit_tests.step); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..a69f12b --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,61 @@ +.{ + .name = "tmz", + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.1.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + .minimum_zig_version = "0.12.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + // See `zig fetch --save <url>` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + //"LICENSE", + "README.md", + }, +} diff --git a/example/build.zig b/example/build.zig new file mode 100644 index 0000000..ca1ecb3 --- /dev/null +++ b/example/build.zig @@ -0,0 +1,68 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "example", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const exe_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/example/build.zig.zon b/example/build.zig.zon new file mode 100644 index 0000000..df70c89 --- /dev/null +++ b/example/build.zig.zon @@ -0,0 +1,67 @@ +.{ + .name = "game", + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + // See `zig fetch --save <url>` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + // This makes *all* files, recursively, included in this package. It is generally + // better to explicitly list the files and directories instead, to insure that + // fetching from tarballs, file system paths, and version control all result + // in the same contents hash. + "", + // For example... + //"build.zig", + //"build.zig.zon", + //"src", + //"LICENSE", + //"README.md", + }, +} diff --git a/example/game.zig b/example/game.zig new file mode 100644 index 0000000..737ac0a --- /dev/null +++ b/example/game.zig @@ -0,0 +1,19 @@ +const std = @import("std"); +const tmz = @import("tmz"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + defer _ = gpa.deinit(); + + const parsed_map = try tmz.parseJson(@embedFile("map.tmj"), allocator); + defer parsed_map.deinit(); + + const stdout_file = std.io.getStdOut().writer(); + var bw = std.io.bufferedWriter(stdout_file); + const stdout = bw.writer(); + + try stdout.print("Map: {any}\n", .{parsed_map.map}); + + try bw.flush(); // don't forget to flush! +} diff --git a/example/src/main.zig b/example/src/main.zig new file mode 100644 index 0000000..c8a3f67 --- /dev/null +++ b/example/src/main.zig @@ -0,0 +1,24 @@ +const std = @import("std"); + +pub fn main() !void { + // Prints to stderr (it's a shortcut based on `std.io.getStdErr()`) + std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); + + // stdout is for the actual output of your application, for example if you + // are implementing gzip, then only the compressed bytes should be sent to + // stdout, not any debugging messages. + const stdout_file = std.io.getStdOut().writer(); + var bw = std.io.bufferedWriter(stdout_file); + const stdout = bw.writer(); + + try stdout.print("Run `zig build test` to run the tests.\n", .{}); + + try bw.flush(); // don't forget to flush! +} + +test "simple test" { + var list = std.ArrayList(i32).init(std.testing.allocator); + defer list.deinit(); // try commenting this out and see if zig detects the memory leak! + try list.append(42); + try std.testing.expectEqual(@as(i32, 42), list.pop()); +} diff --git a/example/src/map.tmj b/example/src/map.tmj new file mode 100644 index 0000000..8723533 --- /dev/null +++ b/example/src/map.tmj @@ -0,0 +1,105 @@ +{ "class":"bar", + "compressionlevel":-1, + "height":30, + "infinite":false, + "layers":[ + { + "class":"bar", + "compression":"zstd", + "data":"KLUv\/WAADo0WAOLOIhewJekMw4AmSQqb4zwHkkxSSmknYWS3FL3tKaVakYricx+o1U0H6HiOrIpawkr20Ux75XaikAGvO8JVvSFVRWuob50v1ZNByLH9iAJCBkQmKGTQgZDD\/WGhIIdCDjO2j9CPWlvYpqHXVGnohm1sxA6qOnn4WvVpVV3oXLbp3WZeyPtZjbWonpMkkg+BU6jB6QvNAaEEyChHMWcHEZCIwJGEiChKBs0ag\/WoKoS5gp76\/p+G5cawdf2AkXsGPwUnt+jgPJ7zhrn26D9Aeus6Hyyf6BNbJaeIEfzi8uPDXHvFbzPJALzjJyR7H9sz5+4BuY89HqnnOJ6YyUnYVfZjDPgrOc29sHaf911o19ny2Yo58M+3Ee1LTfmVdy836PBu\/4kzFZMp\/4Y0PPPvTF9qH4AaMV9IHKfF7C2h4vr\/SDtjE30QBqzZ3iPh7svCD7wNB\/2ny34\/E8yAS8tQezmPVwbhvqPAQdCy4pCyFt6Tdp9r2v4Xx2CcloneN8Qv9sWDW9z+N3sUXpk9skLP2xL3T7JzZv3wBHjSlj3P3Ptr6QNxfwVWFFdkMJVZ5IPo31F8cD\/0\/vp5+nuMePDMtP1JJ\/6K\/14vYucn32yTSW8XH7kc787uXO+RwXmDw4ryMw+TVHYe\/\/3sQO2OeB0nPV0PBm1YpvDFOnri3bbBlrnF4cD\/aPaKOOeHpJ\/t5aFunfuFD57AhmcPMaptd5vEwfQL6LJOfBdHuN5czo+tQLzpP4r9aQ1dBmj0+7vxZ3IDeW4W\/uHxA0krKXi2Bj6Eo32cd0qD1yicts8yzwZU9uivOzf9+26Mwzc6j7d0dCsWYEWJXcZk8QCbKY3OH8Po\/SavNPGeOx8sxt3JgHPU20x24EAb\/MJ3YD8BjZhLnBznCu\/3v3Y\/g6PwQb+W8hNPenxROT6V3WnwE5\/PoXjgT9hhuOqtPPNXuvlotZ1rLng1VAE=", + "encoding":"base64", + "height":30, + "id":1, + "name":"ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":32, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":2, + "name":"objects", + "objects":[ + { + "height":16, + "id":1, + "name":"pool", + "rotation":0, + "type":"", + "visible":true, + "width":16, + "x":88, + "y":96 + }, + { + "height":20, + "id":2, + "name":"", + "rotation":0, + "text": + { + "color":"#241f31", + "fontfamily":"Serif", + "text":"tmz", + "wrap":true + }, + "type":"", + "visible":true, + "width":30, + "x":70, + "y":16 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }, + { + "id":3, + "image":"tiles.png", + "name":"tile_image", + "offsetx":1, + "offsety":1, + "opacity":1, + "type":"imagelayer", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":4, + "nextobjectid":3, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":8, + "tilesets":[ + { + "firstgid":1, + "source":"tiles.tsj" + }, + { + "columns":4, + "firstgid":17, + "image":"tiles.png", + "imageheight":32, + "imagewidth":32, + "margin":0, + "name":"embedded_tiles", + "spacing":0, + "tilecount":16, + "tileheight":8, + "tilewidth":8 + }, + { + "firstgid":33, + "source":"..\/src\/test\/tiles.tsj" + }], + "tilewidth":8, + "type":"map", + "version":"1.10", + "width":32 +}
\ No newline at end of file diff --git a/example/src/tiles.png b/example/src/tiles.png Binary files differnew file mode 100644 index 0000000..499259c --- /dev/null +++ b/example/src/tiles.png diff --git a/example/src/tiles.tsj b/example/src/tiles.tsj new file mode 100644 index 0000000..4e0ca0d --- /dev/null +++ b/example/src/tiles.tsj @@ -0,0 +1,51 @@ +{ "columns":4, + "image":"tiles.png", + "imageheight":32, + "imagewidth":32, + "margin":0, + "name":"tiles", + "spacing":0, + "tilecount":16, + "tiledversion":"1.10.2", + "tileheight":8, + "tiles":[ + { + "id":8, + "probability":3 + }, + { + "id":14, + "probability":0.25 + }], + "tilewidth":8, + "type":"tileset", + "version":"1.10", + "wangsets":[ + { + "colors":[ + { + "color":"#ff0000", + "name":"", + "probability":1, + "tile":-1 + }, + { + "color":"#00ff00", + "name":"", + "probability":1, + "tile":-1 + }], + "name":"corners", + "tile":-1, + "type":"corner", + "wangtiles":[ + { + "tileid":0, + "wangid":[0, 1, 0, 1, 0, 1, 0, 1] + }, + { + "tileid":1, + "wangid":[0, 2, 0, 2, 0, 2, 0, 2] + }] + }] +}
\ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b27a8da --- /dev/null +++ b/flake.lock @@ -0,0 +1,162 @@ +{ + "nodes": { + "devshell": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1713532798, + "narHash": "sha256-wtBhsdMJA3Wa32Wtm1eeo84GejtI43pMrFrmwLXrsEc=", + "owner": "numtide", + "repo": "devshell", + "rev": "12e914740a25ea1891ec619bb53cf5e6ca922e40", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1714641030, + "narHash": "sha256-yzcRNDoyVP7+SCNX0wmuDju1NUCt8Dz9+lyUXEI0dbI=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "e5d10a24b66c3ea8f150e47dfdb0416ab7c3390e", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1704161960, + "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "63143ac2c9186be6d9da6035fa22620018c85932", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1714640452, + "narHash": "sha256-QBx10+k6JWz6u7VsohfSw8g8hjdBZEf8CFzXH1/1Z94=", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/50eb7ecf4cd0a5756d7275c8ba36790e5bd53e33.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/50eb7ecf4cd0a5756d7275c8ba36790e5bd53e33.tar.gz" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1715266358, + "narHash": "sha256-doPgfj+7FFe9rfzWo1siAV2mVCasW+Bh8I1cToAXEE4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f1010e0469db743d14519a1efd37e23f8513d714", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1708475490, + "narHash": "sha256-g1v0TsWBQPX97ziznfJdWhgMyMGtoBFs102xSYO4syU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "0e74ca98a74bc7270d28838369593635a5db3260", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devshell": "devshell", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_2", + "treefmt-nix": "treefmt-nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1714058656, + "narHash": "sha256-Qv4RBm4LKuO4fNOfx9wl40W2rBbv5u5m+whxRYUMiaA=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "c6aaf729f34a36c445618580a9f95a48f5e4e03f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..07c985c --- /dev/null +++ b/flake.nix @@ -0,0 +1,59 @@ +{ + description = "tmz - a library for loading Tiled Maps and Tilesets"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + flake-parts.url = "github:hercules-ci/flake-parts"; + + devshell.url = "github:numtide/devshell"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + }; + + outputs = inputs @ {flake-parts, ...}: + flake-parts.lib.mkFlake {inherit inputs;} { + imports = [ + inputs.devshell.flakeModule + inputs.treefmt-nix.flakeModule + ]; + systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin"]; + perSystem = { + config, + self', + inputs', + pkgs, + system, + ... + }: { + treefmt.config = { + projectRootFile = "flake.nix"; + + flakeCheck = true; + flakeFormatter = true; + + programs.zig.enable = true; + programs.alejandra.enable = true; + }; + + devshells.default = { + commands = [ + { + name = "fmt"; + help = "format the repo"; + command = "nix fmt"; + } + { + name = "run"; + help = "build and run"; + command = "zig build run"; + } + ]; + + packages = [ + pkgs.zig_0_12 + pkgs.kcov + ]; + }; + }; + }; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..9f0597c --- /dev/null +++ b/src/main.zig @@ -0,0 +1,28 @@ +const std = @import("std"); +const tmz = @import("tmz"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + defer _ = gpa.deinit(); + + const file = try std.fs.cwd().openFile( + "./src/test/map.tmj", + .{}, + ); + defer file.close(); + const map_bytes = try file.readToEndAlloc(allocator, std.math.maxInt(usize)); + defer allocator.free(map_bytes); + + const parsed_map = try tmz.parseMap(allocator, map_bytes); + defer parsed_map.deinit(); + + const stdout_file = std.io.getStdOut().writer(); + var bw = std.io.bufferedWriter(stdout_file); + const stdout = bw.writer(); + + const map = parsed_map.value; + try stdout.print("Layers: {}\n", .{map.layers.len}); + + try bw.flush(); // don't forget to flush! +} diff --git a/src/test/map-base64-gzip.tmj b/src/test/map-base64-gzip.tmj new file mode 100644 index 0000000..0a6ee2f --- /dev/null +++ b/src/test/map-base64-gzip.tmj @@ -0,0 +1,35 @@ +{ "compressionlevel":-1, + "height":5, + "infinite":false, + "layers":[ + { + "class":"bar", + "compression":"gzip", + "data":"H4sIAAAAAAAAA+NiYGDgB2JWIGZjYDgAxA6sUD5UrAGIGZiBcsxockB+AzOUDTJDAEmOFcrnBmIAadKM8GQAAAA=", + "encoding":"base64", + "height":5, + "id":1, + "name":"ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":5, + "x":0, + "y":0 + }], + "nextlayerid":2, + "nextobjectid":1, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":8, + "tilesets":[ + { + "firstgid":1, + "source":"tiles.tsj" + }], + "tilewidth":8, + "type":"map", + "version":"1.10", + "width":5 +} diff --git a/src/test/map-base64-none.tmj b/src/test/map-base64-none.tmj new file mode 100644 index 0000000..87757aa --- /dev/null +++ b/src/test/map-base64-none.tmj @@ -0,0 +1,35 @@ +{ "compressionlevel":-1, + "height":5, + "infinite":false, + "layers":[ + { + "class":"bar", + "compression":"", + "data":"CgAAAA8AAAAFAAAABgAAwAYAAEAFAAAABQAAAAUAAAAGAACABgAAAAMAAMADAABABQAAAAUAAAAFAAAAAwAAgAMAAAAFAAAADwAAABAAAAAFAAAABQAAAAUAAAAQAAAACwAAAA==", + "encoding":"base64", + "height":5, + "id":1, + "name":"base64-ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":5, + "x":0, + "y":0 + }], + "nextlayerid":2, + "nextobjectid":1, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":8, + "tilesets":[ + { + "firstgid":1, + "source":"tiles.tsj" + }], + "tilewidth":8, + "type":"map", + "version":"1.10", + "width":5 +}
\ No newline at end of file diff --git a/src/test/map-base64-zlib.tmj b/src/test/map-base64-zlib.tmj new file mode 100644 index 0000000..e0eb4e2 --- /dev/null +++ b/src/test/map-base64-zlib.tmj @@ -0,0 +1,35 @@ +{ "compressionlevel":-1, + "height":5, + "infinite":false, + "layers":[ + { + "class":"bar", + "compression":"zlib", + "data":"eJzjYmBg4AdiViBmY2A4AMQOrFA+VKwBiBmYgXLMaHJAfgMzlA0yQwBJjhXK5wZiAOC8A68=", + "encoding":"base64", + "height":5, + "id":1, + "name":"base64-ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":5, + "x":0, + "y":0 + }], + "nextlayerid":2, + "nextobjectid":1, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":8, + "tilesets":[ + { + "firstgid":1, + "source":"tiles.tsj" + }], + "tilewidth":8, + "type":"map", + "version":"1.10", + "width":5 +}
\ No newline at end of file diff --git a/src/test/map-base64-zstd.tmj b/src/test/map-base64-zstd.tmj new file mode 100644 index 0000000..17a0b6f --- /dev/null +++ b/src/test/map-base64-zstd.tmj @@ -0,0 +1,35 @@ +{ "compressionlevel":-1, + "height":5, + "infinite":false, + "layers":[ + { + "class":"bar", + "compression":"zstd", + "data":"KLUv\/SBkFQIA1AIKAAAADwAAAAUAAAAGAADABgAAQAWABgAAAAMAAMADAwAAgAMQEAAAAAsAAAAHACDAj92BQ3jJ9M+ONzM42WQC", + "encoding":"base64", + "height":5, + "id":1, + "name":"base64-ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":5, + "x":0, + "y":0 + }], + "nextlayerid":2, + "nextobjectid":1, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":8, + "tilesets":[ + { + "firstgid":1, + "source":"tiles.tsj" + }], + "tilewidth":8, + "type":"map", + "version":"1.10", + "width":5 +}
\ No newline at end of file diff --git a/src/test/map-csv.tmj b/src/test/map-csv.tmj new file mode 100644 index 0000000..3323039 --- /dev/null +++ b/src/test/map-csv.tmj @@ -0,0 +1,37 @@ +{ "compressionlevel":-1, + "height":5, + "infinite":false, + "layers":[ + { + "class":"bar", + "data":[10, 15, 5, 3221225478, 1073741830, + 5, 5, 5, 2147483654, 6, + 3221225475, 1073741827, 5, 5, 5, + 2147483651, 3, 5, 15, 16, + 5, 5, 5, 16, 11], + "height":5, + "id":1, + "name":"base64-ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":5, + "x":0, + "y":0 + }], + "nextlayerid":2, + "nextobjectid":1, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":8, + "tilesets":[ + { + "firstgid":1, + "source":"tiles.tsj" + }], + "tilewidth":8, + "type":"map", + "version":"1.10", + "width":5 +}
\ No newline at end of file diff --git a/src/test/map.tmj b/src/test/map.tmj new file mode 100644 index 0000000..1aa1b1b --- /dev/null +++ b/src/test/map.tmj @@ -0,0 +1,107 @@ +{ "class":"bar", + "compressionlevel":-1, + "height":30, + "infinite":false, + "layers":[ + { + "class":"bar", + "compression":"zstd", + "data":"KLUv\/WAADo0WAOLOIhewJekMw4AmSQqb4zwHkkxSSmknYWS3FL3tKaVakYricx+o1U0H6HiOrIpawkr20Ux75XaikAGvO8JVvSFVRWuob50v1ZNByLH9iAJCBkQmKGTQgZDD\/WGhIIdCDjO2j9CPWlvYpqHXVGnohm1sxA6qOnn4WvVpVV3oXLbp3WZeyPtZjbWonpMkkg+BU6jB6QvNAaEEyChHMWcHEZCIwJGEiChKBs0ag\/WoKoS5gp76\/p+G5cawdf2AkXsGPwUnt+jgPJ7zhrn26D9Aeus6Hyyf6BNbJaeIEfzi8uPDXHvFbzPJALzjJyR7H9sz5+4BuY89HqnnOJ6YyUnYVfZjDPgrOc29sHaf911o19ny2Yo58M+3Ee1LTfmVdy836PBu\/4kzFZMp\/4Y0PPPvTF9qH4AaMV9IHKfF7C2h4vr\/SDtjE30QBqzZ3iPh7svCD7wNB\/2ny34\/E8yAS8tQezmPVwbhvqPAQdCy4pCyFt6Tdp9r2v4Xx2CcloneN8Qv9sWDW9z+N3sUXpk9skLP2xL3T7JzZv3wBHjSlj3P3Ptr6QNxfwVWFFdkMJVZ5IPo31F8cD\/0\/vp5+nuMePDMtP1JJ\/6K\/14vYucn32yTSW8XH7kc787uXO+RwXmDw4ryMw+TVHYe\/\/3sQO2OeB0nPV0PBm1YpvDFOnri3bbBlrnF4cD\/aPaKOOeHpJ\/t5aFunfuFD57AhmcPMaptd5vEwfQL6LJOfBdHuN5czo+tQLzpP4r9aQ1dBmj0+7vxZ3IDeW4W\/uHxA0krKXi2Bj6Eo32cd0qD1yicts8yzwZU9uivOzf9+26Mwzc6j7d0dCsWYEWJXcZk8QCbKY3OH8Po\/SavNPGeOx8sxt3JgHPU20x24EAb\/MJ3YD8BjZhLnBznCu\/3v3Y\/g6PwQb+W8hNPenxROT6V3WnwE5\/PoXjgT9hhuOqtPPNXuvlotZ1rLng1VAE=", + "encoding":"base64", + "height":30, + "id":1, + "name":"ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":32, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":2, + "name":"objects", + "objects":[ + { + "height":16, + "id":1, + "name":"pool", + "rotation":0, + "type":"", + "visible":true, + "width":16, + "x":88, + "y":96 + }, + { + "height":20, + "id":2, + "name":"", + "rotation":0, + "text": + { + "color":"#241f31", + "fontfamily":"Serif", + "text":"tmz", + "wrap":true + }, + "type":"", + "visible":true, + "width":30, + "x":70, + "y":16 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }, + { + "id":3, + "image":"tiles.png", + "name":"tile_image", + "offsetx":1, + "offsety":1, + "opacity":1, + "type":"imagelayer", + "visible":true, + "x":0, + "y":0 + }], + "nextlayerid":4, + "nextobjectid":3, + "orientation":"orthogonal", + "properties":[ + { + "name":"beautiful", + "type":"bool", + "value":true + }], + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":8, + "tilesets":[ + { + "firstgid":1, + "source":"tiles.tsj" + }, + { + "columns":4, + "firstgid":17, + "image":"tiles.png", + "imageheight":32, + "imagewidth":32, + "margin":0, + "name":"embedded_tiles", + "spacing":0, + "tilecount":16, + "tileheight":8, + "tilewidth":8 + }], + "tilewidth":8, + "type":"map", + "version":"1.10", + "width":32 +}
\ No newline at end of file diff --git a/src/test/tiles.png b/src/test/tiles.png Binary files differnew file mode 100644 index 0000000..499259c --- /dev/null +++ b/src/test/tiles.png diff --git a/src/test/tiles.tsj b/src/test/tiles.tsj new file mode 100644 index 0000000..0f697f4 --- /dev/null +++ b/src/test/tiles.tsj @@ -0,0 +1,61 @@ +{ "columns":4, + "image":"tiles.png", + "imageheight":32, + "imagewidth":32, + "margin":0, + "name":"tiles", + "spacing":0, + "tilecount":16, + "tiledversion":"1.10.2", + "tileheight":8, + "tiles":[ + { + "id":6, + "objectgroup": + { + "draworder":"index", + "id":2, + "name":"", + "objects":[ + { + "height":7, + "id":1, + "name":"", + "rotation":0, + "type":"", + "visible":true, + "width":7, + "x":0, + "y":0 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + } + }, + { + "animation":[ + { + "duration":200, + "tileid":8 + }, + { + "duration":200, + "tileid":10 + }, + { + "duration":200, + "tileid":9 + }, + { + "duration":200, + "tileid":11 + }], + "id":8 + }], + "tilewidth":8, + "type":"tileset", + "version":"1.10" +}
\ No newline at end of file diff --git a/src/tmz.zig b/src/tmz.zig new file mode 100644 index 0000000..3a108c2 --- /dev/null +++ b/src/tmz.zig @@ -0,0 +1,514 @@ +//! Tiled Map parser +//! +//! This is essentially a thin wrapper around std.json to parse the contents of JSON Map files (.tmj). +//! +//! Supports both CSV and Base64 encoded Layer and Chunk data, which will be automatically uncompressed. Supports: +//! - gzip +//! - zlib +//! - zstd +//! +//! Use `parseMap` to parse Tiled JSON Map string +//! +//! Use `parseTileset` to parse Tiled JSON Tileset string + +const std = @import("std"); +const base64_decoder = std.base64.standard.Decoder; +const equals = std.testing.expectEqual; +const stringEquals = std.testing.expectEqualStrings; +const fixedBufferStream = std.io.fixedBufferStream; +const ParseOptions = std.json.ParseOptions; +const Value = std.json.Value; +const Allocator = std.mem.Allocator; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#map +pub const Map = struct { + // Hex-formatted color (#RRGGBB or #AARRGGBB) + background_color: ?[]const u8 = null, + class: ?[]const u8 = null, + // The compression level to use for tile layer data (defaults to -1, which means to use the algorithm default) + // TODO: actually support this? + compression_level: i32 = -1, + // Number of tile rows + height: u32, + // Length of the side of a hex tile in pixels (hexagonal maps only) + hex_side_length: ?u32 = null, + infinite: bool, + layers: []Layer, + // Auto-increments for each layer + next_layer_id: u32, + // Auto-increments for each placed object + next_object_id: u32, + orientation: Orientation, + parallax_origin_x: ?f32 = 0, + parallax_origin_y: ?f32 = 0, + properties: ?[]const Property = null, + // currently only supported for orthogonal maps + render_order: RenderOrder, + // staggered / hexagonal maps only + stagger_axis: ?StaggerAxis = null, + // staggered / hexagonal maps only + stagger_index: ?StaggerIndex = null, + tiled_version: []const u8, + // Map grid height + tile_height: u32, + tilesets: []Tileset, + // Map grid width + tile_width: u32, + // TODO: always "map" - ignore? + type: Type, + version: []const u8, + // Number of tile columns + width: u32, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } + + pub const Orientation = enum { orthogonal, isometric, staggered, hexagonal }; + pub const RenderOrder = enum { @"right-down", @"right-up", @"left-down", @"left-up" }; + pub const StaggerAxis = enum { x, y }; + pub const StaggerIndex = enum { odd, even }; + pub const Type = enum { map }; +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#layer +pub const Layer = struct { + chunks: ?[]const Chunk = null, + class: ?[]const u8 = null, + compression: ?Compression = null, + data: ?[]u32 = null, + draw_order: ?DrawOrder = .topdown, + encoding: ?Encoding = .csv, + height: ?u32 = null, + id: u32, + image: ?[]const u8 = null, + layers: ?[]Layer = null, + locked: bool = false, + name: []const u8, + objects: ?[]Object = null, + offset_x: f32 = 0, + offset_y: f32 = 0, + opacity: f32, + parallax_x: f32 = 1, + parallax_y: f32 = 1, + properties: ?[]Property = null, + repeat_x: ?bool = null, + repeat_y: ?bool = null, + start_x: ?i32 = null, + start_y: ?i32 = null, + tint_color: ?[]const u8 = null, + transparent_color: ?[]const u8 = null, + type: Type, + visible: bool, + width: ?u32 = null, + x: i32, + y: i32, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + var layer = try jsonParseRename(@This(), allocator, source, options); + + if (layer.type == .tilelayer) { + if (source.object.get("data")) |data| { + if (layer.encoding == .csv) { + layer.data = try std.json.parseFromValueLeaky([]u32, allocator, data, options); + } else { + const base64_data = try std.json.parseFromValueLeaky([]const u8, allocator, data, options); + const size = base64_decoder.calcSizeForSlice(base64_data) catch return error.UnknownField; + var decoded = try allocator.alloc(u8, size); + defer allocator.free(decoded); + base64_decoder.decode(decoded, base64_data) catch return error.UnknownField; + + const map_size: usize = (layer.width orelse 0) * (layer.height orelse 0); + layer.data = allocator.alloc(u32, map_size) catch null; + if (layer.data) |*layer_data| { + if (layer.compression) |cmp| { + const buf = allocator.alloc(u8, map_size * @alignOf(u32)) catch return error.UnknownField; + var decompressed = fixedBufferStream(buf); + var compressed = fixedBufferStream(decoded); + switch (cmp) { + .gzip => { + std.compress.gzip.decompress(compressed.reader(), decompressed.writer()) catch return error.UnknownField; + decoded = decompressed.getWritten(); + }, + .zlib => { + std.compress.zlib.decompress(compressed.reader(), decompressed.writer()) catch return error.UnknownField; + decoded = decompressed.getWritten(); + }, + .zstd => { + const window_buffer = allocator.alloc(u8, 1 << 23) catch unreachable; + var zstd_stream = std.compress.zstd.decompressor(compressed.reader(), .{ .window_buffer = window_buffer }); + _ = zstd_stream.reader().readAll(buf) catch unreachable; + decoded = buf; + }, + .none => {}, + } + } + + var tile_index: usize = 0; + if (map_size * @alignOf(u32) != decoded.len) { + @panic("oh no"); + } + for (layer_data.*) |*tile| { + tile.* = toLittleEndian(getU32(decoded, tile_index)); + tile_index += @alignOf(u32); + } + } + } + } + } + return layer; + } + + pub const Compression = enum { + none, + zlib, + gzip, + zstd, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + _ = allocator; + _ = options; + return switch (source) { + .string, .number_string => |value| cmp: { + if (value.len == 0) { + break :cmp .none; + } else { + break :cmp std.meta.stringToEnum(Compression, value) orelse .none; + } + }, + else => .none, + }; + } + }; + pub const DrawOrder = enum { topdown, index }; + pub const Encoding = enum { csv, base64 }; + + pub const Type = enum { + tilelayer, + objectgroup, + imagelayer, + group, + }; +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#chunk +pub const Chunk = struct { + data: []const u32, + height: u32, + width: u32, + x: u32, + y: u32, +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#object +pub const Object = struct { + ellipse: ?bool = null, + gid: ?u32 = null, + height: f32, + id: u32, + name: []const u8, + point: ?bool = null, + polygon: ?[]Point = null, + polyline: ?[]Point = null, + properties: ?[]Property = null, + rotation: f32, + template: ?[]const u8 = null, + text: ?Text = null, + type: ?[]const u8 = null, + visible: bool, + width: f32, + x: f32, + y: f32, +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#point +pub const Point = struct { + x: f32, + y: f32, +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#text +pub const Text = struct { + bold: bool, + color: []const u8, + font_family: []const u8 = "sans-serif", + h_align: enum { center, right, justify, left } = .left, + italic: bool = false, + kerning: bool = true, + pixel_size: usize = 16, + strikeout: bool = false, + text: []const u8, + underline: bool = false, + v_align: enum { center, bottom, top } = .top, + wrap: bool = false, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } +}; + +/// https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#properties +pub const Property = struct { + name: []const u8, + type: Type = .string, + property_type: []const u8 = "", + + pub const Type = enum { + string, + int, + float, + bool, + color, + file, + object, + class, + }; + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#tileset +pub const Tileset = struct { + background_color: ?[]const u8 = null, + class: ?[]const u8 = null, + columns: ?u32 = null, + fill_mode: ?FillMode = .stretch, + first_gid: u32, + grid: ?Grid = null, + image: ?[]const u8 = null, + image_height: ?u32 = null, + image_width: ?u32 = null, + margin: ?u32 = null, + name: ?[]const u8 = null, + object_alignment: ?ObjectAlignment = .unspecified, + properties: ?[]Property = null, + source: ?[]const u8 = null, + spacing: ?i32 = null, + terrains: ?[]Terrain = null, + tile_count: ?u32 = null, + tile_version: ?[]const u8 = null, + tile_height: ?u32 = null, + tile_offset: ?Point = null, + tile_render_size: ?TileRenderSize = .tile, + tiles: ?[]Tile = null, + tile_width: ?u32 = null, + transformations: ?Transformations = null, + transparent_color: ?[]const u8 = null, + type: ?Type = .tileset, + version: ?[]const u8 = null, + wang_sets: ?[]WangSet = null, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + const tileset = try jsonParseRename(@This(), allocator, source, options); + + if (tileset.source) |s| { + const file = std.fs.cwd().openFile(s, .{}) catch @panic("couldn't open tileset"); + defer file.close(); + // const tileset_bytes = file.readToEndAlloc(allocator, std.math.maxInt(usize)) catch @panic("couldn't read tileset file "); + // return try parseJsonLeaky(Tileset, allocator, tileset_bytes); + } + return tileset; + } + + pub const FillMode = enum { stretch, @"preserve-aspect-fit" }; + pub const ObjectAlignment = enum { + unspecified, + topleft, + top, + topright, + left, + center, + right, + bottomleft, + bottom, + bottomright, + }; + + pub const TileRenderSize = enum { tile, grid }; + pub const Type = enum { tileset }; +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#grid +pub const Grid = struct { + orientation: enum { orthogonal, isometric } = .orthogonal, + height: i32, + width: i32, +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#terrain +pub const Terrain = struct { + name: []const u8, + properties: []Property, + tile: i32, +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#tile-definition +pub const Tile = struct { + animation: []Frame, + id: i32, + name: ?[]const u8, + image_height: u32, + image_width: u32, + x: i32, + y: i32, + width: u32, + height: u32, + objectgroup: ?Layer, + probability: f32, + properties: []Property, + terrain: []i32, + type: []const u8, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#frame +pub const Frame = struct { + duration: i32, + tile_id: i32, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#transformations +pub const Transformations = struct { + h_flip: bool, + v_flip: bool, + rotate: bool, + prefer_untransformed: bool, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#wang-set +pub const WangSet = struct { + class: ?[]const u8 = null, + colors: ?[]WangColor = null, + name: []const u8, + properties: ?[]Property = null, + tile: i32, + type: Type, + wang_tiles: []WangTile, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } + + pub const Type = enum { corner, edge, mixed }; +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#wang-color +pub const WangColor = struct { + class: ?[]const u8 = null, + color: []const u8, + name: []const u8, + probability: f32, + properties: ?[]Property = null, + tile: i32, +}; + +/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#wang-tile +pub const WangTile = struct { + tile_id: i32, + wang_id: [8]u8, + + pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() { + return try jsonParseRename(@This(), allocator, source, options); + } +}; + +/// parse a JSON Map string +/// You must call `deinit()` of the returned ParsedMap to clean up allocated resources. +/// If you are using a `std.heap.ArenaAllocator` or similar, consider calling `parseMapLeaky` instead. +pub fn parseMap(allocator: Allocator, bytes: []const u8) !std.json.Parsed(Map) { + var parsed_map = std.json.Parsed(Map){ + .arena = try allocator.create(std.heap.ArenaAllocator), + .value = undefined, + }; + errdefer allocator.destroy(parsed_map.arena); + + parsed_map.arena.* = std.heap.ArenaAllocator.init(allocator); + errdefer parsed_map.arena.deinit(); + + parsed_map.value = try parseJsonLeaky(Map, parsed_map.arena.allocator(), bytes); + + return parsed_map; +} + +/// You must call `deinit()` of the returned ParsedMap to clean up allocated resources. +/// If you are using a `std.heap.ArenaAllocator` or similar, consider calling `parseMapLeaky` instead. +pub fn parseTileset(allocator: Allocator, bytes: []const u8) !std.json.Parsed(Tileset) { + var parsed = std.json.Parsed(Tileset){ + .arena = try allocator.create(std.heap.ArenaAllocator), + .value = undefined, + }; + errdefer allocator.destroy(parsed.arena); + + parsed.arena.* = std.heap.ArenaAllocator.init(allocator); + errdefer parsed.arena.deinit(); + + parsed.value = try parseJsonLeaky(Tileset, parsed.arena.allocator(), bytes); + + return parsed; +} + +/// Parses a json Tiled Map or Tileset document from `bytes` and returns a `Map`. +/// Allocations made during this operation are not carefully tracked and may not be possible to individually clean up. +/// It is recommended to use a `std.heap.ArenaAllocator` or similar. +pub fn parseJsonLeaky(comptime T: type, allocator: Allocator, bytes: []const u8) !T { + const json_value = try std.json.parseFromSliceLeaky(Value, allocator, bytes, .{}); + return try std.json.parseFromValueLeaky(T, allocator, json_value, .{ .ignore_unknown_fields = true }); +} + +// converts masheduppropertynames to Zig style field names +fn jsonParseRename(T: type, allocator: Allocator, source: Value, options: ParseOptions) !T { + var t: T = undefined; + + inline for (@typeInfo(T).Struct.fields) |field| { + if (comptime std.mem.eql(u8, field.name, "data")) continue; + const size = comptime std.mem.replacementSize(u8, field.name, "_", ""); + var tiled_name: [size]u8 = undefined; + _ = std.mem.replace(u8, field.name, "_", "", &tiled_name); + + const source_field = source.object.get(&tiled_name); + if (source_field) |s| { + @field(t, field.name) = try std.json.innerParseFromValue(field.type, allocator, s, options); + } else { + if (field.default_value) |val| { + @field(t, field.name) = @as(*align(1) const field.type, @ptrCast(val)).*; + } + } + } + + return t; +} + +// Instead of shifting bits, use bitCast to convert 4 byte array to a u32 +// Taken from https://www.reddit.com/r/Zig/comments/17jzb2b/comment/k76tmdq/ +inline fn getU32(buffer: []u8, index: usize) u32 { + if (@alignOf(u8) != @alignOf([4]u8)) + @compileError("mismatching alignment for u8 and [4]u8"); + + return @as(u32, @bitCast(@as(*[4]u8, @ptrCast(&buffer[index])).*)); +} + +// Layer and Chunk data GIDs are stored in little-endian byte order +// this will convert an u32 to little-endian if not already +inline fn toLittleEndian(int: u32) u32 { + return switch (@import("builtin").cpu.arch.endian()) { + .big => @byteSwap(int), + .little => int, + }; +} + +test { + _ = @import("./tmz_test.zig"); +} diff --git a/src/tmz_test.zig b/src/tmz_test.zig new file mode 100644 index 0000000..7a29368 --- /dev/null +++ b/src/tmz_test.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const equals = std.testing.expectEqual; +const stringEquals = std.testing.expectEqualStrings; +const tmz = @import("./tmz.zig"); +const Layer = tmz.Layer; +const Map = tmz.Map; + +const full_map = @embedFile("test/map.tmj"); + +test "parseJson works" { + const parsed_map = try tmz.parseMap(std.testing.allocator, full_map); + defer parsed_map.deinit(); + + const map = parsed_map.value; + try fullMapTests(map); +} + +test "parseJsonLeaky works" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const map = try tmz.parseJsonLeaky(Map, arena.allocator(), full_map); + try fullMapTests(map); +} + +fn fullMapTests(map: Map) !void { + try equals(null, map.background_color); + try stringEquals("bar", map.class.?); + try equals(-1, map.compression_level); + try equals(30, map.height); + try equals(null, map.hex_side_length); + try equals(false, map.infinite); + try equals(4, map.next_layer_id); + try equals(3, map.next_object_id); + try equals(.orthogonal, map.orientation); + try equals(0, map.parallax_origin_x); + try equals(0, map.parallax_origin_y); + + try equals(Map.RenderOrder.@"right-down", map.render_order); + + try equals(Map.Type.map, map.type); + try stringEquals("1.10", map.version); + + try equals(3, map.layers.len); + const layer = map.layers[0]; + + try stringEquals("bar", layer.class.?); + try equals(Layer.Type.tilelayer, layer.type); + try equals(32, layer.width.?); + try equals(0, layer.x); + try equals(0, layer.y); + + try equals(16, layer.data.?[0]); + try equals(11, layer.data.?[1]); +} + +test "Map with CSV encoded layer data parses correctly" { + const parsed_map = try tmz.parseMap(std.testing.allocator, @embedFile("test/map-csv.tmj")); + defer parsed_map.deinit(); + + const map = parsed_map.value; + + try equals(1, map.layers.len); + + const layer = map.layers[0]; + try equals(null, layer.compression); + + try equals(10, layer.data.?[0]); + try equals(15, layer.data.?[1]); + try equals(5, layer.data.?[2]); +} + +test "Map with Base64 encoded layer data parses correctly" { + inline for (@typeInfo(Layer.Compression).Enum.fields) |field| { + const map_file = @embedFile("test/map-base64-" ++ field.name ++ ".tmj"); + const parsed_map = try tmz.parseMap(std.testing.allocator, map_file); + defer parsed_map.deinit(); + + const map = parsed_map.value; + + try equals(1, map.layers.len); + + const layer = map.layers[0]; + try equals(@as(Layer.Compression, @enumFromInt(field.value)), layer.compression); + + try equals(10, layer.data.?[0]); + try equals(15, layer.data.?[1]); + try equals(5, layer.data.?[2]); + } +} + +test "everybody wang chung tonight" { + const wang_set_json = + \\{ + \\"class": "foo", + \\"colors": [{ + \\ "color": "#ffaa00", + \\ "name": "roof", + \\ "probability": 1.5, + \\ "tile": 3 + \\}], + \\"name": "wang_set", + \\"tile": 1, + \\"type": "corner", + \\"wangtiles": [{ + \\ "tileid": 0, + \\ "wangid": [2, 0, 1, 0, 1, 0, 2, 0] + \\}] + \\} + ; + + const value = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, wang_set_json, .{ .ignore_unknown_fields = true }); + defer value.deinit(); + const parsed = try std.json.parseFromValue(tmz.WangSet, std.testing.allocator, value.value, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + + const wang_set = parsed.value; + try stringEquals("foo", wang_set.class.?); + + const wang_tiles = wang_set.wang_tiles; + try equals(1, wang_set.wang_tiles.len); + try equals(0, wang_tiles[0].tile_id); + try equals(8, wang_tiles[0].wang_id.len); + try equals(2, wang_tiles[0].wang_id[0]); +} |