diff --git a/build.zig b/build.zig index c5b1acf..ef95187 100644 --- a/build.zig +++ b/build.zig @@ -13,6 +13,7 @@ pub fn build(b: *std.Build) !void { const has_console = b.option(bool, "console", "Show console (Window only)") orelse (optimize == .Debug); const isWasm = target.result.cpu.arch.isWasm(); + const isWindows = target.result.os.tag == .windows; if (isWasm) { has_tracy = false; @@ -25,7 +26,12 @@ pub fn build(b: *std.Build) !void { .link_libc = true, }); - const dep_sokol = b.dependency("sokol", .{ .target = target, .optimize = optimize, .with_sokol_imgui = has_imgui, .vulkan = true }); + const dep_sokol = b.dependency("sokol", .{ + .target = target, + .optimize = optimize, + .with_sokol_imgui = has_imgui, + .vulkan = if (isWasm or isWindows) null else true + }); mod_main.linkLibrary(dep_sokol.artifact("sokol_clib")); mod_main.addImport("sokol", dep_sokol.module("sokol")); @@ -62,7 +68,8 @@ pub fn build(b: *std.Build) !void { { var cflags_buffer: [64][]const u8 = undefined; var cflags = std.ArrayListUnmanaged([]const u8).initBuffer(&cflags_buffer); - switch (sokol.resolveSokolBackend(.vulkan, target.result)) { + const backend: sokol.SokolBackend = if (isWasm or isWindows) .auto else .vulkan; + switch (sokol.resolveSokolBackend(backend, target.result)) { .d3d11 => try cflags.appendBounded("-DSOKOL_D3D11"), .metal => try cflags.appendBounded("-DSOKOL_METAL"), .gl => try cflags.appendBounded("-DSOKOL_GLCORE"), @@ -118,8 +125,8 @@ fn buildNative(b: *std.Build, name: []const u8, mod: *std.Build.Module, has_cons .root_source_file = b.path("tools/png-to-icon.zig"), }), }); - const dep_stb_image = b.dependency("stb_image", .{}); - png_to_icon_tool.root_module.addImport("stb_image", dep_stb_image.module("stb_image")); + const dep_stb = b.dependency("stb", .{}); + png_to_icon_tool.root_module.addImport("stb_image", dep_stb.module("stb_image")); const png_to_icon_step = b.addRunArtifact(png_to_icon_tool); png_to_icon_step.addFileArg(b.path("src/assets/icon.png")); @@ -209,7 +216,11 @@ fn buildWasm(b: *std.Build, opts: BuildWasmOptions) !void { const dep_emsdk = opts.dep_sokol.builder.dependency("emsdk", .{}); - patchWasmIncludeDirs(opts.mod_main, dep_emsdk.path("upstream/emscripten/cache/sysroot/include"), &(opts.dep_sokol.artifact("sokol_clib").step)); + patchWasmIncludeDirs( + opts.mod_main, + dep_emsdk.path("upstream/emscripten/cache/sysroot/include"), + &(opts.dep_sokol.artifact("sokol_clib").step) + ); // create a build step which invokes the Emscripten linker const link_step = try sokol.emLinkStep(b, .{ @@ -221,6 +232,7 @@ fn buildWasm(b: *std.Build, opts: BuildWasmOptions) !void { .use_emmalloc = true, .use_filesystem = false, .shell_file_path = b.path("src/engine/shell.html"), + .extra_args = &.{ "-sASSERTIONS=1", "-sALLOW_MEMORY_GROWTH=1" } }); // attach to default target b.getInstallStep().dependOn(&link_step.step); diff --git a/src/combat_screen.zig b/src/combat_screen.zig index e3abdd9..110b89d 100644 --- a/src/combat_screen.zig +++ b/src/combat_screen.zig @@ -1,9 +1,12 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const assert = std.debug.assert; const Assets = @import("./assets.zig"); const State = @import("./state.zig"); +const GenerationalArrayList = @import("./generational_array_list.zig").GenerationalArrayList; + const Engine = @import("./engine/root.zig"); const Nanoseconds = Engine.Nanoseconds; const Vec2 = Engine.Vec2; @@ -87,20 +90,54 @@ const Enemy = struct { kinetic: Kinetic, speed: f32, size: f32, + target: ?Vec2 = null, + distance_to_target: ?f32 = null, - dead: bool = false + dead: bool = false, + + const List = GenerationalArrayList(Enemy); + const Id = List.Id; }; +pub const Manager = struct { + arena: std.heap.ArenaAllocator, + kind: union(enum) { + snake: struct { + enemies: std.ArrayList(Enemy.Id) = .empty, + }, + }, + + pub fn deinit(self: Manager) void { + self.arena.deinit(); + } + + const List = GenerationalArrayList(Manager); + const Id = List.Id; +}; + const Wave = struct { + kind: Kind, started_at: Nanoseconds, duration: Nanoseconds, enemies_spawned: u32, total_enemies: u32, + min_snake_length: u32, + max_snake_length: u32, + + const Kind = enum { + regular, + snake + }; + const Info = struct { + kind: Kind, enemies: u32, duration_s: u32, starts_at_s: u32, + + min_snake_length: u32 = 3, + max_snake_length: u32 = 5, }; }; @@ -117,10 +154,19 @@ const world_size = Vec2.init(20 * 16, 15 * 16); const invincibility_duration_s = 0.5; const pickup_spawn_duration_s = Range.init(1, 5); const wave_infos = [_]Wave.Info{ - .{ - .enemies = 10, - .duration_s = 10, - .starts_at_s = 0 + // .{ + // .kind = .regular, + // .enemies = 10, + // .duration_s = 10, + // .starts_at_s = 0 + // }, + Wave.Info{ + .kind = .snake, + .enemies = 1, + .duration_s = 1, + .starts_at_s = 0, + .min_snake_length = 5, + .max_snake_length = 5 } }; @@ -128,7 +174,8 @@ gpa: Allocator, assets: *Assets, rng: RNGState, -enemies: std.ArrayList(Enemy) = .empty, +managers: Manager.List = .empty, +enemies: Enemy.List = .empty, player: Player = .{}, bullets: std.ArrayList(Bullet) = .empty, @@ -166,6 +213,11 @@ pub fn init(gpa: Allocator, seed: u64, assets: *Assets, state: State) CombatScre } pub fn deinit(self: *CombatScreen) void { + var manager_iter = self.managers.iterator(); + while (manager_iter.nextItem()) |manager| { + manager.deinit(); + } + self.bullets.deinit(self.gpa); self.enemies.deinit(self.gpa); self.waves.deinit(self.gpa); @@ -173,46 +225,57 @@ pub fn deinit(self: *CombatScreen) void { self.spawned_waves.deinit(self.gpa); self.lasers.deinit(self.gpa); self.bombs.deinit(self.gpa); + self.managers.deinit(self.gpa); } -pub fn spawnEnemy(self: *CombatScreen) !void { - const spawn_area_margin = 20; - const spawn_area_size = 10; +const EnemyOptions = struct { + pos: ?Vec2 = null +}; - const top_spawn_area = (Rect{ - .pos = .init(0, -spawn_area_size - spawn_area_margin), - .size = .init(world_size.x, spawn_area_size) - }).growX(spawn_area_size); +pub fn spawnEnemy(self: *CombatScreen, opts: EnemyOptions) !Enemy.Id { - const bottom_spawn_area = (Rect{ - .pos = .init(0, world_size.y + spawn_area_margin), - .size = .init(world_size.x, spawn_area_size) - }).growX(spawn_area_size); + var pos: Vec2 = undefined; + if (opts.pos) |opts_pos| { + pos = opts_pos; + } else { + const spawn_area_margin = 20; + const spawn_area_size = 10; - const left_spawn_area = (Rect{ - .pos = .init(-spawn_area_margin-spawn_area_size, 0), - .size = .init(spawn_area_size, world_size.y) - }).growY(spawn_area_size); + const top_spawn_area = (Rect{ + .pos = .init(0, -spawn_area_size - spawn_area_margin), + .size = .init(world_size.x, spawn_area_size) + }).growX(spawn_area_size); - const right_spawn_area = (Rect{ - .pos = .init(world_size.x + spawn_area_margin, 0), - .size = .init(spawn_area_size, world_size.y) - }).growY(spawn_area_size); + const bottom_spawn_area = (Rect{ + .pos = .init(0, world_size.y + spawn_area_margin), + .size = .init(world_size.x, spawn_area_size) + }).growX(spawn_area_size); - const spawn_areas = [_]Rect{ - top_spawn_area, - bottom_spawn_area, - left_spawn_area, - right_spawn_area - }; + const left_spawn_area = (Rect{ + .pos = .init(-spawn_area_margin-spawn_area_size, 0), + .size = .init(spawn_area_size, world_size.y) + }).growY(spawn_area_size); - const rand = self.rng.random(); - const spawn_area = spawn_areas[rand.uintLessThan(usize, spawn_areas.len)]; - const pos = Vec2.initRandomRect(rand, spawn_area); + const right_spawn_area = (Rect{ + .pos = .init(world_size.x + spawn_area_margin, 0), + .size = .init(spawn_area_size, world_size.y) + }).growY(spawn_area_size); - try self.enemies.append(self.gpa, .{ + const spawn_areas = [_]Rect{ + top_spawn_area, + bottom_spawn_area, + left_spawn_area, + right_spawn_area + }; + + const rand = self.rng.random(); + const spawn_area = spawn_areas[rand.uintLessThan(usize, spawn_areas.len)]; + pos = Vec2.initRandomRect(rand, spawn_area); + } + + return try self.enemies.insert(self.gpa, Enemy{ .kinetic = .{ .pos = pos }, - .speed = 10, + .speed = 50, .size = 20 }); } @@ -255,10 +318,13 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul try self.spawned_waves.append(self.gpa, i); try self.waves.append(self.gpa, .{ + .kind = wave_info.kind, .started_at = self.wave_timer, .duration = @as(u64, wave_info.duration_s) * std.time.ns_per_s, .enemies_spawned = 0, - .total_enemies = wave_info.enemies + .total_enemies = wave_info.enemies, + .min_snake_length = wave_info.min_snake_length, + .max_snake_length = wave_info.max_snake_length, }); } @@ -275,7 +341,45 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul const expected_enemies: u32 = @intFromFloat(wave_total_enemies * percent_complete); while (wave.enemies_spawned < expected_enemies) { - try self.spawnEnemy(); + switch (wave.kind) { + .regular => { + _ = try self.spawnEnemy(.{}); + }, + .snake => { + var arena = std.heap.ArenaAllocator.init(self.gpa); + var enemies: std.ArrayList(Enemy.Id) =.empty; + + assert(wave.min_snake_length >= 1); + const rand = self.rng.random(); + const snake_length = wave.min_snake_length + rand.uintAtMost(u32, wave.max_snake_length - wave.min_snake_length); + + const head_id = try self.spawnEnemy(.{ }); + try enemies.append(arena.allocator(), head_id); + + const head = self.enemies.getAssumeExists(head_id); + const head_pos = head.kinetic.pos; + const world_center = world_size.divideScalar(2); + const dir_to_center = world_center.sub(head_pos).normalized(); + + const gap = 20; + for (0..snake_length) |i| { + const tail_pos = head_pos.sub(dir_to_center.multiplyScalar(@floatFromInt(i * gap))); + const tail_id = try self.spawnEnemy(.{ + .pos = tail_pos + }); + try enemies.append(arena.allocator(), tail_id); + } + + _ = try self.managers.insert(self.gpa, Manager{ + .arena = arena, + .kind = .{ + .snake = .{ + .enemies = enemies + } + }, + }); + } + } wave.enemies_spawned += 1; } @@ -404,7 +508,8 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul const bullet_rect = getCenteredRect(bullet.kinetic.pos, bullet.size); - for (self.enemies.items) |*enemy| { + var enemy_iter = self.enemies.iterator(); + while (enemy_iter.nextItem()) |enemy| { const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); if (enemy_rect.hasOverlap(bullet_rect)) { enemy.dead = true; @@ -431,7 +536,8 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul }; const laser_quad = laser_line.getQuad(laser.size); - for (self.enemies.items) |*enemy| { + var enemy_iter = self.enemies.iterator(); + while (enemy_iter.nextItem()) |enemy| { const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); if (laser_quad.isRectOverlap(enemy_rect)) { enemy.dead = true; @@ -469,7 +575,8 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul if (frame.time_ns >= bomb.explode_at) { destroy = true; - for (self.enemies.items) |*enemy| { + var enemy_iter = self.enemies.iterator(); + while (enemy_iter.nextItem()) |enemy| { const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); if (enemy_rect.checkCircleOverlap(bomb.kinetic.pos, bomb.explosion_radius)) { enemy.dead = true; @@ -493,32 +600,97 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul } } - for (self.enemies.items) |*enemy| { - const dir_to_player = self.player.kinetic.pos.sub(enemy.kinetic.pos).normalized(); - enemy.kinetic.vel = dir_to_player.multiplyScalar(50); - enemy.kinetic.update(dt, .{}); + { + var enemy_iter = self.enemies.iterator(); + while (enemy_iter.nextItem()) |enemy| { + enemy.target = null; + } + } - const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); + { + var manager_iter = self.managers.iterator(); + while (manager_iter.next()) |tuple| { + var destroy: bool = false; + const manager_id = tuple.id; + const manager = tuple.item; - if (enemy_rect.hasOverlap(self.player.getRect())) { - var is_invincible = false; - if (self.player.invincible_until) |invincible_until| { - is_invincible = frame.time_ns < invincible_until; + switch (manager.kind) { + .snake => |*snake| { + var index: usize = 0; + while (index < snake.enemies.items.len) { + const enemy_id = snake.enemies.items[index]; + if (self.enemies.exists(enemy_id)) { + index += 1; + } else { + _ = snake.enemies.orderedRemove(index); + } + } + + for (1..snake.enemies.items.len) |i| { + const enemy_id = snake.enemies.items[i]; + const enemy = self.enemies.getAssumeExists(enemy_id); + const follow_enemy_id = snake.enemies.items[i-1]; + const follow_enemy = self.enemies.getAssumeExists(follow_enemy_id); + + enemy.target = follow_enemy.kinetic.pos; + enemy.distance_to_target = 20; + } + + if (snake.enemies.items.len <= 1) { + destroy = true; + } + } } - if (self.player.health > 0 and !is_invincible) { - self.player.health -= 1; - - var invincible_until = frame.time_ns; - invincible_until += @as(Nanoseconds, @intFromFloat(invincibility_duration_s * std.time.ns_per_s)); - self.player.invincible_until = invincible_until; + if (destroy) { + manager.deinit(); + self.managers.removeAssumeExists(manager_id); } } + } - frame.drawRectangle(.{ - .rect = enemy_rect, - .color = rgb(20, 200, 20) - }); + { + var enemy_iter = self.enemies.iterator(); + while (enemy_iter.nextItem()) |enemy| { + if (enemy.target == null) { + enemy.target = self.player.kinetic.pos; + } + + var apply_vel = true; + const to_target = enemy.target.?.sub(enemy.kinetic.pos); + if (enemy.distance_to_target) |distance_to_target| { + apply_vel = to_target.length() > distance_to_target; + } + + if (apply_vel) { + const dir_to_target = to_target.normalized(); + enemy.kinetic.vel = dir_to_target.multiplyScalar(enemy.speed); + } else { + enemy.kinetic.vel = .zero; + } + + const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); + if (enemy_rect.hasOverlap(self.player.getRect())) { + var is_invincible = false; + if (self.player.invincible_until) |invincible_until| { + is_invincible = frame.time_ns < invincible_until; + } + + if (self.player.health > 0 and !is_invincible) { + self.player.health -= 1; + + var invincible_until = frame.time_ns; + invincible_until += @as(Nanoseconds, @intFromFloat(invincibility_duration_s * std.time.ns_per_s)); + self.player.invincible_until = invincible_until; + } + } + + enemy.kinetic.update(dt, .{}); + frame.drawRectangle(.{ + .rect = enemy_rect, + .color = rgb(20, 200, 20) + }); + } } { @@ -563,12 +735,10 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul } { - var i: usize = 0; - while (i < self.enemies.items.len) { - if (self.enemies.items[i].dead) { - _ = self.enemies.swapRemove(i); - } else { - i += 1; + var enemy_iter = self.enemies.iterator(); + while (enemy_iter.next()) |tuple| { + if (tuple.item.dead) { + self.enemies.removeAssumeExists(tuple.id); } } } @@ -586,7 +756,7 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul frame.drawTextFormat(.init(10, 70), text_opts, "{?}", .{ self.player.gun }); result.player_died = (self.player.health == 0); - if (self.enemies.items.len == 0 and self.waves.items.len == 0 and self.spawned_waves.items.len == wave_infos.len) { + if (self.enemies.count == 0 and self.waves.items.len == 0 and self.spawned_waves.items.len == wave_infos.len) { result.player_finished = true; } diff --git a/src/game.zig b/src/game.zig index f65e393..38b4f69 100644 --- a/src/game.zig +++ b/src/game.zig @@ -125,7 +125,7 @@ pub fn debug(self: *Game) !void { defer imgui.endWindow(); if (imgui.button("Spawn enemy")) { - try self.combat_screen.spawnEnemy(); + _ = try self.combat_screen.spawnEnemy(.{}); } if (imgui.button("Restart")) { @@ -148,6 +148,6 @@ pub fn debug(self: *Game) !void { imgui.textFmt("Waves: {}\n", .{screen.waves.items.len}); imgui.textFmt("Bullets: {}\n", .{screen.bullets.items.len}); - imgui.textFmt("Enemies: {}\n", .{screen.enemies.items.len}); + imgui.textFmt("Enemies: {}\n", .{screen.enemies.count}); imgui.textFmt("Time until next pickup: {d:.2}s\n", .{@as(f32, @floatFromInt(time_left_til_pickup)) / std.time.ns_per_s}); } diff --git a/src/generational_array_list.zig b/src/generational_array_list.zig new file mode 100644 index 0000000..52befe7 --- /dev/null +++ b/src/generational_array_list.zig @@ -0,0 +1,480 @@ +const std = @import("std"); +const tracy = @import("tracy"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const Index = u24; +const Generation = u8; + +pub fn GenerationalArrayList(Item: type) type { + assert(@bitSizeOf(Generation) % 8 == 0); + assert(@bitSizeOf(Index) % 8 == 0); + + return struct { + const Self = @This(); + + items: [*]Item, + generations: [*]Generation, + unused: [*]u8, + + len: u32, + capacity: u32, + + count: u32, + + pub const empty = Self{ + .items = &[_]Item{}, + .generations = &[_]Generation{}, + .unused = &[_]u8{}, + .capacity = 0, + .len = 0, + .count = 0 + }; + + pub const Id = packed struct { + generation: Generation, + index: Index, + + // TODO: Maybe `Id.Optional` type should be created to ensure .wrap() and .toOptional() + pub const none = Id{ + .generation = std.math.maxInt(Generation), + .index = std.math.maxInt(Index), + }; + + pub fn format(self: Id, writer: *std.Io.Writer) std.Io.Writer.Error!void { + if (self == Id.none) { + try writer.print("Id({s}){{ .none }}", .{ @typeName(Item) }); + } else { + try writer.print("Id({s}){{ {}, {} }}", .{ @typeName(Item), self.index, self.generation }); + } + } + + pub fn asInt(self: Id) u32 { + return @bitCast(self); + } + }; + + pub const ItemWithId = struct { + id: Id, + item: *Item, + }; + + pub const Iterator = struct { + array_list: *Self, + index: Index, + + pub fn nextId(self: *Iterator) ?Id { + while (self.index < self.array_list.len) { + const index = self.index; + self.index += 1; + + // TODO: Inline the `byte_index` calculate for better speed. + // Probably not needed. Idk + if (self.array_list.isUnused(index)) { + continue; + } + + return Id{ + .index = @intCast(index), + .generation = self.array_list.generations[index] + }; + } + + return null; + } + + pub fn nextItem(self: *Iterator) ?*Item { + if (self.nextId()) |id| { + return &self.array_list.items[id.index]; + } + + return null; + } + + pub fn next(self: *Iterator) ?ItemWithId { + if (self.nextId()) |id| { + return ItemWithId{ + .id = id, + .item = &self.array_list.items[id.index] + }; + } + + return null; + } + }; + + pub const Metadata = extern struct { + len: u32, + count: u32 + }; + + fn divCeilGeneration(num: u32) u32 { + return std.math.divCeil(u32, num, @bitSizeOf(Generation)) catch unreachable; + } + + fn divFloorGeneration(num: u32) u32 { + return @divFloor(num, @bitSizeOf(Generation)); + } + + pub fn ensureTotalCapacityPrecise(self: *Self, allocator: Allocator, new_capacity: u32) !void { + if (new_capacity > std.math.maxInt(Index)) { + return error.OutOfIndexSpace; + } + + // TODO: Shrinking is not supported + assert(new_capacity >= self.capacity); + + const unused_bit_array_len = divCeilGeneration(self.capacity); + const new_unused_bit_array_len = divCeilGeneration(new_capacity); + + // TODO: Handle allocation failure case + const new_unused = try allocator.realloc(self.unused[0..unused_bit_array_len], new_unused_bit_array_len); + const new_items = try allocator.realloc(self.items[0..self.capacity], new_capacity); + const new_generations = try allocator.realloc(self.generations[0..self.capacity], new_capacity); + + self.unused = new_unused.ptr; + self.items = new_items.ptr; + self.generations = new_generations.ptr; + self.capacity = new_capacity; + } + + fn growCapacity(current: u32, minimum: u32) u32 { + const init_capacity = @as(comptime_int, @max(1, std.atomic.cache_line / @sizeOf(Item))); + + var new = current; + while (true) { + new +|= new / 2 + init_capacity; + if (new >= minimum) { + return new; + } + } + } + + pub fn ensureTotalCapacity(self: *Self, allocator: Allocator, new_capacity: u32) !void { + if (self.capacity >= new_capacity) return; + + const better_capacity = Self.growCapacity(self.capacity, new_capacity); + try self.ensureTotalCapacityPrecise(allocator, better_capacity); + } + + pub fn clearRetainingCapacity(self: *Self) void { + self.count = 0; + self.len = 0; + } + + pub fn ensureUnusedCapacity(self: *Self, allocator: Allocator, unused_capacity: u32) !void { + try self.ensureTotalCapacity(allocator, self.len + unused_capacity); + } + + fn findFirstUnused(self: *Self) ?Index { + for (0..divCeilGeneration(self.len)) |byte_index| { + if (self.unused[byte_index] != 0) { + const found = @ctz(self.unused[byte_index]) + byte_index * @bitSizeOf(Generation); + if (found < self.len) { + return @intCast(found); + } else { + return null; + } + } + } + + return null; + } + + fn markUnused(self: *Self, index: Index, unused: bool) void { + assert(index < self.len); + + const byte_index = divFloorGeneration(index); + const bit_index = @mod(index, @bitSizeOf(Generation)); + const bit_flag = @as(u8, 1) << @intCast(bit_index); + if (unused) { + self.unused[byte_index] |= bit_flag; + } else { + self.unused[byte_index] &= ~bit_flag; + } + } + + fn isUnused(self: *Self, index: Index) bool { + assert(index < self.len); + + const byte_index = divFloorGeneration(index); + const bit_index = @mod(index, @bitSizeOf(Generation)); + const bit_flag = @as(u8, 1) << @intCast(bit_index); + return (self.unused[byte_index] & bit_flag) != 0; + } + + pub fn insertUndefined(self: *Self, allocator: Allocator) !Id { + var unused_index: Index = undefined; + + if (self.findFirstUnused()) |index| { + unused_index = index; + } else { + try self.ensureUnusedCapacity(allocator, 1); + + unused_index = @intCast(self.len); + self.len += 1; + self.generations[unused_index] = 0; + } + + self.markUnused(unused_index, false); + self.count += 1; + + const id = Id{ + .index = @intCast(unused_index), + .generation = self.generations[unused_index] + }; + + assert(id != Id.none); + + return id; + } + + pub fn insert(self: *Self, allocator: Allocator, item: Item) !Id { + const id = try self.insertUndefined(allocator); + + const new_item_ptr = self.getAssumeExists(id); + new_item_ptr.* = item; + + return id; + } + + pub fn exists(self: *Self, id: Id) bool { + if (id.index >= self.len) { + return false; + } + + if (self.isUnused(id.index)) { + return false; + } + + if (self.generations[id.index] != id.generation) { + return false; + } + + return true; + } + + pub fn removeAssumeExists(self: *Self, id: Id) void { + assert(self.exists(id)); + + self.markUnused(id.index, true); + // TODO: Maybe a log should be shown when a wrap-around occurs? + self.generations[id.index] +%= 1; + self.count -= 1; + } + + pub fn remove(self: *Self, id: Id) bool { + if (!self.exists(id)) { + return false; + } + + self.removeAssumeExists(id); + return true; + } + + pub fn getAssumeExists(self: *Self, id: Id) *Item { + assert(self.exists(id)); + + return &self.items[id.index]; + } + + pub fn get(self: *Self, id: Id) ?*Item { + if (self.exists(id)) { + return self.getAssumeExists(id); + } else { + return null; + } + } + + pub fn iterator(self: *Self) Iterator { + return Iterator{ + .array_list = self, + .index = 0 + }; + } + + pub fn deinit(self: *Self, allocator: Allocator) void { + allocator.free(self.unused[0..divCeilGeneration(self.capacity)]); + allocator.free(self.generations[0..self.capacity]); + allocator.free(self.items[0..self.capacity]); + } + + pub fn clone(self: *Self, allocator: Allocator) !Self { + const items = try allocator.dupe(Item, self.items[0..self.capacity]); + errdefer allocator.free(items); + + const generations = try allocator.dupe(Generation, self.generations[0..self.capacity]); + errdefer allocator.free(generations); + + const unused = try allocator.dupe(u8, self.unused[0..divCeilGeneration(self.capacity)]); + errdefer allocator.free(unused); + + return Self{ + .items = items.ptr, + .generations = generations.ptr, + .unused = unused.ptr, + .len = self.len, + .count = self.count, + .capacity = self.capacity + }; + } + + pub fn getMetadata(self: *Self) Metadata { + return Metadata{ + .len = self.len, + .count = self.count + }; + } + + pub fn write(self: *Self, writer: *std.Io.Writer, endian: std.builtin.Endian) !void { + const zone = tracy.beginZone(@src(), .{ .name = "gen array list write" }); + defer zone.end(); + + try writer.writeSliceEndian(Item, self.items[0..self.len], endian); + try writer.writeSliceEndian(Generation, self.generations[0..self.len], endian); + try writer.writeAll(self.unused[0..divCeilGeneration(self.len)]); + } + + pub fn read( + self: *Self, + allocator: Allocator, + reader: *std.Io.Reader, + endian: std.builtin.Endian, + metadata: Metadata + ) !void { + const zone = tracy.beginZone(@src(), .{ .name = "gen array list read" }); + defer zone.end(); + + try self.ensureTotalCapacity(allocator, metadata.len); + + try reader.readSliceEndian(Item, self.items[0..metadata.len], endian); + try reader.readSliceEndian(Generation, self.generations[0..metadata.len], endian); + try reader.readSliceAll(self.unused[0..divCeilGeneration(metadata.len)]); + + self.len = metadata.len; + self.count = metadata.count; + } + }; +} + +const TestArray = GenerationalArrayList(u32); + +test "insert & remove" { + const expect = std.testing.expect; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + const id1 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id1)); + try expect(array_list.remove(id1)); + try expect(!array_list.exists(id1)); + try expect(!array_list.remove(id1)); + + const id2 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id2)); + try expect(!array_list.exists(id1)); + try expect(id1.index == id2.index); +} + +test "generation wrap around" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + // Grow array list so that at least 1 slot exists + const id1 = try array_list.insert(gpa, 10); + array_list.removeAssumeExists(id1); + + // Artificially increase generation count + array_list.generations[id1.index] = std.math.maxInt(Generation); + + // Check if generation wraps around + const id2 = try array_list.insert(gpa, 10); + array_list.removeAssumeExists(id2); + try expectEqual(id1.index, id2.index); + try expectEqual(0, array_list.generations[id1.index]); +} + +test "iterator" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + // Create array which has a hole + const id1 = try array_list.insert(gpa, 1); + const id2 = try array_list.insert(gpa, 2); + const id3 = try array_list.insert(gpa, 3); + + array_list.removeAssumeExists(id2); + + var iter = array_list.iterator(); + try expectEqual( + TestArray.ItemWithId{ + .id = id1, + .item = array_list.getAssumeExists(id1) + }, + iter.next().? + ); + try expectEqual( + TestArray.ItemWithId{ + .id = id3, + .item = array_list.getAssumeExists(id3) + }, + iter.next().? + ); + try expectEqual(null, iter.next()); +} + +test "read & write" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list1: TestArray = .empty; + defer array_list1.deinit(gpa); + + var array_list2: TestArray = .empty; + defer array_list2.deinit(gpa); + + const id1 = try array_list1.insert(gpa, 1); + const id2 = try array_list1.insert(gpa, 2); + const id3 = try array_list1.insert(gpa, 3); + + var buffer: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + const native_endian = builtin.cpu.arch.endian(); + + try array_list1.write(&writer, native_endian); + + var reader = std.Io.Reader.fixed(writer.buffered()); + try array_list2.read(gpa, &reader, native_endian, array_list1.getMetadata()); + + try expectEqual(array_list1.getAssumeExists(id1).*, array_list2.getAssumeExists(id1).*); + try expectEqual(array_list1.getAssumeExists(id2).*, array_list2.getAssumeExists(id2).*); + try expectEqual(array_list1.getAssumeExists(id3).*, array_list2.getAssumeExists(id3).*); + try expectEqual(array_list1.count, array_list2.count); +} + +test "clear retaining capacity" { + const expect = std.testing.expect; + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + const id1 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id1)); + array_list.clearRetainingCapacity(); + + const id2 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id2)); + + try expectEqual(id1, id2); +}