preview
WASD • Mouse
๐Ÿ”ฅ slow (GPU melter) ๐Ÿ“ฑ RIP mobile users
source
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  <title>Shepherd Survivors</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    html, body { width: 100%; height: 100%; overflow: hidden; }
    body {
      background: #1a2a1a;
      font-family: system-ui, sans-serif;
      color: #e0e8e0;
    }
    #game-container {
      position: relative;
      width: 100vw;
      height: 100vh;
      overflow: hidden;
    }
    #canvas {
      display: block;
      width: 100%;
      height: 100%;
      cursor: crosshair;
    }
    #hud {
      position: absolute;
      top: 8px;
      left: 8px;
      padding: 6px 10px;
      background: rgba(0,0,0,0.6);
      border-radius: 6px;
      font-size: 14px;
      z-index: 10;
    }
    #merchant-panel {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      padding: 20px;
      background: #2a3a2a;
      border: 3px solid #6a8a6a;
      border-radius: 12px;
      display: none;
      z-index: 20;
      min-width: 200px;
    }
    #merchant-panel.visible { display: block; }
    #merchant-panel h3 { margin-bottom: 12px; font-size: 16px; }
    #merchant-panel button {
      display: block;
      width: 100%;
      margin: 6px 0;
      padding: 10px;
      font-size: 14px;
      cursor: pointer;
      background: #4a6a4a;
      border: none;
      border-radius: 6px;
      color: #e0e8e0;
    }
    #merchant-panel button:hover { background: #5a7a5a; }
    #merchant-panel button:disabled { opacity: 0.5; cursor: not-allowed; }
    #instructions {
      position: absolute;
      bottom: 8px;
      left: 8px;
      font-size: 12px;
      color: #8a9a8a;
      z-index: 10;
    }
  </style>
</head>
<body>
  <div id="game-container">
    <canvas id="canvas" tabindex="0"></canvas>
    <div id="hud">
      <span>๐Ÿ‘ Sheep: <span id="sheep-count">0</span></span>
      <span style="margin-left: 12px;">๐Ÿงถ Wool: <span id="wool-kg">0</span> kg</span>
    </div>
    <div id="merchant-panel">
      <h3>๐Ÿงณ Wandering Merchant</h3>
      <button id="sell-wool">Sell all wool (get sheep)</button>
      <button id="buy-sheep">Buy sheep (cost: wool)</button>
      <button id="close-merchant">Close</button>
    </div>
    <div id="instructions">
      WASD move ยท Space herd ยท Left-click smack (aim at mouse)
    </div>
  </div>
  <script>
/* Shepherd Survivors - Game Constants (tweak these for balance) */
const CONFIG = {
  CANVAS_WIDTH: 800,
  CANVAS_HEIGHT: 600,
  TILE_SIZE: 40,
  GRASS_MAX: 10,
  GRASS_EAT_RATE: 100,
  GRASS_REGEN_RATE: 0.01,
  /* Below this, treat tile as depleted (regen trickle would otherwise trap sheep) */
  GRASS_MIN_TO_EAT: 1,
  GRASS_BLADES_PER_TILE: 10,
  GRASS_NOISE_FREQ: 0.08,
  GRASS_NOISE_POWER: 3.0,
  GRASS_FBM_OCTAVES: 4,
  GRASS_FBM_LACUNARITY: 2,
  GRASS_FBM_GAIN: 0.5,
  GRASS_BLADE_MAX_LENGTH: 16,

  PLAYER_SPEED: 140,
  SHEEP_SPEED: 40,
  SHEEP_FLEE_SPEED: 90,
  WOLF_SPEED: 120,
  MERCHANT_SPEED: 40,

  HERD_RADIUS: 300,
  HERD_DURATION_MS: 2400,
  STICK_ARC_DEGREES: 100,
  STICK_RANGE: 70,
  STICK_COOLDOWN_MS: 400,
  STICK_DAMAGE: 1,

  SHEEP_HP: 3,
  WOLF_DAMAGE: 0.5,
  WOLF_DAMAGE_INTERVAL_MS: 500,

  SHEEP_ROAM_RADIUS: 10,
  SHEEP_TARGET_RECHECK: 0.12,
  SHEEP_WOLF_DETECT_RANGE: 120,
  SHEEP_WOOL_MIN: 0.5,
  SHEEP_WOOL_MAX: 3,
  /* Wool gained per 1 grass unit consumed (no hidden timers) */
  SHEEP_WOOL_PER_GRASS: 0.1,

  VELOCITY_DAMPING: 0.1,

  WOOL_PICKUP_RADIUS: 30,
  MERCHANT_WOOL_PER_SHEEP: 5,
  MERCHANT_INTERACTION_RADIUS: 60,

  START_SHEEP: 3,
  WOLF_SPAWN_INTERVAL_MS: 6000,
  MERCHANT_SPAWN_INTERVAL_MS: 45000,

  DEBUG_HITBOXES: false
};

