๐ŸŽฎArcadeLab

Boids Flock

by ArcadeLab
440 lines11.7 KB
โ–ถ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Boids Flock</title>
<style>
  :root {
    --bg: #06121a;
    --panel: rgba(8, 28, 38, 0.82);
    --line: rgba(74, 222, 222, 0.22);
    --teal: #38e1d4;
    --teal-soft: #7ff0e7;
    --text: #d6f3f1;
    --muted: #6f9c9c;
  }
  * { box-sizing: border-box; }
  html, body {
    margin: 0;
    height: 100%;
    overflow: hidden;
    background: var(--bg);
    color: var(--text);
    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    -webkit-tap-highlight-color: transparent;
  }
  #stage {
    position: fixed;
    inset: 0;
    background:
      radial-gradient(900px 600px at 70% 20%, rgba(56, 225, 212, 0.10), transparent 60%),
      radial-gradient(700px 500px at 15% 95%, rgba(56, 225, 212, 0.07), transparent 60%),
      var(--bg);
  }
  #c { display: block; width: 100%; height: 100%; touch-action: none; }

  #hud {
    position: fixed;
    top: 0; left: 0; right: 0;
    padding: 14px 16px 0;
    pointer-events: none;
    display: flex;
    justify-content: space-between;
    gap: 12px;
    animation: fade 0.7s ease both;
  }
  #title h1 {
    margin: 0;
    font-size: clamp(17px, 2.4vw, 22px);
    font-weight: 700;
    letter-spacing: 0.2px;
  }
  #title p {
    margin: 3px 0 0;
    font-size: clamp(12px, 1.6vw, 13.5px);
    color: var(--muted);
    max-width: 60vw;
  }
  #count {
    font-variant-numeric: tabular-nums;
    font-size: clamp(12px, 1.6vw, 13.5px);
    color: var(--teal-soft);
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: 12px;
    padding: 7px 11px;
    white-space: nowrap;
    align-self: flex-start;
  }

  #panel {
    position: fixed;
    left: 50%;
    bottom: max(14px, env(safe-area-inset-bottom));
    transform: translateX(-50%);
    width: min(520px, calc(100vw - 24px));
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: 16px;
    padding: 14px 16px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    box-shadow: 0 14px 40px rgba(0, 0, 0, 0.45);
    animation: rise 0.7s cubic-bezier(0.2, 0.8, 0.2, 1) both;
  }
  .row {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 9px 0;
  }
  .row label {
    flex: 0 0 84px;
    font-size: 13px;
    color: var(--teal-soft);
  }
  .row .val {
    flex: 0 0 34px;
    text-align: right;
    font-size: 12.5px;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
  }
  input[type=range] {
    flex: 1 1 auto;
    -webkit-appearance: none;
    appearance: none;
    height: 6px;
    border-radius: 999px;
    background: linear-gradient(90deg, var(--teal), rgba(56, 225, 212, 0.18));
    outline: none;
    margin: 0;
  }
  input[type=range]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 20px; height: 20px;
    border-radius: 50%;
    background: #eafffd;
    border: 3px solid var(--teal);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
    cursor: pointer;
  }
  input[type=range]::-moz-range-thumb {
    width: 20px; height: 20px;
    border-radius: 50%;
    background: #eafffd;
    border: 3px solid var(--teal);
    cursor: pointer;
  }
  #panel .foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    margin-top: 12px;
    flex-wrap: wrap;
  }
  #panel .tip {
    font-size: 11.5px;
    color: var(--muted);
  }
  button {
    font-family: inherit;
    font-size: 13.5px;
    font-weight: 650;
    color: #06121a;
    background: linear-gradient(180deg, var(--teal-soft), var(--teal));
    border: none;
    border-radius: 12px;
    padding: 9px 16px;
    cursor: pointer;
    transition: transform 0.12s ease, filter 0.12s ease;
  }
  button:hover { filter: brightness(1.08); }
  button:active { transform: scale(0.95); }

  @keyframes rise { from { opacity: 0; transform: translateX(-50%) translateY(16px); } }
  @keyframes fade { from { opacity: 0; } }
</style>
</head>
<body>
<div id="stage">
  <canvas id="c"></canvas>
</div>

<div id="hud">
  <div id="title">
    <h1>๐Ÿฆ Boids Flock</h1>
    <p>Tune the three rules below. Tap the sky to scatter the flock.</p>
  </div>
  <div id="count">โ€” boids</div>
</div>

