diff --git a/assets/tiled/map.tmx b/assets/tiled/map.tmx new file mode 100644 index 0000000..f392817 --- /dev/null +++ b/assets/tiled/map.tmx @@ -0,0 +1,76 @@ + + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,90,90,90,90,90,90,90,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,90,90,90,90,90,90,90,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,90,90,90,90,90,90,90,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,90,90,90,90,90,90,90,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,90,90,90,90,90,90,90,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,90,90,90,90,90,90,90,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,90,90,90,90,90,90,90,90,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,98,98,101,98,98,98,98,98,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,98,0,0,98,0,0,0,98,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,98,0,0,0,0,0,0,98,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,98,0,0,0,0,0,0,98,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,98,0,0,0,0,0,0,101,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,98,0,0,98,0,0,0,101,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,98,98,98,98,98,101,101,101,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,7,0,0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,7,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + diff --git a/assets/tiled/sokoban.tiled-project b/assets/tiled/sokoban.tiled-project new file mode 100644 index 0000000..d0eb592 --- /dev/null +++ b/assets/tiled/sokoban.tiled-project @@ -0,0 +1,14 @@ +{ + "automappingRulesFile": "", + "commands": [ + ], + "compatibilityVersion": 1100, + "extensionsPath": "extensions", + "folders": [ + "." + ], + "properties": [ + ], + "propertyTypes": [ + ] +} diff --git a/assets/tiled/sokoban.tiled-session b/assets/tiled/sokoban.tiled-session new file mode 100644 index 0000000..7fda9ae --- /dev/null +++ b/assets/tiled/sokoban.tiled-session @@ -0,0 +1,21 @@ +{ + "activeFile": "", + "expandedProjectPaths": [ + ], + "fileStates": { + }, + "last.imagePath": "/home/rokas/code/games/sokol-template-v2/assets/sokoban", + "map.lastUsedFormat": "tmx", + "map.tileHeight": 64, + "map.tileWidth": 64, + "openFiles": [ + ], + "project": "sokoban.tiled-project", + "recentFiles": [ + ], + "tileset.lastUsedFormat": "tsx", + "tileset.tileSize": { + "height": 64, + "width": 64 + } +} diff --git a/assets/tiled/tileset.tsx b/assets/tiled/tileset.tsx new file mode 100644 index 0000000..1433f9c --- /dev/null +++ b/assets/tiled/tileset.tsx @@ -0,0 +1,4 @@ + + + + diff --git a/build.zig b/build.zig index 94c6221..218d296 100644 --- a/build.zig +++ b/build.zig @@ -8,7 +8,9 @@ pub fn build(b: *std.Build) !void { const assets_dir = "assets"; const asset_files = [_][]const u8{ "sokoban/sokoban_tilesheet.png", - "roboto-font/Roboto-Regular.ttf" + "roboto-font/Roboto-Regular.ttf", + "tiled/map.tmx", + "tiled/tileset.tsx" }; const asset_dir_realpath = try b.build_root.join(b.graph.arena, &.{ assets_dir }); @@ -42,6 +44,9 @@ pub fn build(b: *std.Build) !void { }); exe_mod.addImport("math", math_mod); + const dep_tiled = b.dependency("tiled", .{}); + exe_mod.addImport("tiled", dep_tiled.module("tiled")); + const dep_sokol = b.dependency("sokol", .{ .target = target, .optimize = optimize, diff --git a/build.zig.zon b/build.zig.zon index 80d956e..bf65b84 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -11,6 +11,9 @@ .stb = .{ .path = "./libs/stb", }, + .tiled = .{ + .path = "./libs/tiled", + }, .tracy = .{ .path = "./libs/tracy", }, 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..661c065 --- /dev/null +++ b/libs/tiled/src/xml.zig @@ -0,0 +1,687 @@ +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() != '=') { + 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/app.zig b/src/app.zig index b9ee9c6..0d916bd 100644 --- a/src/app.zig +++ b/src/app.zig @@ -1,12 +1,14 @@ const std = @import("std"); const CLI = @import("./cli.zig"); const log = std.log.scoped(.app); +const Allocator = std.mem.Allocator; const Math = @import("math"); const Vec2 = Math.Vec2; const STBImage = @import("stb_image"); const STBTrueType = @import("stb_truetype"); +const Tiled = @import("tiled"); const Platform = @import("./platform.zig"); const Gfx = Platform.Gfx; @@ -15,61 +17,162 @@ const Rect = Platform.Rect; const App = @This(); -bg: Platform.Color, +const Direction = enum { + up, + down, + left, + right +}; + +const PlayerSprites = struct { + up: [3]Gfx.Sprite.Id, + down: [3]Gfx.Sprite.Id, + left: [3]Gfx.Sprite.Id, + right: [3]Gfx.Sprite.Id, + + pub fn getDirection(self: *PlayerSprites, dir: Direction) []Gfx.Sprite.Id { + return switch (dir) { + .up => &self.up, + .down => &self.down, + .left => &self.left, + .right => &self.right, + }; + } +}; + show_first_window: bool, check: bool, player_pos: Vec2, -sprite_sprite: Gfx.Sprite.Id, wall_sprite: Gfx.Sprite.Id, roboto_font: Gfx.Font.Id, +tilesheet: Tilesheet, + +player_sprites: PlayerSprites, +player_direction: Direction, +player_frame_index: u32, +animation_timer: Platform.Nanoseconds, + +const Tilesheet = struct { + image: Gfx.ImageData, + sprites: []Gfx.Sprite.Id, + tile_size: Vec2, + width: u32, + height: u32, + + pub fn init(gpa: Allocator, image: Gfx.ImageData, width: u32, height: u32, tile_size: Vec2) !Tilesheet { + const sprites = try gpa.alloc(Gfx.Sprite.Id, width * height); + errdefer gpa.free(sprites); + @memset(sprites, Gfx.getNilSprite()); + + return Tilesheet{ + .sprites = sprites, + .image = image, + .width = width, + .height = height, + .tile_size = tile_size + }; + } + + pub fn deinit(self: Tilesheet) void { + for (self.sprites) |sprite| { + Gfx.deinitSprite(sprite); + } + } + + pub fn get(self: Tilesheet, x: u32, y: u32) Gfx.Sprite.Id { + if (x >= self.width or y >= self.height) { + log.warn("Attempt to get tile which is out of bounds", .{}); + return Gfx.getNilSprite(); + } + + const sprite_index = y * self.width + x; + if (self.sprites[sprite_index] == Gfx.getNilSprite()) { + const sprite = Gfx.initSprite(.{ .padding = 1 }); + Gfx.setSprite(sprite, .{ + .image = self.image, + .rect = Rect.init(@floatFromInt(x), @floatFromInt(y), 1, 1).multiply(self.tile_size) + }); + self.sprites[sprite_index] = sprite; + } + + return self.sprites[sprite_index]; + } +}; + pub fn init(self: *App, plt: Platform.Init) !?u8 { - const sokoban_spritesheet = plt.assets.readFile("sokoban/sokoban_tilesheet.png"); - - // const font = try STBTrueType.Font.init(robot_font, STBTrueType.Font.getFontOffsetForIndex(robot_font, 0).?); - - // const glyph = font.findGlyphIndex('A').?; - // const scale = font.scaleForPixelHeight(16); - // const box = font.getGlyphBitmapBox(glyph, scale, scale); - // const width = box.x1 - box.x0; - // const height = box.y1 - box.y0; - // font.makeGlyphBitmap - // std.debug.print("{}\n", .{box}); - const robot_font = Gfx.initFont(); Gfx.setFont(robot_font, plt.assets.readFile("roboto-font/Roboto-Regular.ttf")); - var image = try STBImage.load(sokoban_spritesheet); - defer image.deinit(); + var scratch = std.heap.ArenaAllocator.init(plt.gpa); + defer scratch.deinit(); - const spritesheet = Gfx.ImageData{ - .width = image.width, - .height = image.height, - .pixels = .{ .rgba8 = image.rgba8_pixels } - }; + var buffers = Tiled.xml.Lexer.Buffers.init(plt.gpa); + defer buffers.deinit(); - const tile_size = Vec2.init(64, 64); + const tilemap = try Tiled.Tilemap.initFromBuffer(plt.gpa, &scratch, &buffers, plt.assets.readFile("tiled/map.tmx")); + defer tilemap.deinit(); - const player_sprite = Gfx.initSprite(.{}); - Gfx.setSprite(player_sprite, .{ - .image = spritesheet, - .rect = Rect.init(0, 4, 1, 1).multiply(tile_size) - }); + var tilesets: Tiled.Tileset.List = .empty; + defer tilesets.deinit(plt.gpa); - const wall_sprite = Gfx.initSprite(.{}); - Gfx.setSprite(wall_sprite, .{ - .image = spritesheet, - .rect = Rect.init(1, 0, 1, 1).multiply(tile_size) - }); + try tilesets.add(plt.gpa, "tiled/tilemap.tsx", try Tiled.Tileset.initFromBuffer(plt.gpa, &scratch, &buffers, plt.assets.readFile("tiled/tileset.tsx"))); + + for (tilemap.layers) |layer| { + std.debug.print("{s}\n", .{layer.name}); + } + + const tilesheet_png = try STBImage.load(plt.assets.readFile("sokoban/sokoban_tilesheet.png")); + const tilesheet = try Tilesheet.init( + plt.arena, + Gfx.ImageData{ + .width = tilesheet_png.width, + .height = tilesheet_png.height, + .pixels = .{ .rgba8 = tilesheet_png.rgba8_pixels } + }, + 13, + 8, + .init(64, 64) + ); + + const wall_sprite = tilesheet.get(1, 0); + + var player_sprites: PlayerSprites = undefined; + inline for (.{ + .{ + .dir = .up, + .positions = [_]Vec2{ .init(3, 4), .init(4, 4), .init(5, 4) } + }, + .{ + .dir = .down, + .positions = [_]Vec2{ .init(0, 4), .init(1, 4), .init(2, 4) } + }, + .{ + .dir = .right, + .positions = [_]Vec2{ .init(0, 6), .init(1, 6), .init(2, 6) }, + }, + .{ + .dir = .left, + .positions = [_]Vec2{ .init(3, 6), .init(4, 6), .init(5, 6) } + } + }) |entry| { + var sprite_ids = player_sprites.getDirection(entry.dir); + inline for (0.., entry.positions) |i, pos| { + sprite_ids[i] = tilesheet.get(@intFromFloat(pos.x), @intFromFloat(pos.y)); + } + } self.* = App{ - .bg = .black, .player_pos = .init(100, 100), .show_first_window = true, .check = false, - .sprite_sprite = player_sprite, + .player_sprites = player_sprites, .wall_sprite = wall_sprite, - .roboto_font = robot_font + .roboto_font = robot_font, + .player_direction = .left, + .player_frame_index = 0, + .animation_timer = 0, + .tilesheet = tilesheet }; return null; @@ -96,37 +199,44 @@ pub fn frame(self: *App, plt: Platform.Frame) !void { dir = dir.normalized(); self.player_pos = self.player_pos.add(dir.multiplyScalar(50 * dt)); - Gfx.setClearColor(self.bg); - Gfx.drawSprite(self.sprite_sprite, self.player_pos, .init(100, 100), .white); - Gfx.drawSprite(self.wall_sprite, self.player_pos.add(.init(0, 200)), .init(100, 100), .white); + var new_direction = self.player_direction; + if (dir.x < 0) { + new_direction = .left; + } else if (dir.x > 0) { + new_direction = .right; + } else if (dir.y > 0) { + new_direction = .down; + } else if (dir.y < 0) { + new_direction = .up; + } + + if (self.player_direction != new_direction) { + self.player_frame_index = 0; + self.player_direction = new_direction; + } + + const frames = self.player_sprites.getDirection(self.player_direction); + + if (dir.x != 0 or dir.y != 0) { + self.animation_timer += plt.dt; + const animatin_time = std.time.ns_per_s / 5; + while (self.animation_timer >= animatin_time) { + self.player_frame_index = (self.player_frame_index + 1) % @as(u32, @intCast(frames.len)); + self.animation_timer -= animatin_time; + } + } else { + self.animation_timer = 0; + self.player_frame_index = 0; + } + + Gfx.setClearColor(.rgb(40, 40, 40)); + Gfx.drawSprite(frames[self.player_frame_index], self.player_pos, .init(64, 64), .white); + Gfx.drawSprite(self.tilesheet.get(11, 6), self.player_pos.add(.init(0, 200)), .init(64, 64), .white); Gfx.drawText(self.roboto_font, .{ .pos = .init(300, 100), .text = "Hello, World!", - .height = 32, + .height = 64, }); - - if (ImGUI.beginWindow(.{ - .name = "Hello", - .size = .init(400, 200), - .open = &self.show_first_window - })) { - defer ImGUI.endWindow(); - - ImGUI.text("Player pos: {}", .{self.player_pos}); - if (ImGUI.button("foo")) { - std.debug.print("pressed\n", .{}); - } - if (ImGUI.checkbox("Hello", &self.check)) { - std.debug.print("checkbox changed\n", .{}); - } - ImGUI.separator(); - if (ImGUI.colorEdit(.{ - .label = "Color", - .value = &self.bg, - })) { - std.debug.print("color changed\n", .{}); - } - } } pub fn deinit(self: *App, plt: Platform.Deinit) void { diff --git a/src/graphics.zig b/src/graphics.zig index 6961596..4e9639e 100644 --- a/src/graphics.zig +++ b/src/graphics.zig @@ -34,7 +34,10 @@ const State = struct { error_sprite: Sprite.Id, default_spritesheet: Spritesheet.Id, default_font_spritesheet: Spritesheet.Id, - default_sampler: sg.Sampler, + + linear_sampler: sg.Sampler, + nearest_sampler: sg.Sampler, + default_sampler: Sampler, quads_buffer: [2048]Quad, quads: std.ArrayList(Quad), @@ -71,13 +74,19 @@ pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { const shader = sg.makeShader(shd.mainShaderDesc(sg.queryBackend())); assert(shader.id != sg.invalid_id); - // TODO: Make sampler configurable. - const default_sampler = sg.makeSampler(.{ + const linear_sampler = sg.makeSampler(.{ .min_filter = .LINEAR, .mag_filter = .LINEAR, - .label = "default-sampler" + .label = "linear-sampler" }); - assert(default_sampler.id != sg.invalid_id); + assert(linear_sampler.id != sg.invalid_id); + + const nearest_sampler = sg.makeSampler(.{ + .min_filter = .NEAREST, + .mag_filter = .NEAREST, + .label = "nearest-sampler" + }); + assert(linear_sampler.id != sg.invalid_id); const pipeline = sg.makePipeline(.{ .primitive_type = .TRIANGLES, @@ -144,7 +153,6 @@ pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { .shader = shader, .pipeline = pipeline, .bindings = bindings, - .default_sampler = default_sampler, .quads_buffer = undefined, .quads = .initBuffer(&self.quads_buffer), .clear_color = .black, @@ -153,6 +161,10 @@ pub fn init(gpa: std.mem.Allocator, logger: sg.Logger) !void { .default_spritesheet = undefined, .default_font_spritesheet = undefined, + .linear_sampler = linear_sampler, + .nearest_sampler = nearest_sampler, + .default_sampler = .linear, + .textures_buffer = undefined, .textures = .init(.initBuffer(&self.textures_buffer)), .nil_texture = undefined, @@ -285,9 +297,6 @@ pub fn deinit() void { } } - sg.destroyPipeline(self.pipeline); - sg.destroyShader(self.shader); - sg.destroySampler(self.default_sampler); sg.shutdown(); } @@ -336,7 +345,7 @@ pub fn beginFrame() void { const default_spritesheet = self.spritesheets.getAssumeExists(self.default_spritesheet); const spritesheet_texture = self.textures.getAssumeExists(default_spritesheet.texture); self.bindings.views[shd.VIEW_tex] = spritesheet_texture.view; - self.bindings.samplers[shd.SMP_smp] = self.default_sampler; + self.bindings.samplers[shd.SMP_smp] = self.linear_sampler; } pub fn showDebug() void { @@ -667,7 +676,18 @@ fn bindView(view: sg.View, opts: FlushOptions) void { } } +pub fn setDefaultSampler(sampler: Sampler) void { + const self = &g_state; + self.default_sampler = sampler; +} + +pub const Sampler = enum { + linear, + nearest, +}; + const DrawOptions = struct { + sampler: ?Sampler = null, texture: Texture.Id, quad: Quad }; @@ -704,6 +724,16 @@ pub fn draw(opts: DrawOptions) void { bindView(view, .{}); + const sampler = switch (opts.sampler orelse self.default_sampler) { + .linear => self.linear_sampler, + .nearest => self.nearest_sampler, + }; + const bound_sampler = &self.bindings.samplers[shd.SMP_smp]; + if (bound_sampler.id != sampler.id) { + flush(.{}); + bound_sampler.* = sampler; + } + if (self.quads.items.len == self.quads.capacity) { flush(.{}); } @@ -716,6 +746,12 @@ const SpriteOptions = struct { padding: u32 = 0, }; +// TODO: I don't like this getter, maybe nil_sprite should be a public global variable. +pub fn getNilSprite() Sprite.Id { + const self = &g_state; + return self.nil_sprite; +} + pub fn initSprite(opts: SpriteOptions) Sprite.Id { const self = &g_state; const id = self.sprites.insertBounded() catch { @@ -1112,8 +1148,8 @@ pub fn deinitFont(id: Font.Id) void { return; } - if (self.fonts.get(id)) { - // TODO: deinit sprites + if (self.fonts.get(id)) |font| { + font.cache.deinit(); self.fonts.removeAssumeExists(id); } } @@ -1142,6 +1178,8 @@ pub fn setFont(id: Font.Id, ttf_data: [:0]const u8) void { // TODO: deinit previous glyphs font.stb = stb_font; + + font.cache.deinit(); font.cache = .init(); } @@ -1508,6 +1546,17 @@ pub const Font = struct { return self; } + fn deinit(self: *GlyphCache) void { + for (self.glyphs_hashmap) |glyph_id| { + if (glyph_id == GlyphSlot.nil_id) { + continue; + } + const slot = self.glyphs[glyph_id]; + slot.glyph.deinit(); + } + self.* = undefined; + } + fn get(self: *GlyphCache, id: GlyphSlot.Id) *Glyph { return &self.glyphs[id].glyph; } diff --git a/src/main.zig b/src/main.zig index e8f15ee..c1fb15c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,12 @@ const std = @import("std"); const Platform = @import("platform.zig"); const App = @import("app.zig"); +pub const std_options: std.Options = .{ + .log_scope_levels = &.{ + .{ .scope = .graphics, .level = .info } + } +}; + pub fn main(init: std.process.Init) void { Platform.run(App, .{ .init = init, diff --git a/src/platform.zig b/src/platform.zig index cbde187..5d6b26f 100644 --- a/src/platform.zig +++ b/src/platform.zig @@ -34,6 +34,7 @@ fn PlatformType(App: type) type { const Self = @This(); gpa: Allocator, + arena: *std.heap.ArenaAllocator, io: Io, cli_args: []const [:0]const u8, @@ -71,6 +72,7 @@ fn PlatformType(App: type) type { if (@hasDecl(App, "init")) { const plt = Init{ .gpa = self.gpa, + .arena = self.arena.allocator(), .io = self.io, .assets = &self.assets }; @@ -112,9 +114,10 @@ fn PlatformType(App: type) type { if (@hasDecl(App, "frame")) { const plt = Frame{ - .t = t, - .dt = dt, + .t = t.nanoseconds, + .dt = dt.nanoseconds, .gpa = self.gpa, + .arena = self.arena.allocator(), .io = self.io, .input = &self.input, .input_events = self.events.items, @@ -494,6 +497,7 @@ pub const Assets = struct { pub const Init = struct { gpa: Allocator, + arena: Allocator, io: Io, assets: *Assets }; @@ -503,12 +507,15 @@ pub const Deinit = struct { io: Io, }; -pub const Frame = struct { - t: Io.Duration, - dt: Io.Duration, +pub const Nanoseconds = i96; + +pub const Frame = struct { + t: Nanoseconds, + dt: Nanoseconds, - frame: Allocator, gpa: Allocator, + arena: Allocator, + frame: Allocator, io: Io, assets: *Assets, input: *Input, @@ -519,11 +526,11 @@ pub const Frame = struct { } pub fn deltaTime(self: Frame) f32 { - return nanosecondsToSeconds(self.dt.nanoseconds); + return nanosecondsToSeconds(self.dt); } pub fn time(self: Frame) f32 { - return nanosecondsToSeconds(self.t.nanoseconds); + return nanosecondsToSeconds(self.t); } }; @@ -545,6 +552,7 @@ pub fn run(App: type, opts: RunOptions) void { const plt = arena.create(PlatformApp) catch @panic("OOM"); plt.* = PlatformApp{ .gpa = gpa, + .arena = opts.init.arena, .io = io, .cli_args = opts.init.minimal.args.toSlice(arena) catch @panic("OOM"), .app = undefined,