/* Derived (updated on resize) */
let COLS, ROWS;

/* Emojis */
const E = {
  shepherd: '๐Ÿง‘โ€๐ŸŒพ',
  sheep: '๐Ÿ‘',
  wolf: '๐Ÿบ',
  merchant: '๐Ÿงณ',
  wool: '๐Ÿงถ'
};

/* Game state */
let canvas, ctx;
let keys = {};
let mouse = { x: 0, y: 0 };

let player = {
  x: CONFIG.CANVAS_WIDTH / 2 - 20,
  y: CONFIG.CANVAS_HEIGHT / 2 - 20
};

let grassGrid = [];
let sheep = [];
let wolves = [];
let woolDrops = [];
let merchant = null;
let merchantPanelVisible = false;
let merchantCloseCooldown = 0;

let stickCooldownUntil = 0;
let lastWolfDamageTime = {};
let wolfSpawnTimer = 0;
let merchantSpawnTimer = 0;
let playerWoolKg = 0;

function resizeCanvas() {
  CONFIG.CANVAS_WIDTH = window.innerWidth;
  CONFIG.CANVAS_HEIGHT = window.innerHeight;
  canvas.width = CONFIG.CANVAS_WIDTH;
  canvas.height = CONFIG.CANVAS_HEIGHT;
  COLS = Math.ceil(CONFIG.CANVAS_WIDTH / CONFIG.TILE_SIZE);
  ROWS = Math.ceil(CONFIG.CANVAS_HEIGHT / CONFIG.TILE_SIZE);
  initGrass();
  player.x = Math.min(player.x, CONFIG.CANVAS_WIDTH - 40);
  player.y = Math.min(player.y, CONFIG.CANVAS_HEIGHT - 40);
  sheep.forEach(s => {
    s.x = Math.min(s.x, CONFIG.CANVAS_WIDTH - 32);
    s.y = Math.min(s.y, CONFIG.CANVAS_HEIGHT - 32);
  });
  wolves.forEach(w => {
    w.x = Math.min(w.x, CONFIG.CANVAS_WIDTH - 48);
    w.y = Math.min(w.y, CONFIG.CANVAS_HEIGHT - 48);
  });
}

/* Init */
function init() {
  canvas = document.getElementById('canvas');
  ctx = canvas.getContext('2d');
  resizeCanvas();
  window.addEventListener('resize', resizeCanvas);

  if (sheep.length === 0) spawnSheep(CONFIG.START_SHEEP);
  /* Spawn player in same place as sheep (center + small offset to not overlap) */
  const spawnX = CONFIG.CANVAS_WIDTH / 2 - 20;
  const spawnY = CONFIG.CANVAS_HEIGHT / 2 - 20;
  player.x = spawnX;
  player.y = spawnY;

  document.addEventListener('keydown', e => {
    if (!keys[e.key.toLowerCase()] && e.key === ' ' && !merchantPanelVisible) herdSheep();
    keys[e.key.toLowerCase()] = true;
    if (e.key === ' ') e.preventDefault();
  });
  document.addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
  canvas.addEventListener('mousemove', e => {
    const rect = canvas.getBoundingClientRect();
    mouse.x = (e.clientX - rect.left) * (canvas.width / rect.width);
    mouse.y = (e.clientY - rect.top) * (canvas.height / rect.height);
  });
  canvas.addEventListener('click', onStickSwing);

  document.getElementById('sell-wool').addEventListener('click', sellWool);
  document.getElementById('buy-sheep').addEventListener('click', buySheep);
  document.getElementById('close-merchant').addEventListener('click', closeMerchant);

  canvas.focus();
  requestAnimationFrame(gameLoop);
}

