- Zero-dependency Node server: HTTP static + hand-rolled RFC6455 WebSocket + 20Hz authoritative simulation - Seeded island worldgen (tiles, forest decor, spawns) - 4 playable species, needs (food/water/stamina), rest, growth stages Hatchling->Apex - AI fauna: critters, fish shoals, Dryosaurus/Psittacosaurus herds; carcass feasting - PvP with knockback, kill feed, chat, leaderboard, minimap, day/night cycle - True top-down procedural dino renderer shared by menu cards and in-game - Developer mode (` key): god-mode, x6 XP, instant evolve, spawn AI, Alt+Click tp - Headless smoke tests, real-browser visual tests, pixel match checks
1428 lines
55 KiB
JavaScript
1428 lines
55 KiB
JavaScript
// ---------------------------------------------------------------
|
|
// 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 = `<div class="v">${v}</div><div class="l">${l}</div>`;
|
|
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',
|
|
`<div class="sb"><i>${lbl}</i><div class="sbd"><div class="sbf" style="width:${v / 5 * 100}%"></div></div></div>`);
|
|
}
|
|
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);
|