From d2ecfcafe95893fc9d54b9aa5a22845c952c5c75 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sat, 31 Jan 2026 23:20:34 +0200 Subject: [PATCH] implement cluster enemy --- src/combat_screen.zig | 269 ++++++++++++++++++++++++++++-------------- 1 file changed, 179 insertions(+), 90 deletions(-) diff --git a/src/combat_screen.zig b/src/combat_screen.zig index 110b89d..da23ded 100644 --- a/src/combat_screen.zig +++ b/src/combat_screen.zig @@ -93,6 +93,9 @@ const Enemy = struct { target: ?Vec2 = null, distance_to_target: ?f32 = null, + avoidance_group: ?[]Enemy.Id = null, + avoidance_group_distance: ?f32 = null, + dead: bool = false, const List = GenerationalArrayList(Enemy); @@ -101,10 +104,12 @@ const Enemy = struct { pub const Manager = struct { arena: std.heap.ArenaAllocator, - kind: union(enum) { - snake: struct { - enemies: std.ArrayList(Enemy.Id) = .empty, - }, + enemies: std.ArrayList(Enemy.Id) = .empty, + center_enemy: ?Enemy.Id = null, + center_enemy_pos: ?Vec2 = null, + kind: enum { + snake, + cluster }, pub fn deinit(self: Manager) void { @@ -122,12 +127,13 @@ const Wave = struct { enemies_spawned: u32, total_enemies: u32, - min_snake_length: u32, - max_snake_length: u32, + min_group_size: u32, + max_group_size: u32, const Kind = enum { regular, - snake + snake, + cluster }; const Info = struct { @@ -136,8 +142,8 @@ const Wave = struct { duration_s: u32, starts_at_s: u32, - min_snake_length: u32 = 3, - max_snake_length: u32 = 5, + min_group_size: u32 = 3, + max_group_size: u32 = 5, }; }; @@ -154,19 +160,27 @@ 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{ - // .{ - // .kind = .regular, - // .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 + .min_group_size = 5, + .max_group_size = 5 + }, + Wave.Info{ + .kind = .cluster, + .enemies = 1, + .duration_s = 1, + .starts_at_s = 0, + .min_group_size = 30, + .max_group_size = 30 } }; @@ -229,54 +243,54 @@ pub fn deinit(self: *CombatScreen) void { } const EnemyOptions = struct { - pos: ?Vec2 = null + pos: ?Vec2 = null, + size: f32 = 20 }; -pub fn spawnEnemy(self: *CombatScreen, opts: EnemyOptions) !Enemy.Id { +fn pickSpawnLocation(rand: std.Random, margin: f32, size: f32) Vec2 { + const top_spawn_area = (Rect{ + .pos = .init(0, -size - margin), + .size = .init(world_size.x, size) + }).growX(size); + const bottom_spawn_area = (Rect{ + .pos = .init(0, world_size.y + margin), + .size = .init(world_size.x, size) + }).growX(size); + + const left_spawn_area = (Rect{ + .pos = .init(-margin-size, 0), + .size = .init(size, world_size.y) + }).growY(size); + + const right_spawn_area = (Rect{ + .pos = .init(world_size.x + margin, 0), + .size = .init(size, world_size.y) + }).growY(size); + + const spawn_areas = [_]Rect{ + top_spawn_area, + bottom_spawn_area, + left_spawn_area, + right_spawn_area + }; + + const spawn_area = spawn_areas[rand.uintLessThan(usize, spawn_areas.len)]; + return Vec2.initRandomRect(rand, spawn_area); +} + +pub fn spawnEnemy(self: *CombatScreen, opts: EnemyOptions) !Enemy.Id { var pos: Vec2 = undefined; if (opts.pos) |opts_pos| { pos = opts_pos; } else { - const spawn_area_margin = 20; - const spawn_area_size = 10; - - 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 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 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 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 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); + pos = pickSpawnLocation(self.rng.random(), 20, 10); } return try self.enemies.insert(self.gpa, Enemy{ .kinetic = .{ .pos = pos }, .speed = 50, - .size = 20 + .size = opts.size }); } @@ -323,8 +337,8 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul .duration = @as(u64, wave_info.duration_s) * std.time.ns_per_s, .enemies_spawned = 0, .total_enemies = wave_info.enemies, - .min_snake_length = wave_info.min_snake_length, - .max_snake_length = wave_info.max_snake_length, + .min_group_size = wave_info.min_group_size, + .max_group_size = wave_info.max_group_size, }); } @@ -349,9 +363,9 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul var arena = std.heap.ArenaAllocator.init(self.gpa); var enemies: std.ArrayList(Enemy.Id) =.empty; - assert(wave.min_snake_length >= 1); + assert(wave.min_group_size >= 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 snake_length = rand.intRangeAtMost(u32, wave.min_group_size, wave.max_group_size); const head_id = try self.spawnEnemy(.{ }); try enemies.append(arena.allocator(), head_id); @@ -372,11 +386,36 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul _ = try self.managers.insert(self.gpa, Manager{ .arena = arena, - .kind = .{ - .snake = .{ - .enemies = enemies - } - }, + .enemies = enemies, + .kind = .snake + }); + }, + .cluster => { + var arena = std.heap.ArenaAllocator.init(self.gpa); + var enemies: std.ArrayList(Enemy.Id) =.empty; + + const min_cluster_radius = 10; + const max_cluster_radius = 20; + + const rand = self.rng.random(); + const center = pickSpawnLocation(rand, 20 + max_cluster_radius, 10); + + const group_size = rand.intRangeAtMost(u32, wave.min_group_size, wave.max_group_size); + for (0..group_size) |_| { + const angle = rand.float(f32) * std.math.pi * 2; + const distance = min_cluster_radius + rand.float(f32) * (max_cluster_radius - min_cluster_radius); + + const enemy_id = try self.spawnEnemy(.{ + .pos = center.add(Vec2.initAngle(angle).multiplyScalar(distance)), + .size = 10 + }); + try enemies.append(arena.allocator(), enemy_id); + } + + _ = try self.managers.insert(self.gpa, Manager{ + .arena = arena, + .enemies = enemies, + .kind = .cluster }); } } @@ -604,48 +643,81 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul var enemy_iter = self.enemies.iterator(); while (enemy_iter.nextItem()) |enemy| { enemy.target = null; + enemy.distance_to_target = null; + enemy.avoidance_group = null; + enemy.avoidance_group_distance = null; } } { var manager_iter = self.managers.iterator(); while (manager_iter.next()) |tuple| { - var destroy: bool = false; const manager_id = tuple.id; const manager = tuple.item; - 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); - } - } + var managed: std.ArrayList(*Enemy) = .empty; - 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); + var index: usize = 0; + while (index < manager.enemies.items.len) { + const enemy_id = manager.enemies.items[index]; + if (self.enemies.get(enemy_id)) |enemy| { + try managed.append(frame.arena.allocator(), enemy); + index += 1; + } else { + _ = manager.enemies.orderedRemove(index); + } + } + + if (manager.enemies.items.len <= 1) { + manager.deinit(); + self.managers.removeAssumeExists(manager_id); + continue; + } + + switch (manager.kind) { + .snake => { + for (1..managed.items.len) |i| { + const enemy = managed.items[i]; + const follow_enemy = managed.items[i-1]; enemy.target = follow_enemy.kinetic.pos; enemy.distance_to_target = 20; } + }, + .cluster => { + if (manager.center_enemy != null and !self.enemies.exists(manager.center_enemy.?)) { + manager.center_enemy = null; + } - if (snake.enemies.items.len <= 1) { - destroy = true; + if (manager.center_enemy == null) { + if (manager.center_enemy_pos == null) { + manager.center_enemy = manager.enemies.items[0]; + } else { + var closest_index: usize = 0; + for (1..managed.items.len) |enemy_index| { + const dist = managed.items[enemy_index].kinetic.pos.distanceSqr(manager.center_enemy_pos.?); + const closest_dist = managed.items[closest_index].kinetic.pos.distanceSqr(manager.center_enemy_pos.?); + if (closest_dist > dist) { + closest_index = enemy_index; + } + } + manager.center_enemy = manager.enemies.items[closest_index]; + } + } + const center_enemy_id = manager.center_enemy.?; + const center_enemy = self.enemies.getAssumeExists(center_enemy_id); + manager.center_enemy_pos = center_enemy.kinetic.pos; + + for (0.., managed.items) |i, enemy| { + const enemy_id = manager.enemies.items[i]; + if (enemy_id != center_enemy_id) { + enemy.target = center_enemy.kinetic.pos; + enemy.avoidance_group = try frame.arena.allocator().dupe(Enemy.Id, manager.enemies.items); + enemy.avoidance_group_distance = 20; + } } } } - - if (destroy) { - manager.deinit(); - self.managers.removeAssumeExists(manager_id); - } } } @@ -656,19 +728,36 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul enemy.target = self.player.kinetic.pos; } - var apply_vel = true; - const to_target = enemy.target.?.sub(enemy.kinetic.pos); + var target = enemy.target.?; if (enemy.distance_to_target) |distance_to_target| { - apply_vel = to_target.length() > distance_to_target; + const to_target = enemy.target.?.sub(enemy.kinetic.pos); + const dir_to_target = to_target.normalized(); + target = target.sub(dir_to_target.multiplyScalar(distance_to_target)); } - if (apply_vel) { + var avoidance: Vec2 = .zero; + if (enemy.avoidance_group) |avoidance_group| { + const avoidance_distance = enemy.avoidance_group_distance.?; + for (avoidance_group) |other_enemy_id| { + const other_enemy = self.enemies.get(other_enemy_id) orelse continue; + const dir_away_from_other = other_enemy.kinetic.pos.sub(enemy.kinetic.pos); + const dist_to_other = dir_away_from_other.length(); + if (dist_to_other < avoidance_distance) { + avoidance = avoidance.sub(dir_away_from_other.normalized().multiplyScalar(avoidance_distance - dist_to_other)); + } + } + } + + const to_target = target.sub(enemy.kinetic.pos); + if (to_target.length() > 1) { const dir_to_target = to_target.normalized(); enemy.kinetic.vel = dir_to_target.multiplyScalar(enemy.speed); } else { enemy.kinetic.vel = .zero; } + enemy.kinetic.vel = enemy.kinetic.vel.add(avoidance.multiplyScalar(5)); + const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); if (enemy_rect.hasOverlap(self.player.getRect())) { var is_invincible = false;