function initGrass() {
  grassGrid = [];
  const f = CONFIG.GRASS_NOISE_FREQ;
  const p = CONFIG.GRASS_NOISE_POWER;
  const oct = CONFIG.GRASS_FBM_OCTAVES;
  const lac = CONFIG.GRASS_FBM_LACUNARITY;
  const gain = CONFIG.GRASS_FBM_GAIN;
  for (let row = 0; row < ROWS; row++) {
    grassGrid[row] = [];
    for (let col = 0; col < COLS; col++) {
      const nx = col * f;
      const ny = row * f;
      const n = Math.pow(fbm2d(nx, ny, oct, lac, gain), p);
      const grassMax = Math.round(CONFIG.GRASS_MAX * n);
      const initFill = Math.pow(fbm2d(nx, ny, oct, lac, gain, 200, 0), p);
      const grassAmount = Math.round(grassMax * initFill);
      grassGrid[row].push({ grassAmount, grassMax });
    }
  }
}

function spawnSheep(n) {
  const centerX = CONFIG.CANVAS_WIDTH / 2 - 20;
  const centerY = CONFIG.CANVAS_HEIGHT / 2 - 20;
  for (let i = 0; i < n; i++) {
    sheep.push({
      x: centerX + (Math.random() - 0.5) * 100,
      y: centerY + (Math.random() - 0.5) * 100,
      wool: CONFIG.SHEEP_WOOL_MIN + Math.random() * 0.5,
      hp: CONFIG.SHEEP_HP,
      targetTile: null,
      vx: 0,
      vy: 0
    });
  }
}

function tileAt(wx, wy) {
  const col = Math.floor(wx / CONFIG.TILE_SIZE);
  const row = Math.floor(wy / CONFIG.TILE_SIZE);
  if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return null;
  return grassGrid[row][col];
}

function getTilesWithGrassNearby(cx, cy, radius) {
  const tiles = [];
  const centerCol = Math.floor(cx / CONFIG.TILE_SIZE);
  const centerRow = Math.floor(cy / CONFIG.TILE_SIZE);
  for (let r = -radius; r <= radius; r++) {
    for (let c = -radius; c <= radius; c++) {
      const col = centerCol + c;
      const row = centerRow + r;
      if (col >= 0 && col < COLS && row >= 0 && row < ROWS) {
        const t = grassGrid[row][col];
        if (t.grassAmount >= CONFIG.GRASS_MIN_TO_EAT) {
          tiles.push({
            col, row,
            x: col * CONFIG.TILE_SIZE + CONFIG.TILE_SIZE / 2,
            y: row * CONFIG.TILE_SIZE + CONFIG.TILE_SIZE / 2
          });
        }
      }
    }
  }
  return tiles;
}

function dist(ax, ay, bx, by) {
  return Math.hypot(bx - ax, by - ay);
}

function cellHash(cx, cy, b) {
  let h = (cx * 374761393 ^ cy * 668265263 ^ b * 1274126177) >>> 0;
  h = (h ^ (h >>> 13)) * 1274126177 >>> 0;
  h = (h ^ (h >>> 16)) * 668265263 >>> 0;
  return (h ^ (h >>> 16)) >>> 0;
}

/* 2D simplex-style noise (smooth, seeded) */
const PERM = new Uint8Array(512);
function initNoise() {
  const p = [];
  for (let i = 0; i < 256; i++) p[i] = i;
  for (let i = 255; i > 0; i--) {
    const j = (i * 37 + 17) % (i + 1);
    [p[i], p[j]] = [p[j], p[i]];
  }
  for (let i = 0; i < 512; i++) PERM[i] = p[i & 255];
}
initNoise();

function fade(t) {
  return t * t * t * (t * (t * 6 - 15) + 10);
}

