class Soldier {
constructor(team, x, y, isPlayer) {
this.team = team;
this.pos = createVector(x, y);
this.radius = SOLDIER_RADIUS;
this.isPlayer = isPlayer;
this.alive = true;
if (isPlayer) {
this.speed = PLAYER_SPEED;
this.selectedWeapon = WEAPONS.find(w => w.name === 'Pistol');
this.shootCooldown = random(0.1, 0.3);
this.engagementDist = Infinity;
this.aimJitterStdDev = 0;
this.repathTimerMin = 0;
this.repathTimerMax = 0;
} else {
const difficulty = BOT_DIFFICULTY_PARAMS[currentRankIndex];
this.speed = difficulty.speed;
this.shootCooldown = random(difficulty.fireCooldownMin, difficulty.fireCooldownMax);
this.engagementDist = difficulty.engagementDist;
this.aimJitterStdDev = difficulty.aimJitterStdDev;
this.repathTimerMin = difficulty.repathTimerMin;
this.repathTimerMax = difficulty.repathTimerMax;
this.botBulletSpeed = 750;
this.botBulletCount = 1;
this.botSpread = 0.03;
}
this.angle = (team === TEAM_ATTACK) ? 0 : PI;
this.path = null;
this.pathIndex = 0;
this.repathTimer = random(this.repathTimerMin, this.repathTimerMax);
}
update(dt) {
if (!this.alive) return;
if (this.isPlayer) {
this.updatePlayer(dt);
} else {
this.updateAI(dt);
}
}
updatePlayer(dt) {
let dx = 0;
let dy = 0;
if (keyIsDown(87) || keyIsDown(UP_ARROW)) dy -= 1;
if (keyIsDown(83) || keyIsDown(DOWN_ARROW)) dy += 1;
if (keyIsDown(65) || keyIsDown(LEFT_ARROW)) dx -= 1;
if (keyIsDown(68) || keyIsDown(RIGHT_ARROW)) dx += 1;
const len = Math.hypot(dx, dy);
if (len > 0) {
dx /= len;
dy /= len;
this.move(dx * this.speed * dt, dy * this.speed * dt);
}
const worldMouse = screenToWorld(mouseX, mouseY);
this.angle = Math.atan2(worldMouse.y - this.pos.y, worldMouse.x - this.pos.x);
if (this.shootCooldown > 0) {
this.shootCooldown -= dt;
}
if (mouseIsPressed && mouseButton === LEFT) {
this.tryShootTowards(
worldMouse.x, worldMouse.y,
this.selectedWeapon.fireCooldown,
this.selectedWeapon.bulletSpeed,
this.selectedWeapon.bulletCount,
this.selectedWeapon.spread
);
}
}
updateAI(dt) {
if (this.shootCooldown > 0) {
this.shootCooldown -= dt;
}
const enemies = (this.team === TEAM_ATTACK) ? defenders : attackers;
let target = null;
let minDist = this.engagementDist;
for (let e of enemies) {
if (!e.alive) continue;
const d = p5.Vector.dist(this.pos, e.pos);
if (d < minDist) {
minDist = d;
target = e;
}
}
const distToBomb = bomb ? p5.Vector.dist(this.pos, bomb.pos) : Infinity;
let moveTarget = null;
if (this.team === TEAM_DEFEND) {
if (bomb) {
const guardRadius = bomb.radius * 1.4;
if (distToBomb > guardRadius) {
moveTarget = bomb.pos;
} else if (target && minDist < this.engagementDist * 0.6 && hasLineOfSight(this.pos.x, this.pos.y, target.pos.x, target.pos.y)) {
moveTarget = target.pos;
} else {
moveTarget = bomb.pos;
}
}
} else {
if (!bomb) {
if (target) moveTarget = target.pos;
} else if (bomb.captured) {
if (target) moveTarget = target.pos;
} else {
const enemiesInBomb = areEnemiesInBombZone(TEAM_ATTACK);
if (!enemiesInBomb) {
moveTarget = bomb.pos;
} else {
if (target && minDist < this.engagementDist && hasLineOfSight(this.pos.x, this.pos.y, target.pos.x, target.pos.y)) {
moveTarget = target.pos;
} else {
if (random() < 0.05 * dt) {
const randomOffset = p5.Vector.random2D().mult(random(bomb.radius * 0.8, bomb.radius * 1.8));
let candidatePos = p5.Vector.add(bomb.pos, randomOffset);
candidatePos = findFreeSpot(
candidatePos.x - SOLDIER_RADIUS, candidatePos.x + SOLDIER_RADIUS,
candidatePos.y - SOLDIER_RADIUS, candidatePos.y + SOLDIER_RADIUS,
SOLDIER_RADIUS
);
moveTarget = candidatePos;
} else {
moveTarget = bomb.pos;
}
}
}
}
}
if (moveTarget) {
this.followPathTowards(moveTarget, dt);
}
if (target) {
const tx = target.pos.x - this.pos.x;
const ty = target.pos.y - this.pos.y;
this.angle = Math.atan2(ty, tx);
if (minDist < this.engagementDist && this.shootCooldown <= 0) {
if (hasLineOfSight(this.pos.x, this.pos.y, target.pos.x, target.pos.y)) {
const baseAngle = this.angle;
const aimJitter = randomGaussian(0, this.aimJitterStdDev);
const shotAngle = baseAngle + aimJitter;
const nx = Math.cos(shotAngle);
const ny = Math.sin(shotAngle);
this.fireBullet(nx, ny, this.botBulletSpeed, this.botBulletCount, this.botSpread);
this.shootCooldown = random(this.fireCooldownMin, this.fireCooldownMax);
}
}
}
}
followPathTowards(targetPos, dt) {
if (!targetPos) return;
if (navGrid.length === 0) {
const vx = targetPos.x - this.pos.x;
const vy = targetPos.y - this.pos.y;
const len = Math.hypot(vx, vy);
if (len > 5) {
this.move((vx / len) * this.speed * dt, (vy / len) * this.speed * dt);
}
return;
}
this.repathTimer -= dt;
const goalDist = p5.Vector.dist(this.pos, targetPos);
const needNewPath =
!this.path ||
this.pathIndex >= this.path.length ||
this.repathTimer <= 0 ||
p5.Vector.dist(this.path[this.path.length - 1], targetPos) > NAV_CELL_SIZE * 1.5;
if (needNewPath) {
const newPath = findPath(this.pos, targetPos);
if (newPath && newPath.length > 0) {
this.path = newPath;
this.pathIndex = 0;
} else {
this.path = null;
this.pathIndex = 0;
}
this.repathTimer = random(this.repathTimerMin, this.repathTimerMax);
}
if (this.path && this.path.length > 0) {
while (this.pathIndex < this.path.length) {
const wp = this.path[this.pathIndex];
const dx = wp.x - this.pos.x;
const dy = wp.y - this.pos.y;
const d = Math.hypot(dx, dy);
if (d < 10 && this.pathIndex < this.path.length - 1) {
this.pathIndex++;
continue;
}
if (d > 2) {
this.move((dx / d) * this.speed * dt, (dy / d) * this.speed * dt);
}
break;
}
} else {
const vx = targetPos.x - this.pos.x;
const vy = targetPos.y - this.pos.y;
const len = Math.hypot(vx, vy);
if (len > 5) {
this.move((vx / len) * this.speed * dt, (vy / len) * this.speed * dt);
}
}
}
move(dx, dy) {
this.pos.x += dx;
resolveCircleVsObstacles(this);
this.pos.x = constrain(this.pos.x, this.radius, MAP_W - this.radius);
this.pos.y += dy;
resolveCircleVsObstacles(this);
this.pos.y = constrain(this.pos.y, this.radius, MAP_H - this.radius);
}
tryShootTowards(tx, ty, cooldown, bulletSpeed, bulletCount, spread) {
if (this.shootCooldown > 0) return;
const dirX = tx - this.pos.x;
const dirY = ty - this.pos.y;
const len = Math.hypot(dirX, dirY);
if (len < 1) return;
const baseNx = dirX / len;
const baseNy = dirY / len;
for (let i = 0; i < bulletCount; i++) {
const bulletAngle = Math.atan2(baseNy, baseNx) + random(-spread, spread);
const nx = Math.cos(bulletAngle);
const ny = Math.sin(bulletAngle);
this.fireBullet(nx, ny, bulletSpeed);
}
this.shootCooldown = cooldown;
}
fireBullet(nx, ny, bulletSpeed) {
const startX = this.pos.x + nx * (this.radius + 6);
const startY = this.pos.y + ny * (this.radius + 6);
bullets.push(new Bullet(startX, startY, nx, ny, this.team, bulletSpeed));
}
draw() {
push();
translate(this.pos.x, this.pos.y);
rotate(this.angle);
noStroke();
if (this.team === TEAM_ATTACK) {
fill(220, 60, 60);
} else {
fill(60, 130, 255);
}
ellipse(0, 0, this.radius * 2, this.radius * 2);
fill(40);
arc(0, 0, this.radius * 2, this.radius * 2, -HALF_PI, HALF_PI);
fill(15);
rectMode(CENTER);
rect(this.radius + 6, 0, 20, 4, 2);
pop();
}
}