//! 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"); }