function grad2d(hash, x, y) {
  const u = hash < 8 ? x : y;
  const v = hash < 4 ? y : hash === 12 || hash === 14 ? x : 0;
  return ((hash & 1) ? -u : u) + ((hash & 2) ? -v : v) * 0.5;
}

function noise2d(x, y) {
  const xi = Math.floor(x) & 255;
  const yi = Math.floor(y) & 255;
  const xf = x - Math.floor(x);
  const yf = y - Math.floor(y);
  const u = fade(xf);
  const v = fade(yf);
  const aa = PERM[PERM[xi] + yi];
  const ab = PERM[PERM[xi] + yi + 1];
  const ba = PERM[PERM[xi + 1] + yi];
  const bb = PERM[PERM[xi + 1] + yi + 1];
  return (1 + (
    (1 - u) * ((1 - v) * grad2d(aa, xf, yf) + v * grad2d(ab, xf, yf - 1)) +
    u * ((1 - v) * grad2d(ba, xf - 1, yf) + v * grad2d(bb, xf - 1, yf - 1))
  )) / 2;
}

function fbm2d(x, y, octaves, lacunarity, gain, offsetX = 0, offsetY = 0) {
  let value = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxValue = 0;
  for (let i = 0; i < octaves; i++) {
    value += amplitude * noise2d(x * frequency + offsetX, y * frequency + offsetY);
    maxValue += amplitude;
    amplitude *= gain;
    frequency *= lacunarity;
  }
  return value / maxValue;
}

function normalize(v) {
  const d = Math.hypot(v.x, v.y);
  if (d > 0) return { x: v.x / d, y: v.y / d };
  return null;
}

function move(entity, dir, speed, dt, minX, minY, maxX, maxY) {
  if (!dir || speed <= 0) return;
  entity.x = Math.max(minX, Math.min(maxX, entity.x + dir.x * speed * dt));
  entity.y = Math.max(minY, Math.min(maxY, entity.y + dir.y * speed * dt));
}

function herdSheep() {
  const now = performance.now();
  sheep.forEach(s => {
    const d = dist(player.x + 20, player.y + 20, s.x + 16, s.y + 16);
    if (d <= CONFIG.HERD_RADIUS && d > 0) {
      const away = normalize({ x: s.x - player.x, y: s.y - player.y });
      if (away) {
        /* Closer sheep stay herded longer (squared falloff) */
        const t = 1 - d / CONFIG.HERD_RADIUS;
        const durationMs = CONFIG.HERD_DURATION_MS * t * t;
        s.herdUntil = now + durationMs;
        s.herdDir = away;
      }
    }
  });
}

function onStickSwing(e) {
  if (merchantPanelVisible) return;
  const now = performance.now();
  if (now < stickCooldownUntil) return;
  stickCooldownUntil = now + CONFIG.STICK_COOLDOWN_MS;

  const px = player.x + 20;
  const py = player.y + 20;
  const angleToMouse = Math.atan2(mouse.y - py, mouse.x - px);
  const arcRad = (CONFIG.STICK_ARC_DEGREES / 2) * Math.PI / 180;

  const hitWolves = [];
  wolves.forEach((w, i) => {
    const dx = (w.x + 24) - px;
    const dy = (w.y + 24) - py;
    const d = Math.hypot(dx, dy);
    if (d > CONFIG.STICK_RANGE) return;
    let angle = Math.atan2(dy, dx);
    let diff = angleToMouse - angle;
    while (diff > Math.PI) diff -= 2 * Math.PI;
    while (diff < -Math.PI) diff += 2 * Math.PI;
    if (Math.abs(diff) <= arcRad) hitWolves.push(i);
  });

  hitWolves.reverse().forEach(i => {
    wolves.splice(i, 1);
  });

  sheep.forEach((s, i) => {
    const dx = (s.x + 16) - px;
    const dy = (s.y + 16) - py;
    const d = Math.hypot(dx, dy);
    if (d > CONFIG.STICK_RANGE) return;
    let angle = Math.atan2(dy, dx);
    let diff = angleToMouse - angle;
    while (diff > Math.PI) diff -= 2 * Math.PI;
    while (diff < -Math.PI) diff += 2 * Math.PI;
    if (Math.abs(diff) <= arcRad && s.wool >= CONFIG.SHEEP_WOOL_MIN + 0.1) {
      const woolToShear = s.wool - CONFIG.SHEEP_WOOL_MIN;
      s.wool = CONFIG.SHEEP_WOOL_MIN;
      woolDrops.push({
        x: s.x + 8,
        y: s.y + 8,
        kg: woolToShear
      });
    }
  });
}