<div id="panel">
  <div class="row">
    <label for="sep">Separation</label>
    <input type="range" id="sep" min="0" max="200" value="100">
    <span class="val" id="sepv">1.0</span>
  </div>
  <div class="row">
    <label for="ali">Alignment</label>
    <input type="range" id="ali" min="0" max="200" value="100">
    <span class="val" id="aliv">1.0</span>
  </div>
  <div class="row">
    <label for="coh">Cohesion</label>
    <input type="range" id="coh" min="0" max="200" value="100">
    <span class="val" id="cohv">1.0</span>
  </div>
  <div class="foot">
    <span class="tip">Triangles point where they fly ยท hue maps to speed</span>
    <button id="reset">Reset</button>
  </div>
</div>

<script>
(function () {
  "use strict";

  var canvas = document.getElementById("c");
  var ctx = canvas ? canvas.getContext("2d") : null;
  if (!ctx) return;

  var DPR = Math.min(window.devicePixelRatio || 1, 2);
  var W = 1, H = 1;

  function resize() {
    var r = canvas.getBoundingClientRect();
    W = Math.max(1, r.width);
    H = Math.max(1, r.height);
    canvas.width = Math.round(W * DPR);
    canvas.height = Math.round(H * DPR);
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  window.addEventListener("resize", resize);

  // ----- simulation params -----
  var sepEl = document.getElementById("sep");
  var aliEl = document.getElementById("ali");
  var cohEl = document.getElementById("coh");
  var sepv = document.getElementById("sepv");
  var aliv = document.getElementById("aliv");
  var cohv = document.getElementById("cohv");
  var countEl = document.getElementById("count");

  var params = { sep: 1, ali: 1, coh: 1 };

  function syncParams() {
    params.sep = (+sepEl.value) / 100;
    params.ali = (+aliEl.value) / 100;
    params.coh = (+cohEl.value) / 100;
    sepv.textContent = params.sep.toFixed(1);
    aliv.textContent = params.ali.toFixed(1);
    cohv.textContent = params.coh.toFixed(1);
  }
  sepEl.addEventListener("input", syncParams);
  aliEl.addEventListener("input", syncParams);
  cohEl.addEventListener("input", syncParams);
  syncParams();

  // tuning constants
  var PERCEPTION = 56;       // neighbour radius for align/cohesion
  var SEP_RADIUS = 26;       // close-range avoidance radius
  var MAX_SPEED = 2.7;
  var MIN_SPEED = 1.3;
  var MAX_FORCE = 0.06;
  var SCARE_RADIUS = 130;
  var SCARE_FORCE = 0.9;

  var boids = [];
  var BOID_COUNT = 200;

  function rand(a, b) { return a + Math.random() * (b - a); }

  function makeBoid() {
    var a = rand(0, Math.PI * 2);
    var s = rand(MIN_SPEED, MAX_SPEED);
    return {
      x: rand(0, W || 800),
      y: rand(0, H || 600),
      vx: Math.cos(a) * s,
      vy: Math.sin(a) * s,
      hue: 175
    };
  }

  function initBoids() {
    boids.length = 0;
    for (var i = 0; i < BOID_COUNT; i++) boids.push(makeBoid());
  }

  // ----- pointer scatter -----
  var scare = { active: false, x: 0, y: 0 };

  function pointerPos(e) {
    var r = canvas.getBoundingClientRect();
    return { x: e.clientX - r.left, y: e.clientY - r.top };
  }
  canvas.addEventListener("pointerdown", function (e) {
    e.preventDefault();
    var p = pointerPos(e);
    scare.active = true; scare.x = p.x; scare.y = p.y;
  });
  canvas.addEventListener("pointermove", function (e) {
    if (!scare.active) return;
    e.preventDefault();
    var p = pointerPos(e);
    scare.x = p.x; scare.y = p.y;
  });
  function endScare() { scare.active = false; }
  canvas.addEventListener("pointerup", endScare);
  canvas.addEventListener("pointercancel", endScare);
  canvas.addEventListener("pointerleave", endScare);

  document.getElementById("reset").addEventListener("click", function () {
    sepEl.value = 100; aliEl.value = 100; cohEl.value = 100;
    syncParams();
    initBoids();
  });

  function limit(x, y, max) {
    var m = Math.sqrt(x * x + y * y);
    if (m > max && m > 0) { var k = max / m; return [x * k, y * k]; }
    return [x, y];
  }

  function step() {
    var n = boids.length;
    for (var i = 0; i < n; i++) {
      var b = boids[i];
      var sx = 0, sy = 0;            // separation
      var ax = 0, ay = 0, ac = 0;   // alignment accumulators
      var cx = 0, cy = 0, cc = 0;   // cohesion accumulators

      for (var j = 0; j < n; j++) {
        if (j === i) continue;
        var o = boids[j];
        var dx = b.x - o.x, dy = b.y - o.y;
        var d2 = dx * dx + dy * dy;
        if (d2 > PERCEPTION * PERCEPTION || d2 === 0) continue;
        var d = Math.sqrt(d2);
        if (d < SEP_RADIUS) {
          sx += dx / d / d;
          sy += dy / d / d;
        }
        ax += o.vx; ay += o.vy; ac++;
        cx += o.x; cy += o.y; cc++;
      }

      var fx = 0, fy = 0, s;

      // separation
      if (sx !== 0 || sy !== 0) {
        s = limit(sx, sy, MAX_SPEED);
        s = limit(s[0] * 60 - b.vx, s[1] * 60 - b.vy, MAX_FORCE);
        fx += s[0] * 1.6 * params.sep;
        fy += s[1] * 1.6 * params.sep;
      }
      // alignment
      if (ac > 0) {
        s = limit(ax / ac - b.vx, ay / ac - b.vy, MAX_FORCE);
        fx += s[0] * params.ali;
        fy += s[1] * params.ali;
      }
      // cohesion
      if (cc > 0) {
        s = limit((cx / cc - b.x) * 0.045 - b.vx, (cy / cc - b.y) * 0.045 - b.vy, MAX_FORCE);
        fx += s[0] * params.coh;
        fy += s[1] * params.coh;
      }
      // scatter from pointer
      if (scare.active) {
        var px = b.x - scare.x, py = b.y - scare.y;
        var pd = Math.sqrt(px * px + py * py);
        if (pd < SCARE_RADIUS && pd > 0) {
          var pw = (1 - pd / SCARE_RADIUS) * SCARE_FORCE;
          fx += (px / pd) * pw;
          fy += (py / pd) * pw;
        }
      }

      b.fx = fx; b.fy = fy;
    }

    // integrate
    for (var k = 0; k < n; k++) {
      var bb = boids[k];
      bb.vx += bb.fx; bb.vy += bb.fy;
      var sp = Math.sqrt(bb.vx * bb.vx + bb.vy * bb.vy);
      if (sp > MAX_SPEED) { var d1 = MAX_SPEED / sp; bb.vx *= d1; bb.vy *= d1; }
      else if (sp < MIN_SPEED && sp > 0) { var d2b = MIN_SPEED / sp; bb.vx *= d2b; bb.vy *= d2b; }
      bb.x += bb.vx; bb.y += bb.vy;

      if (bb.x < -8) bb.x = W + 8; else if (bb.x > W + 8) bb.x = -8;
      if (bb.y < -8) bb.y = H + 8; else if (bb.y > H + 8) bb.y = -8;

      // hue maps to speed: slow = teal, fast = pale cyan-green
      var t = (sp - MIN_SPEED) / (MAX_SPEED - MIN_SPEED);
      bb.hue = 188 - t * 56;
    }
  }

  function draw() {
    // soft trailing fade for motion blur
    ctx.fillStyle = "rgba(6, 18, 26, 0.34)";
    ctx.fillRect(0, 0, W, H);

    for (var i = 0; i < boids.length; i++) {
      var b = boids[i];
      var ang = Math.atan2(b.vy, b.vx);
      ctx.save();
      ctx.translate(b.x, b.y);
      ctx.rotate(ang);
      ctx.beginPath();
      ctx.moveTo(7, 0);
      ctx.lineTo(-5, 3.4);
      ctx.lineTo(-5, -3.4);
      ctx.closePath();
      ctx.fillStyle = "hsl(" + b.hue.toFixed(0) + ", 78%, 62%)";
      ctx.fill();
      ctx.restore();
    }

    // glow ring while scattering
    if (scare.active) {
      ctx.beginPath();
      ctx.arc(scare.x, scare.y, SCARE_RADIUS, 0, Math.PI * 2);
      ctx.strokeStyle = "rgba(255, 130, 120, 0.45)";
      ctx.lineWidth = 2;
      ctx.stroke();
    }
  }

  var lastCount = -1;
  function updateCount() {
    if (boids.length !== lastCount) {
      lastCount = boids.length;
      countEl.textContent = lastCount + " boids";
    }
  }

  function loop() {
    step();
    draw();
    updateCount();
    requestAnimationFrame(loop);
  }

  // boot
  resize();
  initBoids();
  ctx.fillStyle = "#06121a";
  ctx.fillRect(0, 0, W, H);
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>

Game Source: Boids Flock

Creator: ArcadeLab

Libraries: none

Complexity: complex (440 lines, 11.7 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: boids-flock-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.