implement cluster enemy

This commit is contained in:
Rokas Puzonas 2026-01-31 23:20:34 +02:00
parent 11be4f21ac
commit d2ecfcafe9

View File

@ -93,6 +93,9 @@ const Enemy = struct {
target: ?Vec2 = null, target: ?Vec2 = null,
distance_to_target: ?f32 = null, distance_to_target: ?f32 = null,
avoidance_group: ?[]Enemy.Id = null,
avoidance_group_distance: ?f32 = null,
dead: bool = false, dead: bool = false,
const List = GenerationalArrayList(Enemy); const List = GenerationalArrayList(Enemy);
@ -101,10 +104,12 @@ const Enemy = struct {
pub const Manager = struct { pub const Manager = struct {
arena: std.heap.ArenaAllocator, 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 { pub fn deinit(self: Manager) void {
@ -122,12 +127,13 @@ const Wave = struct {
enemies_spawned: u32, enemies_spawned: u32,
total_enemies: u32, total_enemies: u32,
min_snake_length: u32, min_group_size: u32,
max_snake_length: u32, max_group_size: u32,
const Kind = enum { const Kind = enum {
regular, regular,
snake snake,
cluster
}; };
const Info = struct { const Info = struct {
@ -136,8 +142,8 @@ const Wave = struct {
duration_s: u32, duration_s: u32,
starts_at_s: u32, starts_at_s: u32,
min_snake_length: u32 = 3, min_group_size: u32 = 3,
max_snake_length: u32 = 5, 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 invincibility_duration_s = 0.5;
const pickup_spawn_duration_s = Range.init(1, 5); const pickup_spawn_duration_s = Range.init(1, 5);
const wave_infos = [_]Wave.Info{ const wave_infos = [_]Wave.Info{
// .{ .{
// .kind = .regular, .kind = .regular,
// .enemies = 10, .enemies = 10,
// .duration_s = 10, .duration_s = 10,
// .starts_at_s = 0 .starts_at_s = 0
// }, },
Wave.Info{ Wave.Info{
.kind = .snake, .kind = .snake,
.enemies = 1, .enemies = 1,
.duration_s = 1, .duration_s = 1,
.starts_at_s = 0, .starts_at_s = 0,
.min_snake_length = 5, .min_group_size = 5,
.max_snake_length = 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,37 +243,30 @@ pub fn deinit(self: *CombatScreen) void {
} }
const EnemyOptions = struct { 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 {
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{ const top_spawn_area = (Rect{
.pos = .init(0, -spawn_area_size - spawn_area_margin), .pos = .init(0, -size - margin),
.size = .init(world_size.x, spawn_area_size) .size = .init(world_size.x, size)
}).growX(spawn_area_size); }).growX(size);
const bottom_spawn_area = (Rect{ const bottom_spawn_area = (Rect{
.pos = .init(0, world_size.y + spawn_area_margin), .pos = .init(0, world_size.y + margin),
.size = .init(world_size.x, spawn_area_size) .size = .init(world_size.x, size)
}).growX(spawn_area_size); }).growX(size);
const left_spawn_area = (Rect{ const left_spawn_area = (Rect{
.pos = .init(-spawn_area_margin-spawn_area_size, 0), .pos = .init(-margin-size, 0),
.size = .init(spawn_area_size, world_size.y) .size = .init(size, world_size.y)
}).growY(spawn_area_size); }).growY(size);
const right_spawn_area = (Rect{ const right_spawn_area = (Rect{
.pos = .init(world_size.x + spawn_area_margin, 0), .pos = .init(world_size.x + margin, 0),
.size = .init(spawn_area_size, world_size.y) .size = .init(size, world_size.y)
}).growY(spawn_area_size); }).growY(size);
const spawn_areas = [_]Rect{ const spawn_areas = [_]Rect{
top_spawn_area, top_spawn_area,
@ -268,15 +275,22 @@ pub fn spawnEnemy(self: *CombatScreen, opts: EnemyOptions) !Enemy.Id {
right_spawn_area right_spawn_area
}; };
const rand = self.rng.random();
const spawn_area = spawn_areas[rand.uintLessThan(usize, spawn_areas.len)]; const spawn_area = spawn_areas[rand.uintLessThan(usize, spawn_areas.len)];
pos = Vec2.initRandomRect(rand, spawn_area); 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 {
pos = pickSpawnLocation(self.rng.random(), 20, 10);
} }
return try self.enemies.insert(self.gpa, Enemy{ return try self.enemies.insert(self.gpa, Enemy{
.kinetic = .{ .pos = pos }, .kinetic = .{ .pos = pos },
.speed = 50, .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, .duration = @as(u64, wave_info.duration_s) * std.time.ns_per_s,
.enemies_spawned = 0, .enemies_spawned = 0,
.total_enemies = wave_info.enemies, .total_enemies = wave_info.enemies,
.min_snake_length = wave_info.min_snake_length, .min_group_size = wave_info.min_group_size,
.max_snake_length = wave_info.max_snake_length, .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 arena = std.heap.ArenaAllocator.init(self.gpa);
var enemies: std.ArrayList(Enemy.Id) =.empty; 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 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(.{ }); const head_id = try self.spawnEnemy(.{ });
try enemies.append(arena.allocator(), head_id); 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{ _ = try self.managers.insert(self.gpa, Manager{
.arena = arena, .arena = arena,
.kind = .{ .enemies = enemies,
.snake = .{ .kind = .snake
.enemies = enemies });
}
}, },
.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,47 +643,80 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul
var enemy_iter = self.enemies.iterator(); var enemy_iter = self.enemies.iterator();
while (enemy_iter.nextItem()) |enemy| { while (enemy_iter.nextItem()) |enemy| {
enemy.target = null; enemy.target = null;
enemy.distance_to_target = null;
enemy.avoidance_group = null;
enemy.avoidance_group_distance = null;
} }
} }
{ {
var manager_iter = self.managers.iterator(); var manager_iter = self.managers.iterator();
while (manager_iter.next()) |tuple| { while (manager_iter.next()) |tuple| {
var destroy: bool = false;
const manager_id = tuple.id; const manager_id = tuple.id;
const manager = tuple.item; const manager = tuple.item;
switch (manager.kind) { var managed: std.ArrayList(*Enemy) = .empty;
.snake => |*snake| {
var index: usize = 0; var index: usize = 0;
while (index < snake.enemies.items.len) { while (index < manager.enemies.items.len) {
const enemy_id = snake.enemies.items[index]; const enemy_id = manager.enemies.items[index];
if (self.enemies.exists(enemy_id)) { if (self.enemies.get(enemy_id)) |enemy| {
try managed.append(frame.arena.allocator(), enemy);
index += 1; index += 1;
} else { } else {
_ = snake.enemies.orderedRemove(index); _ = manager.enemies.orderedRemove(index);
} }
} }
for (1..snake.enemies.items.len) |i| { if (manager.enemies.items.len <= 1) {
const enemy_id = snake.enemies.items[i]; manager.deinit();
const enemy = self.enemies.getAssumeExists(enemy_id); self.managers.removeAssumeExists(manager_id);
const follow_enemy_id = snake.enemies.items[i-1]; continue;
const follow_enemy = self.enemies.getAssumeExists(follow_enemy_id); }
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.target = follow_enemy.kinetic.pos;
enemy.distance_to_target = 20; enemy.distance_to_target = 20;
} }
},
if (snake.enemies.items.len <= 1) { .cluster => {
destroy = true; if (manager.center_enemy != null and !self.enemies.exists(manager.center_enemy.?)) {
} manager.center_enemy = null;
}
} }
if (destroy) { if (manager.center_enemy == null) {
manager.deinit(); if (manager.center_enemy_pos == null) {
self.managers.removeAssumeExists(manager_id); 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;
}
}
}
} }
} }
} }
@ -656,19 +728,36 @@ pub fn tick(self: *CombatScreen, state: *State, frame: *Engine.Frame) !TickResul
enemy.target = self.player.kinetic.pos; enemy.target = self.player.kinetic.pos;
} }
var apply_vel = true; var target = enemy.target.?;
const to_target = enemy.target.?.sub(enemy.kinetic.pos);
if (enemy.distance_to_target) |distance_to_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(); const dir_to_target = to_target.normalized();
enemy.kinetic.vel = dir_to_target.multiplyScalar(enemy.speed); enemy.kinetic.vel = dir_to_target.multiplyScalar(enemy.speed);
} else { } else {
enemy.kinetic.vel = .zero; enemy.kinetic.vel = .zero;
} }
enemy.kinetic.vel = enemy.kinetic.vel.add(avoidance.multiplyScalar(5));
const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size); const enemy_rect = getCenteredRect(enemy.kinetic.pos, enemy.size);
if (enemy_rect.hasOverlap(self.player.getRect())) { if (enemy_rect.hasOverlap(self.player.getRect())) {
var is_invincible = false; var is_invincible = false;