function sellWool() {
  if (playerWoolKg < CONFIG.MERCHANT_WOOL_PER_SHEEP) return;
  const count = Math.floor(playerWoolKg / CONFIG.MERCHANT_WOOL_PER_SHEEP);
  playerWoolKg -= count * CONFIG.MERCHANT_WOOL_PER_SHEEP;
  spawnSheep(count);
  if (merchant) {
    for (let i = sheep.length - count; i < sheep.length; i++) {
      sheep[i].x = merchant.x + 20;
      sheep[i].y = merchant.y + 20;
    }
  }
  updateHud();
  refreshMerchantPanel();
}

function buySheep() {
  if (playerWoolKg >= CONFIG.MERCHANT_WOOL_PER_SHEEP) {
    playerWoolKg -= CONFIG.MERCHANT_WOOL_PER_SHEEP;
    spawnSheep(1);
    if (merchant) {
      sheep[sheep.length - 1].x = merchant.x + 20;
      sheep[sheep.length - 1].y = merchant.y + 20;
    }
    updateHud();
    refreshMerchantPanel();
  }
}

function closeMerchant() {
  merchantPanelVisible = false;
  merchantCloseCooldown = 2;
  document.getElementById('merchant-panel').classList.remove('visible');
}

function refreshMerchantPanel() {
  document.getElementById('sell-wool').textContent =
    `Sell wool (${playerWoolKg.toFixed(1)} kg โ†’ ${Math.floor(playerWoolKg / CONFIG.MERCHANT_WOOL_PER_SHEEP)} sheep)`;
  document.getElementById('sell-wool').disabled = playerWoolKg < CONFIG.MERCHANT_WOOL_PER_SHEEP;
  document.getElementById('buy-sheep').textContent =
    `Buy sheep (${CONFIG.MERCHANT_WOOL_PER_SHEEP} kg wool)`;
  document.getElementById('buy-sheep').disabled = playerWoolKg < CONFIG.MERCHANT_WOOL_PER_SHEEP;
}

function updateHud() {
  document.getElementById('sheep-count').textContent = sheep.length;
  document.getElementById('wool-kg').textContent = playerWoolKg.toFixed(1);
}

