7
Swarm Foraging Without Pheromones
tap to drop a food patch
idle
261 lines ยท vanilla
view source
// Pheromone-free swarm foraging. Reynolds-style local rules + simple
// state machine (roam -> seek food -> return to nest -> deposit -> roam).
// No trail field; agents discover food purely by sensing radius + alignment.
const N_AGENTS = 150;
const SENSE_R = 70; // food detection radius
const NEIGH_R = 28; // alignment / separation radius
const NEIGH_R2 = NEIGH_R * NEIGH_R;
const SEP_R = 14;
const SEP_R2 = SEP_R * SEP_R;
const CELL = NEIGH_R;
const ROAM_SPEED = 55;
const TASK_SPEED = 95; // faster when seeking food or returning home
const MAX_TURN = 4.5; // rad/sec
const ROAM_JITTER = 2.2; // rad/sec random walk amplitude
const NEST_R = 18;
const DEPOSIT_R = 22;
const PICKUP_R = 7;
const FOOD_MAX = 28;
const FOOD_MIN = 12;
const FOOD_PER_BITE = 1;
let agents = [];
let foods = [];
let nest;
let W, H;
let cols, rows, grid;
let collected = 0;
let frameCount = 0;
function spawnFood(x, y, amount) {
foods.push({ x, y, amount: amount ?? (FOOD_MIN + Math.random() * (FOOD_MAX - FOOD_MIN)) });
}
function reseedFood() {
const k = 2 + Math.floor(Math.random() * 2);
for (let i = 0; i < k; i++) {
const margin = 60;
const x = margin + Math.random() * (W - 2 * margin);
const y = margin + Math.random() * (H - 2 * margin);
// keep food at least one nest-radius away from the nest
const dx = x - nest.x, dy = y - nest.y;
if (dx * dx + dy * dy < 120 * 120) {
i--;
continue;
}
spawnFood(x, y);
}
}
function init({ canvas, ctx, width, height }) {
W = width;
H = height;
nest = { x: W * 0.5, y: H * 0.5 };
agents = [];
for (let i = 0; i < N_AGENTS; i++) {
const a = Math.random() * Math.PI * 2;
const r = NEST_R + Math.random() * 40;
agents.push({
x: nest.x + Math.cos(a) * r,
y: nest.y + Math.sin(a) * r,
heading: Math.random() * Math.PI * 2,
carrying: false,
next: null,
});
}
foods = [];
reseedFood();
collected = 0;
frameCount = 0;
cols = Math.max(1, Math.ceil(W / CELL));
rows = Math.max(1, Math.ceil(H / CELL));
grid = new Array(cols * rows);
ctx.fillStyle = '#0a0d14';
ctx.fillRect(0, 0, W, H);
}
function rebuildGrid() {
for (let i = 0; i < grid.length; i++) grid[i] = null;
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
const gx = Math.min(cols - 1, Math.max(0, Math.floor(a.x / CELL)));
const gy = Math.min(rows - 1, Math.max(0, Math.floor(a.y / CELL)));
const k = gy * cols + gx;
a.next = grid[k];
grid[k] = a;
}
}
function nearestFood(x, y) {
let best = -1;
let bestD2 = SENSE_R * SENSE_R;
for (let i = 0; i < foods.length; i++) {
const f = foods[i];
const dx = f.x - x, dy = f.y - y;
const d2 = dx * dx + dy * dy;
if (d2 < bestD2) {
bestD2 = d2;
best = i;
}
}
return best;
}
function angleDelta(target, current) {
let d = target - current;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return d;
}
function tick({ ctx, dt, width, height, input }) {
if (dt > 0.05) dt = 0.05;
frameCount++;
if (width !== W || height !== H) {
W = width; H = height;
nest.x = W * 0.5; nest.y = H * 0.5;
cols = Math.max(1, Math.ceil(W / CELL));
rows = Math.max(1, Math.ceil(H / CELL));
grid = new Array(cols * rows);
}
// Click drops a new food patch at the cursor.
for (const c of input.consumeClicks()) {
spawnFood(c.x, c.y, FOOD_MAX);
}
rebuildGrid();
// ---- agent update ----
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
let desiredHeading = a.heading;
let speed = ROAM_SPEED;
let lockedTarget = false;
if (a.carrying) {
// Head straight home.
desiredHeading = Math.atan2(nest.y - a.y, nest.x - a.x);
speed = TASK_SPEED;
lockedTarget = true;
const dx = nest.x - a.x, dy = nest.y - a.y;
if (dx * dx + dy * dy < DEPOSIT_R * DEPOSIT_R) {
a.carrying = false;
collected++;
// turn around so we don't immediately re-enter the nest
a.heading += Math.PI + (Math.random() - 0.5) * 0.6;
desiredHeading = a.heading;
}
} else {
// Look for food within sense radius.
const fi = nearestFood(a.x, a.y);
if (fi >= 0) {
const f = foods[fi];
desiredHeading = Math.atan2(f.y - a.y, f.x - a.x);
speed = TASK_SPEED;
lockedTarget = true;
const dx = f.x - a.x, dy = f.y - a.y;
if (dx * dx + dy * dy < PICKUP_R * PICKUP_R) {
a.carrying = true;
f.amount -= FOOD_PER_BITE;
if (f.amount <= 0) foods.splice(fi, 1);
a.heading = Math.atan2(nest.y - a.y, nest.x - a.x);
desiredHeading = a.heading;
}
} else {
// Roam: random-walk heading.
a.heading += (Math.random() - 0.5) * ROAM_JITTER * dt;
desiredHeading = a.heading;
}
}
// ---- Reynolds: gentle alignment + separation from neighbors. No cohesion. ----
const gx = Math.min(cols - 1, Math.max(0, Math.floor(a.x / CELL)));
const gy = Math.min(rows - 1, Math.max(0, Math.floor(a.y / CELL)));
let alignVX = 0, alignVY = 0, nAlign = 0;
let sepX = 0, sepY = 0;
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
const nx = gx + ox, ny = gy + oy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
let o = grid[ny * cols + nx];
while (o) {
if (o !== a) {
const dx = o.x - a.x, dy = o.y - a.y;
const d2 = dx * dx + dy * dy;
if (d2 < NEIGH_R2) {
alignVX += Math.cos(o.heading);
alignVY += Math.sin(o.heading);
nAlign++;
if (d2 < SEP_R2 && d2 > 0.0001) {
sepX -= dx / d2;
sepY -= dy / d2;
}
}
}
o = o.next;
}
}
}
// Blend Reynolds influence into desired heading.
let bx = Math.cos(desiredHeading);
let by = Math.sin(desiredHeading);
const alignW = lockedTarget ? 0.15 : 0.6;
const sepW = lockedTarget ? 60 : 120;
if (nAlign > 0) {
bx += (alignVX / nAlign) * alignW;
by += (alignVY / nAlign) * alignW;
}
bx += sepX * sepW;
by += sepY * sepW;
desiredHeading = Math.atan2(by, bx);
// Clamp turn rate.
const dH = angleDelta(desiredHeading, a.heading);
const maxStep = MAX_TURN * dt;
if (dH > maxStep) a.heading += maxStep;
else if (dH < -maxStep) a.heading -= maxStep;
else a.heading = desiredHeading;
// Move.
a.x += Math.cos(a.heading) * speed * dt;
a.y += Math.sin(a.heading) * speed * dt;
// Soft walls: reflect heading instead of wrapping; the world is bounded.
if (a.x < 4) { a.x = 4; a.heading = Math.PI - a.heading; }
else if (a.x > W - 4) { a.x = W - 4; a.heading = Math.PI - a.heading; }
if (a.y < 4) { a.y = 4; a.heading = -a.heading; }
else if (a.y > H - 4) { a.y = H - 4; a.heading = -a.heading; }
}
// Reseed if everything has been eaten.
if (foods.length === 0) reseedFood();
// ---- render ----
ctx.fillStyle = 'rgba(10,13,20,0.30)';
ctx.fillRect(0, 0, W, H);
// Nest: concentric rings + faint glow.
ctx.save();
const ring = (NEST_R + Math.sin(frameCount * 0.06) * 2.5);
const grad = ctx.createRadialGradient(nest.x, nest.y, 0, nest.x, nest.y, ring * 3);
grad.addColorStop(0, 'rgba(120, 180, 255, 0.35)');
grad.addColorStop(1, 'rgba(120, 180, 255, 0.0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(nest.x, nest.y, ring * 3, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(180,210,255,0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(nest.x, nest.y, NEST_R, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(180,210,255,0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(nest.x, nest.y, NEST_R - 6, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
// Food patches: size proportional to remaining amount.
for (let i = 0; i < foods.length; i++) {
const f = foods[i];
const r = 4 + Math.sqrt(Math.max(0, f.amount)) * 2.2;
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(120,255,160,0.95)');
g.addColorStop(1, 'rgba(60,160,90,0.0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(160,255,180,0.7)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.stroke();
}
// Agents: small oriented triangles, tinted by state.
for (let i = 0; i < agents.length; i++) {
const a = agents[i];
const cs = Math.cos(a.heading);
const sn = Math.sin(a.heading);
if (a.carrying) {
ctx.fillStyle = '#ffd34d';
} else {
ctx.fillStyle = '#9aa3b2';
}
ctx.beginPath();
ctx.moveTo(a.x + cs * 5, a.y + sn * 5);
ctx.lineTo(a.x + (-cs * 3 - sn * 2.4), a.y + (-sn * 3 + cs * 2.4));
ctx.lineTo(a.x + (-cs * 3 + sn * 2.4), a.y + (-sn * 3 - cs * 2.4));
ctx.closePath();
ctx.fill();
}
// HUD
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(8, 8, 168, 44);
ctx.fillStyle = '#e8edf5';
ctx.font = '12px system-ui, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText(`collected: ${collected}`, 16, 14);
ctx.fillText(`active patches: ${foods.length}`, 16, 30);
}
Comments (0)
Log in to comment.