aboutsummaryrefslogtreecommitdiffstats
path: root/src/tmz.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tmz.zig')
-rw-r--r--src/tmz.zig551
1 files changed, 551 insertions, 0 deletions
diff --git a/src/tmz.zig b/src/tmz.zig
new file mode 100644
index 0000000..b743e58
--- /dev/null
+++ b/src/tmz.zig
@@ -0,0 +1,551 @@
+//! 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. If Layer is compressed, data will be
+//! automatically decompressed, using all supported compression methods in Tiled (as of 1.10.2):
+//! - 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,
+ version: []const u8,
+ /// Number of tile columns
+ width: u32,
+
+ 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 fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() {
+ return try jsonParser(@This(), allocator, source, options);
+ }
+};
+
+/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#layer
+pub const Layer = struct {
+ chunks: ?[]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 const DrawOrder = enum { topdown, index };
+ pub const Encoding = enum { csv, base64 };
+ pub const Type = enum { tilelayer, objectgroup, imagelayer, group };
+
+ 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 fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() {
+ var layer = try jsonParser(@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);
+ if (source.object.get("chunks")) |chunks| {
+ layer.chunks = try std.json.parseFromValueLeaky([]Chunk, allocator, chunks, options);
+ }
+ } else {
+ const base64_data = try std.json.parseFromValueLeaky([]const u8, allocator, data, options);
+ const layer_size: usize = (layer.width orelse 0) * (layer.height orelse 0);
+
+ layer.data = parseBase64(allocator, base64_data, layer_size, layer.compression orelse .none);
+
+ if (layer.chunks) |chunks| {
+ for (chunks) |*chunk| {
+ const chunk_size: usize = chunk.width * chunk.height;
+ chunk.tiles = parseBase64(allocator, chunk.data.base64, chunk_size, layer.compression orelse .none);
+ }
+ }
+ }
+ }
+ }
+ return layer;
+ }
+};
+
+/// https://doc.mapeditor.org/en/stable/reference/json-map-format/#chunk
+pub const Chunk = struct {
+ data: DataEncoding,
+ tiles: []u32,
+ height: u32,
+ width: u32,
+ x: u32,
+ y: u32,
+
+ const DataEncoding = union(enum) {
+ csv: []const u32,
+ base64: []const u8,
+ };
+
+ pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() {
+ var chunk = try jsonParser(@This(), allocator, source, options);
+
+ if (source.object.get("data")) |data| {
+ switch (data) {
+ .array => {
+ chunk.data = .{ .csv = try std.json.parseFromValueLeaky([]const u32, allocator, data, options) };
+ },
+ .string => {
+ chunk.data = .{ .base64 = try std.json.parseFromValueLeaky([]const u8, allocator, data, options) };
+ },
+ else => return error.UnexpectedToken,
+ }
+ }
+ return chunk;
+ }
+};
+
+/// 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 jsonParser(@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 = null,
+
+ pub const Type = enum {
+ string,
+ int,
+ float,
+ bool,
+ color,
+ file,
+ object,
+ class,
+ };
+
+ pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() {
+ return try jsonParser(@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 const FillMode = enum { stretch, @"preserve-aspect-fit" };
+ pub const TileRenderSize = enum { tile, grid };
+ pub const Type = enum { tileset };
+
+ pub const ObjectAlignment = enum {
+ unspecified,
+ topleft,
+ top,
+ topright,
+ left,
+ center,
+ right,
+ bottomleft,
+ bottom,
+ bottomright,
+ };
+
+ pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() {
+ const tileset = try jsonParser(@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 parseJsonLeaky(Tileset, allocator, tileset_bytes) catch @panic("could not parse tileset");
+ }
+ return 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 jsonParser(@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 jsonParser(@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 jsonParser(@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 const Type = enum { corner, edge, mixed };
+
+ pub fn jsonParseFromValue(allocator: Allocator, source: Value, options: ParseOptions) !@This() {
+ return try jsonParser(@This(), allocator, source, options);
+ }
+};
+
+/// 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 jsonParser(@This(), allocator, source, options);
+ }
+};
+
+/// From https://www.openmymind.net/Zigs-std-json-Parsed/
+pub fn Managed(comptime T: type) type {
+ return struct {
+ value: T,
+ arena: *std.heap.ArenaAllocator,
+
+ const Self = @This();
+
+ pub fn fromJson(parsed: std.json.Parsed(T)) Self {
+ return .{
+ .arena = parsed.arena,
+ .value = parsed.value,
+ };
+ }
+
+ pub fn deinit(self: Self) void {
+ const arena = self.arena;
+ const allocator = arena.child_allocator;
+ arena.deinit();
+ allocator.destroy(arena);
+ }
+ };
+}
+
+/// Parse a JSON Map string
+/// You must call `deinit()` on the returned `Managed(Map)` 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) !Managed(Map) {
+ return Managed(Map).fromJson(try parse(Map, allocator, bytes));
+}
+
+pub fn parseMapLeaky(allocator: Allocator, bytes: []const u8) !Map {
+ return try parseJsonLeaky(Map, allocator, bytes);
+}
+
+/// Parse a JSON Tileset string
+/// You must call `deinit()` on the returned `Managed(Tileset)` to clean up allocated resources.
+/// If you are using a `std.heap.ArenaAllocator` or similar, consider calling `parseTilesetLeaky` instead.
+pub fn parseTileset(allocator: Allocator, bytes: []const u8) !Managed(Tileset) {
+ return Managed(Tileset).fromJson(try parse(Tileset, allocator, bytes));
+}
+
+pub fn parseTilesetLeaky(allocator: Allocator, bytes: []const u8) !Tileset {
+ return try parseJsonLeaky(Tileset, allocator, bytes);
+}
+
+fn parse(comptime T: type, allocator: Allocator, bytes: []const u8) !std.json.Parsed(T) {
+ var parsed = std.json.Parsed(T){
+ .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(T, parsed.arena.allocator(), bytes);
+
+ return parsed;
+}
+
+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 snake_case field names and ignores data
+fn jsonParser(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;
+}
+
+fn parseBase64(allocator: Allocator, base64_data: []const u8, chunk_size: usize, compression: Layer.Compression) []u32 {
+ const size = base64_decoder.calcSizeForSlice(base64_data) catch @panic("Unable to decode base64 data");
+
+ var decoded = allocator.alloc(u8, size) catch @panic("OOM");
+ defer allocator.free(decoded);
+ base64_decoder.decode(decoded, base64_data) catch @panic("Unable to decode base64 data");
+
+ const data = allocator.alloc(u32, chunk_size) catch @panic("OOM");
+ const alignment = @alignOf(u32);
+ const buf = allocator.alloc(u8, chunk_size * alignment) catch @panic("OOM");
+ var decompressed = fixedBufferStream(buf);
+ var compressed = fixedBufferStream(decoded);
+ switch (compression) {
+ .gzip => {
+ std.compress.gzip.decompress(compressed.reader(), decompressed.writer()) catch @panic("Unable to decompress gzip");
+ decoded = decompressed.getWritten();
+ },
+ .zlib => {
+ std.compress.zlib.decompress(compressed.reader(), decompressed.writer()) catch @panic("Unable to decompress zlib");
+ decoded = decompressed.getWritten();
+ },
+ .zstd => {
+ const window_buffer = allocator.alloc(u8, 1 << 23) catch @panic("OOM");
+ var zstd_stream = std.compress.zstd.decompressor(compressed.reader(), .{ .window_buffer = window_buffer });
+ _ = zstd_stream.reader().readAll(buf) catch @panic("Unable to decompress zstd");
+ decoded = buf;
+ },
+ .none => {},
+ }
+
+ if (buf.len != decoded.len) {
+ @panic("data size does not match Layer dimensions");
+ }
+
+ var tile_index: usize = 0;
+ for (data) |*tile| {
+ const end = tile_index + alignment;
+ tile.* = std.mem.readInt(u32, decoded[tile_index..end][0..alignment], .little);
+ tile_index += alignment;
+ }
+ return data;
+}
+
+test {
+ _ = @import("./tmz_test.zig");
+}