function update(dt) {
  if (merchantPanelVisible) return;
  merchantCloseCooldown = Math.max(0, merchantCloseCooldown - dt);

  const pdx = (keys.d ? 1 : 0) - (keys.a ? 1 : 0);
  const pdy = (keys.s ? 1 : 0) - (keys.w ? 1 : 0);
  const pdir = normalize({ x: pdx, y: pdy });
  move(player, pdir, CONFIG.PLAYER_SPEED, dt, 0, 0, CONFIG.CANVAS_WIDTH - 40, CONFIG.CANVAS_HEIGHT - 40);

  for (let row = 0; row < ROWS; row++) {
    for (let col = 0; col < COLS; col++) {
      const t = grassGrid[row][col];
      const cap = t.grassMax ?? CONFIG.GRASS_MAX;
      if (t.grassAmount < cap) {
        t.grassAmount = Math.min(cap, t.grassAmount + CONFIG.GRASS_REGEN_RATE * dt);
      }
    }
  }

  sheep.forEach((s, si) => {
    const vx = s.vx ?? 0;
    const vy = s.vy ?? 0;
    const damp = CONFIG.VELOCITY_DAMPING;

    const now = performance.now();
    const isHerded = s.herdUntil > now;

    let desiredVx = 0;
    let desiredVy = 0;

    if (isHerded && s.herdDir) {
      desiredVx = s.herdDir.x * CONFIG.SHEEP_SPEED;
      desiredVy = s.herdDir.y * CONFIG.SHEEP_SPEED;
    } else {
      const tile = tileAt(s.x + 16, s.y + 16);
      let nearestWolf = null;
      let nearestWolfDist = Infinity;
      wolves.forEach(w => {
        const d = dist(s.x + 16, s.y + 16, w.x + 24, w.y + 24);
        if (d < nearestWolfDist && d < CONFIG.SHEEP_WOLF_DETECT_RANGE) {
          nearestWolfDist = d;
          nearestWolf = w;
        }
      });

      if (nearestWolf) {
        const flee = normalize({ x: s.x - nearestWolf.x, y: s.y - nearestWolf.y });
        if (flee) {
          desiredVx = flee.x * CONFIG.SHEEP_FLEE_SPEED;
          desiredVy = flee.y * CONFIG.SHEEP_FLEE_SPEED;
        }
      } else if (tile && tile.grassAmount >= CONFIG.GRASS_MIN_TO_EAT) {
        const eatAmount = Math.min(CONFIG.GRASS_EAT_RATE * dt, tile.grassAmount);
        tile.grassAmount = Math.max(0, tile.grassAmount - eatAmount);
        s.wool = Math.min(CONFIG.SHEEP_WOOL_MAX, s.wool + eatAmount * CONFIG.SHEEP_WOOL_PER_GRASS);
      } else {
        const tiles = getTilesWithGrassNearby(s.x, s.y, CONFIG.SHEEP_ROAM_RADIUS);
        const targetStillValid = s.targetTile && grassGrid[s.targetTile.row]?.[s.targetTile.col]?.grassAmount >= CONFIG.GRASS_MIN_TO_EAT;
        if (!targetStillValid || Math.random() < CONFIG.SHEEP_TARGET_RECHECK) {
          if (tiles.length > 0) {
            tiles.sort((a, b) =>
              dist(s.x + 16, s.y + 16, a.x, a.y) - dist(s.x + 16, s.y + 16, b.x, b.y)
            );
            s.targetTile = tiles[0];
          } else s.targetTile = null;
        }
        if (s.targetTile) {
          const tx = s.targetTile.x - (s.x + 16);
          const ty = s.targetTile.y - (s.y + 16);
          const d = Math.hypot(tx, ty);
          if (d > 4) {
            const to = normalize({ x: tx, y: ty });
            if (to) {
              desiredVx = to.x * CONFIG.SHEEP_SPEED;
              desiredVy = to.y * CONFIG.SHEEP_SPEED;
            }
          } else s.targetTile = null;
        }
      }
    }

    s.vx = damp * vx + (1 - damp) * desiredVx;
    s.vy = damp * vy + (1 - damp) * desiredVy;
    const spd = Math.hypot(s.vx, s.vy);
    const dir = spd > 0 ? { x: s.vx / spd, y: s.vy / spd } : null;
    move(s, dir, spd, dt, 0, 0, CONFIG.CANVAS_WIDTH - 32, CONFIG.CANVAS_HEIGHT - 32);
  });

  wolves.forEach((w, wi) => {
    let target = null;
    let targetDist = Infinity;
    sheep.forEach(s => {
      const d = dist(w.x + 24, w.y + 24, s.x + 16, s.y + 16);
      if (d < targetDist) {
        targetDist = d;
        target = s;
      }
    });
    if (!target) {
      const d = dist(w.x + 24, w.y + 24, player.x + 20, player.y + 20);
      if (d < 200) target = { x: player.x, y: player.y, w: 40, h: 40 };
    }
    const dir = target ? normalize({
      x: (target.x + (target.w ? target.w / 2 : 16)) - (w.x + 24),
      y: (target.y + (target.h ? target.h / 2 : 16)) - (w.y + 24)
    }) : null;
    move(w, dir, CONFIG.WOLF_SPEED, dt, 0, 0, CONFIG.CANVAS_WIDTH - 48, CONFIG.CANVAS_HEIGHT - 48);

    sheep.forEach((s, si) => {
      const d = dist(w.x + 24, w.y + 24, s.x + 16, s.y + 16);
      if (d < 35) {
        const k = `w${wi}s${si}`;
        const now = performance.now();
        if (!lastWolfDamageTime[k]) lastWolfDamageTime[k] = 0;
        if (now - lastWolfDamageTime[k] > CONFIG.WOLF_DAMAGE_INTERVAL_MS) {
          lastWolfDamageTime[k] = now;
          s.hp -= CONFIG.WOLF_DAMAGE;
          if (s.hp <= 0) s.dead = true;
        }
      }
    });
  });

  sheep = sheep.filter(s => !s.dead);

  woolDrops.forEach((w, i) => {
    const d = dist(w.x, w.y, player.x + 20, player.y + 20);
    if (d < CONFIG.WOOL_PICKUP_RADIUS) {
      playerWoolKg += w.kg;
      woolDrops.splice(i, 1);
      updateHud();
    }
  });

  if (merchant) {
    if (merchant.wanderTimer > 0) {
      merchant.wanderTimer -= dt;
    } else {
      const centerX = CONFIG.CANVAS_WIDTH / 2 - 30;
      const centerY = CONFIG.CANVAS_HEIGHT / 2 - 30;
      const biasToCenter = Math.random() < 0.6;
      let tx, ty;
      if (biasToCenter) {
        const innerW = CONFIG.CANVAS_WIDTH * 0.5;
        const innerH = CONFIG.CANVAS_HEIGHT * 0.5;
        tx = centerX + (Math.random() - 0.5) * innerW;
        ty = centerY + (Math.random() - 0.5) * innerH;
      } else {
        tx = Math.random() * CONFIG.CANVAS_WIDTH - 30;
        ty = Math.random() * CONFIG.CANVAS_HEIGHT - 30;
      }
      merchant.wanderTarget = { x: tx, y: ty };
      merchant.wanderTimer = 2 + Math.random() * 2;
    }
    const target = merchant.wanderTarget || { x: merchant.x, y: merchant.y };
    const dx = target.x - merchant.x;
    const dy = target.y - merchant.y;
    const d = Math.hypot(dx, dy);
    const dir = d > 5 ? normalize({ x: dx, y: dy }) : normalize({ x: Math.random() - 0.5, y: Math.random() - 0.5 }) || { x: 1, y: 0 };
    move(merchant, dir, CONFIG.MERCHANT_SPEED, dt, -80, -80, CONFIG.CANVAS_WIDTH + 80, CONFIG.CANVAS_HEIGHT + 80);
    const inBounds = merchant.x > -80 && merchant.x < CONFIG.CANVAS_WIDTH && merchant.y > -80 && merchant.y < CONFIG.CANVAS_HEIGHT;
    if (!inBounds) merchant = null;
  }

  wolfSpawnTimer += dt * 1000;
  if (wolfSpawnTimer >= CONFIG.WOLF_SPAWN_INTERVAL_MS) {
    wolfSpawnTimer = 0;
    const edge = Math.floor(Math.random() * 4);
    let x, y;
    if (edge === 0) { x = Math.random() * CONFIG.CANVAS_WIDTH; y = -30; }
    else if (edge === 1) { x = CONFIG.CANVAS_WIDTH + 10; y = Math.random() * CONFIG.CANVAS_HEIGHT; }
    else if (edge === 2) { x = Math.random() * CONFIG.CANVAS_WIDTH; y = CONFIG.CANVAS_HEIGHT + 10; }
    else { x = -30; y = Math.random() * CONFIG.CANVAS_HEIGHT; }
    wolves.push({
      x: Math.max(-50, Math.min(CONFIG.CANVAS_WIDTH, x)) - 24,
      y: Math.max(-50, Math.min(CONFIG.CANVAS_HEIGHT, y)) - 24
    });
  }

  merchantSpawnTimer += dt * 1000;
  if (merchantSpawnTimer >= CONFIG.MERCHANT_SPAWN_INTERVAL_MS && !merchant) {
    merchantSpawnTimer = 0;
    const edge = Math.floor(Math.random() * 4);
    let x, y;
    if (edge === 0) { x = Math.random() * CONFIG.CANVAS_WIDTH; y = -60; }
    else if (edge === 1) { x = CONFIG.CANVAS_WIDTH + 20; y = Math.random() * CONFIG.CANVAS_HEIGHT; }
    else if (edge === 2) { x = Math.random() * CONFIG.CANVAS_WIDTH; y = CONFIG.CANVAS_HEIGHT + 20; }
    else { x = -60; y = Math.random() * CONFIG.CANVAS_HEIGHT; }
    const centerX = CONFIG.CANVAS_WIDTH / 2 - 30;
    const centerY = CONFIG.CANVAS_HEIGHT / 2 - 30;
    merchant = {
      x, y,
      wanderTarget: { x: centerX, y: centerY },
      wanderTimer: 3
    };
  }

  if (merchant && !merchantPanelVisible && merchantCloseCooldown <= 0) {
    const d = dist(merchant.x + 30, merchant.y + 30, player.x + 20, player.y + 20);
    if (d < CONFIG.MERCHANT_INTERACTION_RADIUS) {
      merchantPanelVisible = true;
      document.getElementById('merchant-panel').classList.add('visible');
      refreshMerchantPanel();
    }
  }
}

