// ---------------------------------------------------------------
// Dino Isle Online — web client
// ---------------------------------------------------------------
'use strict';
/* ================= constants / state ================= */
const TILE = 48;
const STAGE_SCALE = [0.55, 0.78, 1.0, 1.28];
const STAGE_NAMES = ['Hatchling', 'Adolescent', 'Adult', 'Apex'];
const MENU_SPECIES = [
{ key: 'compy', name: 'Compsognathus', diet: 'omni', blurb: 'Tiny, quick & grows fast. Eats anything.', bars: { spd: 5, pwr: 1, hp: 1, grw: 5 } },
{ key: 'raptor', name: 'Velociraptor', diet: 'carn', blurb: 'Fast hunter. Tears prey apart.', bars: { spd: 5, pwr: 3, hp: 2, grw: 3 } },
{ key: 'trike', name: 'Triceratops', diet: 'herb', blurb: 'Armored grazer. Hardy and hard to kill.', bars: { spd: 2, pwr: 3, hp: 5, grw: 3 } },
{ key: 'rex', name: 'Tyrannosaurus', diet: 'carn', blurb: 'Apex predator. Slow to grow, terrifying when grown.', bars: { spd: 2, pwr: 5, hp: 5, grw: 1 } },
];
const DIET_LABEL = { herb: '🌿 Herbivore', carn: '🍖 Carnivore', omni: '🍽️ Omnivore' };
// Render-only stats for AI herd dinos (server owns behavior)
const AI_RENDER = {
dryo: { key: 'dryo', name: 'Dryosaurus', diet: 'herb', radius: 19 },
psitt: { key: 'psitt', name: 'Psittacosaurus', diet: 'herb', radius: 25 },
};
const ZOOMS = [1.06, 0.96, 0.86, 0.78];
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let DPR = Math.min(window.devicePixelRatio || 1, 2);
let ws = null;
let myId = -1;
let SP = {}; // species table from server
let tiles = null; // Uint8Array map
let mapW = 0, mapH = 0;
let decor = null;
let joined = false;
let dead = false;
let chosenSp = localStorage.getItem('dino_sp') || 'raptor';
let muted = localStorage.getItem('dino_mute') === '1';
let dayT = 0.3;
// own dino predicted state
const me = {
x: 0, y: 0, vx: 0, vy: 0, dir: 0,
sp: 'raptor', stage: 0, hp: 100, maxHp: 100, stam: 100, food: 80, water: 80,
xp: 0, need: 42, rest: 0, ex: 0, cd: 0, alive: 1, radius: 18, speed: 240,
walkPhase: 0, biteAnim: 0, eatAnim: 0, dev: 0,
};
const ents = new Map(); // "k i" -> ent {k,i,x,y,tx,ty,dir,tDir,...}
let lastSnapAt = 0;
// fx
const parts = []; // particles
let shake = 0;
let vignetteT = 0;
/* ================= helpers ================= */
function $(id) { return document.getElementById(id); }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function lerp(a, b, t) { return a + (b - a) * t; }
function angLerp(a, b, t) {
let d = b - a;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
return a + d * t;
}
function hash2i(x, y) { let h = (x * 374761393 + y * 668265263) | 0; h = (h ^ (h >> 13)) | 0; h = Math.imul(h, 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; }
/* ================= audio (tiny synth) ================= */
let AC = null;
function audioInit() {
if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (_) {} }
if (AC && AC.state === 'suspended') AC.resume();
}
function tone(freq, dur, type, vol, slideTo) {
if (!AC || muted) return;
const o = AC.createOscillator(), g = AC.createGain();
o.type = type || 'sine'; o.frequency.value = freq;
if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), AC.currentTime + dur);
g.gain.setValueAtTime(vol || 0.12, AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + dur);
o.connect(g).connect(AC.destination);
o.start(); o.stop(AC.currentTime + dur + 0.02);
}
const sfx = {
bite() { tone(190, 0.09, 'square', 0.10, 70); },
hitme() { tone(130, 0.22, 'sawtooth', 0.16, 60); },
eat() { tone(480, 0.08, 'triangle', 0.10, 690); },
drink() { tone(300, 0.14, 'sine', 0.07, 240); },
splash() { tone(220, 0.16, 'sine', 0.09, 520); },
grow() { [392, 494, 587, 784].forEach((f, i) => setTimeout(() => tone(f, 0.24, 'triangle', 0.13), i * 110)); },
die() { tone(220, 0.9, 'sawtooth', 0.18, 45); },
swing() { tone(420, 0.07, 'sine', 0.05, 140); },
};
/* ================= connection ================= */
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/ws');
ws.onopen = () => { $('menuStatus').textContent = ''; };
ws.onmessage = (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch (_) { return; }
handleMessage(m);
};
ws.onclose = () => {
ents.clear();
if (joined && !dead) { showMenu('Connection lost. Reconnecting…'); }
else if (!joined) { $('menuStatus').textContent = 'Connecting to island…'; }
setTimeout(connect, 1500);
};
}
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
function handleMessage(m) {
switch (m.t) {
case 'welcome': onWelcome(m); break;
case 's': onSnapshot(m); break;
case 'evt': onEvent(m.ev); break;
case 'chat': onChatMsg(m); break;
case 'lb': onLeaderboard(m); break;
case 'dead': onDead(m); break;
case 'pong': break;
}
}
function onWelcome(m) {
myId = m.id;
SP = m.species || {};
Object.assign(SP, AI_RENDER);
tiles = base64ToBytes(m.map.data);
mapW = m.map.w; mapH = m.map.h;
decor = m.decor;
buildMinimapBase();
renderMenuCards(); // refresh with server data (names identical anyway)
}
function base64ToBytes(b64) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function joinGame(name, sp) {
send({ t: 'join', name, sp });
}
function onSnapshot(m) {
lastSnapAt = performance.now();
dayT = m.dayT;
const y = m.you;
const prevStage = me.stage;
Object.assign(me, {
tx: y.x, ty: y.y, dirTarget: y.d,
hp: y.hp, maxHp: y.maxHp, stam: y.st, food: y.fd, water: y.wt,
xp: y.xp, need: y.need, stage: y.stg, rest: y.rest, ex: y.ex, cd: y.cd, alive: y.al,
dev: y.dv,
});
// reconcile position softly (snap on big jumps: respawn / knockback)
const jump = Math.hypot(y.x - me.x, y.y - me.y);
if (jump > 500) { me.x = y.x; me.y = y.y; me.vx = 0; me.vy = 0; }
else { me.x += (y.x - me.x) * 0.25; me.y += (y.y - me.y) * 0.25; }
if (typeof y.d === 'number') me.dir = angLerp(me.dir, y.d, 0.35);
const spec = SP[me.sp];
if (spec) { me.speed = spec.speed * (1 - 0.05 * me.stage); me.radius = spec.radius * STAGE_SCALE[me.stage]; }
if (me.stage > prevStage && prevStage !== undefined) { /* evolve banner comes via evt */ }
// merge entities
const seen = new Set();
for (const e of m.ents) {
const key = e.k + ' ' + e.i;
seen.add(key);
let ent = ents.get(key);
if (!ent) {
ent = { k: e.k, i: e.i, x: e.x, y: e.y, dir: e.d || 0, born: performance.now(), phase: Math.random() * 10 };
ents.set(key, ent);
if (e.k === 'p') popText(e.x, e.y - 40, e.n, '#cfe8c9');
}
ent.tx = e.x; ent.ty = e.y;
if (typeof e.d === 'number') ent.tDir = e.d;
if (e.h !== undefined) ent.hpFrac = e.h / 100;
if (e.n !== undefined) ent.name = e.n;
if (e.s !== undefined) ent.sp = e.s;
if (e.t !== undefined) ent.stage = e.t;
if (e.r !== undefined) ent.rest = e.r;
if (e.ci !== undefined) ent.ci = e.ci;
if (e.v !== undefined) ent.variant = e.v;
if (e.a !== undefined) ent.amt = e.a;
if (e.m !== undefined) ent.meat = e.m;
ent.seenAt = lastSnapAt;
}
// drop stale
for (const [key, ent] of ents) {
if (ent.seenAt !== lastSnapAt && key !== '__') {
if (lastSnapAt - (ent.seenAt || 0) > 2500) ents.delete(key);
}
}
}
/* ================= events -> fx ================= */
function onEvent(ev) {
if (!ev) return;
switch (ev.e) {
case 'swing':
me.biteAnim = 0.28;
if (ev.a !== undefined) me.dir = ev.a;
sfx.swing();
break;
case 'hit':
spawnBlood(ev.x, ev.y, ev.a);
shake = Math.min(shake + 4, 10);
tone(150, 0.1, 'square', 0.09, 60);
break;
case 'hitme':
sfx.hitme();
vignetteT = 1;
spawnBlood(me.x, me.y, undefined, 10);
shake = Math.min(shake + 7, 14);
popText(me.x, me.y - me.radius * 2 - 14, '-' + ev.dmg, '#ff8f7a');
break;
case 'ate':
sfx.eat();
me.eatAnim = 0.3;
popStars(me.x, me.y - me.radius, '#ffd97a', ev.f >= 25 ? 8 : 5);
break;
case 'graze':
me.eatAnim = 0.3;
if (Math.random() < 0.4) sfx.eat();
popStars(me.x + Math.cos(me.dir) * 26, me.y + Math.sin(me.dir) * 26, '#8fdc7a', 4);
break;
case 'drink':
me.eatAnim = 0.25;
if (Math.random() < 0.35) sfx.drink();
spawnRipple(me.x + Math.cos(me.dir) * (me.radius + 8), me.y + Math.sin(me.dir) * (me.radius + 8));
break;
case 'splash':
sfx.splash();
spawnSplash(ev.x, ev.y);
break;
case 'eat':
sfx.eat();
popStars(ev.x, ev.y, ev.k === 'fish' ? '#9fd8ff' : '#ff9d76', 6);
break;
case 'grow':
sfx.grow();
showBanner(`⭐ EVOLVED → ${STAGE_NAMES[ev.st].toUpperCase()} ⭐`);
spawnRing(me.x, me.y, '#ffe9a0');
break;
case 'healfx':
popStars(me.x, me.y - me.radius, '#8fdc7a', 8);
spawnRing(me.x, me.y, '#9be36f');
tone(600, 0.15, 'triangle', 0.09, 900);
break;
case 'spawned':
popText(me.x, me.y - 60, ev.k + ' spawned nearby', '#e0b0ff');
break;
case 'dev':
me.dev = ev.on ? 1 : 0;
$('devBadge').classList.toggle('hidden', !me.dev);
popText(me.x, me.y - 55, ev.on ? '🛠 DEV MODE ON' : 'DEV MODE OFF', '#ffd9ff');
tone(ev.on ? 700 : 400, 0.18, 'square', 0.08, ev.on ? 1000 : 200);
break;
case 'die':
sfx.die();
spawnPoof(ev.x, ev.y);
shake = 10;
break;
}
}
/* ================= chat / feed / lb ================= */
function onChatMsg(m) {
const log = $('chatLog');
const div = document.createElement('div');
div.className = 'cl' + (m.sys ? ' sys' : '');
if (m.sys) div.textContent = m.msg;
else { const b = document.createElement('b'); b.textContent = m.from + ': '; div.appendChild(b); div.appendChild(document.createTextNode(m.msg)); }
log.appendChild(div);
while (log.children.length > 40) log.removeChild(log.firstChild);
log.scrollTop = log.scrollHeight;
if (m.sys && /devoured/.test(m.msg)) addKillFeed(m.msg);
}
function addKillFeed(text) {
const kf = $('killFeed');
const div = document.createElement('div');
div.className = 'kf';
div.textContent = '🩸 ' + text;
kf.appendChild(div);
setTimeout(() => div.remove(), 5200);
while (kf.children.length > 4) kf.removeChild(kf.firstChild);
}
function onLeaderboard(m) {
$('onlineCount').textContent = m.online;
const ol = $('lbList');
ol.innerHTML = '';
for (const row of m.list) {
const li = document.createElement('li');
if (row.i === myId) li.className = 'me';
const nm = document.createElement('span');
nm.textContent = `${row.n} `;
const stg = document.createElement('span'); stg.className = 'stg';
stg.textContent = `· ${STAGE_NAMES[row.t]}`;
const sc = document.createElement('span'); sc.className = 'sc';
sc.textContent = row.sc;
li.appendChild(nm); li.appendChild(stg); li.appendChild(sc);
ol.appendChild(li);
}
}
function onDead(m) {
dead = true;
$('deathBy').textContent = 'by ' + m.by;
const st = $('deathStats');
st.innerHTML = '';
const rows = [
['Kills', m.stats.kills],
['Meals', Math.floor(m.stats.eaten)],
['Reached', m.stats.stage],
['Species', m.stats.sp],
];
for (const [l, v] of rows) {
const d = document.createElement('div');
d.innerHTML = `
${v}
${l}
`;
st.appendChild(d);
}
$('hud').classList.add('hidden');
$('deathScreen').classList.remove('hidden');
}
/* ================= menu ================= */
function showMenu(statusText) {
joined = false;
$('menu').classList.remove('hidden');
$('hud').classList.add('hidden');
$('deathScreen').classList.add('hidden');
if (statusText) $('menuStatus').textContent = statusText;
}
/* ---------- menu card previews ---------- */
function drawCardPreview(cv, spKey) {
const c = cv.getContext('2d');
const W = cv.width, H = cv.height;
c.clearRect(0, 0, W, H);
// backdrop
const g = c.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, 'rgba(120,190,140,0.16)');
g.addColorStop(1, 'rgba(30,60,45,0.28)');
c.fillStyle = g;
c.beginPath(); c.roundRect(0, 0, W, H, 8); c.fill();
// draw with the SAME renderer as in-game so shapes/colors always match
c.save();
const scale = spKey === 'rex' ? 1.5 : spKey === 'compy' ? 0.95 : spKey === 'trike' ? 1.05 : 1.18;
c.translate(W / 2 - 6 * scale, H * 0.60);
c.scale(scale, scale);
paintDino(c, {
x: 0, y: 4, dir: 0, r: 17, sp: spKey, stage: 2,
phase: 1.15, moving: false, resting: false,
bite: spKey === 'rex' ? 0.85 : 0, eat: 0,
ci: 0, preview: true,
});
c.restore();
}
function renderMenuCards() {
const wrap = $('dinoCards');
wrap.innerHTML = '';
for (const sp of MENU_SPECIES) {
const card = document.createElement('div');
card.className = 'card' + (sp.key === chosenSp ? ' sel' : '');
card.dataset.key = sp.key;
const cv = document.createElement('canvas');
cv.width = 180; cv.height = 88;
card.appendChild(cv);
drawCardPreview(cv, sp.key);
const h4 = document.createElement('h4'); h4.textContent = sp.name;
const diet = document.createElement('span'); diet.className = 'diet ' + sp.diet; diet.textContent = DIET_LABEL[sp.diet];
const p = document.createElement('p'); p.textContent = sp.blurb;
const sb = document.createElement('div'); sb.className = 'statbars';
const rows = [['SPD', sp.bars.spd], ['PWR', sp.bars.pwr], ['HP', sp.bars.hp], ['GRW', sp.bars.grw]];
for (const [lbl, v] of rows) {
sb.insertAdjacentHTML('beforeend',
``);
}
card.appendChild(h4); card.appendChild(diet); card.appendChild(p); card.appendChild(sb);
card.onclick = () => {
chosenSp = sp.key;
localStorage.setItem('dino_sp', sp.key);
document.querySelectorAll('.card').forEach(c => c.classList.toggle('sel', c.dataset.key === sp.key));
audioInit(); tone(500, 0.06, 'triangle', 0.08, 700);
};
wrap.appendChild(card);
}
}
function setupMenu() {
const savedName = localStorage.getItem('dino_name') || '';
$('nameInput').value = savedName;
renderMenuCards();
$('playBtn').onclick = tryJoin;
$('nameInput').addEventListener('keydown', e => { if (e.key === 'Enter') tryJoin(); e.stopPropagation(); });
$('respawnBtn').onclick = () => {
$('deathScreen').classList.add('hidden');
dead = false;
tryJoin();
};
}
function tryJoin() {
audioInit();
const name = ($('nameInput').value || '').trim() || ('Dino' + ((Math.random() * 900 + 100) | 0));
localStorage.setItem('dino_name', name);
if (!ws || ws.readyState !== 1) { $('menuStatus').textContent = 'Still connecting… try again.'; return; }
me.sp = chosenSp; me.stage = 0;
joinGame(name, chosenSp);
// optimistic switch; server will correct us with snapshots
$('menu').classList.add('hidden');
$('hud').classList.remove('hidden');
joined = true;
dead = false;
}
/* ================= input ================= */
const keys = { u: 0, d: 0, l: 0, r: 0, sp: 0, et: 0 };
let chatFocused = false;
function sendInput(extra) {
const msg = { t: 'in', u: keys.u, d: keys.d, l: keys.l, r: keys.r, sp: keys.sp, et: keys.et };
if (extra) Object.assign(msg, extra);
send(msg);
}
setInterval(() => { if (joined && !dead) sendInput(); }, 200);
window.addEventListener('keydown', (e) => {
if (chatFocused) {
if (e.code === 'Enter') { sendChat(); e.preventDefault(); }
else if (e.code === 'Escape') { blurChat(); e.preventDefault(); }
return;
}
if (!joined || e.repeat) return;
switch (e.code) {
case 'KeyW': case 'ArrowUp': keys.u = 1; sendInput(); break;
case 'KeyS': case 'ArrowDown': keys.d = 1; sendInput(); break;
case 'KeyA': case 'ArrowLeft': keys.l = 1; sendInput(); break;
case 'KeyD': case 'ArrowRight': keys.r = 1; sendInput(); break;
case 'ShiftLeft': case 'ShiftRight': keys.sp = 1; sendInput(); break;
case 'KeyE': keys.et = 1; sendInput(); break;
case 'Space': doBite(); e.preventDefault(); break;
case 'KeyR': sendInput({ rs: 1 }); break;
case 'KeyM':
muted = !muted; localStorage.setItem('dino_mute', muted ? '1' : '0');
popText(me.x, me.y - 50, muted ? '🔇 muted' : '🔊 sound on', '#cfe8c9');
break;
case 'Backquote':
send({ t: 'dev', on: !me.dev });
break;
case 'Digit1':
if (me.dev) send({ t: 'devact', act: 'heal' });
break;
case 'Digit2':
if (me.dev) { send({ t: 'devact', act: 'grow' }); }
break;
case 'Digit3':
if (me.dev) send({ t: 'devact', act: 'spawnai' });
break;
case 'Enter': focusChat(); e.preventDefault(); break;
}
});
window.addEventListener('keyup', (e) => {
switch (e.code) {
case 'KeyW': case 'ArrowUp': keys.u = 0; sendInput(); break;
case 'KeyS': case 'ArrowDown': keys.d = 0; sendInput(); break;
case 'KeyA': case 'ArrowLeft': keys.l = 0; sendInput(); break;
case 'KeyD': case 'ArrowRight': keys.r = 0; sendInput(); break;
case 'ShiftLeft': case 'ShiftRight': keys.sp = 0; sendInput(); break;
case 'KeyE': keys.et = 0; sendInput(); break;
}
});
canvas.addEventListener('mousedown', (e) => {
if (!joined || chatFocused) return;
if (e.button === 0) {
if (e.altKey && me.dev && window.CAM) {
// developer teleport to cursor
const wx = window.CAM.x + (e.clientX - window.CAM.W / 2) / window.CAM.zoom;
const wy = window.CAM.y + (e.clientY - window.CAM.H / 2) / window.CAM.zoom;
send({ t: 'tp', x: clamp(wx, 40, mapW * TILE - 40), y: clamp(wy, 40, mapH * TILE - 40) });
spawnRing(wx, wy, '#e05ce0');
} else {
doBite();
}
}
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
function doBite() { if (joined && !dead && me.alive) { sendInput({ bt: 1 }); me.biteAnim = 0.28; } }
function focusChat() { chatFocused = true; const ci = $('chatInput'); ci.style.display = 'block'; ci.focus(); }
function blurChat() { chatFocused = false; $('chatInput').blur(); }
function sendChat() {
const v = $('chatInput').value.trim();
$('chatInput').value = '';
if (v) send({ t: 'chat', msg: v });
blurChat();
}
/* ================= local prediction ================= */
function tileAtC(wx, wy) {
const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE);
if (tx < 0 || ty < 0 || tx >= mapW || ty >= mapH) return 0;
return tiles[ty * mapW + tx];
}
function blockedC(x, y) {
const W = mapW * TILE, H = mapH * TILE;
if (x < 20 || y < 20 || x > W - 20 || y > H - 20) return true;
return tileAtC(x, y) === 0;
}
function predictMe(dt) {
if (!joined || dead || !me.alive) return;
const spec = SP[me.sp]; if (!spec) return;
let mx = keys.r - keys.l, my = keys.d - keys.u;
const mag = Math.hypot(mx, my);
if (mag > 0) { mx /= mag; my /= mag; }
let target = me.speed;
const t = tileAtC(me.x, me.y);
if (t === 1) target *= 0.55; else if (t === 4) target *= 0.88; else if (t === 2) target *= 0.94;
if (keys.sp && mag > 0 && !me.ex && me.stam > 0) target *= 1.45;
if (me.rest) { mx = 0; my = 0; target = 0; }
const acc = Math.min(1, dt * 8);
me.vx += (mx * target - me.vx) * acc;
me.vy += (my * target - me.vy) * acc;
let nx = me.x + me.vx * dt;
if (!blockedC(nx, me.y)) me.x = nx; else me.vx *= -0.2;
let ny = me.y + me.vy * dt;
if (!blockedC(me.x, ny)) me.y = ny; else me.vy *= -0.2;
const spd = Math.hypot(me.vx, me.vy);
if (spd > 18) me.dir = angLerp(me.dir, Math.atan2(me.vy, me.vx), Math.min(1, spec.turn * dt * clamp(spd / 120, 0, 1)));
me.walkPhase += spd * dt * 0.055;
me.biteAnim = Math.max(0, me.biteAnim - dt);
me.eatAnim = Math.max(0, me.eatAnim - dt);
if (mag === 0 && !keys.sp) me.walkPhase += dt * 1.5;
}
function interpEnts(dt) {
const k = 1 - Math.exp(-11 * dt);
for (const ent of ents.values()) {
if (ent.tx !== undefined) {
ent.px = ent.x;
ent.x += (ent.tx - ent.x) * k;
ent.y += (ent.ty - ent.y) * k;
}
if (ent.tDir !== undefined) ent.dir = angLerp(ent.dir, ent.tDir, Math.min(1, dt * 8));
ent.vel = ent.px === undefined ? 0 : Math.hypot(ent.x - ent.px, ent.y - ent.py) / Math.max(dt, 0.001);
ent.phase += (ent.vel || 0) * dt * 0.055 + dt * 1.2;
}
}
/* ================= particles ================= */
function push(p) { if (parts.length < 600) parts.push(p); }
function spawnBlood(x, y, a, n) {
n = n || 8;
for (let i = 0; i < n; i++) {
const ang = a === undefined ? Math.random() * Math.PI * 2 : a + (Math.random() - 0.5) * 1.6;
const sp2 = 40 + Math.random() * 160;
push({ type: 'blood', x, y, vx: Math.cos(ang) * sp2, vy: Math.sin(ang) * sp2, life: 0.5 + Math.random() * 0.4, max: 0.9, size: 2 + Math.random() * 3.5 });
}
}
function popStars(x, y, color, n) {
for (let i = 0; i < (n || 5); i++) {
const ang = Math.random() * Math.PI * 2;
push({ type: 'star', x, y, vx: Math.cos(ang) * 60, vy: Math.sin(ang) * 60 - 50, life: 0.55, max: 0.55, size: 2 + Math.random() * 2.5, color });
}
}
function popText(x, y, text, color) { push({ type: 'text', x, y, vy: -34, life: 1.15, max: 1.15, text, color }); }
function spawnRipple(x, y) { push({ type: 'ripple', x, y, life: 0.9, max: 0.9 }); }
function spawnSplash(x, y) {
spawnRipple(x, y);
for (let i = 0; i < 10; i++) {
const ang = Math.random() * Math.PI * 2;
const sp2 = 30 + Math.random() * 120;
push({ type: 'drop', x, y, vx: Math.cos(ang) * sp2, vy: Math.sin(ang) * sp2, life: 0.5, max: 0.5, size: 1.5 + Math.random() * 2.5 });
}
}
function spawnRing(x, y, color) { push({ type: 'ring', x, y, life: 1.1, max: 1.1, color }); }
function spawnPoof(x, y) {
for (let i = 0; i < 16; i++) {
const ang = Math.random() * Math.PI * 2;
push({ type: 'poof', x: x + Math.cos(ang) * 10, y: y + Math.sin(ang) * 10, vx: Math.cos(ang) * 55, vy: Math.sin(ang) * 55, life: 0.9, max: 0.9, size: 6 + Math.random() * 10 });
}
spawnBlood(x, y, undefined, 14);
}
function updateParts(dt) {
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.life -= dt;
if (p.life <= 0) { parts.splice(i, 1); continue; }
if (p.vx !== undefined) { p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= (1 - dt * 3); p.vy *= (1 - dt * 3); }
if (p.type === 'drop') p.vy += 260 * dt;
if (p.type === 'text' || p.type === 'zzz') p.y += p.vy * dt;
}
}
function showBanner(text) {
const b = $('banner');
b.textContent = text;
b.classList.remove('hidden');
b.style.animation = 'none';
void b.offsetWidth; // restart animation
b.style.animation = '';
clearTimeout(showBanner._t);
showBanner._t = setTimeout(() => b.classList.add('hidden'), 3100);
}
/* ================= rendering ================= */
const BIOME = {
0: ['#155a86', '#146294'], // deep water
1: ['#2f89b8', '#3793c2'], // shallow
2: ['#dcc38c', '#d4ba82'], // sand
3: ['#69a850', '#71b057'], // grass
4: ['#3f7d3c', '#467f41'], // forest floor
};
function drawTerrain(view) {
const x0 = Math.max(0, Math.floor(view.x0 / TILE)), y0 = Math.max(0, Math.floor(view.y0 / TILE));
const x1 = Math.min(mapW - 1, Math.ceil(view.x1 / TILE)), y1 = Math.min(mapH - 1, Math.ceil(view.y1 / TILE));
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
const t = tiles[ty * mapW + tx];
const h = hash2i(tx, ty);
ctx.fillStyle = BIOME[t][h < 0.5 ? 0 : 1];
ctx.fillRect(tx * TILE, ty * TILE, TILE + 1, TILE + 1);
if (t === 3 && view.zoom > 0.6) { // grass tufts
ctx.strokeStyle = 'rgba(46,102,52,0.55)';
ctx.lineWidth = 1.4;
for (let g = 0; g < 3; g++) {
const gx = tx * TILE + hash2i(tx * 7 + g, ty) * TILE;
const gy = ty * TILE + hash2i(tx, ty * 13 + g) * TILE;
ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx + (h - 0.5) * 6, gy - 5 - g * 2); ctx.stroke();
}
} else if (t === 1) { // shallow shimmer
const w = Math.sin(performance.now() * 0.0016 + tx * 1.7 + ty * 2.3);
ctx.fillStyle = `rgba(255,255,255,${0.05 + 0.05 * w})`;
ctx.fillRect(tx * TILE, ty * TILE + (0.5 + w * 0.2) * TILE * 0.4, TILE + 1, 5);
} else if (t === 0 && view.zoom > 0.6) { // deep waves subtle
const w = Math.sin(performance.now() * 0.001 + tx * 2.1 - ty * 1.3);
if (w > 0.75) { ctx.fillStyle = 'rgba(255,255,255,0.045)'; ctx.fillRect(tx * TILE, ty * TILE + TILE * 0.3, TILE + 1, 4); }
} else if (t === 4 && view.zoom > 0.6) {
ctx.fillStyle = 'rgba(20,48,24,0.35)';
ctx.fillRect(tx * TILE + h * 30, ty * TILE + hash2i(ty, tx) * 30, 4, 3);
}
}
}
// foam pulses near shore
if (decor && decor.foam) {
const now = performance.now() * 0.002;
for (const f of decor.foam) {
if (f.x < view.x0 - 40 || f.x > view.x1 + 40 || f.y < view.y0 - 40 || f.y > view.y1 + 40) continue;
const a = 0.25 + 0.25 * Math.sin(now + f.p);
ctx.strokeStyle = `rgba(255,255,255,${a.toFixed(3)})`;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(f.x, f.y, 10 + 4 * Math.sin(now * 1.3 + f.p), 0, Math.PI * 2); ctx.stroke();
}
}
}
function drawDecor(view) {
if (!decor) return;
const now = performance.now() * 0.001;
for (const r of (decor.rocks || [])) {
if (r.x < view.x0 - 60 || r.x > view.x1 + 60 || r.y < view.y0 - 60 || r.y > view.y1 + 60) continue;
ctx.fillStyle = '#8d8f93';
ctx.beginPath();
ctx.ellipse(r.x, r.y, 14 * r.s, 10 * r.s, r.r * 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.beginPath(); ctx.ellipse(r.x - 3 * r.s, r.y - 3 * r.s, 6 * r.s, 4 * r.s, r.r * 3, 0, Math.PI * 2); ctx.fill();
}
for (const fl of (decor.flowers || [])) {
if (fl.x < view.x0 - 20 || fl.x > view.x1 + 20 || fl.y < view.y0 - 20 || fl.y > view.y1 + 20) continue;
const cols = ['#f2e26b', '#ef8fb0', '#e8f2f2'];
ctx.fillStyle = cols[fl.c];
ctx.beginPath(); ctx.arc(fl.x, fl.y, 2.6, 0, Math.PI * 2); ctx.fill();
}
for (const tr of (decor.trees || [])) {
if (tr.x < view.x0 - 80 || tr.x > view.x1 + 80 || tr.y < view.y0 - 80 || tr.y > view.y1 + 80) continue;
const sway = Math.sin(now * 0.9 + tr.x * 0.05) * 3;
const R = 26 * tr.s;
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath(); ctx.ellipse(tr.x + 8, tr.y + 10, R * 0.95, R * 0.42, 0, 0, Math.PI * 2); ctx.fill();
// trunk
ctx.fillStyle = '#6b4a2c';
ctx.fillRect(tr.x - 4 * tr.s, tr.y - 6, 8 * tr.s, 18);
// canopy blobs
ctx.fillStyle = '#2f6631';
ctx.beginPath(); ctx.arc(tr.x - R * 0.4 + sway, tr.y - R * 0.55, R * 0.72, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(tr.x + R * 0.45 + sway, tr.y - R * 0.45, R * 0.62, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#3d7d3c';
ctx.beginPath(); ctx.arc(tr.x + sway, tr.y - R * 0.85, R * 0.68, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.10)';
ctx.beginPath(); ctx.arc(tr.x - R * 0.2 + sway, tr.y - R * 1.05, R * 0.32, 0, Math.PI * 2); ctx.fill();
}
}
/* ---------- entity drawing ---------- */
function drawPlantsAndCarcasses(view) {
const now = performance.now() * 0.001;
for (const e of ents.values()) {
if (e.x < view.x0 - 80 || e.x > view.x1 + 80 || e.y < view.y0 - 80 || e.y > view.y1 + 80) continue;
if (e.k === 'g') {
const amt = (e.amt ?? 100) / 100;
const tufts = 7;
for (let i = 0; i < tufts; i++) {
const hx = hash2i(e.i + i, 77);
const hy = hash2i(e.i, i * 31 + 5);
const bx = e.x + (hx - 0.5) * 54;
const by = e.y + (hy - 0.5) * 44;
const sway = Math.sin(now * 1.6 + hx * 9) * 3;
ctx.strokeStyle = amt > 0.4 ? '#3f8a3a' : '#7c8f4e';
ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(bx, by);
ctx.quadraticCurveTo(bx + sway, by - 9, bx + sway * 2, by - 15 - amt * 6);
ctx.stroke();
}
if (amt > 0.65) { ctx.fillStyle = 'rgba(255,255,120,0.5)'; ctx.beginPath(); ctx.arc(e.x, e.y - 8, 2.4, 0, Math.PI * 2); ctx.fill(); }
} else if (e.k === 'b') {
const amt = (e.amt ?? 100) / 100;
ctx.fillStyle = '#275c33';
ctx.beginPath(); ctx.arc(e.x - 9, e.y, 13, 0, Math.PI * 2); ctx.arc(e.x + 9, e.y - 3, 12, 0, Math.PI * 2); ctx.arc(e.x, e.y - 10, 12, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.09)';
ctx.beginPath(); ctx.arc(e.x - 4, e.y - 13, 5, 0, Math.PI * 2); ctx.fill();
if (amt > 0.3) {
ctx.fillStyle = '#d8434f';
for (let i = 0; i < 5; i++) {
const bx = e.x + Math.cos(i * 2.4 + e.i) * 11, by = e.y + Math.sin(i * 2.4 + e.i) * 10 - 3;
ctx.beginPath(); ctx.arc(bx, by, 2.6, 0, Math.PI * 2); ctx.fill();
}
}
} else if (e.k === 'k') {
const frac = clamp((e.meat ?? 1) / 6, 0.3, 1);
ctx.save();
ctx.translate(e.x, e.y); ctx.rotate(e.dir || 0);
const S = 0.7 + (e.stage ?? 1) * 0.28;
ctx.scale(S * frac + 0.35, S * frac * 0.8 + 0.3);
ctx.fillStyle = '#7e2f23';
ctx.beginPath(); ctx.ellipse(0, 0, 26, 13, 0, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#e8ddcf'; ctx.lineWidth = 2;
for (let i = -2; i <= 2; i++) { ctx.beginPath(); ctx.arc(i * 8, 0, 9, -Math.PI * 0.85, Math.PI * 0.85); ctx.stroke(); }
ctx.restore();
}
}
}
function drawCritters(view) {
const now = performance.now() * 0.001;
for (const e of ents.values()) {
if (e.k !== 'c' && e.k !== 'f') continue;
if (e.x < view.x0 - 60 || e.x > view.x1 + 60 || e.y < view.y0 - 60 || e.y > view.y1 + 60) continue;
ctx.save();
ctx.translate(e.x, e.y);
if (e.k === 'f') {
ctx.rotate(e.dir || 0);
const wag = Math.sin(now * 12 + e.phase) * 0.5;
ctx.fillStyle = '#bfe3f2';
ctx.strokeStyle = '#5d87a8'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(0, 0, 9, 3.6, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(-8, 0); ctx.lineTo(-14, -4 + wag * 4); ctx.lineTo(-14, 4 + wag * 4); ctx.closePath();
ctx.fillStyle = '#8fc3dc'; ctx.fill();
ctx.fillStyle = '#123';
ctx.beginPath(); ctx.arc(4, -1, 0.9, 0, Math.PI * 2); ctx.fill();
} else {
ctx.rotate(e.dir || 0);
const hop = Math.abs(Math.sin(now * 7 + e.phase * 2)) * 3;
const cols = [['#b58a5a', '#8a6238'], ['#9aa3ad', '#6d757e'], ['#c9b17b', '#97804f'], ['#a5799a', '#7c5673']][e.variant % 4];
ctx.translate(0, -hop);
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath(); ctx.ellipse(0, 4 + hop, 9, 4, 0, 0, Math.PI * 2); ctx.fill();
// body
ctx.fillStyle = cols[0];
ctx.beginPath(); ctx.ellipse(0, 0, 9, 6.5, 0, 0, Math.PI * 2); ctx.fill();
// ears / crest per variant
ctx.fillStyle = cols[1];
if (e.variant % 4 === 0) { // bunny
ctx.beginPath(); ctx.ellipse(4, -6, 2, 5, -0.4, 0, Math.PI * 2); ctx.ellipse(7, -5, 2, 5, 0.3, 0, Math.PI * 2); ctx.fill();
} else if (e.variant % 4 === 1) { // boar
ctx.beginPath(); ctx.moveTo(8, -1); ctx.lineTo(12, -3); ctx.lineTo(8, -4); ctx.fill();
} else if (e.variant % 4 === 2) { // goat
ctx.beginPath(); ctx.moveTo(3, -5); ctx.lineTo(1, -9); ctx.lineTo(5, -7); ctx.fill();
} else { // lizard tail
ctx.beginPath(); ctx.moveTo(-8, 0); ctx.lineTo(-15, Math.sin(now * 8) * 3, ); ctx.lineTo(-14, 2); ctx.fill();
}
// head
ctx.fillStyle = cols[0];
ctx.beginPath(); ctx.arc(7, 0, 4.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#1c1512';
ctx.beginPath(); ctx.arc(9, -1.4, 1.1, 0, Math.PI * 2); ctx.fill();
// tail
if (e.variant % 4 !== 3) {
ctx.strokeStyle = cols[1]; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(-8, 0); ctx.lineTo(-13, Math.sin(now * 6 + e.phase) * 3); ctx.stroke();
}
}
ctx.restore();
}
}
// Procedural dino renderer — the heart of the visuals
// ---------------------------------------------------------------------------
// Shared dino renderer (menu cards + in-game). Side-profile art rotated by
// heading. o:{x,y,dir,r,sp,stage,phase,moving,resting,bite,eat,ci,self,preview}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Shared dino renderer — TRUE TOP-DOWN view (like The Isle's back view):
// spine down the middle, tail swaying laterally, legs striding out both sides,
// head with two eyes. Rotates naturally at any heading.
// o:{x,y,dir,r,sp,stage,phase,moving,resting,bite,eat,ci,self,preview}
// ---------------------------------------------------------------------------
function paintDino(c, o) {
const hueBase = { compy: 95, raptor: 16, trike: 205, rex: 6, dryo: 100, psitt: 50 }[o.sp] ?? 100;
const sat = (o.sp === 'trike' || o.sp === 'psitt') ? 26 : 36;
const hueShift = ((((o.ci || 0) * 47) % 41) + 41) % 41 - 20;
const hue = ((hueBase + hueShift) % 360 + 360) % 360;
const bodyCol = `hsl(${hue},${sat}%,38%)`;
const midCol = `hsl(${hue},${sat}%,46%)`;
const darkCol = `hsl(${hue},${sat + 10}%,22%)`;
const spineHi = `hsl(${hue},${sat - 6}%,52%)`;
const bellySide= `hsl(${hue},${sat}%,30%)`;
const accCol = `hsl(${hue},${sat}%,66%)`;
const R = o.r;
const px = R / 17;
const walking = o.moving;
const stride = walking ? Math.sin(o.phase * 2) : Math.sin(o.phase * 0.8) * 0.15;
const sway = (walking ? 1 : 0.45) * Math.sin(o.phase * 0.9);
const bob = o.resting ? 0 : Math.abs(Math.sin(o.phase)) * 1.2 * px;
const jawOpen = o.bite > 0 ? 1 : (o.eat > 0 ? 0.45 : 0);
c.save();
c.translate(o.x, o.y);
// ground shadow directly under the mass
c.fillStyle = 'rgba(0,0,0,0.20)';
c.beginPath();
c.ellipse(-R * 0.1, R * 0.10, R * 1.9, R * 0.55, 0, 0, Math.PI * 2);
c.fill();
c.rotate(o.dir);
if (o.resting) { c.scale(0.94, 1); }
c.translate(0, -bob * 0.4);
// subtle lateral body sway while moving
if (walking) c.translate(0, Math.sin(o.phase * 2) * R * 0.03);
if (!isTri(o.sp)) {
/* ================= THEROPOD (top-down) ================= */
const carn = o.sp === 'rex' || o.sp === 'raptor';
const hipW = ({ rex: 0.50, raptor: 0.42, compy: 0.34 }[o.sp] ?? 0.40);
// ---- hind legs stride out BOTH sides (the signature top-down read) ----
for (const side of [-1, 1]) {
const ph = stride * side; // opposite phase per leg
const kneeX = -R * 0.28, kneeY = side * hipW * R * 0.72;
// thigh (mostly hidden under hips)
c.fillStyle = bellySide;
c.beginPath();
c.ellipse(kneeX - Math.abs(ph) * R * 0.05, kneeY * 0.82, R * 0.30, R * 0.17, side * 0.5, 0, Math.PI * 2);
c.fill();
// shin+foot pad reaching outward-forward
const fx = kneeX + R * 0.16 + ph * R * 0.34;
const fy = side * (hipW * R * 0.95 + Math.abs(ph) * R * 0.06);
c.fillStyle = darkCol;
c.beginPath();
c.ellipse(fx, fy, R * 0.19, R * 0.105, side * 0.25 + ph * 0.12, 0, Math.PI * 2);
c.fill();
// toes
c.strokeStyle = `hsl(${hue},${sat + 10}%,16%)`; c.lineWidth = 1.6 * px; c.lineCap = 'round';
for (let ttoe = -1; ttoe <= 1; ttoe++) {
c.beginPath();
c.moveTo(fx + R * 0.10, fy + ttoe * R * 0.055);
c.lineTo(fx + R * 0.21 + ph * R * 0.06, fy + ttoe * R * 0.075);
c.stroke();
}
if (o.sp === 'raptor') { // sickle claw
c.strokeStyle = '#e8e4da'; c.lineWidth = 1.3 * px;
c.beginPath();
c.moveTo(fx - R * 0.02, fy + side * R * 0.09);
c.quadraticCurveTo(fx - R * 0.10, fy + side * R * 0.13, fx - R * 0.16, fy + side * R * 0.08);
c.stroke();
}
}
// ---- tail: slender ribbon swaying LATERALLY ----
const tailSway = sway * R * 0.42;
const tail = new Path2D();
tail.moveTo(-R * 0.62, -R * 0.17);
tail.bezierCurveTo(
-R * 1.35, tailSway * 0.35 - R * 0.13,
-R * 2.0, tailSway * 0.85 - R * 0.07,
-R * 2.55, tailSway
);
tail.lineTo(-R * 2.45, tailSway + R * 0.06);
tail.bezierCurveTo(
-R * 1.9, tailSway * 0.8 + R * 0.10,
-R * 1.3, tailSway * 0.32 + R * 0.15,
-R * 0.62, R * 0.18
);
tail.closePath();
c.fillStyle = darkCol; c.fill(tail);
// tail ridge line
c.strokeStyle = `hsl(${hue},${sat}%,15%)`; c.lineWidth = 1.6 * px;
c.beginPath();
c.moveTo(-R * 0.70, 0);
c.quadraticCurveTo(-R * 1.6, tailSway * 0.55, -R * 2.45, tailSway + R * 0.02);
c.stroke();
// ---- torso: chest → waist → hips, elongated ----
const torso = new Path2D();
torso.ellipse(R * 0.40, 0, R * 0.62, R * 0.50, 0, 0, Math.PI * 2); // deep chest
torso.moveTo(-R * 0.92, 0);
torso.ellipse(-R * 0.34, 0, R * 0.58, hipW * R * 0.88, 0, 0, Math.PI * 2); // hips
c.fillStyle = bodyCol; c.fill(torso);
// flank shading (darker sides, lit spine)
c.save(); c.clip(torso);
const grad = c.createLinearGradient(0, -hipW * R, 0, hipW * R);
grad.addColorStop(0, 'rgba(0,0,0,0.35)');
grad.addColorStop(0.42, 'rgba(255,255,255,0.10)');
grad.addColorStop(0.58, 'rgba(255,255,255,0.10)');
grad.addColorStop(1, 'rgba(0,0,0,0.35)');
c.fillStyle = grad; c.fillRect(-R * 1.1, -R, R * 2.2, R * 2);
// pattern across the back
if (carn) {
c.strokeStyle = darkCol; c.globalAlpha = 0.45; c.lineWidth = 3 * px; c.lineCap = 'round';
for (let i = 0; i < 4; i++) {
const sx = -R * 0.7 + i * R * 0.44;
c.beginPath(); c.moveTo(sx, -R * 0.34); c.lineTo(sx + R * 0.10, R * 0.34); c.stroke();
}
c.globalAlpha = 1;
} else {
c.fillStyle = spineHi; c.globalAlpha = 0.4;
for (let i = 0; i < 5; i++) {
c.beginPath();
c.ellipse(-R * 0.65 + i * R * 0.33, ((i % 2) - 0.5) * R * 0.3, R * 0.08, R * 0.05, 0, 0, Math.PI * 2);
c.fill();
}
c.globalAlpha = 1;
}
c.restore();
// ---- spine ridge ----
c.strokeStyle = darkCol; c.lineWidth = 2.2 * px; c.lineCap = 'round';
c.beginPath(); c.moveTo(-R * 0.85, 0); c.lineTo(R * 0.85, 0); c.stroke();
if (o.sp === 'rex') { // osteoderm studs on back
c.fillStyle = darkCol;
for (let i = 0; i < 5; i++) {
c.beginPath();
c.arc(-R * 0.7 + i * R * 0.34, 0, 1.7 * px, 0, Math.PI * 2);
c.fill();
}
}
// silhouette outline
c.strokeStyle = 'rgba(12,8,5,0.45)'; c.lineWidth = Math.max(1, 1.5 * px);
c.stroke(torso);
// ---- tiny forearms ----
c.strokeStyle = darkCol; c.lineWidth = 2.2 * px; c.lineCap = 'round';
for (const side of [-1, 1]) {
c.beginPath();
c.moveTo(R * 0.66, side * R * 0.14);
c.quadraticCurveTo(R * 0.84, side * R * 0.22, R * 0.90, side * R * 0.30);
c.stroke();
}
// ---- neck tapering to head ----
c.strokeStyle = bodyCol; c.lineWidth = R * 0.34; c.lineCap = 'round';
c.beginPath();
c.moveTo(R * 0.58, 0);
c.quadraticCurveTo(R * 0.95, -sway * R * 0.06, R * 1.22, 0);
c.stroke();
// ---- head from above: wedge skull, TWO eyes, jaw splits when biting ----
c.save();
c.translate(R * 1.22, 0);
c.rotate(Math.sin(o.phase * 2) * 0.02);
const hl = R * ({ rex: 0.98, raptor: 0.80, compy: 0.62, dryo: 0.60, psitt: 0.55 }[o.sp] ?? 0.68)
* (o.stage === 0 ? 1.08 : 1);
const hw = R * ({ rex: 0.30, raptor: 0.24, compy: 0.20 }[o.sp] ?? 0.24); // half width of skull
// lower mandibles (visible splaying when jaw opens)
if (jawOpen > 0) {
c.strokeStyle = darkCol; c.lineWidth = hw * 0.55; c.lineCap = 'round';
for (const side of [-1, 1]) {
c.beginPath();
c.moveTo(hl * 0.10, side * hw * 0.25);
c.quadraticCurveTo(hl * 0.55, side * (hw * 0.35 + jawOpen * hw * 0.55), hl * 0.92, side * (hw * 0.18 + jawOpen * hw * 0.75));
c.stroke();
}
// teeth ticks between mandibles
if (carn && R > 12) {
c.strokeStyle = '#f3efe4'; c.lineWidth = 1.2 * px;
for (let i = 0; i < 3; i++) {
const txm = hl * (0.35 + i * 0.17);
for (const side of [-1, 1]) {
c.beginPath();
c.moveTo(txm, side * hw * (0.22 + jawOpen * 0.35));
c.lineTo(txm + hl * 0.05, side * hw * (0.30 + jawOpen * 0.48));
c.stroke();
}
}
}
}
// upper skull
const skull = new Path2D();
skull.moveTo(-hl * 0.28, -hw * 1.05);
skull.quadraticCurveTo(-hl * 0.05, -hw * 1.25, hl * 0.30, -hw * 0.92); // brow right side... (left flank)
skull.quadraticCurveTo(hl * 0.78, -hw * 0.55, hl, -hw * 0.16); // snout left edge to nose
skull.quadraticCurveTo(hl * 1.04, 0, hl, hw * 0.16); // nose tip round
skull.quadraticCurveTo(hl * 0.78, hw * 0.55, hl * 0.30, hw * 0.92);
skull.quadraticCurveTo(-hl * 0.05, hw * 1.25, -hl * 0.28, hw * 1.05);
skull.quadraticCurveTo(-hl * 0.42, 0, -hl * 0.28, -hw * 1.05);
skull.closePath();
c.fillStyle = bodyCol; c.fill(skull);
// snout center ridge + shading sides
c.save(); c.clip(skull);
const hg = c.createLinearGradient(0, -hw, 0, hw);
hg.addColorStop(0, 'rgba(0,0,0,0.30)');
hg.addColorStop(0.45, 'rgba(255,255,255,0.08)');
hg.addColorStop(0.55, 'rgba(255,255,255,0.08)');
hg.addColorStop(1, 'rgba(0,0,0,0.30)');
c.fillStyle = hg; c.fillRect(-hl, -hw * 1.4, hl * 2.2, hw * 2.8);
c.restore();
c.strokeStyle = 'rgba(12,8,5,0.45)'; c.lineWidth = Math.max(1, 1.3 * px);
c.stroke(skull);
// nostrils
c.fillStyle = darkCol;
c.beginPath(); c.arc(hl * 0.87, -hw * 0.28, R * 0.026, 0, Math.PI * 2); c.fill();
c.beginPath(); c.arc(hl * 0.87, hw * 0.28, R * 0.026, 0, Math.PI * 2); c.fill();
// TWO eyes on the flanks
for (const side of [-1, 1]) {
c.fillStyle = '#f2c94c';
c.beginPath(); c.ellipse(hl * 0.16, side * hw * 0.78, R * 0.075, R * 0.06, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = '#181310';
c.beginPath(); c.ellipse(hl * 0.18, side * hw * 0.78, R * 0.026, R * 0.045, 0, 0, Math.PI * 2); c.fill();
if (carn) { // brow ridge
c.strokeStyle = darkCol; c.lineWidth = 1.8 * px;
c.beginPath();
c.moveTo(hl * 0.02, side * hw * 0.98);
c.lineTo(hl * 0.32, side * hw * 0.92);
c.stroke();
}
}
c.restore();
} else {
/* ================= TRICERATOPS (top-down) ================= */
// four feet striding in pairs
c.fillStyle = darkCol;
for (const [bx, side] of [[-R * 0.55, -1], [-R * 0.55, 1], [R * 0.45, -1], [R * 0.45, 1]]) {
const ph = stride * side * (bx > 0 ? -1 : 1);
c.beginPath();
c.ellipse(bx + ph * R * 0.2, side * R * 0.62, R * 0.17, R * 0.11, side * 0.15, 0, Math.PI * 2);
c.fill();
}
// short tail swaying
const ts = sway * R * 0.25;
c.fillStyle = darkCol;
c.beginPath();
c.moveTo(-R * 0.95, -R * 0.14);
c.quadraticCurveTo(-R * 1.45, ts - R * 0.08, -R * 1.7, ts);
c.quadraticCurveTo(-R * 1.4, ts + R * 0.10, -R * 0.95, R * 0.15);
c.closePath(); c.fill();
// wide armored body
const body = new Path2D();
body.ellipse(-R * 0.08, 0, R * 0.98, R * 0.62, 0, 0, Math.PI * 2);
c.fillStyle = bodyCol; c.fill(body);
c.save(); c.clip(body);
const g2 = c.createLinearGradient(0, -R * 0.64, 0, R * 0.64);
g2.addColorStop(0, 'rgba(0,0,0,0.32)');
g2.addColorStop(0.45, 'rgba(255,255,255,0.09)');
g2.addColorStop(0.55, 'rgba(255,255,255,0.09)');
g2.addColorStop(1, 'rgba(0,0,0,0.32)');
c.fillStyle = g2; c.fillRect(-R * 1.2, -R, R * 2.4, R * 2);
c.restore();
// scute rows on the back
c.fillStyle = darkCol; c.globalAlpha = 0.5;
for (let i = 0; i < 4; i++)
for (let j = -1; j <= 1; j += 2) {
c.beginPath();
c.arc(-R * 0.6 + i * R * 0.38, j * R * 0.18, 1.8 * px, 0, Math.PI * 2);
c.fill();
}
c.globalAlpha = 1;
c.strokeStyle = 'rgba(12,8,5,0.45)'; c.lineWidth = Math.max(1, 1.5 * px);
c.stroke(body);
// frill — big fan behind the head
const frill = new Path2D();
frill.ellipse(R * 0.62, 0, R * 0.42, R * 0.72, 0, 0, Math.PI * 2);
c.fillStyle = darkCol; c.fill(frill);
c.strokeStyle = accCol; c.globalAlpha = 0.5; c.lineWidth = 2 * px;
c.stroke(frill); c.globalAlpha = 1;
// head + three horns pointing forward
c.fillStyle = bodyCol;
c.beginPath(); c.ellipse(R * 1.18, 0, R * 0.34, hwTri(o), 0, 0, Math.PI * 2); c.fill();
c.strokeStyle = '#efe6cd'; c.lineWidth = 3.4 * px; c.lineCap = 'round';
for (const side of [-1, 1]) {
c.beginPath(); c.moveTo(R * 1.28, side * R * 0.16);
c.quadraticCurveTo(R * 1.55, side * R * 0.22, R * 1.72, side * R * 0.10);
c.stroke();
}
c.lineWidth = 2.8 * px;
c.beginPath(); c.moveTo(R * 1.44, 0);
c.quadraticCurveTo(R * 1.60, 0, R * 1.70, R * 0.03);
c.stroke();
// beak tip
c.fillStyle = '#d8cfb4';
c.beginPath(); c.moveTo(R * 1.46, -R * 0.09); c.lineTo(R * 1.62, 0); c.lineTo(R * 1.46, R * 0.09);
c.closePath(); c.fill();
// eyes at frill edges
for (const side of [-1, 1]) {
c.fillStyle = '#f2c94c';
c.beginPath(); c.arc(R * 1.10, side * R * 0.20, R * 0.055, 0, Math.PI * 2); c.fill();
c.fillStyle = '#181310';
c.beginPath(); c.arc(R * 1.11, side * R * 0.20, R * 0.026, 0, Math.PI * 2); c.fill();
}
c.strokeStyle = 'rgba(12,8,5,0.45)'; c.lineWidth = Math.max(1, 1.3 * px);
c.stroke(frill);
}
c.restore();
// resting zzz (in-game only)
if (!o.preview && o.resting && Math.random() < 0.03) {
push({ type: 'text', x: o.x + R, y: o.y - R * 1.4, vy: -20, life: 1.2, max: 1.2, text: '💤', color: '#cfe8ff' });
}
}
function isTri(sp) { return sp === 'trike'; }
function hwTri(o) { return ({ trike: 0.22 })[o.sp] ? o.r * 0.22 : o.r * 0.20; }
function drawDino(o) { paintDino(ctx, o); }
function drawEntities(view) {
// carcasses & plants first (ground layer), then critters/fish, then dinos
drawPlantsAndCarcasses(view);
drawCritters(view);
const now = performance.now() * 0.001;
const list = [];
const aiList = [];
for (const e of ents.values()) {
if (e.k === 'p') list.push(e);
else if (e.k === 'd') aiList.push(e);
}
aiList.sort((a, b) => a.y - b.y);
for (const e of aiList) {
const spKey = e.sp || e.s; // snapshot merge stores it as .sp
const spec = SP[spKey]; if (!spec) continue;
const r = spec.radius * STAGE_SCALE[e.stage || 1];
drawDino({
x: e.x, y: e.y, dir: e.dir, r,
sp: spKey, stage: e.stage || 1, phase: e.phase || 0,
moving: (e.vel || 0) > 26, resting: false,
bite: 0, eat: 0, ci: e.i % 360, self: false,
});
if ((e.hpFrac ?? 100) < 100) drawLabel(e.x, e.y - r * 1.9, '', e.hpFrac, r);
}
for (const e of list) {
const spec = SP[e.sp]; if (!spec) continue;
drawDino({
x: e.x, y: e.y, dir: e.dir, r: spec.radius * STAGE_SCALE[e.stage || 0],
sp: e.sp, stage: e.stage || 0, phase: e.phase || 0,
moving: (e.vel || 0) > 26, resting: !!e.rest,
bite: 0, eat: 0, ci: e.ci, self: false,
});
}
// self on top
if (joined && me.alive) {
const spec = SP[me.sp];
if (spec) {
drawDino({
x: me.x, y: me.y, dir: me.dir, r: me.radius,
sp: me.sp, stage: me.stage, phase: me.walkPhase,
moving: Math.hypot(me.vx, me.vy) > 26, resting: !!me.rest,
bite: me.biteAnim, eat: me.eatAnim, ci: myId * 47 % 360, self: true,
});
// selection ring
ctx.strokeStyle = 'rgba(134,226,155,0.5)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(me.x, me.y + me.radius * 0.5, me.radius * 1.35, me.radius * 0.6, 0, 0, Math.PI * 2); ctx.stroke();
}
}
// labels & hp bars above dinos (screen-constant size)
for (const e of list) {
const spec = SP[e.sp]; if (!spec) continue;
const r = spec.radius * STAGE_SCALE[e.stage || 0];
drawLabel(e.x, e.y - r * 1.9, e.name || '?', e.hpFrac, r);
}
}
function drawLabel(x, y, name, hpFrac, r) {
const fs = 12 / view.zoom;
if (name) {
ctx.font = `700 ${fs}px 'Segoe UI', sans-serif`;
ctx.textAlign = 'center';
ctx.lineWidth = 3 / view.zoom;
ctx.strokeStyle = 'rgba(0,0,0,0.7)';
ctx.strokeText(name, x, y);
ctx.fillStyle = '#fff';
ctx.fillText(name, x, y);
}
if (hpFrac !== undefined && hpFrac < 0.995) {
const w = 46 / view.zoom, h = 5 / view.zoom;
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(x - w / 2, y - h - 4 / view.zoom, w, h);
ctx.fillStyle = hpFrac > 0.5 ? '#7ade6a' : hpFrac > 0.25 ? '#e8c04a' : '#e05252';
ctx.fillRect(x - w / 2, y - h - 4 / view.zoom, w * clamp(hpFrac, 0, 1), h);
}
}
function drawParts() {
for (const p of parts) {
const lf = p.life / p.max;
switch (p.type) {
case 'blood':
ctx.globalAlpha = lf;
ctx.fillStyle = '#b32020';
ctx.beginPath(); ctx.arc(p.x, p.y, p.size * lf + 1, 0, Math.PI * 2); ctx.fill();
break;
case 'star':
ctx.globalAlpha = lf;
ctx.fillStyle = p.color || '#ffd97a';
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(lf * 5);
ctx.fillRect(-p.size, -p.size, p.size * 2, p.size * 2);
ctx.restore();
break;
case 'text':
ctx.globalAlpha = Math.min(1, lf * 1.6);
ctx.font = `800 ${p.text.length > 8 ? 13 : 16}px 'Segoe UI', sans-serif`;
ctx.textAlign = 'center';
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(0,0,0,.7)';
ctx.strokeText(p.text, p.x, p.y);
ctx.fillStyle = p.color || '#fff';
ctx.fillText(p.text, p.x, p.y);
break;
case 'ripple': {
ctx.globalAlpha = lf * 0.6;
ctx.strokeStyle = '#dff4ff'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(p.x, p.y, (1 - lf) * 46 + 6, (1 - lf) * 22 + 3, 0, 0, Math.PI * 2); ctx.stroke();
break;
}
case 'drop':
ctx.globalAlpha = lf;
ctx.fillStyle = '#bfe3f2';
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
break;
case 'ring': {
ctx.globalAlpha = lf;
ctx.strokeStyle = p.color; ctx.lineWidth = 5 * lf + 1;
ctx.beginPath(); ctx.arc(p.x, p.y, (1 - lf) * 160 + 20, 0, Math.PI * 2); ctx.stroke();
break;
}
case 'poof':
ctx.globalAlpha = lf * 0.5;
ctx.fillStyle = '#5a5148';
ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (1.6 - lf), 0, Math.PI * 2); ctx.fill();
break;
}
ctx.globalAlpha = 1;
}
}
/* ---------- minimap ---------- */
const mmBase = document.createElement('canvas');
mmBase.width = 164; mmBase.height = 164;
function buildMinimapBase() {
const mc = mmBase.getContext('2d');
const sx = 164 / mapW, sy = 164 / mapH;
const colr = ['#155a86', '#2f89b8', '#dcc38c', '#69a850', '#3f7d3c'];
for (let y = 0; y < mapH; y++)
for (let x = 0; x < mapW; x++) {
mc.fillStyle = colr[tiles[y * mapW + x]];
mc.fillRect(Math.floor(x * sx), Math.floor(y * sy), Math.ceil(sx), Math.ceil(sy));
}
}
function drawMinimap() {
if (!tiles) return;
const mm = $('minimap');
const mc = mm.getContext('2d');
mc.clearRect(0, 0, 164, 164);
mc.drawImage(mmBase, 0, 0);
const W = mapW * TILE, H = mapH * TILE;
const toMx = (x) => x / W * 164, toMy = (y) => y / H * 164;
// players
for (const e of ents.values()) {
if (e.k !== 'p') continue;
mc.fillStyle = '#ff5d4a';
mc.beginPath(); mc.arc(toMx(e.x), toMy(e.y), 2.4, 0, Math.PI * 2); mc.fill();
}
// me
if (joined && me.alive) {
mc.fillStyle = '#fff';
mc.beginPath(); mc.arc(toMx(me.x), toMy(me.y), 3, 0, Math.PI * 2); mc.fill();
mc.strokeStyle = 'rgba(255,255,255,.8)';
mc.lineWidth = 1;
mc.beginPath(); mc.arc(toMx(me.x), toMy(me.y), 5.5, 0, Math.PI * 2); mc.stroke();
}
}
/* ---------- HUD ---------- */
function setBar(id, frac, warn) {
const el = $(id);
el.style.width = clamp(frac * 100, 0, 100) + '%';
el.parentElement.style.borderColor = warn ? 'rgba(255,90,60,.8)' : '';
}
let lastHudUpdate = 0;
function updateHUD(nowMs) {
if (nowMs - lastHudUpdate < 80) return;
lastHudUpdate = nowMs;
setBar('barHp', me.hp / Math.max(me.maxHp, 1), me.hp / me.maxHp < 0.28);
setBar('barStam', me.stam / 100, me.ex === 1);
setBar('barFood', me.food / 100, me.food < 22);
setBar('barWater', me.water / 100, me.water < 22);
$('stageLabel').textContent = STAGE_NAMES[me.stage] || '';
$('barGrowth').style.width = (me.need ? clamp(me.xp / me.need, 0, 1) * 100 : 0) + '%';
$('devBadge').classList.toggle('hidden', !me.dev);
// damage vignette fade
if (vignetteT > 0) {
vignetteT = Math.max(0, vignetteT - 0.05);
$('dmgVignette').style.opacity = vignetteT.toFixed(2);
}
}
/* ---------- main loop ---------- */
let lastFrame = performance.now();
function frame(nowMs) {
requestAnimationFrame(frame);
let dt = (nowMs - lastFrame) / 1000;
lastFrame = nowMs;
dt = Math.min(dt, 0.1);
predictMe(dt);
interpEnts(dt);
updateParts(dt);
shake = Math.max(0, shake - dt * 30);
const W = canvas.width / DPR, H = canvas.height / DPR;
const zoom = (ZOOMS[me.stage] || 0.86) * clamp(W / 1280, 0.72, 1.15);
const camX = me.x + Math.cos(me.dir) * 40;
const camY = me.y + Math.sin(me.dir) * 40;
const shx = shake > 0 ? (Math.random() - 0.5) * shake : 0;
const shy = shake > 0 ? (Math.random() - 0.5) * shake : 0;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.fillStyle = '#0d3a55';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2 + shx, H / 2 + shy);
ctx.scale(zoom, zoom);
ctx.translate(-camX, -camY);
const view = { x0: camX - W / 2 / zoom - 60, y0: camY - H / 2 / zoom - 60, x1: camX + W / 2 / zoom + 60, y1: camY + H / 2 / zoom + 60, zoom };
window.view = view;
window.CAM = { x: camX, y: camY, zoom, W, H };
if (tiles) {
drawTerrain(view);
drawDecor(view);
drawEntities(view);
drawParts();
}
ctx.restore();
// ---- day/night overlay ----
const sun = Math.sin(dayT * Math.PI * 2);
const darkness = clamp(0.66 - sun * 0.95, 0, 0.66);
if (darkness > 0.01) {
const od = document.createElement('canvas'); // cheap: reuse single buffer
if (!frame._night) frame._night = document.createElement('canvas');
const nc = frame._night;
nc.width = W; nc.height = H;
const nx = nc.getContext('2d');
nx.clearRect(0, 0, W, H);
nx.fillStyle = `rgba(9,12,38,${darkness.toFixed(3)})`;
nx.fillRect(0, 0, W, H);
// light hole around player
const px = W / 2, py = H / 2;
const lr = (300 + me.stage * 130) * zoom;
const grad = nx.createRadialGradient(px, py, lr * 0.25, px, py, lr);
grad.addColorStop(0, 'rgba(0,0,0,1)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
nx.globalCompositeOperation = 'destination-out';
nx.fillStyle = grad;
nx.fillRect(px - lr, py - lr, lr * 2, lr * 2);
nx.globalCompositeOperation = 'source-over';
ctx.drawImage(nc, 0, 0);
}
drawMinimap();
updateHUD(nowMs);
}
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(innerWidth * DPR);
canvas.height = Math.floor(innerHeight * DPR);
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
}
window.addEventListener('resize', resize);
/* ================= boot ================= */
if (window.CanvasRenderingContext2D && !CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h) {
this.rect(x, y, w, h);
return this;
};
}
resize();
setupMenu();
connect();
requestAnimationFrame(frame);