// --------------------------------------------------------------- // 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', `
${lbl}
`); } 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);