From 6809b5538c90d755183c7501b030b80e65db69a4 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Tue, 30 Dec 2025 03:28:18 +0200 Subject: [PATCH] reimplement libtmx in zig --- build.zig | 37 +- build.zig.zon | 11 +- libs/tiled/build.zig | 29 ++ libs/tiled/src/buffers.zig | 18 + libs/tiled/src/color.zig | 52 +++ libs/tiled/src/global_tile_id.zig | 15 + libs/tiled/src/layer.zig | 283 ++++++++++++ libs/tiled/src/object.zig | 421 ++++++++++++++++++ libs/tiled/src/position.zig | 20 + libs/tiled/src/property.zig | 153 +++++++ libs/tiled/src/root.zig | 16 + libs/tiled/src/tilemap.zig | 235 ++++++++++ libs/tiled/src/tileset.zig | 226 ++++++++++ libs/tiled/src/xml.zig | 688 ++++++++++++++++++++++++++++++ src/game.zig | 82 ++-- src/main.zig | 14 +- src/tiled.zig | 177 -------- src/window.zig | 4 +- 18 files changed, 2224 insertions(+), 257 deletions(-) create mode 100644 libs/tiled/build.zig create mode 100644 libs/tiled/src/buffers.zig create mode 100644 libs/tiled/src/color.zig create mode 100644 libs/tiled/src/global_tile_id.zig create mode 100644 libs/tiled/src/layer.zig create mode 100644 libs/tiled/src/object.zig create mode 100644 libs/tiled/src/position.zig create mode 100644 libs/tiled/src/property.zig create mode 100644 libs/tiled/src/root.zig create mode 100644 libs/tiled/src/tilemap.zig create mode 100644 libs/tiled/src/tileset.zig create mode 100644 libs/tiled/src/xml.zig delete mode 100644 src/tiled.zig diff --git a/build.zig b/build.zig index f7764dc..44d5a8c 100644 --- a/build.zig +++ b/build.zig @@ -37,41 +37,8 @@ pub fn build(b: *std.Build) !void { const fontstash_dependency = b.dependency("fontstash", .{}); exe_mod.addIncludePath(fontstash_dependency.path("src")); - const libxml2_dependency = b.dependency("libxml2", .{ - .target = target, - .optimize = optimize, - .linkage = .static, - }); - - { - const libtmx_dependency = b.dependency("libtmx", .{}); - const libtmx = b.addLibrary(.{ - .name = "tmx", - .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .link_libc = true - }), - }); - libtmx.installHeader(libtmx_dependency.path("src/tmx.h"), "tmx.h"); - libtmx.root_module.addCSourceFiles(.{ - .root = libtmx_dependency.path("src"), - .files = &.{ - "tmx.c", - "tmx_utils.c", - "tmx_err.c", - "tmx_xml.c", - "tmx_mem.c", - "tmx_hash.c" - }, - .flags = &.{ - "-fno-delete-null-pointer-checks" - } - }); - libtmx.linkLibrary(libxml2_dependency.artifact("xml")); - - exe_mod.linkLibrary(libtmx); - } + const tiled_dependency = b.dependency("tiled", .{}); + exe_mod.addImport("tiled", tiled_dependency.module("tiled")); const sokol_dependency = b.dependency("sokol", .{ .target = target, diff --git a/build.zig.zon b/build.zig.zon index 8d9bb06..0f6b31e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -29,14 +29,9 @@ .url = "git+https://github.com/memononen/fontstash.git#b5ddc9741061343740d85d636d782ed3e07cf7be", .hash = "N-V-__8AAA9xHgAxdLYPmlNTy6qzv9IYqiIePEHQUOPWYQ_6", }, - .libtmx = .{ - .url = "git+https://github.com/baylej/tmx.git#11ffdcdc9bd65669f1a8dbd3a0362a324dda2e0c", - .hash = "N-V-__8AAKQvBQCTT3Q6_we7vTVX-MkAWDZ91YkUev040IRo", - }, - .libxml2 = .{ - .url = "git+https://github.com/allyourcodebase/libxml2.git?ref=2.14.3-4#86c4742a9becd6c86dc79180f806ed344fd2a727", - .hash = "libxml2-2.14.3-4-qHdjhn9FAACpyisv_5DDFVQlegox6QE3mTpdlr44RcbT", - }, + .tiled = .{ + .path = "./libs/tiled" + } }, .paths = .{ "build.zig", diff --git a/libs/tiled/build.zig b/libs/tiled/build.zig new file mode 100644 index 0000000..e717649 --- /dev/null +++ b/libs/tiled/build.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const mod = b.addModule("tiled", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/root.zig") + }); + + const lib = b.addLibrary(.{ + .name = "tiled", + .root_module = mod + }); + b.installArtifact(lib); + + { + const tests = b.addTest(.{ + .root_module = mod + }); + + const run_tests = b.addRunArtifact(tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_tests.step); + } +} diff --git a/libs/tiled/src/buffers.zig b/libs/tiled/src/buffers.zig new file mode 100644 index 0000000..b9ab166 --- /dev/null +++ b/libs/tiled/src/buffers.zig @@ -0,0 +1,18 @@ +const std = @import("std"); + +const Position = @import("./position.zig"); + +const Buffers = @This(); + +allocator: std.mem.Allocator, +points: std.ArrayList(Position) = .empty, + +pub fn init(gpa: std.mem.Allocator) Buffers { + return Buffers{ + .allocator = gpa + }; +} + +pub fn deinit(self: *Buffers) void { + self.points.deinit(self.allocator); +} diff --git a/libs/tiled/src/color.zig b/libs/tiled/src/color.zig new file mode 100644 index 0000000..d8cd764 --- /dev/null +++ b/libs/tiled/src/color.zig @@ -0,0 +1,52 @@ +const std = @import("std"); + +const Color = @This(); + +r: u8, +g: u8, +b: u8, +a: u8, + +pub const black = Color{ + .r = 0, + .g = 0, + .b = 0, + .a = 255, +}; + +pub fn parse(str: []const u8, hash_required: bool) !Color { + var color = Color{ + .r = undefined, + .g = undefined, + .b = undefined, + .a = 255, + }; + + if (str.len < 1) { + return error.InvalidColorFormat; + } + + const has_hash = str[0] == '#'; + if (hash_required and !has_hash) { + return error.InvalidColorFormat; + } + + const hex_str = if (has_hash) str[1..] else str; + + if (hex_str.len == 6) { + color.r = try std.fmt.parseInt(u8, hex_str[0..2], 16); + color.g = try std.fmt.parseInt(u8, hex_str[2..4], 16); + color.b = try std.fmt.parseInt(u8, hex_str[4..6], 16); + + } else if (hex_str.len == 8) { + color.a = try std.fmt.parseInt(u8, hex_str[0..2], 16); + color.r = try std.fmt.parseInt(u8, hex_str[2..4], 16); + color.g = try std.fmt.parseInt(u8, hex_str[4..6], 16); + color.b = try std.fmt.parseInt(u8, hex_str[6..8], 16); + + } else { + return error.InvalidColorFormat; + } + + return color; +} diff --git a/libs/tiled/src/global_tile_id.zig b/libs/tiled/src/global_tile_id.zig new file mode 100644 index 0000000..96d93ad --- /dev/null +++ b/libs/tiled/src/global_tile_id.zig @@ -0,0 +1,15 @@ + +pub const Flag = enum(u32) { + flipped_horizontally = 1 << 31, // bit 32 + flipped_vertically = 1 << 30, // bit 31 + flipped_diagonally = 1 << 29, // bit 30 + rotated_hexagonal_120 = 1 << 28, // bit 29 + _, + + pub const clear: u32 = ~( + @intFromEnum(Flag.flipped_horizontally) | + @intFromEnum(Flag.flipped_vertically) | + @intFromEnum(Flag.flipped_diagonally) | + @intFromEnum(Flag.rotated_hexagonal_120) + ); +}; diff --git a/libs/tiled/src/layer.zig b/libs/tiled/src/layer.zig new file mode 100644 index 0000000..4efc102 --- /dev/null +++ b/libs/tiled/src/layer.zig @@ -0,0 +1,283 @@ +const std = @import("std"); + +const Property = @import("./property.zig"); +const xml = @import("./xml.zig"); +const Color = @import("./color.zig"); +const Object = @import("./object.zig"); +const Position = @import("./position.zig"); + +const Layer = @This(); + +pub const TileVariant = struct { + width: u32, + height: u32, + data: []u32, + + fn initDataFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, + ) ![]u32 { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("data"); + + const encoding = attrs.get("encoding") orelse "csv"; + // TODO: compression + + var temp_tiles: std.ArrayList(u32) = .empty; + + if (std.mem.eql(u8, encoding, "csv")) { + const text = try lexer.nextExpectText(); + var split_iter = std.mem.splitScalar(u8, text, ','); + while (split_iter.next()) |raw_tile_id| { + const tile_id_str = std.mem.trim(u8, raw_tile_id, &std.ascii.whitespace); + const tile_id = try std.fmt.parseInt(u32, tile_id_str, 10); + try temp_tiles.append(scratch.allocator(), tile_id); + } + } else { + return error.UnknownEncodingType; + } + + try iter.finish("data"); + + return try arena.dupe(u32, temp_tiles.items); + } + + pub fn get(self: TileVariant, x: usize, y: usize) ?u32 { + if ((0 <= x and x < self.width) and (0 <= y and y < self.height)) { + return self.data[y * self.width + x]; + } + return null; + } +}; + +pub const ImageVariant = struct { + pub const Image = struct { + // TODO: format + source: []const u8, + transparent_color: ?Color, + width: ?u32, + height: ?u32, + + fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Image { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("image"); + + // TODO: format + const source = try attrs.getDupe(arena, "source") orelse return error.MissingSource; + const width = try attrs.getNumber(u32, "width") orelse null; + const height = try attrs.getNumber(u32, "height") orelse null; + const transparent_color = try attrs.getColor("trans", false); + + try iter.finish("image"); + + return Image{ + .source = source, + .transparent_color = transparent_color, + .width = width, + .height = height + }; + } + }; + + repeat_x: bool, + repeat_y: bool, + image: ?Image +}; + +pub const ObjectVariant = struct { + pub const DrawOrder = enum { + top_down, + index, + + const map: std.StaticStringMap(DrawOrder) = .initComptime(.{ + .{ "topdown", .top_down }, + .{ "index", .index }, + }); + }; + + color: ?Color, + draw_order: DrawOrder, + items: []Object +}; + +pub const GroupVariant = struct { + layers: []Layer +}; + +pub const Type = enum { + tile, + object, + image, + group, + + const name_map: std.StaticStringMap(Type) = .initComptime(.{ + .{ "layer", .tile }, + .{ "objectgroup", .object }, + .{ "imagelayer", .image }, + .{ "group", .group } + }); + + fn toXmlName(self: Type) []const u8 { + return switch (self) { + .tile => "layer", + .object => "objectgroup", + .image => "imagelayer", + .group => "group", + }; + } +}; + +pub const Variant = union(Type) { + tile: TileVariant, + object: ObjectVariant, + image: ImageVariant, + group: GroupVariant +}; + +id: u32, +name: []const u8, +class: []const u8, +opacity: f32, +visible: bool, +tint_color: ?Color, +offset_x: f32, +offset_y: f32, +parallax_x: f32, +parallax_y: f32, +properties: Property.List, +variant: Variant, + +pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, +) !Layer { + const value = try lexer.peek() orelse return error.MissingStartTag; + if (value != .start_tag) return error.MissingStartTag; + + var layer_type = Type.name_map.get(value.start_tag.name) orelse return error.UnknownLayerType; + + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin(layer_type.toXmlName()); + + const id = try attrs.getNumber(u32, "id") orelse return error.MissingId; + const name = try attrs.getDupe(arena, "name") orelse ""; + const class = try attrs.getDupe(arena, "class") orelse ""; + const opacity = try attrs.getNumber(f32, "opacity") orelse 1; + const visible = try attrs.getBool("visible", "1", "0") orelse true; + const offset_x = try attrs.getNumber(f32, "offsetx") orelse 0; + const offset_y = try attrs.getNumber(f32, "offsety") orelse 0; + const parallax_x = try attrs.getNumber(f32, "parallaxx") orelse 1; + const parallax_y = try attrs.getNumber(f32, "parallaxy") orelse 1; + const tint_color = try attrs.getColor("tintcolor", true); + + var variant: Variant = undefined; + switch (layer_type) { + .tile => { + const width = try attrs.getNumber(u32, "width") orelse return error.MissingWidth; + const height = try attrs.getNumber(u32, "height") orelse return error.MissingHeight; + variant = .{ + .tile = TileVariant{ + .width = width, + .height = height, + .data = &[0]u32{} + } + }; + }, + .image => { + const repeat_x = try attrs.getBool("repeatx", "1", "0") orelse false; + const repeat_y = try attrs.getBool("repeaty", "1", "0") orelse false; + variant = .{ + .image = ImageVariant{ + .repeat_x = repeat_x, + .repeat_y = repeat_y, + .image = null + } + }; + }, + .object => { + const draw_order = try attrs.getEnum(ObjectVariant.DrawOrder, "draworder", ObjectVariant.DrawOrder.map) orelse .top_down; + const color = try attrs.getColor("color", true); + + variant = .{ + .object = ObjectVariant{ + .color = color, + .draw_order = draw_order, + .items = &.{} + } + }; + }, + .group => { + variant = .{ + .group = .{ + .layers = &.{} + } + }; + }, + } + + var properties: Property.List = .empty; + var objects: std.ArrayList(Object) = .empty; + var layers: std.ArrayList(Layer) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("properties")) { + properties = try Property.List.initFromXml(arena, scratch, lexer); + continue; + } + + if (variant == .tile and node.isTag("data")) { + variant.tile.data = try TileVariant.initDataFromXml(arena, scratch, lexer); + continue; + } + + if (variant == .image and node.isTag("image")) { + variant.image.image = try ImageVariant.Image.initFromXml(arena, lexer); + continue; + } + + if (variant == .object and node.isTag("object")) { + const object = try Object.initFromXml(arena, scratch, lexer); + try objects.append(scratch.allocator(), object); + continue; + } + + if (variant == .group and isLayerNode(node)) { + const layer = try initFromXml(arena, scratch, lexer); + try layers.append(scratch.allocator(), layer); + continue; + } + + try iter.skip(); + } + + try iter.finish(layer_type.toXmlName()); + + if (variant == .object) { + variant.object.items = try arena.dupe(Object, objects.items); + } + + if (variant == .group) { + variant.group.layers = try arena.dupe(Layer, layers.items); + } + + return Layer{ + .id = id, + .name = name, + .class = class, + .opacity = opacity, + .visible = visible, + .tint_color = tint_color, + .offset_x = offset_x, + .offset_y = offset_y, + .parallax_x = parallax_x, + .parallax_y = parallax_y, + .properties = properties, + .variant = variant, + }; +} + +pub fn isLayerNode(node: xml.TagParser.Node) bool { + return node.isTag("layer") or node.isTag("objectgroup") or node.isTag("imagelayer") or node.isTag("group"); +} diff --git a/libs/tiled/src/object.zig b/libs/tiled/src/object.zig new file mode 100644 index 0000000..e52ba05 --- /dev/null +++ b/libs/tiled/src/object.zig @@ -0,0 +1,421 @@ +const std = @import("std"); + +const xml = @import("./xml.zig"); +const Position = @import("./position.zig"); +const Color = @import("./color.zig"); + +const Object = @This(); + +pub const Shape = union (Type) { + pub const Type = enum { + rectangle, + point, + ellipse, + polygon, + tile, + // TODO: template + text + }; + + pub const Rectangle = struct { + x: f32, + y: f32, + width: f32, + height: f32, + }; + + pub const Tile = struct { + x: f32, + y: f32, + width: f32, + height: f32, + gid: u32 + }; + + pub const Ellipse = struct { + x: f32, + y: f32, + width: f32, + height: f32, + }; + + pub const Polygon = struct { + x: f32, + y: f32, + points: []const Position + }; + + pub const Text = struct { + pub const Font = struct { + family: []const u8, + pixel_size: f32, + bold: bool, + italic: bool, + underline: bool, + strikeout: bool, + kerning: bool + }; + + pub const HorizontalAlign = enum { + left, + center, + right, + justify, + + const map: std.StaticStringMap(HorizontalAlign) = .initComptime(.{ + .{ "left", .left }, + .{ "center", .center }, + .{ "right", .right }, + .{ "justify", .justify }, + }); + }; + + pub const VerticalAlign = enum { + top, + center, + bottom, + + const map: std.StaticStringMap(VerticalAlign) = .initComptime(.{ + .{ "top", .top }, + .{ "center", .center }, + .{ "bottom", .bottom }, + }); + }; + + x: f32, + y: f32, + width: f32, + height: f32, + word_wrap: bool, + color: Color, + font: Font, + horizontal_align: HorizontalAlign, + vertical_align: VerticalAlign, + content: []const u8 + }; + + pub const Point = struct { + x: f32, + y: f32, + }; + + rectangle: Rectangle, + point: Point, + ellipse: Ellipse, + polygon: Polygon, + tile: Tile, + text: Text +}; + +id: u32, +name: []const u8, +class: []const u8, +rotation: f32, // TODO: maybe this field should be moved to Shape struct +visible: bool, +shape: Shape, + +pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, +) !Object { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("object"); + + const id = try attrs.getNumber(u32, "id") orelse return error.MissingId; + const name = try attrs.getDupe(arena, "name") orelse ""; + const class = try attrs.getDupe(arena, "type") orelse ""; + const x = try attrs.getNumber(f32, "x") orelse 0; + const y = try attrs.getNumber(f32, "y") orelse 0; + const width = try attrs.getNumber(f32, "width") orelse 0; + const height = try attrs.getNumber(f32, "height") orelse 0; + const rotation = try attrs.getNumber(f32, "rotation") orelse 0; + const visible = try attrs.getBool("visible", "1", "0") orelse true; + + var shape: ?Shape = null; + + while (try iter.next()) |node| { + if (shape == null) { + if (node.isTag("point")) { + shape = .{ + .point = Shape.Point{ + .x = x, + .y = y, + } + }; + + } else if (node.isTag("ellipse")) { + shape = .{ + .ellipse = Shape.Ellipse{ + .x = x, + .y = y, + .width = width, + .height = height + } + }; + + } else if (node.isTag("text")) { + const HorizontalShape = Shape.Text.HorizontalAlign; + const VerticalAlign = Shape.Text.VerticalAlign; + + const text_attrs = node.tag.attributes; + const word_wrap = try text_attrs.getBool("wrap", "1", "0") orelse false; + const color = try text_attrs.getColor("color", true) orelse Color.black; + const horizontal_align = try text_attrs.getEnum(HorizontalShape, "halign", HorizontalShape.map) orelse .left; + const vertical_align = try text_attrs.getEnum(VerticalAlign, "valign", VerticalAlign.map) orelse .top; + const bold = try text_attrs.getBool("bold", "1", "0") orelse false; + const italic = try text_attrs.getBool("italic", "1", "0") orelse false; + const strikeout = try text_attrs.getBool("strikeout", "1", "0") orelse false; + const underline = try text_attrs.getBool("underline", "1", "0") orelse false; + const kerning = try text_attrs.getBool("kerning", "1", "0") orelse true; + const pixel_size = try text_attrs.getNumber(f32, "pixelsize") orelse 16; + const font_family = try text_attrs.getDupe(arena, "fontfamily") orelse "sans-serif"; + + _ = try lexer.nextExpectStartTag("text"); + var content: []const u8 = ""; + const content_value = try lexer.peek(); + if (content_value != null and content_value.? == .text) { + content = try arena.dupe(u8, content_value.?.text); + } + try lexer.skipUntilMatchingEndTag("text"); + + shape = .{ + .text = Shape.Text{ + .x = x, + .y = y, + .width = width, + .height = height, + .word_wrap = word_wrap, + .color = color, + .horizontal_align = horizontal_align, + .vertical_align = vertical_align, + .content = try arena.dupe(u8, content), + .font = .{ + .bold = bold, + .italic = italic, + .strikeout = strikeout, + .underline = underline, + .pixel_size = pixel_size, + .kerning = kerning, + .family = font_family, + } + } + }; + continue; + + } else if (node.isTag("polygon")) { + const points_str = node.tag.attributes.get("points") orelse ""; + + var points: std.ArrayList(Position) = .empty; + var point_iter = std.mem.splitScalar(u8, points_str, ' '); + while (point_iter.next()) |point_str| { + const point = try Position.parseCommaDelimited(point_str); + try points.append(scratch.allocator(), point); + } + + shape = .{ + .polygon = Shape.Polygon{ + .x = x, + .y = y, + .points = try arena.dupe(Position, points.items) + } + }; + } + } + + try iter.skip(); + } + + if (shape == null) { + if (try attrs.getNumber(u32, "gid")) |gid| { + shape = .{ + .tile = Shape.Tile{ + .x = x, + .y = y, + .width = width, + .height = height, + .gid = gid + } + }; + } else { + shape = .{ + .rectangle = Shape.Rectangle{ + .x = x, + .y = y, + .width = width, + .height = height + } + }; + } + } + + try iter.finish("object"); + + return Object{ + .id = id, + .name = name, + .class = class, + .rotation = rotation, + .visible = visible, + .shape = shape orelse return error.UnknownShapeType + }; +} + +fn expectParsedEquals(expected: Object, body: []const u8) !void { + const allocator = std.testing.allocator; + var ctx: xml.Lexer.TestingContext = undefined; + ctx.init(allocator, body); + defer ctx.deinit(); + + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const parsed = try initFromXml(arena.allocator(), &scratch, &ctx.lexer); + try std.testing.expectEqualDeep(expected, parsed); +} + +test Object { + try expectParsedEquals( + Object{ + .id = 10, + .name = "rectangle", + .class = "object class", + .rotation = 0, + .visible = true, + .shape = .{ + .rectangle = .{ + .x = 12.34, + .y = 56.78, + .width = 31.5, + .height = 20.25, + } + } + }, + \\ + ); + + try expectParsedEquals( + Object{ + .id = 3, + .name = "point", + .class = "foo", + .rotation = 0, + .visible = true, + .shape = .{ + .point = .{ + .x = 77.125, + .y = 99.875 + } + } + }, + \\ + \\ + \\ + ); + + try expectParsedEquals( + Object{ + .id = 4, + .name = "ellipse", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .ellipse = .{ + .x = 64.25, + .y = 108.25, + .width = 22.375, + .height = 15.375 + } + } + }, + \\ + \\ + \\ + ); + + try expectParsedEquals( + Object{ + .id = 5, + .name = "", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .polygon = .{ + .x = 40.125, + .y = 96.25, + .points = &[_]Position{ + .{ .x = 0, .y = 0 }, + .{ .x = 13.25, .y = -4.25 }, + .{ .x = 10.125, .y = 18.625 }, + .{ .x = 2.25, .y = 17.375 }, + .{ .x = -0.125, .y = 25.75 }, + .{ .x = -3.875, .y = 20.75 }, + } + } + } + }, + \\ + \\ + \\ + ); + + try expectParsedEquals( + Object{ + .id = 2, + .name = "tile", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .tile = .{ + .x = 60.125, + .y = 103.5, + .width = 8, + .height = 8, + .gid = 35 + } + } + }, + \\ + ); + + try expectParsedEquals( + Object{ + .id = 6, + .name = "text", + .class = "", + .rotation = 0, + .visible = true, + .shape = .{ + .text = .{ + .x = 64.3906, + .y = 92.8594, + .width = 87.7188, + .height = 21.7813, + .content = "Hello World", + .word_wrap = true, + .color = .black, + .horizontal_align = .center, + .vertical_align = .top, + .font = .{ + .family = "sans-serif", + .pixel_size = 16, + .bold = false, + .italic = false, + .underline = false, + .strikeout = false, + .kerning = true + }, + } + } + }, + \\ + \\ Hello World + \\ + ); +} diff --git a/libs/tiled/src/position.zig b/libs/tiled/src/position.zig new file mode 100644 index 0000000..79179d4 --- /dev/null +++ b/libs/tiled/src/position.zig @@ -0,0 +1,20 @@ +const std = @import("std"); + +const Position = @This(); + +x: f32, +y: f32, + +pub fn parseCommaDelimited(str: []const u8) !Position { + const comma_index = std.mem.indexOfScalar(u8, str, ',') orelse return error.MissingComma; + const x_str = str[0..comma_index]; + const y_str = str[(comma_index+1)..]; + + const x = try std.fmt.parseFloat(f32, x_str); + const y = try std.fmt.parseFloat(f32, y_str); + + return Position{ + .x = x, + .y = y + }; +} diff --git a/libs/tiled/src/property.zig b/libs/tiled/src/property.zig new file mode 100644 index 0000000..1fa29df --- /dev/null +++ b/libs/tiled/src/property.zig @@ -0,0 +1,153 @@ +const std = @import("std"); +const xml = @import("./xml.zig"); + +const Property = @This(); + +pub const Type = enum { + string, + int, + bool, + + const map: std.StaticStringMap(Type) = .initComptime(.{ + .{ "string", .string }, + .{ "int", .int }, + .{ "bool", .bool }, + }); +}; + +pub const Value = union(Type) { + string: []const u8, + int: i32, + bool: bool +}; + +name: []const u8, +value: Value, + +pub const List = struct { + items: []Property, + + pub const empty = List{ + .items = &[0]Property{} + }; + + pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer + ) !Property.List { + var iter = xml.TagParser.init(lexer); + _ = try iter.begin("properties"); + + var temp_properties: std.ArrayList(Property) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("property")) { + const property = try Property.initFromXml(arena, lexer); + try temp_properties.append(scratch.allocator(), property); + continue; + } + + try iter.skip(); + } + try iter.finish("properties"); + + const properties = try arena.dupe(Property, temp_properties.items); + + return List{ + .items = properties + }; + } + + pub fn get(self: List, name: []const u8) ?Value { + for (self.items) |item| { + if (std.mem.eql(u8, item.name, name)) { + return item.value; + } + } + return null; + } + + pub fn getString(self: List, name: []const u8) ?[]const u8 { + if (self.get(name)) |value| { + return value.string; + } + return null; + } +}; + +pub fn init(name: []const u8, value: Value) Property { + return Property{ + .name = name, + .value = value + }; +} + +pub fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Property { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("property"); + + const name = try attrs.getDupe(arena, "name") orelse return error.MissingName; + const prop_type_str = attrs.get("type") orelse "string"; + const value_str = attrs.get("value") orelse ""; + + const prop_type = Type.map.get(prop_type_str) orelse return error.UnknownPropertyType; + const value = switch(prop_type) { + .string => Value{ + .string = try arena.dupe(u8, value_str) + }, + .int => Value{ + .int = try std.fmt.parseInt(i32, value_str, 10) + }, + .bool => Value{ + .bool = std.mem.eql(u8, value_str, "true") + } + }; + + try iter.finish("property"); + + return Property{ + .name = name, + .value = value + }; +} + +fn expectParsedEquals(expected: Property, body: []const u8) !void { + const allocator = std.testing.allocator; + var ctx: xml.Lexer.TestingContext = undefined; + ctx.init(allocator, body); + defer ctx.deinit(); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const parsed = try initFromXml(arena.allocator(), &ctx.lexer); + try std.testing.expectEqualDeep(expected, parsed); +} + +test Property { + try expectParsedEquals( + Property.init("solid", .{ .string = "hello" }), + \\ + ); + + try expectParsedEquals( + Property.init("solid", .{ .string = "hello" }), + \\ + ); + + try expectParsedEquals( + Property.init("integer", .{ .int = 123 }), + \\ + ); + + try expectParsedEquals( + Property.init("boolean", .{ .bool = true }), + \\ + ); + + try expectParsedEquals( + Property.init("boolean", .{ .bool = false }), + \\ + ); +} diff --git a/libs/tiled/src/root.zig b/libs/tiled/src/root.zig new file mode 100644 index 0000000..50a97ee --- /dev/null +++ b/libs/tiled/src/root.zig @@ -0,0 +1,16 @@ +const std = @import("std"); + +// Warning: +// This library is not complete, it does not cover all features that the specification provides. +// But there are enough features implemented so that I could use this for my games. + +// Map format specification: +// https://doc.mapeditor.org/en/stable/reference/tmx-map-format/# + +pub const xml = @import("./xml.zig"); +pub const Tileset = @import("./tileset.zig"); +pub const Tilemap = @import("./tilemap.zig"); + +test { + _ = std.testing.refAllDeclsRecursive(@This()); +} diff --git a/libs/tiled/src/tilemap.zig b/libs/tiled/src/tilemap.zig new file mode 100644 index 0000000..c6cde2e --- /dev/null +++ b/libs/tiled/src/tilemap.zig @@ -0,0 +1,235 @@ +const std = @import("std"); +const xml = @import("./xml.zig"); +const Io = std.Io; +const assert = std.debug.assert; + +const Property = @import("./property.zig"); +const Layer = @import("./layer.zig"); +const Object = @import("./object.zig"); +const Position = @import("./position.zig"); +const Tileset = @import("./tileset.zig"); +const GlobalTileId = @import("./global_tile_id.zig"); + +const Tilemap = @This(); + +pub const Orientation = enum { + orthogonal, + staggered, + hexagonal, + isometric, + + const map: std.StaticStringMap(Orientation) = .initComptime(.{ + .{ "orthogonal", .orthogonal }, + .{ "staggered", .staggered }, + .{ "hexagonal", .hexagonal }, + .{ "isometric", .isometric } + }); +}; + +pub const RenderOrder = enum { + right_down, + right_up, + left_down, + left_up, + + const map: std.StaticStringMap(RenderOrder) = .initComptime(.{ + .{ "right-down", .right_down }, + .{ "right-up", .right_up }, + .{ "left-down", .left_down }, + .{ "left-up", .left_up }, + }); +}; + +pub const StaggerAxis = enum { + x, + y, + + const map: std.StaticStringMap(StaggerAxis) = .initComptime(.{ + .{ "x", .x }, + .{ "y", .y } + }); +}; + +pub const TilesetReference = struct { + source: []const u8, + first_gid: u32, + + pub fn initFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !TilesetReference { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("tileset"); + + const source = try attrs.getDupe(arena, "source") orelse return error.MissingFirstGid; + const first_gid = try attrs.getNumber(u32, "firstgid") orelse return error.MissingFirstGid; + + try iter.finish("tileset"); + + return TilesetReference{ + .source = source, + .first_gid = first_gid + }; + } +}; + +pub const Tile = struct { + tileset: *const Tileset, + id: u32, + + pub fn getProperties(self: Tile) Property.List { + return self.tileset.getTileProperties(self.id) orelse .empty; + } +}; + +arena: std.heap.ArenaAllocator, + +version: []const u8, +tiled_version: ?[]const u8, +orientation: Orientation, +render_order: RenderOrder, +width: u32, +height: u32, +tile_width: u32, +tile_height: u32, +infinite: bool, + +stagger_axis: ?StaggerAxis, +next_layer_id: u32, +next_object_id: u32, + +tilesets: []TilesetReference, +layers: []Layer, + +pub fn initFromBuffer( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + buffer: []const u8 +) !Tilemap { + var reader = Io.Reader.fixed(buffer); + return initFromReader(gpa, scratch, xml_buffers, &reader); +} + +pub fn initFromReader( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + reader: *Io.Reader, +) !Tilemap { + var lexer = xml.Lexer.init(reader, xml_buffers); + return initFromXml(gpa, scratch, &lexer); +} + +// Map specification: +// https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#map +pub fn initFromXml( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, +) !Tilemap { + var arena_allocator = std.heap.ArenaAllocator.init(gpa); + errdefer arena_allocator.deinit(); + + const arena = arena_allocator.allocator(); + + var iter = xml.TagParser.init(lexer); + const map_attrs = try iter.begin("map"); + + const version = try map_attrs.getDupe(arena, "version") orelse return error.MissingVersion; + const tiled_version = try map_attrs.getDupe(arena, "tiledversion"); + const orientation = try map_attrs.getEnum(Orientation, "orientation", Orientation.map) orelse return error.MissingOrientation; + const render_order = try map_attrs.getEnum(RenderOrder, "renderorder", RenderOrder.map) orelse return error.MissingRenderOrder; + // TODO: compressionlevel + const width = try map_attrs.getNumber(u32, "width") orelse return error.MissingWidth; + const height = try map_attrs.getNumber(u32, "height") orelse return error.MissingHeight; + const tile_width = try map_attrs.getNumber(u32, "tilewidth") orelse return error.MissingTileWidth; + const tile_height = try map_attrs.getNumber(u32, "tileheight") orelse return error.MissingTileHeight; + // TODO: hexidelength + const infinite_int = try map_attrs.getNumber(u32, "infinite") orelse 0; + const infinite = infinite_int != 0; + const next_layer_id = try map_attrs.getNumber(u32, "nextlayerid") orelse return error.MissingLayerId; + const next_object_id = try map_attrs.getNumber(u32, "nextobjectid") orelse return error.MissingObjectId; + // TODO: parallaxoriginx + // TODO: parallaxoriginy + // TODO: backgroundcolor + + var stagger_axis: ?StaggerAxis = null; + if (orientation == .hexagonal or orientation == .staggered) { + stagger_axis = try map_attrs.getEnum(StaggerAxis, "staggeraxis", StaggerAxis.map) orelse return error.MissingRenderOrder; + // TODO: staggerindex + } + + var tileset_list: std.ArrayList(TilesetReference) = .empty; + var layer_list: std.ArrayList(Layer) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("tileset")) { + try tileset_list.append(scratch.allocator(), try TilesetReference.initFromXml(arena, lexer)); + continue; + } else if (Layer.isLayerNode(node)) { + const layer = try Layer.initFromXml( + arena, + scratch, + lexer, + ); + try layer_list.append(scratch.allocator(), layer); + continue; + } + + try iter.skip(); + } + try iter.finish("map"); + + const tilesets = try arena.dupe(TilesetReference, tileset_list.items); + const layers = try arena.dupe(Layer, layer_list.items); + + return Tilemap{ + .arena = arena_allocator, + .version = version, + .tiled_version = tiled_version, + .orientation = orientation, + .render_order = render_order, + .width = width, + .height = height, + .tile_width = tile_width, + .tile_height = tile_height, + .infinite = infinite, + .stagger_axis = stagger_axis, + .next_object_id = next_object_id, + .next_layer_id = next_layer_id, + .tilesets = tilesets, + .layers = layers, + }; +} + +fn getTilesetByGid(self: *const Tilemap, gid: u32) ?TilesetReference { + var result: ?TilesetReference = null; + for (self.tilesets) |tileset| { + if (gid < tileset.first_gid) { + continue; + } + if (result != null and result.?.first_gid < tileset.first_gid) { + continue; + } + result = tileset; + } + + return result; +} + +pub fn getTile(self: *const Tilemap, layer: *const Layer, tilesets: Tileset.List, x: usize, y: usize) ?Tile { + assert(layer.variant == .tile); + const tile_variant = layer.variant.tile; + + const gid = tile_variant.get(x, y) orelse return null; + const tileset_ref = self.getTilesetByGid(gid & GlobalTileId.Flag.clear) orelse return null; + const tileset = tilesets.get(tileset_ref.source) orelse return null; + const id = gid - tileset_ref.first_gid; + + return Tile{ + .tileset = tileset, + .id = id + }; +} + +pub fn deinit(self: *const Tilemap) void { + self.arena.deinit(); +} diff --git a/libs/tiled/src/tileset.zig b/libs/tiled/src/tileset.zig new file mode 100644 index 0000000..04b9305 --- /dev/null +++ b/libs/tiled/src/tileset.zig @@ -0,0 +1,226 @@ +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const xml = @import("./xml.zig"); +const Property = @import("./property.zig"); +const Position = @import("./position.zig"); + +const Tileset = @This(); + +pub const Image = struct { + source: []const u8, + width: u32, + height: u32, + + pub fn intFromXml(arena: std.mem.Allocator, lexer: *xml.Lexer) !Image { + var iter = xml.TagParser.init(lexer); + const attrs = try iter.begin("image"); + + const source = try attrs.getDupe(arena, "width") orelse return error.MissingSource; + const width = try attrs.getNumber(u32, "width") orelse return error.MissingWidth; + const height = try attrs.getNumber(u32, "height") orelse return error.MissingHeight; + + try iter.finish("image"); + + return Image{ + .source = source, + .width = width, + .height = height + }; + } +}; + +pub const Tile = struct { + id: u32, + properties: Property.List, + + pub fn initFromXml( + arena: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer, + ) !Tile { + var iter = xml.TagParser.init(lexer); + const tile_attrs = try iter.begin("tile"); + + const id = try tile_attrs.getNumber(u32, "id") orelse return error.MissingId; + + var properties: Property.List = .empty; + + while (try iter.next()) |node| { + if (node.isTag("properties")) { + properties = try Property.List.initFromXml(arena, scratch, lexer); + continue; + } + + try iter.skip(); + } + + try iter.finish("tile"); + + return Tile{ + .id = id, + .properties = properties + }; + } +}; + +pub const List = struct { + const Entry = struct { + name: []const u8, + tileset: Tileset + }; + + list: std.ArrayList(Entry), + + pub const empty = List{ + .list = .empty + }; + + pub fn add(self: *List, gpa: Allocator, name: []const u8, tileset: Tileset) !void { + if (self.get(name) != null) { + return error.DuplicateName; + } + + const name_dupe = try gpa.dupe(u8, name); + errdefer gpa.free(name_dupe); + + try self.list.append(gpa, .{ + .name = name_dupe, + .tileset = tileset + }); + } + + pub fn get(self: *const List, name: []const u8) ?*const Tileset { + for (self.list.items) |*entry| { + if (std.mem.eql(u8, entry.name, name)) { + return &entry.tileset; + } + } + return null; + } + + pub fn deinit(self: *List, gpa: Allocator) void { + for (self.list.items) |entry| { + gpa.free(entry.name); + entry.tileset.deinit(); + } + self.list.deinit(gpa); + } +}; + +arena: std.heap.ArenaAllocator, + +version: []const u8, +tiled_version: []const u8, +name: []const u8, +tile_width: u32, +tile_height: u32, +tile_count: u32, +columns: u32, + +image: Image, + +tiles: []Tile, + +pub fn initFromBuffer( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + buffer: []const u8 +) !Tileset { + var reader = Io.Reader.fixed(buffer); + return initFromReader(gpa, scratch, xml_buffers, &reader); +} + +pub fn initFromReader( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + xml_buffers: *xml.Lexer.Buffers, + reader: *Io.Reader, +) !Tileset { + var lexer = xml.Lexer.init(reader, xml_buffers); + return initFromXml(gpa, scratch, &lexer); +} + +pub fn initFromXml( + gpa: std.mem.Allocator, + scratch: *std.heap.ArenaAllocator, + lexer: *xml.Lexer +) !Tileset { + var arena_state = std.heap.ArenaAllocator.init(gpa); + const arena = arena_state.allocator(); + + var iter = xml.TagParser.init(lexer); + const tileset_attrs = try iter.begin("tileset"); + + const version = try tileset_attrs.getDupe(arena, "version") orelse return error.MissingTilesetTag; + const tiled_version = try tileset_attrs.getDupe(arena, "tiledversion") orelse return error.MissingTilesetTag; + const name = try tileset_attrs.getDupe(arena, "name") orelse return error.MissingTilesetTag; + const tile_width = try tileset_attrs.getNumber(u32, "tilewidth") orelse return error.MissingTilesetTag; + const tile_height = try tileset_attrs.getNumber(u32, "tileheight") orelse return error.MissingTilesetTag; + const tile_count = try tileset_attrs.getNumber(u32, "tilecount") orelse return error.MissingTilesetTag; + const columns = try tileset_attrs.getNumber(u32, "columns") orelse return error.MissingTilesetTag; + + var image: ?Image = null; + var tiles_list: std.ArrayList(Tile) = .empty; + + while (try iter.next()) |node| { + if (node.isTag("image")) { + image = try Image.intFromXml(arena, lexer); + continue; + } else if (node.isTag("tile")) { + const tile = try Tile.initFromXml(arena, scratch, lexer); + try tiles_list.append(scratch.allocator(), tile); + continue; + } + + try iter.skip(); + } + try iter.finish("tileset"); + + const tiles = try arena.dupe(Tile, tiles_list.items); + + return Tileset{ + .arena = arena_state, + .version = version, + .tiled_version = tiled_version, + .name = name, + .tile_width = tile_width, + .tile_height = tile_height, + .tile_count = tile_count, + .columns = columns, + .image = image orelse return error.MissingImageTag, + .tiles = tiles + }; +} + +pub fn getTileProperties(self: *const Tileset, id: u32) ?Property.List { + for (self.tiles) |tile| { + if (tile.id == id) { + return tile.properties; + } + } + return null; +} + +pub fn getTilePositionInImage(self: *const Tileset, id: u32) ?Position { + if (id >= self.tile_count) { + return null; + } + + const tileset_width = @divExact(self.image.width, self.tile_width); + + const tile_x = @mod(id, tileset_width); + const tile_y = @divFloor(id, tileset_width); + + return Position{ + .x = @floatFromInt(tile_x * self.tile_width), + .y = @floatFromInt(tile_y * self.tile_height), + }; + +} + +pub fn deinit(self: *const Tileset) void { + self.arena.deinit(); +} diff --git a/libs/tiled/src/xml.zig b/libs/tiled/src/xml.zig new file mode 100644 index 0000000..e6c7f0c --- /dev/null +++ b/libs/tiled/src/xml.zig @@ -0,0 +1,688 @@ +const std = @import("std"); +const Io = std.Io; +const assert = std.debug.assert; + +const Color = @import("./color.zig"); + +pub const Attribute = struct { + name: []const u8, + value: []const u8, + + pub const List = struct { + items: []const Attribute, + + pub fn get(self: List, name: []const u8) ?[]const u8 { + for (self.items) |attr| { + if (std.mem.eql(u8, attr.name, name)) { + return attr.value; + } + } + return null; + } + + pub fn getDupe(self: List, gpa: std.mem.Allocator, name: []const u8) !?[]u8 { + if (self.get(name)) |value| { + return try gpa.dupe(u8, value); + } + return null; + } + + pub fn getNumber(self: List, T: type, name: []const u8) !?T { + if (self.get(name)) |value| { + if (@typeInfo(T) == .int) { + return try std.fmt.parseInt(T, value, 10); + } else if (@typeInfo(T) == .float) { + return try std.fmt.parseFloat(T, value); + } + } + return null; + } + + pub fn getBool(self: List, name: []const u8, true_value: []const u8, false_value: []const u8) !?bool { + if (self.get(name)) |value| { + if (std.mem.eql(u8, value, true_value)) { + return true; + } else if (std.mem.eql(u8, value, false_value)) { + return false; + } else { + return error.InvalidBoolean; + } + } + return null; + } + + pub fn getEnum(self: List, T: type, name: []const u8, map: std.StaticStringMap(T)) !?T { + if (self.get(name)) |value| { + return map.get(value) orelse return error.InvalidEnumValue; + } + return null; + } + + pub fn getColor(self: List, name: []const u8, hash_required: bool) !?Color { + if (self.get(name)) |value| { + return try Color.parse(value, hash_required); + } + return null; + } + + pub fn format(self: List, writer: *Io.Writer) Io.Writer.Error!void { + if (self.items.len > 0) { + try writer.writeAll("{ "); + for (self.items, 0..) |attribute, i| { + if (i > 0) { + try writer.writeAll(", "); + } + try writer.print("{f}", .{attribute}); + } + try writer.writeAll(" }"); + } else { + try writer.writeAll("{ }"); + } + } + }; + + pub fn format(self: *const Attribute, writer: *Io.Writer) Io.Writer.Error!void { + try writer.print("{s}{{ .name='{s}', .value='{s}' }}", .{ @typeName(Attribute), self.name, self.value }); + } + + pub fn formatSlice(data: []const Attribute, writer: *Io.Writer) Io.Writer.Error!void { + if (data.len > 0) { + try writer.writeAll("{ "); + for (data, 0..) |attribute, i| { + if (i > 0) { + try writer.writeAll(", "); + } + try writer.print("{f}", .{attribute}); + } + try writer.writeAll(" }"); + } else { + try writer.writeAll("{ }"); + } + } + + fn altSlice(data: []const Attribute) std.fmt.Alt(Attribute.List, Attribute.List.format) { + return .{ .data = data }; + } +}; + +pub const Tag = struct { + name: []const u8, + attributes: Attribute.List +}; + +pub const Lexer = struct { + pub const Buffers = struct { + scratch: std.heap.ArenaAllocator, + text: std.ArrayList(u8), + + pub fn init(allocator: std.mem.Allocator) Buffers { + return Buffers{ + .scratch = std.heap.ArenaAllocator.init(allocator), + .text = .empty + }; + } + + pub fn clear(self: *Buffers) void { + self.text.clearRetainingCapacity(); + _ = self.scratch.reset(.retain_capacity); + } + + pub fn deinit(self: *Buffers) void { + const allocator = self.scratch.child_allocator; + self.scratch.deinit(); + self.text.deinit(allocator); + } + }; + + pub const Token = union(enum) { + start_tag: Tag, + end_tag: []const u8, + text: []const u8, + + pub fn isStartTag(self: Token, name: []const u8) bool { + if (self == .start_tag) { + return std.mem.eql(u8, self.start_tag.name, name); + } + return false; + } + + pub fn isEndTag(self: Token, name: []const u8) bool { + if (self == .end_tag) { + return std.mem.eql(u8, self.end_tag, name); + } + return false; + } + }; + + pub const StepResult = struct { + token: ?Token, + self_closing: bool = false, + }; + + pub const TestingContext = struct { + io_reader: Io.Reader, + buffers: Buffers, + lexer: Lexer, + + pub fn init(self: *TestingContext, allocator: std.mem.Allocator, body: []const u8) void { + self.* = TestingContext{ + .lexer = undefined, + .io_reader = Io.Reader.fixed(body), + .buffers = Buffers.init(allocator) + }; + self.lexer = Lexer.init(&self.io_reader, &self.buffers); + } + + pub fn deinit(self: *TestingContext) void { + self.buffers.deinit(); + } + }; + + io_reader: *Io.Reader, + buffers: *Buffers, + + peeked_value: ?Token, + cursor: usize, + queued_end_tag: ?[]const u8, + + pub fn init(reader: *Io.Reader, buffers: *Buffers) Lexer { + buffers.clear(); + + return Lexer{ + .io_reader = reader, + .buffers = buffers, + .cursor = 0, + .queued_end_tag = null, + .peeked_value = null + }; + } + + fn step(self: *Lexer) !StepResult { + _ = self.buffers.scratch.reset(.retain_capacity); + + if (try self.peekByte() == '<') { + self.tossByte(); + + if (try self.peekByte() == '/') { + // End tag + self.tossByte(); + + const name = try self.parseName(); + try self.skipWhiteSpace(); + + if (!std.mem.eql(u8, try self.takeBytes(1), ">")) { + return error.InvalidEndTag; + } + + const token = Token{ .end_tag = name }; + return .{ .token = token }; + + } else if (try self.peekByte() == '?') { + // Prolog tag + self.tossByte(); + if (!std.mem.eql(u8, try self.takeBytes(4), "xml ")) { + return error.InvalidPrologTag; + } + + const attributes = try self.parseAttributes(); + try self.skipWhiteSpace(); + + if (!std.mem.eql(u8, try self.takeBytes(2), "?>")) { + return error.MissingPrologEnd; + } + + const version = attributes.get("version") orelse return error.InvalidProlog; + if (!std.mem.eql(u8, version, "1.0")) { + return error.InvalidPrologVersion; + } + const encoding = attributes.get("encoding") orelse return error.InvalidProlog; + if (!std.mem.eql(u8, encoding, "UTF-8")) { + return error.InvalidPrologEncoding; + } + + return .{ .token = null }; + + } else { + // Start tag + const name = try self.parseName(); + const attributes = try self.parseAttributes(); + try self.skipWhiteSpace(); + + const token = Token{ + .start_tag = .{ + .name = name, + .attributes = attributes + } + }; + + var self_closing = false; + if (std.mem.eql(u8, try self.peekBytes(1), ">")) { + self.tossBytes(1); + + } else if (std.mem.eql(u8, try self.peekBytes(2), "/>")) { + self.tossBytes(2); + + self_closing = true; + + } else { + return error.UnfinishedStartTag; + } + + return .{ + .token = token, + .self_closing = self_closing + }; + } + + } else { + try self.skipWhiteSpace(); + + const text_start = self.cursor; + while (try self.peekByte() != '<') { + self.tossByte(); + } + var text: []const u8 = self.buffers.text.items[text_start..self.cursor]; + text = std.mem.trimEnd(u8, text, &std.ascii.whitespace); + + var token: ?Token = null; + if (text.len > 0) { + token = Token{ .text = text }; + } + return .{ .token = token }; + } + } + + pub fn next(self: *Lexer) !?Token { + if (self.peeked_value) |value| { + self.peeked_value = null; + return value; + } + + if (self.queued_end_tag) |name| { + self.queued_end_tag = null; + return Token{ + .end_tag = name + }; + } + + while (true) { + if (self.buffers.text.items.len == 0) { + self.readIntoTextBuffer() catch |e| switch (e) { + error.EndOfStream => break, + else => return e + }; + } + + const saved_cursor = self.cursor; + const result = self.step() catch |e| switch(e) { + error.EndOfTextBuffer => { + self.cursor = saved_cursor; + + const unused_capacity = self.buffers.text.capacity - self.buffers.text.items.len; + if (unused_capacity == 0 and self.cursor > 0) { + self.rebaseBuffer(); + } else { + self.readIntoTextBuffer() catch |read_err| switch (read_err) { + error.EndOfStream => break, + else => return read_err + }; + } + + continue; + }, + else => return e + }; + + if (result.token) |token| { + if (token == .start_tag and result.self_closing) { + self.queued_end_tag = token.start_tag.name; + } + return token; + } + } + + return null; + } + + pub fn nextExpectEndTag(self: *Lexer, name: []const u8) !void { + const value = try self.next() orelse return error.MissingEndTag; + if (!value.isEndTag(name)) return error.MissingEndTag; + } + + pub fn nextExpectStartTag(self: *Lexer, name: []const u8) !Attribute.List { + const value = try self.next() orelse return error.MissingStartTag; + if (!value.isStartTag(name)) return error.MissingStartTag; + return value.start_tag.attributes; + } + + pub fn nextExpectText(self: *Lexer) ![]const u8 { + const value = try self.next() orelse return error.MissingTextTag; + if (value != .text) return error.MissingTextTag; + return value.text; + } + + pub fn skipUntilMatchingEndTag(self: *Lexer, name: ?[]const u8) !void { + var depth: usize = 0; + while (true) { + const value = try self.next() orelse return error.MissingEndTag; + + if (depth == 0 and value == .end_tag) { + if (name != null and !std.mem.eql(u8, value.end_tag, name.?)) { + return error.MismatchedEndTag; + } + break; + } + + if (value == .start_tag) { + depth += 1; + } else if (value == .end_tag) { + depth -= 1; + } + } + } + + pub fn peek(self: *Lexer) !?Token { + if (try self.next()) |value| { + self.peeked_value = value; + return value; + } + + return null; + } + + fn readIntoTextBuffer(self: *Lexer) !void { + const gpa = self.buffers.scratch.child_allocator; + const text = &self.buffers.text; + try text.ensureUnusedCapacity(gpa, 1); + + var writer = Io.Writer.fixed(text.allocatedSlice()); + writer.end = text.items.len; + + _ = self.io_reader.stream(&writer, .limited(text.capacity - text.items.len)) catch |e| switch (e) { + error.WriteFailed => unreachable, + else => |ee| return ee + }; + text.items.len = writer.end; + } + + fn rebaseBuffer(self: *Lexer) void { + if (self.cursor == 0) { + return; + } + + const text = &self.buffers.text; + @memmove( + text.items[0..(text.items.len - self.cursor)], + text.items[self.cursor..] + ); + + text.items.len -= self.cursor; + self.cursor = 0; + } + + fn isNameStartChar(c: u8) bool { + return c == ':' or c == '_' or std.ascii.isAlphabetic(c); + } + + fn isNameChar(c: u8) bool { + return isNameStartChar(c) or c == '-' or c == '.' or ('0' <= c and c <= '9'); + } + + fn hasBytes(self: *Lexer, n: usize) bool { + const text = self.buffers.text.items; + return self.cursor + n <= text.len; + } + + fn peekBytes(self: *Lexer, n: usize) ![]const u8 { + if (self.hasBytes(n)) { + const text = self.buffers.text.items; + return text[self.cursor..][0..n]; + } + return error.EndOfTextBuffer; + } + + fn tossBytes(self: *Lexer, n: usize) void { + assert(self.hasBytes(n)); + self.cursor += n; + } + + fn takeBytes(self: *Lexer, n: usize) ![]const u8 { + const result = try self.peekBytes(n); + self.tossBytes(n); + return result; + } + + fn peekByte(self: *Lexer) !u8 { + return (try self.peekBytes(1))[0]; + } + + fn tossByte(self: *Lexer) void { + self.tossBytes(1); + } + + fn takeByte(self: *Lexer) !u8 { + return (try self.takeBytes(1))[0]; + } + + fn parseName(self: *Lexer) ![]const u8 { + const name_start = self.cursor; + + if (isNameStartChar(try self.peekByte())) { + self.tossByte(); + + while (isNameChar(try self.peekByte())) { + self.tossByte(); + } + } + + return self.buffers.text.items[name_start..self.cursor]; + } + + fn skipWhiteSpace(self: *Lexer) !void { + while (std.ascii.isWhitespace(try self.peekByte())) { + self.tossByte(); + } + } + + fn parseAttributeValue(self: *Lexer) ![]const u8 { + const quote = try self.takeByte(); + if (quote != '"' and quote != '\'') { + return error.InvalidAttributeValue; + } + + const value_start: usize = self.cursor; + var value_len: usize = 0; + + while (true) { + const c = try self.takeByte(); + if (c == '<' or c == '&') { + return error.InvalidAttributeValue; + } + if (c == quote) { + break; + } + value_len += 1; + } + + return self.buffers.text.items[value_start..][0..value_len]; + } + + fn parseAttributes(self: *Lexer) !Attribute.List { + const arena = self.buffers.scratch.allocator(); + var attributes: std.ArrayList(Attribute) = .empty; + + while (true) { + try self.skipWhiteSpace(); + const name = try self.parseName(); + if (name.len == 0) { + break; + } + + try self.skipWhiteSpace(); + if (try self.takeByte() != '=') { + std.debug.print("{s}\n", .{self.buffers.text.items[self.cursor..]}); + return error.MissingAttributeEquals; + } + try self.skipWhiteSpace(); + const value = try self.parseAttributeValue(); + + const list = Attribute.List{ .items = attributes.items }; + if (list.get(name) != null) { + return error.DuplicateAttribute; + } + + try attributes.append(arena, Attribute{ + .name = name, + .value = value + }); + } + + return Attribute.List{ + .items = attributes.items + }; + } + + test "self closing tag" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "tag" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "tag with prolog" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + \\ + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "text content" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ Hello World + ); + defer ctx.deinit(); + + try std.testing.expect((try ctx.lexer.next()).?.isStartTag("hello")); + try std.testing.expectEqualStrings("Hello World", (try ctx.lexer.next()).?.text); + try std.testing.expect((try ctx.lexer.next()).?.isEndTag("hello")); + try std.testing.expect((try ctx.lexer.next()) == null); + } + + test "attributes" { + const allocator = std.testing.allocator; + var ctx: TestingContext = undefined; + ctx.init(allocator, + \\ + ); + defer ctx.deinit(); + + const token = try ctx.lexer.next(); + const attrs = token.?.start_tag.attributes; + try std.testing.expectEqualStrings("1", attrs.get("a").?); + try std.testing.expectEqualStrings("2", attrs.get("b").?); + } +}; + +// TODO: The API for this is easy to misuse. +// Design a better API for using Reader +// As a compromise `assert` was used to guard against some of the ways this can be misused +pub const TagParser = struct { + lexer: *Lexer, + + begin_called: bool = false, + finish_called: bool = false, + + pub const Node = union(enum) { + tag: Tag, + text: []const u8, + + pub fn isTag(self: Node, name: []const u8) bool { + if (self == .tag) { + return std.mem.eql(u8, self.tag.name, name); + } + return false; + } + }; + + pub fn init(lexer: *Lexer) TagParser { + return TagParser{ + .lexer = lexer, + }; + } + + pub fn begin(self: *TagParser, name: []const u8) !Attribute.List { + assert(!self.begin_called); + self.begin_called = true; + + return try self.lexer.nextExpectStartTag(name); + } + + pub fn finish(self: *TagParser, name: []const u8) !void { + assert(self.begin_called); + assert(!self.finish_called); + self.finish_called = true; + + try self.lexer.skipUntilMatchingEndTag(name); + } + + pub fn next(self: *TagParser) !?Node { + assert(self.begin_called); + assert(!self.finish_called); + + const value = try self.lexer.peek() orelse return error.MissingEndTag; + if (value == .end_tag) { + return null; + } + + return switch (value) { + .text => |text| Node{ .text = text }, + .start_tag => |start_tag| Node{ .tag = start_tag }, + .end_tag => unreachable, + }; + } + + pub fn skip(self: *TagParser) !void { + assert(self.begin_called); + assert(!self.finish_called); + + const value = try self.lexer.next() orelse return error.MissingNode; + if (value == .end_tag) { + return error.UnexpectedEndTag; + } else if (value == .start_tag) { + // TODO: Make this configurable + var name_buffer: [64]u8 = undefined; + var name: std.ArrayList(u8) = .initBuffer(&name_buffer); + try name.appendSliceBounded(value.start_tag.name); + + try self.lexer.skipUntilMatchingEndTag(name.items); + } + } +}; diff --git a/src/game.zig b/src/game.zig index 882ba03..b2a2023 100644 --- a/src/game.zig +++ b/src/game.zig @@ -14,7 +14,7 @@ const imgui = @import("./imgui.zig"); const Gfx = @import("./graphics.zig"); const Entity = @import("./entity.zig"); -const tiled = @import("./tiled.zig"); +const tiled = @import("tiled"); const Game = @This(); @@ -90,15 +90,30 @@ pub fn init(gpa: Allocator) !Game { }; errdefer self.deinit(); - const manager = try tiled.ResourceManager.init(); - defer manager.deinit(); + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); - try manager.loadTilesetFromBuffer(@embedFile("assets/tiled/tileset.tsx"), "tileset.tsx"); + var xml_buffers = tiled.xml.Lexer.Buffers.init(gpa); + defer xml_buffers.deinit(); - try self.levels.append(gpa, try loadLevelFromEmbedFile(gpa, manager, "assets/tiled/first.tmx")); - try self.levels.append(gpa, try loadLevelFromEmbedFile(gpa, manager, "assets/tiled/second.tmx")); - try self.levels.append(gpa, try loadLevelFromEmbedFile(gpa, manager, "assets/tiled/third.tmx")); - try self.levels.append(gpa, try loadLevelFromEmbedFile(gpa, manager, "assets/tiled/fourth.tmx")); + var tilesets: tiled.Tileset.List = .empty; + defer tilesets.deinit(gpa); + + try tilesets.add( + gpa, + "tileset.tsx", + try tiled.Tileset.initFromBuffer( + gpa, + &scratch, + &xml_buffers, + @embedFile("assets/tiled/tileset.tsx") + ) + ); + + try self.levels.append(gpa, try loadLevelFromEmbedTiled(gpa, tilesets, "assets/tiled/first.tmx")); + try self.levels.append(gpa, try loadLevelFromEmbedTiled(gpa, tilesets, "assets/tiled/second.tmx")); + try self.levels.append(gpa, try loadLevelFromEmbedTiled(gpa, tilesets, "assets/tiled/third.tmx")); + try self.levels.append(gpa, try loadLevelFromEmbedTiled(gpa, tilesets, "assets/tiled/fourth.tmx")); try self.restartLevel(); @@ -125,46 +140,57 @@ fn nextLevel(self: *Game) !void { } } -fn loadLevelFromEmbedFile(gpa: Allocator, manager: tiled.ResourceManager, comptime path: []const u8) !Level { - const map = try manager.loadMapFromBuffer(@embedFile(path)); +fn loadLevelFromEmbedTiled(gpa: Allocator, tilesets: tiled.Tileset.List, comptime path: []const u8) !Level { + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); + + var xml_buffers = tiled.xml.Lexer.Buffers.init(gpa); + defer xml_buffers.deinit(); + + const map = try tiled.Tilemap.initFromBuffer( + gpa, + &scratch, + &xml_buffers, + @embedFile(path) + ); defer map.deinit(); - return try loadLevelFromTiled(gpa, map); -} - -fn loadLevelFromTiled(gpa: Allocator, map: tiled.Map) !Level { var level: Level = .empty; errdefer level.deinit(gpa); - var layer_iter = map.iterLayers(); - while (layer_iter.next()) |layer| { - if (layer.layer.visible == 0) { + for (map.layers) |*layer| { + if (!layer.visible) { continue; } - if (layer.layer.type != @intFromEnum(tiled.Layer.Type.layer)) { + if (layer.variant != .tile) { continue; } - const map_width = map.map.width; - for (0..map.map.height) |y| { - for (0..map_width) |x| { + for (0..map.height) |y| { + for (0..map.width) |x| { + const tile = map.getTile(layer, tilesets, x, y) orelse continue; + const tile_props = tile.getProperties(); + const tile_type = tile_props.getString("type") orelse ""; - const tile = map.getTile(layer, x, y) orelse continue; - const tile_props = tiled.Properties{ .inner = tile.tile.properties }; - const tile_type: []const u8 = std.mem.span(tile_props.getPropertyString("type") orelse ""); - - const tile_size = Vec2.init(8, 8); + const tile_width: f32 = @floatFromInt(tile.tileset.tile_width); + const tile_height: f32 = @floatFromInt(tile.tileset.tile_height); + const tile_position_in_image = tile.tileset.getTilePositionInImage(tile.id).?; var entity: Entity = .{ .type = .nil, .position = Vec2.init(@floatFromInt(x), @floatFromInt(y)), - .render_tile = .{ .position = tile.getUpperLeft().divide(tile_size) }, + .render_tile = .{ + .position = .{ + .x = tile_position_in_image.x / tile_width, + .y = tile_position_in_image.y / tile_height, + } + }, }; if (std.mem.eql(u8, tile_type, "player")) { entity.type = .player; } else if (std.mem.eql(u8, tile_type, "key")) { entity.type = .key; } else if (std.mem.eql(u8, tile_type, "locked_door")) { - entity.type = .door; + entity.type = .door; entity.locked = true; } else if (std.mem.eql(u8, tile_type, "pot")) { entity.type = .pot; diff --git a/src/main.zig b/src/main.zig index 852be9c..ae2c29b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -94,12 +94,14 @@ pub fn main() !void { tracy.setThreadName("Main"); - // var sa: std.posix.Sigaction = .{ - // .handler = .{ .handler = signalHandler }, - // .mask = std.posix.sigemptyset(), - // .flags = std.posix.SA.RESTART, - // }; - // std.posix.sigaction(std.posix.SIG.INT, &sa, null); + if (builtin.os.tag == .linux) { + var sa: std.posix.Sigaction = .{ + .handler = .{ .handler = signalHandler }, + .mask = std.posix.sigemptyset(), + .flags = std.posix.SA.RESTART, + }; + std.posix.sigaction(std.posix.SIG.INT, &sa, null); + } sapp.run(.{ .init_cb = init, diff --git a/src/tiled.zig b/src/tiled.zig deleted file mode 100644 index 58700ba..0000000 --- a/src/tiled.zig +++ /dev/null @@ -1,177 +0,0 @@ -const std = @import("std"); -const c = @cImport({ - @cInclude("stdlib.h"); - @cInclude("tmx.h"); -}); - -const math = @import("math.zig"); -const Vec2 = math.Vec2; - -const log = std.log.scoped(.tiled); - -pub const TmxError = error { - MakeResourceManager, - LoadTileset, - LoadMap, -}; - -pub fn init() void { - c.tmx_alloc_func = c.realloc; - c.tmx_free_func = c.free; -} - -pub const ResourceManager = struct { - manager: *anyopaque, - - pub fn init() !ResourceManager { - const manager = c.tmx_make_resource_manager(); - if (manager == null) { - log.err("tmx_make_resource_manager: {s}", .{c.tmx_strerr()}); - return TmxError.MakeResourceManager; - } - - return ResourceManager{ - .manager = manager.?, - }; - } - - pub fn loadTilesetFromBuffer(self: *const ResourceManager, tileset: []const u8, key: [*:0]const u8) !void { - const success = c.tmx_load_tileset_buffer(self.manager, tileset.ptr, @intCast(tileset.len), key); - if (success != 1) { - log.err("tmx_load_tileset_buffer: {s}", .{c.tmx_strerr()}); - return TmxError.LoadTileset; - } - } - - pub fn loadMapFromBuffer(self: *const ResourceManager, map: []const u8) !Map { - const map_handle = c.tmx_rcmgr_load_buffer(self.manager, map.ptr, @intCast(map.len)); - if (map_handle == null) { - log.err("tmx_rcmgr_load_buffer: {s}", .{c.tmx_strerr()}); - return TmxError.LoadMap; - } - - return Map{ - .map = map_handle.? - }; - } - - pub fn deinit(self: *const ResourceManager) void { - c.tmx_free_resource_manager(self.manager); - } -}; - -pub const Tile = struct { - tile: *c.tmx_tile, - - pub fn getUpperLeft(self: Tile) Vec2 { - return Vec2.init( - @floatFromInt(self.tile.ul_x), - @floatFromInt(self.tile.ul_y), - ); - } -}; - -pub const TileWithFlags = struct { - tile: Tile, - flags: u32 -}; - -pub const Map = struct { - map: *c.tmx_map, - - pub fn deinit(self: *const Map) void { - c.tmx_map_free(self.map); - } - - pub fn iterLayers(self: *const Map) Layer.Iterator { - return Layer.Iterator{ - .current = self.map.ly_head - }; - } - - pub fn getTile(self: *const Map, layer: Layer, x: usize, y: usize) ?Tile { - if (self.getTileWithFlags(layer, x, y)) |tile_with_flags| { - return tile_with_flags.tile; - } - - return null; - } - - pub fn getTileWithFlags(self: *const Map, layer: Layer, x: usize, y: usize) ?TileWithFlags { - if (layer.layer.type != @intFromEnum(Layer.Type.layer)) { - return null; - } - - const gid = layer.layer.content.gids[(y*self.map.width) + x]; - const flags = gid & ~FLIP_BITS_REMOVAL; - const maybe_tile = self.map.tiles[gid & FLIP_BITS_REMOVAL]; - if (maybe_tile == null) { - return null; - } - - return TileWithFlags{ - .tile = Tile{ .tile = maybe_tile.? }, - .flags = flags - }; - } -}; - -pub const Layer = struct { - layer: *c.tmx_layer, - - pub const Type = enum(c_uint) { - none = c.L_NONE, - layer = c.L_LAYER, - object_group = c.L_OBJGR, - image = c.L_IMAGE, - group = c.L_GROUP - }; - - pub const Iterator = struct { - current: ?*c.tmx_layer, - - pub fn next(self: *Iterator) ?Layer { - if (self.current) |current| { - self.current = current.next; - return Layer{ .layer = current }; - } - return null; - } - }; -}; - -pub const Properties = struct { - inner: ?*c.tmx_properties, - - pub fn getPropertyString(self: Properties, key: [*:0]const u8) ?[*:0]const u8 { - const inner = self.inner orelse return null; - - const maybe_prop = c.tmx_get_property(inner, key); - if (maybe_prop == null) { - return null; - } - const prop: *c.tmx_property = maybe_prop.?; - if (prop.type != c.PT_STRING) { - return null; - } - return prop.value.string; - } - - pub fn getPropertyBool(self: Properties, key: [*:0]const u8) ?bool { - const inner = self.inner orelse return null; - - const maybe_prop = c.tmx_get_property(inner, key); - if (maybe_prop == null) { - return null; - } - - const prop: *c.tmx_property = maybe_prop.?; - if (prop.type != c.PT_BOOL) { - return null; - } - - return prop.value.boolean != 0; - } -}; - -pub const FLIP_BITS_REMOVAL: u32 = c.TMX_FLIP_BITS_REMOVAL; diff --git a/src/window.zig b/src/window.zig index a2db1b4..7bd8bd3 100644 --- a/src/window.zig +++ b/src/window.zig @@ -5,7 +5,7 @@ const sokol = @import("sokol"); const sapp = sokol.app; const Gfx = @import("./graphics.zig"); -const Tiled = @import("./tiled.zig"); +const tiled = @import("tiled"); const Math = @import("./math.zig"); const Vec2 = Math.Vec2; @@ -228,8 +228,6 @@ released_keys: std.EnumSet(KeyCode) = .initEmpty(), pressed_keys_at: std.EnumMap(KeyCode, Nanoseconds) = .init(.{}), pub fn init(self: *Window, gpa: Allocator) !void { - Tiled.init(); - var events: std.ArrayList(Event) = .empty; errdefer events.deinit(gpa); try events.ensureTotalCapacityPrecise(gpa, 50);