function render() {
  ctx.fillStyle = '#3d5c3d';
  ctx.fillRect(0, 0, CONFIG.CANVAS_WIDTH, CONFIG.CANVAS_HEIGHT);

  const cap = (t) => (t.grassMax ?? CONFIG.GRASS_MAX);
  const maxLen = CONFIG.GRASS_BLADE_MAX_LENGTH;
  for (let row = 0; row < ROWS; row++) {
    for (let col = 0; col < COLS; col++) {
      const t = grassGrid[row][col];
      const gx = col * CONFIG.TILE_SIZE;
      const gy = row * CONFIG.TILE_SIZE;
      const maxG = cap(t);
      if (maxG <= 0) continue;
      const grown = t.grassAmount / maxG;
      const len = grown * maxLen;
      ctx.strokeStyle = 'rgba(20, 70, 20, 0.75)';
      ctx.lineWidth = 1;
      for (let b = 0; b < CONFIG.GRASS_BLADES_PER_TILE; b++) {
        const bx = gx + (cellHash(col, row, b) / 0xffffffff) * CONFIG.TILE_SIZE;
        const by = gy + (cellHash(col, row, b + 256) / 0xffffffff) * CONFIG.TILE_SIZE;
        if (len > 0) {
          ctx.beginPath();
          ctx.moveTo(bx, by);
          ctx.lineTo(bx, by - len);
          ctx.stroke();
        }
      }
    }
  }

  woolDrops.forEach(w => {
    ctx.font = '20px serif';
    ctx.fillText(E.wool, w.x - 8, w.y + 8);
  });

  sheep.forEach(s => {
    const scale = 0.8 + (s.wool - CONFIG.SHEEP_WOOL_MIN) / (CONFIG.SHEEP_WOOL_MAX - CONFIG.SHEEP_WOOL_MIN) * 0.6;
    ctx.font = `${28 * scale}px serif`;
    ctx.fillText(E.sheep, s.x, s.y + 28 * scale);
    if (CONFIG.DEBUG_HITBOXES) {
      ctx.strokeStyle = 'blue';
      ctx.strokeRect(s.x, s.y, 32, 32);
    }
  });

  wolves.forEach(w => {
    ctx.font = '36px serif';
    ctx.fillText(E.wolf, w.x, w.y + 36);
    if (CONFIG.DEBUG_HITBOXES) {
      ctx.strokeStyle = 'red';
      ctx.strokeRect(w.x, w.y, 48, 48);
    }
  });

  ctx.font = '32px serif';
  ctx.fillText(E.shepherd, player.x, player.y + 32);
  if (CONFIG.DEBUG_HITBOXES) {
    ctx.strokeStyle = 'green';
    ctx.strokeRect(player.x, player.y, 40, 40);
  }

  if (merchant) {
    ctx.font = '40px serif';
    ctx.fillText(E.merchant, merchant.x, merchant.y + 40);
  }
}

function gameLoop(ts) {
  const dt = Math.min(0.05, (ts - (gameLoop.last || ts)) / 1000);
  gameLoop.last = ts;
  update(dt);
  render();
  updateHud();
  requestAnimationFrame(gameLoop);
}

init();


  </script>
</body>
</html>

remixes

no remixes yet... email to remix@gameslop.net