diff options
Diffstat (limited to 'src/tmz.zig')
-rw-r--r-- | src/tmz.zig | 551 |
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"); +} |