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
