From 764da2ee6ccd06b4c51aa8ef6e0c5210d42f4793 Mon Sep 17 00:00:00 2001 From: deepseek Date: Sun, 23 Aug 2026 07:00:08 +0000 Subject: [PATCH] Dino Isle Online: multiplayer dino survival game (The Isle-like) - 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 --- .gitignore | 3 + README.md | 73 +++ package.json | 11 + public/client.js | 1427 +++++++++++++++++++++++++++++++++++++++++++ public/index.html | 87 +++ public/style.css | 154 +++++ server.js | 120 ++++ sim.js | 911 +++++++++++++++++++++++++++ test/matchcheck.mjs | 82 +++ test/pixcheck.mjs | 57 ++ test/smoke.mjs | 230 +++++++ test/visual.mjs | 84 +++ worldgen.js | 121 ++++ ws.js | 149 +++++ 14 files changed, 3509 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public/client.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server.js create mode 100644 sim.js create mode 100644 test/matchcheck.mjs create mode 100644 test/pixcheck.mjs create mode 100644 test/smoke.mjs create mode 100644 test/visual.mjs create mode 100644 worldgen.js create mode 100644 ws.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bde971 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# 🦖 Dino Isle Online + +A multiplayer dino-survival island game in the spirit of *The Isle* — playable +in any browser. Eat, drink, rest, grow through 4 evolution stages, hunt AI prey, +and devour the other players. + +**Zero dependencies** — pure Node.js (HTTP + hand-rolled WebSocket) and vanilla +JS canvas on the client. + +## ▶ Play + +``` +node server.js # default port 8095 (PORT=xxxx node server.js to change) +``` + +Open **http://localhost:8095** — open more tabs / other devices on the LAN for +multiplayer. Everyone plays on the same persistent island. + +## 🎮 How to survive + +| Key | Action | +|---|---| +| `WASD` / arrows | Move | +| `Shift` | Sprint (drains stamina) | +| `Space` / left click | Bite (hunt & fight) | +| `E` (hold) | Context action: eat carcass · graze plants · drink at water | +| `R` | Rest — regenerates stamina/HP fast, but you're defenseless | +| `Enter` | Chat · `M` mute | + +### Survival loop +- **Food** decays constantly; sprinting drains it faster. Starving hurts. +- **Water** decays faster; stand in shallow water or at the shore and hold `E`. +- **Health** only regenerates when fed & hydrated — or fast while resting. +- **Growth**: every meal gives XP → **Hatchling → Adolescent → Adult → Apex**. + Each stage makes you visibly bigger, stronger and tougher (camera zooms out). +- Diets matter: herbivores graze grass patches & berry bushes, carnivores hunt, + omnivores eat everything. +- **Fauna**: skittish critters roam land, fish shoals dart around shallows — wade + in and time your bite when they tire — and **AI dinosaur herds** (*Dryosaurus*, + *Psittacosaurus*) graze the plains: medium prey that flee hard but drop big + carcasses worth feasting on. +- **PvP**: everyone can attack everyone. Kills drop a carcass — feast on your + rivals for big XP. Death shows a recap screen; respawn keeps playing. +- Watch the day/night cycle — nights are dark and dangerous. + +### 🛠 Developer mode (testing helpers) +Press **`` ` ``** (backquote) in-game to toggle. While active: +- God-mode: no hunger/thirst decay, HP & stamina stay full +- **×6 growth XP**, ×2.5 bite damage, +28% speed +- Hotkeys: **`1`** full heal · **`2`** evolve one stage instantly · + **`3`** spawn an AI dino nearby · **`Alt+Click`** teleport to cursor + +## 🧪 Tests + +``` +node test/smoke.mjs # headless 2-player integration test (join, sync, move, + # hunt, AI-dino hunt, PvP kill, carcass feast, respawn, + # chat, developer mode) +node test/visual.mjs # real-browser UI test via playwright-core (menu → play) +``` + +## 🗂 Structure + +``` +server.js HTTP static server + WebSocket upgrade + game loop (20 Hz tick) +sim.js Authoritative simulation: species, needs, growth, AI, combat +worldgen.js Seeded island generation (tiles, forest decor, spawns) +ws.js Minimal RFC6455 WebSocket implementation (no deps) +public/ index.html · style.css · client.js (canvas renderer, prediction) +test/smoke.mjs Headless multiplayer smoke test +``` + +Notes: `DEBUG_CHEATS=1` enables a `{t:'tp'}` teleport message used by tests. diff --git a/package.json b/package.json new file mode 100644 index 0000000..1cb7e5d --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "dino-isle-online", + "version": "1.0.0", + "description": "Multiplayer dino survival island game (web, zero dependencies)", + "main": "server.js", + "type": "commonjs", + "scripts": { + "start": "node server.js" + }, + "license": "MIT" +} diff --git a/public/client.js b/public/client.js new file mode 100644 index 0000000..a717689 --- /dev/null +++ b/public/client.js @@ -0,0 +1,1427 @@ +// --------------------------------------------------------------- +// 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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..fa9b7bd --- /dev/null +++ b/public/index.html @@ -0,0 +1,87 @@ + + + + + +Dino Isle Online + + + + + + + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..ed3abde --- /dev/null +++ b/public/style.css @@ -0,0 +1,154 @@ +/* Dino Isle Online — styles */ +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { width: 100%; height: 100%; overflow: hidden; background: #0b1520; } +body { font-family: 'Segoe UI', 'Trebuchet MS', system-ui, sans-serif; color: #e8f0e0; user-select: none; } + +#game { position: fixed; inset: 0; display: block; cursor: crosshair; } + +.hidden { display: none !important; } + +/* ---------- overlays ---------- */ +.overlay { + position: fixed; inset: 0; z-index: 50; + display: flex; align-items: center; justify-content: center; + background: radial-gradient(ellipse at 50% 30%, rgba(20,60,50,.55), rgba(4,10,14,.92)); + backdrop-filter: blur(3px); +} +.menu-box { + max-width: 680px; width: min(94vw, 680px); max-height: 94vh; overflow-y: auto; + background: linear-gradient(160deg, rgba(16,38,32,.96), rgba(10,22,26,.96)); + border: 1px solid #2f5c46; border-radius: 18px; + padding: 28px 34px 24px; text-align: center; + box-shadow: 0 24px 70px rgba(0,0,0,.65), inset 0 1px 0 rgba(255,255,255,.06); +} +.title { font-size: 42px; letter-spacing: 2px; color: #b6f09c; text-shadow: 0 3px 0 #23421f, 0 8px 24px rgba(0,0,0,.5); } +.title span { color: #7fd7c4; font-size: 20px; vertical-align: middle; letter-spacing: 6px; } +.subtitle { color: #9dbfa8; margin: 6px 0 18px; font-style: italic; } + +#nameInput { + width: 100%; padding: 11px 14px; border-radius: 10px; outline: none; + border: 1px solid #35604a; background: rgba(6,16,14,.8); color: #dff5e1; + font-size: 17px; text-align: center; margin-bottom: 14px; +} +#nameInput:focus { border-color: #6fc98d; box-shadow: 0 0 0 3px rgba(111,201,141,.15); } + +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; margin-bottom: 16px; } +.card { + background: rgba(10,25,20,.75); border: 1px solid #2b4a39; border-radius: 12px; + padding: 12px 10px 10px; cursor: pointer; transition: all .13s ease; position: relative; +} +.card:hover { transform: translateY(-2px); border-color: #57a06f; } +.card.sel { border-color: #86e29b; background: rgba(30,64,40,.85); box-shadow: 0 0 0 2px rgba(134,226,155,.25), 0 8px 20px rgba(0,0,0,.4); } +.card canvas { width: 100%; height: 74px; display: block; } +.card h4 { font-size: 14.5px; color: #cdeec9; margin: 6px 0 2px; } +.card .diet { font-size: 11px; padding: 1px 8px; border-radius: 20px; display: inline-block; margin-bottom: 5px; } +.diet.herb { background: #274a22; color: #a9e08a; } +.diet.carn { background: #4a2222; color: #f0a0a0; } +.diet.omni { background: #45401f; color: #ecd88a; } +.card p { font-size: 11px; color: #93b39a; line-height: 1.35; min-height: 30px; } +.statbars { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; } +.sb { display: flex; align-items: center; gap: 5px; font-size: 9.5px; color: #7fa184; } +.sb i { width: 34px; text-align: right; font-style: normal; letter-spacing: .5px; } +.sbd { flex: 1; height: 5px; background: #14231b; border-radius: 4px; overflow: hidden; } +.sbf { height: 100%; border-radius: 4px; background: linear-gradient(90deg,#4d9e63,#8fdc7a); } + +.btn-play { + width: 100%; padding: 14px; font-size: 20px; letter-spacing: 2px; font-weight: 700; + background: linear-gradient(180deg, #58a05f, #33703f); color: #eaffea; + border: none; border-radius: 12px; cursor: pointer; + box-shadow: 0 6px 0 #1e4426, 0 12px 26px rgba(0,0,0,.45); + transition: transform .1s ease, filter .1s ease; +} +.btn-play:hover { filter: brightness(1.12); } +.btn-play:active { transform: translateY(3px); box-shadow: 0 3px 0 #1e4426; } +.controls-help { margin-top: 14px; font-size: 12px; color: #8aa892; line-height: 1.7; } +.controls-help b { color: #cfe8c9; background: rgba(255,255,255,.07); border: 1px solid #3a5a47; border-radius: 5px; padding: 1px 6px; } +.menu-status { margin-top: 10px; font-size: 12.5px; color: #ff9d76; min-height: 16px; } + +/* ---------- HUD ---------- */ +#hud { position: fixed; inset: 0; z-index: 10; pointer-events: none; } +#vitals { position: absolute; left: 16px; bottom: 16px; width: 240px; display: flex; flex-direction: column; gap: 5px; } +.bar { + position: relative; height: 19px; border-radius: 9px; overflow: hidden; + background: rgba(8,14,12,.72); border: 1px solid rgba(120,180,140,.28); +} +.bar span { + position: absolute; inset: 0; font-size: 10.5px; font-weight: 700; letter-spacing: 1px; + display: flex; align-items: center; justify-content: center; color: #f2fbe9; + text-shadow: 0 1px 2px rgba(0,0,0,.8); +} +.fill { height: 100%; width: 100%; transition: width .18s ease; border-radius: 8px; } +.fill.hp { background: linear-gradient(90deg,#a32323,#e05252); } +.fill.stam { background: linear-gradient(90deg,#b8912a,#eeda68); } +.fill.food { background: linear-gradient(90deg,#b45f1e,#eda24b); } +.fill.water { background: linear-gradient(90deg,#1e5fb0,#54a8ef); } +.growth-row { display: flex; align-items: center; gap: 8px; margin-top: 3px; } +.growth-row > span { font-size: 11px; font-weight: 700; color: #b9e8a5; white-space: nowrap; text-shadow: 0 1px 2px #000; } +.bar.growth { flex: 1; height: 11px; } +.fill.growth { background: linear-gradient(90deg,#3f8f56,#9be36f); transition: width .3s ease; } + +#minimap { position: absolute; top: 14px; right: 14px; border-radius: 12px; border: 2px solid rgba(140,200,150,.35); box-shadow: 0 6px 18px rgba(0,0,0,.5); } +#onlineTag { position: absolute; top: 186px; right: 16px; font-size: 12px; color: #cfe8cf; background: rgba(8,14,12,.6); padding: 3px 9px; border-radius: 12px; } + +#leaderboard { position: absolute; top: 14px; left: 14px; min-width: 190px; background: rgba(8,14,12,.62); border: 1px solid rgba(120,180,140,.22); border-radius: 12px; padding: 8px 12px 9px; } +#leaderboard h3 { font-size: 12px; color: #ffd97a; letter-spacing: 1px; margin-bottom: 5px; } +#lbList { list-style: none; font-size: 12px; } +#lbList li { display: flex; gap: 6px; padding: 1.5px 0; color: #cfe3cd; } +#lbList li .sc { margin-left: auto; color: #ffd97a; font-weight: 700; } +#lbList li.me { color: #9be36f; font-weight: 700; } +#lbList .stg { opacity: .75; font-size: 10px; } + +#killFeed { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 4px; align-items: center; } +.kf { background: rgba(60,10,10,.78); border: 1px solid rgba(255,120,100,.35); color: #ffc9b8; font-size: 12.5px; padding: 4px 14px; border-radius: 16px; animation: kfin .25s ease, kfout .6s ease 4.4s forwards; } +@keyframes kfin { from { opacity: 0; transform: translateY(-8px);} } +@keyframes kfout { to { opacity: 0; } } + +#chatBox { position: absolute; left: 16px; bottom: 168px; width: 300px; pointer-events: auto; } +#chatLog { display: flex; flex-direction: column; gap: 2px; max-height: 148px; overflow-y: auto; margin-bottom: 5px; scrollbar-width: thin; } +.cl { font-size: 12px; background: rgba(8,14,12,.55); border-radius: 7px; padding: 3px 8px; width: fit-content; max-width: 100%; word-wrap: break-word; } +.cl b { color: #9be36f; } +.cl.sys { color: #ffd97a; background: rgba(50,40,8,.5); font-style: italic; } +#chatInput { + width: 100%; padding: 6px 10px; border-radius: 8px; outline: none; font-size: 12.5px; + border: 1px solid rgba(120,180,140,.3); background: rgba(8,14,12,.72); color: #e8f5e0; +} +#chatInput:focus { border-color: #86e29b; } + +#hint { position: absolute; right: 16px; bottom: 16px; font-size: 11.5px; color: rgba(210,230,205,.55); text-shadow: 0 1px 2px #000; } + +#banner { + position: absolute; top: 26%; left: 50%; transform: translateX(-50%); + font-size: 30px; font-weight: 900; letter-spacing: 3px; color: #ffe9a0; white-space: nowrap; + text-shadow: 0 0 24px rgba(255,200,60,.8), 0 3px 0 #5c3a00, 0 10px 30px rgba(0,0,0,.6); + animation: bannerIn 3s ease forwards; text-align: center; +} +@keyframes bannerIn { + 0% { opacity: 0; transform: translateX(-50%) scale(.6); } + 12% { opacity: 1; transform: translateX(-50%) scale(1.12); } + 22% { transform: translateX(-50%) scale(1); } + 80% { opacity: 1; } + 100% { opacity: 0; transform: translateX(-50%) scale(1) translateY(-18px); } +} + +#dmgVignette { position: absolute; inset: 0; pointer-events: none; opacity: 0; background: radial-gradient(ellipse at center, transparent 55%, rgba(180,20,20,.55) 100%); transition: opacity .35s ease; } + +#devBadge { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + background: rgba(90,20,90,.72); border: 1px solid #e05ce0; color: #ffd9ff; + font-size: 12px; font-weight: 700; letter-spacing: .5px; + padding: 4px 14px; border-radius: 16px; text-shadow: 0 1px 2px #000; +} + +/* ---------- death screen ---------- */ +.death-box { + text-align: center; background: linear-gradient(160deg, rgba(40,10,10,.95), rgba(14,8,8,.96)); + border: 1px solid #5c2626; border-radius: 18px; padding: 34px 48px; + box-shadow: 0 24px 70px rgba(0,0,0,.7); +} +.death-box h1 { font-size: 34px; color: #ff7d6e; letter-spacing: 2px; text-shadow: 0 4px 20px rgba(255,60,40,.35); } +#deathBy { color: #e8b0a5; margin: 10px 0 18px; font-size: 17px; } +#deathStats { display: flex; gap: 26px; justify-content: center; margin-bottom: 24px; } +#deathStats div { text-align: center; } +#deathStats .v { font-size: 24px; font-weight: 800; color: #ffe9a0; } +#deathStats .l { font-size: 11px; color: #b98f88; letter-spacing: 1px; text-transform: uppercase; } +#deathScreen .btn-play { background: linear-gradient(180deg,#a05858,#703333); box-shadow: 0 6px 0 #471f1f, 0 12px 26px rgba(0,0,0,.45); } diff --git a/server.js b/server.js new file mode 100644 index 0000000..7273084 --- /dev/null +++ b/server.js @@ -0,0 +1,120 @@ +// --------------------------------------------------------------- +// Dino Isle Online — main server: static files + websocket + game loop +// Run: node server.js (PORT env optional, default 8090) +// --------------------------------------------------------------- +'use strict'; +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const { Game, TICK_MS } = require('./sim'); +const wsattach = require('./ws').attach; + +const PORT = parseInt(process.env.PORT || '8095', 10); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const CHEATS = process.env.DEBUG_CHEATS === '1'; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', +}; + +const server = http.createServer((req, res) => { + let urlPath = decodeURIComponent((req.url || '/').split('?')[0]); + if (urlPath === '/') urlPath = '/index.html'; + const filePath = path.normalize(path.join(PUBLIC_DIR, urlPath)); + if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end('Forbidden'); return; } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': 'no-store', + }); + res.end(data); + }); +}); + +const game = new Game(); + +let nextAllowedMsg = new WeakMap(); // conn -> timestamp budget + +wsattach(server, '/ws', (conn) => { + conn.onmessage = (str) => { + let m; + try { m = JSON.parse(str); } catch (_) { return; } + if (!m || typeof m !== 'object') return; + + // simple flood guard: max ~40 msgs/sec + const now = Date.now(); + const budget = nextAllowedMsg.get(conn) || now; + if (now < budget - 1000) return; // way over -> drop + nextAllowedMsg.set(conn, Math.max(budget + 25, now)); + + const p = game.players.get(conn); + switch (m.t) { + case 'join': { + const player = game.join(conn, String(m.name || '').slice(0, 16), m.sp); + conn.send(JSON.stringify(game.welcomePayload(player))); + break; + } + case 'in': + if (p) game.onInput(p, m); + break; + case 'chat': + if (p && p.alive !== undefined) game.onChat(p, m.msg); + break; + case 'dev': + if (p) game.onDev(p, m); + break; + case 'devact': + if (p) game.onDevAct(p, String(m.act || '')); + break; + case 'ping': conn.send('{"t":"pong"}'); break; + case 'tp': + if (p && (CHEATS || p.dev)) { + p.x = Math.max(40, Math.min(7680 - 40, +m.x || p.x)); + p.y = Math.max(40, Math.min(7680 - 40, +m.y || p.y)); + if (typeof m.a === 'number') p.dir = m.a; + p.vx = 0; p.vy = 0; + } + break; + default: + break; + } + }; + conn.onclose = () => game.leave(conn); +}); + +// heartbeat +setInterval(() => { + for (const p of [...game.players.keys()]) { + const c = p; // key is conn + if (c.writable) c.ping(); + } +}, 25000); + +// main loop with drift compensation +let last = Date.now(); +setInterval(() => { + const now = Date.now(); + let dt = (now - last) / 1000; + last = now; + dt = Math.min(dt, 0.25); // clamp big pauses + try { game.tick(dt); } catch (e) { console.error('tick error', e); } +}, TICK_MS); + +server.listen(PORT, () => { + console.log(`[dino-isle] listening on http://localhost:${PORT} (seed=${game.seed})`); + console.log(`[dino-isle] open multiple browser tabs for multiplayer fun!`); +}); diff --git a/sim.js b/sim.js new file mode 100644 index 0000000..97ef2ba --- /dev/null +++ b/sim.js @@ -0,0 +1,911 @@ +// --------------------------------------------------------------- +// Dino Isle Online — authoritative game simulation (server side) +// --------------------------------------------------------------- +'use strict'; +const { + MAP_W, MAP_H, TILE, WORLD_W, WORLD_H, + generateWorld, generateDecor, findSpawnPoints, tileAt, makeRng, +} = require('./worldgen'); + +const TICK_MS = 50; +const DAY_LEN = 480; // seconds per full day/night cycle +const VIEW_NPC = 1750; +const VIEW_PLAYER = 2700; + +const SPECIES = { + compy: { + key: 'compy', name: 'Compsognathus', diet: 'omni', + hp: 62, dmg: 7, speed: 228, radius: 13, turn: 7.5, + growth: 1.7, biteRange: 16, biteCd: 0.6, armor: 0, + blurb: 'Tiny, quick & grows fast. Eats anything.', + bars: { spd: 5, pwr: 1, hp: 1, grw: 5 }, + }, + raptor: { + key: 'raptor', name: 'Velociraptor', diet: 'carn', + hp: 118, dmg: 14, speed: 242, radius: 17, turn: 6.2, + growth: 1.15, biteRange: 21, biteCd: 0.7, armor: 0, + blurb: 'Fast pack hunter. Tears prey apart.', + bars: { spd: 5, pwr: 3, hp: 2, grw: 3 }, + }, + trike: { + key: 'trike', name: 'Triceratops', diet: 'herb', + hp: 215, dmg: 16, speed: 182, radius: 24, turn: 4.6, + growth: 1.0, biteRange: 23, biteCd: 0.85, armor: 0.34, + blurb: 'Armored grazer. Hardy and hard to kill.', + bars: { spd: 2, pwr: 3, hp: 5, grw: 3 }, + }, + rex: { + key: 'rex', name: 'Tyrannosaurus', diet: 'carn', + hp: 350, dmg: 32, speed: 188, radius: 31, turn: 3.7, + growth: 0.62, biteRange: 32, biteCd: 0.95, armor: 0.15, + 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' }; + +// Huntable AI dinosaurs (medium prey between critters and players) +const AI_SPECIES = { + dryo: { + key: 'dryo', name: 'Dryosaurus', hp: 75, speed: 208, radius: 19, + senseR: 330, meat: [4, 6], xp: 15, stage: 1, + }, + psitt: { + key: 'psitt', name: 'Psittacosaurus', hp: 115, speed: 178, radius: 25, + senseR: 290, meat: [6, 9], xp: 22, stage: 1, + }, +}; + +const STAGE_NAMES = ['Hatchling', 'Adolescent', 'Adult', 'Apex']; +const STAGE_SCALE = [0.55, 0.78, 1.0, 1.28]; +const XP_NEED = [42, 135, 310]; + +let ID = 1; + +class Game { + constructor(seed) { + this.seed = seed || ((Math.random() * 1e9) | 0); + this.tiles = generateWorld(this.seed); + this.decor = generateDecor(this.tiles, this.seed); + this.spawnPoints = findSpawnPoints(this.tiles, 64); + this.players = new Map(); // ws -> player + this.byId = new Map(); // id -> player + this.critters = []; + this.fishes = []; + this.aidinos = []; + this.plants = []; // static resources {id,x,y,k(g/b),amt,regrowT,maxAmt} + this.carcasses = []; + this.chatLog = []; + this.feed = []; // recent system messages + this.time = DAY_LEN * 0.3; // start mid-morning + this.lbTimer = 0; + const rng = makeRng(this.seed ^ 0x9e37); + this.rng = rng; + this.spawnResources(rng); + this.spawnFaunaInitial(rng); + this.leaderboard = []; + } + + // ---------------- setup ---------------- + spawnResources(rng) { + let guard = 0; + // grass patches on grassland + while (this.plants.filter(p => p.k === 'g').length < 420 && guard++ < 60000) { + const tx = 1 + Math.floor(rng() * (MAP_W - 2)), ty = 1 + Math.floor(rng() * (MAP_H - 2)); + if (this.tiles[ty * MAP_W + tx] !== 3) continue; + this.plants.push({ + id: ID++, k: 'g', + x: tx * TILE + TILE / 2 + (rng() - 0.5) * 20, + y: ty * TILE + TILE / 2 + (rng() - 0.5) * 20, + amt: 100, maxAmt: 100, regrowT: 0, + }); + } + // berry bushes in forest + guard = 0; + while (this.plants.filter(p => p.k === 'b').length < 150 && guard++ < 60000) { + const tx = 1 + Math.floor(rng() * (MAP_W - 2)), ty = 1 + Math.floor(rng() * (MAP_H - 2)); + if (this.tiles[ty * MAP_W + tx] !== 4) continue; + this.plants.push({ + id: ID++, k: 'b', + x: tx * TILE + TILE / 2 + (rng() - 0.5) * 20, + y: ty * TILE + TILE / 2 + (rng() - 0.5) * 20, + amt: 80, maxAmt: 80, regrowT: 0, + }); + } + this.plantById = new Map(this.plants.map(p => [p.id, p])); + } + + fishAnchorOk(x, y) { + return tileAt(this.tiles, x, y) <= 1; + } + + spawnFaunaInitial(rng) { + // fish shoal anchors in shallow water + this.fishAnchors = []; + guard: for (let n = 0; n < 30; n++) { + for (let tries = 0; tries < 500; tries++) { + const tx = 2 + Math.floor(rng() * (MAP_W - 4)), ty = 2 + Math.floor(rng() * (MAP_H - 4)); + const wx = tx * TILE + TILE / 2, wy = ty * TILE + TILE / 2; + if (tileAt(this.tiles, wx, wy) === 1) { + // prefer near land + let nearLand = false; + for (let a = 0; a < 8; a++) { + const ax = wx + Math.cos(a / 8 * Math.PI * 2) * TILE * 2.2; + const ay = wy + Math.sin(a / 8 * Math.PI * 2) * TILE * 2.2; + if (tileAt(this.tiles, ax, ay) >= 2) { nearLand = true; break; } + } + if (nearLand) { this.fishAnchors.push({ x: wx, y: wy }); break; } + } + } + } + for (const an of this.fishAnchors) { + const cnt = 4 + Math.floor(rng() * 4); + for (let i = 0; i < cnt; i++) this.fishes.push(this.makeFish(an, rng)); + } + for (let i = 0; i < 26; i++) this.critters.push(this.makeCritter(rng)); + this.critterTimer = 0; + this.aiDinoTimer = 0; + for (let i = 0; i < 12; i++) { const a = this.makeAIDino(rng); if (a) this.aidinos.push(a); } + } + + makeAIDino(rng) { + rng = rng || this.rng; + for (let tries = 0; tries < 400; tries++) { + const tx = 3 + Math.floor(rng() * (MAP_W - 6)), ty = 3 + Math.floor(rng() * (MAP_H - 6)); + if (this.tiles[ty * MAP_W + tx] < 2) continue; + const key = rng() < 0.55 ? 'dryo' : 'psitt'; + const s = AI_SPECIES[key]; + return { + id: ID++, kind: 'aidino', s: key, + x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, + ax: 0, ay: 0, vx: 0, vy: 0, dir: rng() * Math.PI * 2, + hp: s.hp, maxHp: s.hp, state: 'wander', + tgtX: 0, tgtY: 0, retarget: 0, fleeT: 0, idleT: 0, + }; + } + return null; + } + + updateAIDinos(dt, livePlayers) { + for (const a of this.aidinos) { + const s = AI_SPECIES[a.s]; + if (!a.ax && !a.ay) { a.ax = a.x; a.ay = a.y; } + // detect threats + let threat = null, td = 1e9; + for (const p of livePlayers) { + const d = Math.hypot(p.x - a.x, p.y - a.y); + if (d < s.senseR && d < td) { td = d; threat = p; } + } + if (threat) { + a.state = 'flee'; a.fleeT = 2.2; + a.threatX = threat.x; a.threatY = threat.y; + } else if (a.state === 'flee' && a.fleeT <= 0) a.state = 'wander'; + + let mvx = 0, mvy = 0, spd = 0; + if (a.state === 'flee') { + a.fleeT -= dt; + const ang = Math.atan2(a.y - (a.threatY || a.y), a.x - (a.threatX || a.x)) + Math.sin(this.time * 5 + a.id) * 0.35; + mvx = Math.cos(ang); mvy = Math.sin(ang); spd = s.speed; + } else if (a.idleT > 0) { + a.idleT -= dt; // grazing pause + } else { + a.retarget -= dt; + if (a.retarget <= 0 || Math.hypot(a.tgtX - a.x, a.tgtY - a.y) < 26) { + a.retarget = 3 + this.rng() * 5; + if (this.rng() < 0.35) { a.idleT = 1.5 + this.rng() * 3; } + else { + const ang = this.rng() * Math.PI * 2, r = 60 + this.rng() * 420; + let nx = a.x + Math.cos(ang) * r, ny = a.y + Math.sin(ang) * r; + // drift back toward home range + if (Math.hypot(nx - a.ax, ny - a.ay) > 700) { + const back = Math.atan2(a.ay - a.y, a.ax - a.x); + nx = a.x + Math.cos(back) * r; ny = a.y + Math.sin(back) * r; + } + a.tgtX = nx; a.tgtY = ny; + } + } + if (a.idleT <= 0) { + const ang = Math.atan2(a.tgtY - a.y, a.tgtX - a.x); + mvx = Math.cos(ang); mvy = Math.sin(ang); spd = 62; + } + } + // avoid deep water + if (spd > 0 && tileAt(this.tiles, a.x + mvx * 34, a.y + mvy * 34) < 2) { + const alt = ang2(mvx, mvy) + (this.rng() < 0.5 ? 1.7 : -1.7); + mvx = Math.cos(alt); mvy = Math.sin(alt); + if (tileAt(this.tiles, a.x + mvx * 34, a.y + mvy * 34) < 2) { mvx *= -1; mvy *= -1; } + } + a.vx += (mvx * spd - a.vx) * Math.min(1, dt * 5); + a.vy += (mvy * spd - a.vy) * Math.min(1, dt * 5); + const nx = a.x + a.vx * dt, ny = a.y + a.vy * dt; + if (tileAt(this.tiles, nx, a.y) >= 2) a.x = nx; else a.vx = 0; + if (tileAt(this.tiles, a.x, ny) >= 2) a.y = ny; else a.vy = 0; + const v = Math.hypot(a.vx, a.vy); + if (v > 8) a.dir = Math.atan2(a.vy, a.vx); + } + } + + makeCritter(rng) { + rng = rng || this.rng; + for (let tries = 0; tries < 400; tries++) { + const tx = 2 + Math.floor(rng() * (MAP_W - 4)), ty = 2 + Math.floor(rng() * (MAP_H - 4)); + if (this.tiles[ty * MAP_W + tx] < 2) continue; + return { + id: ID++, kind: 'critter', x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, + vx: 0, vy: 0, dir: rng() * Math.PI * 2, hp: 10, state: 'wander', + tgtX: 0, tgtY: 0, retarget: 0, fleeT: 0, variant: Math.floor(rng() * 4), + }; + } + return null; + } + + makeFish(anchor, rng) { + rng = rng || this.rng; + return { + id: ID++, kind: 'fish', ax: anchor.x, ay: anchor.y, + x: anchor.x + (rng() - 0.5) * 220, y: anchor.y + (rng() - 0.5) * 220, + vx: 0, vy: 0, dir: rng() * Math.PI * 2, hp: 8, + phase: rng() * Math.PI * 2, fleeT: 0, tired: 0, + }; + } + + // ---------------- players ---------------- + specOf(p) { return SPECIES[p.sp]; } + + derive(p) { + const s = this.specOf(p); + const sc = STAGE_SCALE[p.stage]; + p.maxHp = Math.round(s.hp * (1 + 0.45 * p.stage)); + p.radius = s.radius * sc; + p.dmg = Math.round(s.dmg * (1 + 0.42 * p.stage)); + p.speed = s.speed * (1 - 0.05 * p.stage); + p.biteRange = s.biteRange * (0.8 + 0.35 * sc); + // developer mode boosts + if (p.dev) { + p.speed *= 1.28; + p.dmg = Math.round(p.dmg * 2.5); + } + } + + pickSpawn(nearAvoid) { + let best = null, bestScore = -1; + for (const pt of this.spawnPoints) { + let minD = 1e9; + for (const pl of this.players.values()) { + if (!pl.alive) continue; + const d = Math.hypot(pl.x - pt.x, pl.y - pt.y); + if (d < minD) minD = d; + } + const score = Math.min(minD, 2500) + this.rng() * 400; + if (score > bestScore) { bestScore = score; best = pt; } + } + return best || { x: WORLD_W / 2, y: WORLD_H / 2 }; + } + + join(ws, name, spKey) { + const sp = SPECIES[spKey] ? spKey : 'raptor'; + const old = this.players.get(ws); + const pt = this.pickSpawn(); + const p = { + id: old ? old.id : ID++, + ws, name: String(name || 'Dino').slice(0, 16), + sp, x: pt.x, y: pt.y, vx: 0, vy: 0, dir: this.rng() * Math.PI * 2, + hp: 1, stam: 100, food: 82, water: 82, + xp: 0, stage: 0, kills: 0, eatenN: 0, + biteCd: 0, exhausted: false, resting: false, + alive: true, lastHitAt: -99, lastHitBy: null, + colorIdx: (ID * 47) % 360, + input: { u: 0, d: 0, l: 0, r: 0, sp: 0, bt: 0, et: 0, rs: 0 }, + eatPulse: 0, joinedAt: this.time, + dev: false, + }; + this.derive(p); + p.hp = p.maxHp; + this.players.set(ws, p); + this.byId.set(p.id, p); + if (old) { this.byId.delete(old.id); } + this.sysMsg(`${p.name} hatched as a ${SPECIES[p.sp].name} (${STAGE_NAMES[p.stage]}).`); + return p; + } + + leave(ws) { + const p = this.players.get(ws); + if (!p) return; + this.players.delete(ws); + this.byId.delete(p.id); + this.sysMsg(`${p.name} vanished from the island.`); + } + + onInput(p, m) { + const i = p.input; + if ('u' in m) i.u = m.u ? 1 : 0; + if ('d' in m) i.d = m.d ? 1 : 0; + if ('l' in m) i.l = m.l ? 1 : 0; + if ('r' in m) i.r = m.r ? 1 : 0; + if ('sp' in m) i.sp = m.sp ? 1 : 0; + if ('et' in m) i.et = m.et ? 1 : 0; + if (m.bt) i.bt = 1; + if (m.rs) { if (p.alive) p.resting = !p.resting; } + } + + sysMsg(msg) { + this.chatLog.push({ t: 'chat', from: 'Island', msg, sys: true }); + if (this.chatLog.length > 80) this.chatLog.shift(); + this.broadcast({ t: 'chat', from: '', msg, sys: true }); + } + + broadcast(obj) { + const str = JSON.stringify(obj); + for (const p of this.players.values()) { + if (p.ws && p.ws.writable) p.ws.send(str); + } + } + + onChat(p, msg) { + msg = String(msg || '').slice(0, 120).trim(); + if (!msg) return; + this.broadcast({ t: 'chat', from: p.name, msg }); + } + + // ---------- developer mode ---------- + onDev(p, m) { + p.dev = !!m.on; + this.derive(p); + if (p.dev) { p.hp = p.maxHp; p.stam = 100; p.food = 100; p.water = 100; } + this.sendTo(p, { t: 'evt', ev: { e: 'dev', on: p.dev } }); + } + + onDevAct(p, act) { + if (!p.dev || !p.alive) return; + switch (act) { + case 'grow': { + if (p.stage >= 3) { + p.xp = 0; + this.sendTo(p, { t: 'evt', ev: { e: 'ate' } }); + } else { + // add exactly the missing xp (no growth/dev scaling), then promote once + p.xp += Math.max(0.5, XP_NEED[p.stage] - p.xp + 0.5); + this.evolveCheck(p); + } + break; + } + case 'heal': + p.hp = p.maxHp; p.stam = 100; p.food = 100; p.water = 100; + this.sendTo(p, { t: 'evt', ev: { e: 'healfx' } }); + break; + case 'spawnai': { + if (this.aidinos.length >= 90) { // hard cap so spam can't bloat the world + this.sendTo(p, { t: 'evt', ev: { e: 'spawned', k: 'AI limit reached' } }); + break; + } + const key = this.rng() < 0.5 ? 'dryo' : 'psitt'; + for (let tries = 0; tries < 60; tries++) { + const ang = this.rng() * Math.PI * 2; + const r = 160 + this.rng() * 220; + const x = clampW(p.x + Math.cos(ang) * r), y = clampH(p.y + Math.sin(ang) * r); + if (tileAt(this.tiles, x, y) >= 2) { + const s = AI_SPECIES[key]; + const a = { + id: ID++, kind: 'aidino', s: key, + x, y, ax: x, ay: y, vx: 0, vy: 0, + dir: this.rng() * Math.PI * 2, + hp: s.hp, maxHp: s.hp, state: 'wander', + tgtX: x, tgtY: y, retarget: 3, fleeT: 0, idleT: 0, + }; + this.aidinos.push(a); + this.fxArea(p, x, y, { e: 'splash' }); + this.sendTo(p, { t: 'evt', ev: { e: 'spawned', k: s.name } }); + break; + } + } + break; + } + } + } + + // ---------------- main tick ---------------- + tick(dt) { + this.time += dt; + const playerList = [...this.players.values()]; + const livePlayers = playerList.filter(p => p.alive); + + for (const p of playerList) this.updatePlayer(p, dt, livePlayers); + this.updateCritters(dt, livePlayers); + this.updateFishes(dt, livePlayers); + this.updateAIDinos(dt, livePlayers); + this.updatePlants(dt); + this.updateCarcasses(dt); + + // fauna population control + const wantCritters = Math.max(16, Math.min(70, livePlayers.length * 7)); + this.critterTimer -= dt; + if (this.critters.length < wantCritters && this.critterTimer <= 0) { + const c = this.makeCritter(this.rng); + if (c) this.critters.push(c); + this.critterTimer = 2.5; + } + const wantAIDinos = Math.max(8, Math.min(26, livePlayers.length * 5)); + this.aiDinoTimer -= dt; + if (this.aidinos.length < wantAIDinos && this.aiDinoTimer <= 0) { + const a = this.makeAIDino(this.rng); + if (a) this.aidinos.push(a); + this.aiDinoTimer = 6; + } + // respawn fish into shoals that ran dry + for (const an of this.fishAnchors) { + const local = this.fishes.filter(f => f.ax === an.x && f.ay === an.y); + if (local.length < 3 && this.rng() < dt * 0.08) this.fishes.push(this.makeFish(an, this.rng)); + } + + this.lbTimer -= dt; + if (this.lbTimer <= 0) { this.lbTimer = 2.5; this.sendLeaderboards(); } + + for (const p of playerList) this.sendSnapshot(p); + } + + blocked(wx, wy) { + if (wx < 20 || wy < 20 || wx > WORLD_W - 20 || wy > WORLD_H - 20) return true; + return tileAt(this.tiles, wx, wy) === 0; + } + + updatePlayer(p, dt, livePlayers) { + if (!p.alive) return; + const s = this.specOf(p); + const i = p.input; + + // ---- movement ---- + let mx = i.r - i.l, my = i.d - i.u; + const mag = Math.hypot(mx, my); + if (mag > 0) { mx /= mag; my /= mag; p.resting = false; } + + let targetSpeed = p.speed; + const tile = tileAt(this.tiles, p.x, p.y); + let inShallow = false; + if (tile === 1) { targetSpeed *= 0.55; inShallow = true; } + else if (tile === 4) targetSpeed *= 0.88; + if (tile === 2) targetSpeed *= 0.94; + + const wantsSprint = i.sp && mag > 0 && !p.exhausted && p.stam > 0; + if (wantsSprint) { + targetSpeed *= 1.45; + p.stam -= dt * 11; + if (p.stam <= 0) { p.stam = 0; p.exhausted = true; } + } else { + p.stam = Math.min(100, p.stam + dt * (p.resting ? 17 : 7.5)); + if (p.exhausted && p.stam > 22) p.exhausted = false; + } + + if (p.resting) { mx = 0; my = 0; targetSpeed = 0; } + + const desiredVx = mx * targetSpeed, desiredVy = my * targetSpeed; + const acc = Math.min(1, dt * 8); + p.vx += (desiredVx - p.vx) * acc; + p.vy += (desiredVy - p.vy) * acc; + + // integrate with collision (axis separated) + let nx = p.x + p.vx * dt; + if (!this.blocked(nx, p.y)) p.x = nx; else p.vx *= -0.2; + let ny = p.y + p.vy * dt; + if (!this.blocked(p.x, ny)) p.y = ny; else p.vy *= -0.2; + + // facing + const spd = Math.hypot(p.vx, p.vy); + if (spd > 18) { + const ta = Math.atan2(p.vy, p.vx); + let da = ta - p.dir; + while (da > Math.PI) da -= Math.PI * 2; + while (da < -Math.PI) da += Math.PI * 2; + const tr = this.specOf(p).turn * Math.min(1, spd / 120); + p.dir += da * Math.min(1, tr * dt); + } + + // ---- survival needs ---- + const sinceHit = this.time - p.lastHitAt; + if (p.dev) { + p.stam = 100; p.food = 100; p.water = 100; p.hp = p.maxHp; + p.exhausted = false; + } else { + const decayMult = (p.resting ? 0.5 : 1) * (wantsSprint ? 1.5 : 1); + p.food = Math.max(0, p.food - dt * 0.185 * decayMult); + p.water = Math.max(0, p.water - dt * 0.24 * decayMult); + + if (p.food <= 0) p.hp -= dt * 1.3; + if (p.water <= 0) p.hp -= dt * 2.1; + if (p.food > 35 && p.water > 35 && sinceHit > 6) { + p.hp = Math.min(p.maxHp, p.hp + dt * (p.resting ? 3.4 : 1.4)); + } else if (p.food > 20 && p.water > 20 && p.resting && sinceHit > 8) { + p.hp = Math.min(p.maxHp, p.hp + dt * 1.0); + } + } + + p.biteCd = Math.max(0, p.biteCd - dt); + p.eatPulse = Math.max(0, p.eatPulse - dt); + + // ---- actions ---- + if (i.bt) { + i.bt = 0; + if (p.biteCd <= 0 && !p.resting) this.tryBite(p, livePlayers); + } + if (i.et) this.tryConsume(p, dt, inShallow); + + // ---- death from starvation ---- + if (p.hp <= 0) { + const credited = sinceHit < 4 ? p.lastHitBy : null; + this.killPlayer(p, credited ? credited : 'starve', credited); + } + } + + tryBite(p, livePlayers) { + const s = this.specOf(p); + p.biteCd = s.biteCd; + const reach = p.radius + p.biteRange; + let best = null, bestD = 1e9; + + const consider = (e, isPlayer, obj) => { + const dx = obj.x - p.x, dy = obj.y - p.y; + const d = Math.hypot(dx, dy); + if (d > reach + (obj.radius || 14)) return; + const ang = Math.atan2(dy, dx); + let da = ang - p.dir; + while (da > Math.PI) da -= Math.PI * 2; + while (da < -Math.PI) da += Math.PI * 2; + if (Math.abs(da) > 1.25) return; + if (d < bestD) { bestD = d; best = { e, isPlayer, obj, dx, dy, d }; } + }; + + for (const o of livePlayers) if (o !== p) consider(o.id, true, o); + for (const c of this.critters) consider(c.id, false, c); + for (const f of this.fishes) consider(f.id, false, f); + for (const a of this.aidinos) consider(a.id, false, a); + + p.ws.send(JSON.stringify({ t: 'evt', ev: { e: 'swing', a: p.dir } })); + + if (!best) return; + const { isPlayer, obj } = best; + const kb = 95 + p.stage * 45; + const ang = Math.atan2(best.dy, best.dx); + + if (isPlayer) { + const armor = SPECIES[obj.sp].armor; + const dmg = p.dmg * (1 - armor); + obj.hp -= dmg; + obj.vx += Math.cos(ang) * kb; obj.vy += Math.sin(ang) * kb; + obj.lastHitAt = this.time; obj.lastHitBy = p; + obj.resting = false; + this.sendTo(obj, { t: 'evt', ev: { e: 'hitme', dmg: Math.round(dmg), by: p.name } }); + this.fxArea(p, obj.x, obj.y, { e: 'hit', a: ang }); + if (obj.hp <= 0) { + p.kills++; + this.gainXp(p, 38 + obj.stage * 14); + this.killPlayer(obj, p, p); + } + } else { + obj.hp -= p.dmg; + const kbN = 95 + p.stage * 45; + obj.vx += Math.cos(ang) * kbN; obj.vy += Math.sin(ang) * kbN; this.fxArea(p, obj.x, obj.y, { e: 'hit', a: ang }); + if (obj.hp <= 0) { + if (obj.kind === 'critter') { + this.critters.splice(this.critters.indexOf(obj), 1); + this.eatReward(p, 30, 9); + this.fxArea(p, obj.x, obj.y, { e: 'eat', k: 'meat' }); + } else if (obj.kind === 'aidino') { + // big prey: drops a carcass to feast on + const s = AI_SPECIES[obj.s]; + const chunks = s.meat[0] + Math.floor(this.rng() * (s.meat[1] - s.meat[0] + 1)); + this.carcasses.push({ + id: ID++, x: obj.x, y: obj.y, dir: obj.dir, + meat: chunks, born: this.time, stage: s.stage, sp: obj.s, + }); + this.gainXp(p, s.xp); + p.eatenN++; + this.fxArea(p, obj.x, obj.y, { e: 'eat', k: 'meat' }); + this.sysMsg(`${p.name} brought down a ${s.name}.`); + this.aidinos.splice(this.aidinos.indexOf(obj), 1); + } else { + this.fishes.splice(this.fishes.indexOf(obj), 1); + this.eatReward(p, 22, 6); + this.fxArea(p, obj.x, obj.y, { e: 'splash' }); + this.fxArea(p, obj.x, obj.y, { e: 'eat', k: 'fish' }); + } + } else if (obj.kind === 'fish') { obj.fleeT = 1.1; obj.tired = 0; } + else if (obj.kind === 'aidino') { obj.state = 'flee'; obj.fleeT = 2.4; } + else { obj.state = 'flee'; obj.fleeT = 2.2; } + } + } + + eatReward(p, food, xp) { + p.food = Math.min(100, p.food + food); + this.gainXp(p, xp); + p.eatenN++; + p.eatPulse = 0.35; + this.sendTo(p, { t: 'evt', ev: { e: 'ate', f: food } }); + } + + evolveCheck(p) { + while (p.stage < 3 && p.xp >= XP_NEED[p.stage]) { + p.xp -= XP_NEED[p.stage]; + p.stage++; + this.derive(p); + p.hp = Math.min(p.maxHp, p.hp + p.maxHp * 0.5); + this.sendTo(p, { t: 'evt', ev: { e: 'grow', st: p.stage } }); + this.sysMsg(`${p.name} evolved into an ${STAGE_NAMES[p.stage].toUpperCase()} ${SPECIES[p.sp].name}!`); + } + } + + gainXp(p, amount, opts) { + if (!p.alive) return; + const devMult = (opts && opts.raw) ? 1 : (p.dev ? 6 : 1); + p.xp += amount * this.specOf(p).growth * devMult; + this.evolveCheck(p); + } + + killPlayer(victim, killer, credit) { + if (!victim.alive) return; + victim.alive = false; + victim.deaths = (victim.deaths || 0) + 1; + // drop carcass + const chunks = 3 + victim.stage * 3; + this.carcasses.push({ + id: ID++, x: victim.x, y: victim.y, dir: victim.dir, + meat: chunks, born: this.time, stage: victim.stage, sp: victim.sp, + }); + if (credit && credit !== victim) { + this.sysMsg(`${credit.name} the ${SPECIES[credit.sp].name} devoured ${victim.name}!`); + } else { + this.sysMsg(`${victim.name} ${killer === 'starve' ? 'starved on the island.' : 'died.'}`); + } + const byName = credit && credit !== victim ? credit.name : (killer === 'starve' ? 'starvation' : 'the island'); + this.sendTo(victim, { + t: 'dead', + by: byName, + stats: { kills: victim.kills, eaten: victim.eatenN, stage: STAGE_NAMES[victim.stage], sp: SPECIES[victim.sp].name }, + }); + this.fxArea(victim, victim.x, victim.y, { e: 'die', x: Math.round(victim.x), y: Math.round(victim.y) }); + // reset for respawn + victim.kills = 0; victim.eatenN = 0; + } + + tryConsume(p, dt, inShallow) { + const diet = this.specOf(p).diet; + + // 1) carcass chunks (carn/omni) + if (diet !== 'herb') { + for (const c of this.carcasses) { + const d = Math.hypot(c.x - p.x, c.y - p.y); + if (d < p.radius + 46 && c.meat > 0) { + c.eatAcc = (c.eatAcc || 0) + dt; + if (c.eatAcc >= 0.55) { + c.eatAcc = 0; c.meat--; + this.eatReward(p, 18, 6); + this.fxArea(p, c.x, c.y, { e: 'eat', k: 'meat' }); + } + return; + } + } + } + + // 2) grazing plants (herb/omni) + if (diet !== 'carn') { + for (const pl of this.plants) { + if (pl.amt <= 0) continue; + const d = Math.hypot(pl.x - p.x, pl.y - p.y); + const rr = p.radius + (pl.k === 'g' ? 46 : 52); + if (d < rr) { + const rate = pl.k === 'g' ? 15 : 12; + const take = Math.min(pl.amt, rate * dt); + pl.amt -= take; + p.food = Math.min(100, p.food + take * (pl.k === 'g' ? 0.16 : 0.22)); + this.gainXp(p, dt * 1.4); + p.eatenN += dt * 0.5; + p.eatPulse = 0.3; + if (pl.amt <= 0) { pl.amt = 0; pl.regrowT = 70 + this.rng() * 40; } + if (this.rng() < dt * 3) this.sendTo(p, { t: 'evt', ev: { e: 'graze' } }); + return; + } + } + } + + // 3) drinking + if (inShallow || this.nearWater(p)) { + p.water = Math.min(100, p.water + dt * 16); + if (this.rng() < dt * 2.5) this.sendTo(p, { t: 'evt', ev: { e: 'drink' } }); + } + } + + nearWater(p) { + for (let a = 0; a < 8; a++) { + const ang = a / 8 * Math.PI * 2; + if (tileAt(this.tiles, p.x + Math.cos(ang) * (p.radius + 30), p.y + Math.sin(ang) * (p.radius + 30)) <= 1) return true; + } + return false; + } + + fxArea(src, x, y, ev) { + // send fx to nearby players + for (const q of this.players.values()) { + if (!q.ws || !q.ws.writable) continue; + if (Math.hypot(q.x - x, q.y - y) < VIEW_NPC) { + this.sendTo(q, { t: 'evt', ev }); + } + } + } + + sendTo(p, obj) { if (p.ws && p.ws.writable) p.ws.send(JSON.stringify(obj)); } + + // ---------------- fauna ---------------- + updateCritters(dt, livePlayers) { + for (const c of this.critters) { + // threat detection + let threat = null, td = 1e9; + for (const p of livePlayers) { + const d = Math.hypot(p.x - c.x, p.y - c.y); + if (d < 260 && d < td) { td = d; threat = p; } + } + if (threat) { c.state = 'flee'; c.fleeT = Math.max(c.fleeT, 1.6); c.threatX = threat.x; c.threatY = threat.y; } + + let mvx = 0, mvy = 0, spd = 95; + if (c.state === 'flee') { + c.fleeT -= dt; + const ang = Math.atan2(c.y - (c.threatY || c.y), c.x - (c.threatX || c.x)); + mvx = Math.cos(ang); mvy = Math.sin(ang); spd = 185; + if (c.fleeT <= 0) { c.state = 'wander'; c.retarget = 0; } + } else { + c.retarget -= dt; + if (c.retarget <= 0 || Math.hypot(c.tgtX - c.x, c.tgtY - c.y) < 30) { + c.retarget = 2 + this.rng() * 4; + const a = this.rng() * Math.PI * 2, r = 80 + this.rng() * 320; + c.tgtX = c.x + Math.cos(a) * r; c.tgtY = c.y + Math.sin(a) * r; + } + const ang = Math.atan2(c.tgtY - c.y, c.tgtX - c.x); + mvx = Math.cos(ang); mvy = Math.sin(ang); + } + // avoid water + const ahead = 26; + if (tileAt(this.tiles, c.x + mvx * ahead, c.y + mvy * ahead) < 2) { + const alt = this.rng() < 0.5 ? ang2(mvx, mvy) + 1.6 : ang2(mvx, mvy) - 1.6; + mvx = Math.cos(alt); mvy = Math.sin(alt); + if (tileAt(this.tiles, c.x + mvx * ahead, c.y + mvy * ahead) < 2) { mvx *= -1; mvy *= -1; } + } + c.vx += (mvx * spd - c.vx) * Math.min(1, dt * 6); + c.vy += (mvy * spd - c.vy) * Math.min(1, dt * 6); + const nx = c.x + c.vx * dt, ny = c.y + c.vy * dt; + if (tileAt(this.tiles, nx, c.y) >= 2) c.x = nx; else c.vx = 0; + if (tileAt(this.tiles, c.x, ny) >= 2) c.y = ny; else c.vy = 0; + const s = Math.hypot(c.vx, c.vy); + if (s > 10) c.dir = Math.atan2(c.vy, c.vx); + } + } + + updateFishes(dt, livePlayers) { + for (const f of this.fishes) { + f.phase += dt; + let threat = null, td = 1e9; + for (const p of livePlayers) { + const d = Math.hypot(p.x - f.x, p.y - f.y); + if (d < 200 && d < td) { td = d; threat = p; } + } + if (threat && f.tired <= 0) { f.fleeT = 0.9; f.threatX = threat.x; f.threatY = threat.y; } + f.tired -= dt; + + let mvx, mvy, spd; + if (f.fleeT > 0) { + f.fleeT -= dt; + if (f.fleeT <= 0) f.tired = 1.4; + const ang = Math.atan2(f.y - (f.threatY || f.y), f.x - (f.threatX || f.x)); + mvx = Math.cos(ang); mvy = Math.sin(ang); spd = 235; + } else { + // lazy orbit around shoal anchor + const oa = f.phase * 0.35 + f.id; + const tx = f.ax + Math.cos(oa) * 130 + Math.cos(f.phase * 1.7) * 30; + const ty = f.ay + Math.sin(oa * 1.13) * 110 + Math.sin(f.phase * 1.3) * 30; + const ang = Math.atan2(ty - f.y, tx - f.x); + mvx = Math.cos(ang); mvy = Math.sin(ang); spd = 55; + } + f.vx += (mvx * spd - f.vx) * Math.min(1, dt * 5); + f.vy += (mvy * spd - f.vy) * Math.min(1, dt * 5); + const nx = f.x + f.vx * dt, ny = f.y + f.vy * dt; + if (tileAt(this.tiles, nx, f.y) <= 1) f.x = nx; else { f.vx *= -1; f.fleeT = 0; f.tired = 0.8; } + if (tileAt(this.tiles, f.x, ny) <= 1) f.y = ny; else { f.vy *= -1; f.fleeT = 0; f.tired = 0.8; } + // drift back toward anchor if too far + const da = Math.hypot(f.ax - f.x, f.ay - f.y); + if (da > 320) { + const ang = Math.atan2(f.ay - f.y, f.ax - f.x); + f.vx += Math.cos(ang) * 60 * dt * 5; + f.vy += Math.sin(ang) * 60 * dt * 5; + } + const s = Math.hypot(f.vx, f.vy); + if (s > 8) f.dir = Math.atan2(f.vy, f.vx); + } + } + + updatePlants(dt) { + for (const pl of this.plants) { + if (pl.amt <= 0) { + pl.regrowT -= dt; + if (pl.regrowT <= 0) pl.amt = pl.maxAmt; + } + } + } + + updateCarcasses(dt) { + for (let i = this.carcasses.length - 1; i >= 0; i--) { + const c = this.carcasses[i]; + if (c.meat <= 0 || this.time - c.born > 150) this.carcasses.splice(i, 1); + } + } + + // ---------------- networking out ---------------- + sendSnapshot(p) { + const ents = []; + const px = p.x, py = p.y; + + for (const q of this.players.values()) { + if (q === p) continue; + if (!q.alive) continue; + if (Math.hypot(q.x - px, q.y - py) > VIEW_PLAYER) continue; + ents.push({ + k: 'p', i: q.id, n: q.name, s: q.sp, t: q.stage, + x: Math.round(q.x), y: Math.round(q.y), d: +q.dir.toFixed(2), + h: Math.round(q.hp / q.maxHp * 100), r: q.resting ? 1 : 0, + ci: q.colorIdx, + }); + } + const pushIf = (o, x, y, fn) => { + if (Math.hypot(x - px, y - py) <= VIEW_NPC) ents.push(fn(o)); + }; + for (const c of this.critters) pushIf(c, c.x, c.y, c => ({ k: 'c', i: c.id, x: Math.round(c.x), y: Math.round(c.y), d: +c.dir.toFixed(2), v: c.variant })); + for (const a of this.aidinos) pushIf(a, a.x, a.y, a => ({ k: 'd', i: a.id, s: a.s, x: Math.round(a.x), y: Math.round(a.y), d: +a.dir.toFixed(2), h: Math.round(a.hp / a.maxHp * 100) })); + for (const f of this.fishes) pushIf(f, f.x, f.y, f => ({ k: 'f', i: f.id, x: Math.round(f.x), y: Math.round(f.y), d: +f.dir.toFixed(2) })); + for (const c of this.carcasses) pushIf(c, c.x, c.y, c => ({ k: 'k', i: c.id, x: Math.round(c.x), y: Math.round(c.y), d: +c.dir.toFixed(2), m: c.meat, t: c.stage })); + for (const pl of this.plants) { + if (pl.amt <= 0) continue; + pushIf(pl, pl.x, pl.y, pl => ({ k: pl.k, i: pl.id, x: Math.round(pl.x), y: Math.round(pl.y), a: Math.round(pl.amt / pl.maxAmt * 100) })); + } + + this.sendTo(p, { + t: 's', + tick: Math.round(this.time * 10), + you: { + x: Math.round(p.x), y: Math.round(p.y), d: +p.dir.toFixed(2), + hp: Math.round(p.hp), maxHp: p.maxHp, st: Math.round(p.stam), + fd: Math.round(p.food), wt: Math.round(p.water), + xp: +p.xp.toFixed(1), need: XP_NEED[p.stage] || 0, stg: p.stage, + rest: p.resting ? 1 : 0, ex: p.exhausted ? 1 : 0, cd: +p.biteCd.toFixed(2), + al: p.alive ? 1 : 0, dv: p.dev ? 1 : 0, + }, + ents, + dayT: +(this.time % DAY_LEN / DAY_LEN).toFixed(3), + }); + } + + sendLeaderboards() { + const all = [...this.players.values()].map(p => ({ + i: p.id, n: p.name, s: p.sp, t: p.stage, + sc: Math.round(p.kills * 100 + p.eatenN * 4 + p.xp + p.stage * 120), + k: p.kills, + })).sort((a, b) => b.sc - a.sc); + const list = all.slice(0, 6); + this.broadcast({ t: 'lb', list, online: all.length }); + } + + welcomePayload(p) { + return { + t: 'welcome', + id: p.id, + species: SPECIES, + stages: STAGE_NAMES, + map: { + w: MAP_W, h: MAP_H, tile: TILE, + data: Buffer.from(this.tiles).toString('base64'), + }, + decor: this.decor, + chat: this.chatLog.slice(-30), + }; + } +} + +function ang2(x, y) { return Math.atan2(y, x); } +function clampW(x) { return Math.max(40, Math.min(WORLD_W - 40, x)); } +function clampH(y) { return Math.max(40, Math.min(WORLD_H - 40, y)); } + +module.exports = { Game, SPECIES, STAGE_NAMES, XP_NEED, TICK_MS, DAY_LEN }; diff --git a/test/matchcheck.mjs b/test/matchcheck.mjs new file mode 100644 index 0000000..6718071 --- /dev/null +++ b/test/matchcheck.mjs @@ -0,0 +1,82 @@ +// Verify menu-card dino and in-game dino renderer produce matching art. +'use strict'; +import { createRequire } from 'node:module'; +import { readdirSync, existsSync } from 'node:fs'; +const require = createRequire('/root/kidcraft/node_modules/'); +const { chromium } = require('playwright-core'); +function findBrowserExe() { + const root = '/root/.cache/ms-playwright'; + for (const dir of readdirSync(root)) { + if (!dir.startsWith('chromium')) continue; + for (const sub of ['chrome-headless-shell-linux64/chrome-headless-shell', 'chrome-linux/headless_shell']) { + const p = `${root}/${dir}/${sub}`; + if (existsSync(p)) return p; + } + } +} +let failures = 0; +const ok = (c, l) => { console.log(`${c ? 'PASS' : 'FAIL'} ${l}`); if (!c) failures++; }; + +const browser = await chromium.launch({ executablePath: findBrowserExe(), args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.goto('http://127.0.0.1:8095/', { waitUntil: 'networkidle' }); +await page.waitForTimeout(800); + +const res = await page.evaluate(() => { + function avgColor(canvas) { + const cx = canvas.getContext('2d'); + const d = cx.getImageData(0, 0, canvas.width, canvas.height).data; + let r = 0, g = 0, b = 0, n = 0; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 40) continue; // skip transparent + r += d[i]; g += d[i + 1]; b += d[i + 2]; n++; + } + return n ? [r / n, g / n, b / n] : null; + } + const out = {}; + // menu card canvases are the first canvas child of each .card + const cards = document.querySelectorAll('.card'); + const keys = ['compy', 'raptor', 'trike', 'rex']; + cards.forEach((card, idx) => { + const cv = card.querySelector('canvas'); + out[keys[idx]] = avgColor(cv); + }); + // render in-game style dino for each species via shared renderer + out.game = {}; + for (const k of keys) { + const cv = document.createElement('canvas'); + cv.width = 180; cv.height = 88; + const c = cv.getContext('2d'); + // identical backdrop as drawCardPreview + const g = c.createLinearGradient(0, 0, 0, 88); + 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, 180, 88, 8); c.fill(); + c.save(); + const scale = k === 'rex' ? 1.5 : k === 'compy' ? 0.95 : k === 'trike' ? 1.05 : 1.18; + c.translate(90 - 6 * scale, 88 * 0.60); c.scale(scale, scale); + paintDino(c, { x: 0, y: 4, dir: 0, r: 17, sp: k, stage: 2, phase: 1.15, moving: false, resting: false, bite: k === 'rex' ? 0.85 : 0, eat: 0, ci: 0, preview: true }); + c.restore(); + out.game[k] = avgColor(cv); + } + return out; +}); + +for (const k of ['compy', 'raptor', 'trike', 'rex']) { + const [r1, g1, b1] = res[k], [r2, g2, b2] = res.game[k]; + const d = Math.hypot(r1 - r2, g1 - g2, b1 - b2); + ok(d < 12, `${k}: card vs game avg color Δ=${d.toFixed(1)} rgb(${r1.toFixed(0)},${g1.toFixed(0)},${b1.toFixed(0)}) vs (${r2.toFixed(0)},${g2.toFixed(0)},${b2.toFixed(0)})`); +} + +// screenshots for human review +await page.screenshot({ path: '/tmp/shot-menu-v2.png' }); +await page.fill('#nameInput', 'RexCheck'); +await page.click('.card[data-key="rex"]'); +await page.click('#playBtn'); +await page.waitForTimeout(2200); +await page.screenshot({ path: '/tmp/shot-game-rex.png' }); + +await browser.close(); +console.log(failures ? `\n${failures} CHECK(S) FAILED` : '\nMATCH CHECKS PASSED'); +process.exit(failures ? 1 : 0); diff --git a/test/pixcheck.mjs b/test/pixcheck.mjs new file mode 100644 index 0000000..c9facc5 --- /dev/null +++ b/test/pixcheck.mjs @@ -0,0 +1,57 @@ +// Decode screenshots in headless chromium and verify expected pixel signatures. +'use strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +const require = createRequire('/root/kidcraft/node_modules/'); +const { chromium } = require('playwright-core'); +const EXE = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'; + +let failures = 0; +const ok = (c, l) => { console.log(`${c ? 'PASS' : 'FAIL'} ${l}`); if (!c) failures++; }; + +async function stats(page, path, x0, y0, x1, y1) { + const b64 = readFileSync(path).toString('base64'); + return await page.evaluate(async ({ b64, x0, y0, x1, y1 }) => { + const img = new Image(); + img.src = 'data:image/png;base64,' + b64; + await new Promise(r => { img.onload = r; }); + const cv = document.createElement('canvas'); + cv.width = img.width; cv.height = img.height; + const cx = cv.getContext('2d'); + cx.drawImage(img, 0, 0); + const d = cx.getImageData(x0, y0, x1 - x0, y1 - y0).data; + let blue = 0, green = 0, tan = 0, red = 0, dark = 0, n = 0; + for (let i = 0; i < d.length; i += 4) { + const [r, g, b] = [d[i], d[i + 1], d[i + 2]]; n++; + if (b > r + 20 && b > g + 10) blue++; + else if (g > r + 15 && g > b + 15) green++; + else if (r > 150 && g > 130 && b < g) tan++; + else if (r > 140 && r > g + 50 && r > b + 50) red++; + if (r + g + b < 120) dark++; + } + return { w: img.width, h: img.height, frac: { blue: blue / n, green: green / n, tan: tan / n, red: red / n, dark: dark / n } }; + }, { b64, x0, y0, x1, y1 }); +} + +const browser = await chromium.launch({ executablePath: EXE, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.goto('about:blank'); + +// ---- menu ---- +const menu = await stats(page, '/tmp/shot-menu.png', 0, 0, 1280, 800); +ok(menu.w === 1280 && menu.h === 800, `menu screenshot size ${menu.w}x${menu.h}`); +ok(menu.frac.dark < 0.98, 'menu is not a black screen'); + +// ---- gameplay full frame ---- +const game = await stats(page, '/tmp/shot-game-a.png', 0, 0, 1280, 800); +ok(game.frac.blue > 0.05 || game.frac.green > 0.05, `island terrain painted (blue ${game.frac.blue.toFixed(2)}, green ${game.frac.green.toFixed(2)})`); +// bottom-left vitals region should contain red-ish HP fill +const vitals = await stats(page, '/tmp/shot-game-a.png', 16, 800 - 160, 256, 790); +ok(vitals.frac.red > 0.02, `HP bar red present (${vitals.frac.red.toFixed(3)})`); +// top-right minimap region not empty +const mm = await stats(page, '/tmp/shot-game-a.png', 1116, 14, 1266, 178); +ok(mm.frac.dark < 0.95 && (mm.frac.blue > 0.03 || mm.frac.green > 0.03), 'minimap rendered island'); + +await browser.close(); +console.log(failures === 0 ? '\nPIXEL CHECKS PASSED' : `\n${failures} PIXEL CHECK(S) FAILED`); +process.exit(failures ? 1 : 0); diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..5bd0f66 --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,230 @@ +// Headless smoke test: spawns its own server instance, connects two WS players, +// verifies join/sync/movement/critter-hunt/PvP-kill/chat. Exits 0 on success. +'use strict'; +import { spawn } from 'node:child_process'; + +const PORT = 8123 + Math.floor(Math.random() * 400); +const WS_URL = `ws://127.0.0.1:${PORT}/ws`; +let failures = 0; + +function ok(cond, label) { + console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`); + if (!cond) failures++; +} + +async function waitReady(port, tries = 40) { + for (let i = 0; i < tries; i++) { + try { + const r = await fetch(`http://127.0.0.1:${port}/`); + if (r.ok) return true; + } catch {} + await sleep(150); + } + return false; +} + +class Client { + constructor(name, sp) { + this.name = name; this.sp = sp; + this.handlers = []; + this.snapshots = []; + this.ws = new WebSocket(WS_URL); + this.ws.addEventListener('message', (ev) => { + let m; try { m = JSON.parse(ev.data); } catch { return; } + if (m.t === 's') this.snapshots.push(m); + this.handlers.forEach(h => h(m)); + }); + this.opened = new Promise((res, rej) => { + this.ws.addEventListener('open', res); + this.ws.addEventListener('error', rej); + }); + } + send(o) { this.ws.send(JSON.stringify(o)); } + async join() { + await this.opened; + const p = this.waitFor(m => m.t === 'welcome'); + this.send({ t: 'join', name: this.name, sp: this.sp }); + return p; + } + waitFor(pred, timeout = 8000) { + return new Promise((res, rej) => { + const h2 = (m) => { if (pred(m)) { this.handlers = this.handlers.filter(x => x !== h2); res(m); } }; + this.handlers.push(h2); + setTimeout(() => rej(new Error('timeout waiting: ' + (pred.toString().slice(0, 60)))), timeout); + }); + } + latest() { return this.snapshots[this.snapshots.length - 1]; } + close() { try { this.ws.close(); } catch {} } +} +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +// ---- start isolated server ---- +const srv = spawn('node', ['server.js'], { + cwd: new URL('..', import.meta.url).pathname, + env: { ...process.env, PORT: String(PORT), DEBUG_CHEATS: '1' }, +}); +srv.on('error', (e) => console.error('server spawn error', e)); +srv.stderr.on('data', (d) => process.stderr.write('[srv] ' + d)); +if (!(await waitReady(PORT))) { console.log('FAIL server did not start'); process.exit(1); } + +try { + const A = new Client('AlphaRex', 'rex'); + const B = new Client('BetaRaptor', 'raptor'); + const wA = await A.join(); await B.join(); + ok(wA.id > 0 && wA.map && wA.map.data.length > 1000, 'welcome contains map data'); + ok(wA.species && wA.species.rex, 'welcome contains species table'); + await sleep(800); + + // 1) teleport A next to B (facing right toward B) -> both should see each other + const youB = B.latest().you; + A.send({ t: 'tp', x: youB.x - 60, y: youB.y, a: 0 }); + await sleep(700); + ok(A.latest().ents.some(e => e.k === 'p'), 'player A sees player B after approaching'); + ok(B.latest().ents.some(e => e.k === 'p' && e.n === 'AlphaRex'), 'player B sees AlphaRex'); + + // 2) movement changes position + const before = A.latest().you; + A.send({ t: 'in', u: 1 }); + await sleep(1000); + A.send({ t: 'in', u: 0 }); + const after = A.latest().you; + ok(Math.abs(after.y - before.y) > 30, `movement moves dino (${before.y} -> ${after.y})`); + + // 3) hunt a critter: teleport near one facing it, bite until it dies & feeds us + let hunted = false; + outer: + for (let attempt = 0; attempt < 25 && !hunted; attempt++) { + let snap = A.latest(); + let critter = snap && snap.ents.find(e => e.k === 'c'); + if (!critter) { + // hop somewhere random on land to find fauna + for (let i = 0; i < 40; i++) { + const x = 400 + Math.random() * 6800, y = 400 + Math.random() * 6800; + A.send({ t: 'tp', x, y }); await sleep(160); + snap = A.latest(); + critter = snap && snap.ents.find(e => e.k === 'c'); + if (critter) break; + } + if (!critter) continue; + } + const id = critter.i; + A.send({ t: 'tp', x: critter.x - 34, y: critter.y, a: 0 }); + await sleep(120); + const fdBefore = A.latest().you.fd; + for (let i = 0; i < 7; i++) { + A.send({ t: 'in', bt: 1, r: 0.001 ? 0 : undefined }); // bite, no move keys + A.send({ t: 'in', bt: 0 }); + await sleep(280); + const s2 = A.latest(); + if (!s2.ents.some(e => e.k === 'c' && e.i === id)) { // it died + hunted = true; break; + } + } + const fdAfter = A.latest().you.fd; + if (hunted && fdAfter > fdBefore) break; + if (!hunted) { /* critter escaped; try another */ } + } + ok(hunted, 'biting kills a critter'); + + // 3b) hunt an AI herd dino -> it drops a carcass + let aiKilled = false; + for (let attempt = 0; attempt < 20 && !aiKilled; attempt++) { + let snap = A.latest(); + let ai = snap && snap.ents.find(e => e.k === 'd'); + if (!ai) { + for (let i = 0; i < 40 && !ai; i++) { + const x = 400 + Math.random() * 6800, y = 400 + Math.random() * 6800; + A.send({ t: 'tp', x, y }); await sleep(170); + snap = A.latest(); + ai = snap && snap.ents.find(e => e.k === 'd'); + } + if (!ai) continue; + } + const id = ai.i, lx = ai.x, ly = ai.y; + A.send({ t: 'tp', x: ai.x - 30, y: ai.y, a: 0 }); + await sleep(100); + for (let i = 0; i < 10; i++) { + A.send({ t: 'in', bt: 1 }); await sleep(300); A.send({ t: 'in', bt: 0 }); + const s2 = A.latest(); + if (!s2.ents.some(e => e.k === 'd' && e.i === id)) { aiKilled = true; break; } + // chase: re-tp onto its last seen spot if it fled + const cur = s2.ents.find(e => e.k === 'd' && e.i === id); + if (cur && Math.hypot(cur.x - A.latest().you.x, cur.y - A.latest().you.y) > 70) + A.send({ t: 'tp', x: cur.x - 34, y: cur.y, a: 0 }); + } + if (!aiKilled) continue; + await sleep(300); + aiKilled = A.latest().ents.some(e => e.k === 'k' && Math.hypot(e.x - lx, e.y - ly) < 400); + } + ok(aiKilled, 'hunting AI dino drops carcass'); + + // 4) PvP: rex devours raptor -> death screen on victim, carcass drops + const deadP = B.waitFor(m => m.t === 'dead', 25000); + for (let i = 0; i < 26; i++) { + const yb = B.latest() && B.latest().you; + if (!yb || !A.latest().you.al) break; // stop when A is dead too (shouldn't happen) + A.send({ t: 'tp', x: yb.x - 55, y: yb.y, a: 0 }); // keep closing in on the prey + await sleep(90); + A.send({ t: 'in', bt: 1 }); await sleep(300); A.send({ t: 'in', bt: 0 }); + } + const dm = await deadP.catch(() => null); + ok(dm && dm.by === 'AlphaRex', `PvP kill delivers death screen (by=${dm && dm.by})`); + await sleep(500); + ok(A.latest().ents.some(e => e.k === 'k'), 'carcass drops after death'); + + // eating from the carcass restores food + const carc = A.latest().ents.find(e => e.k === 'k'); + if (carc) { + A.send({ t: 'tp', x: carc.x, y: carc.y, a: 0 }); + const fd0 = A.latest().you.fd; + A.send({ t: 'in', et: 1 }); + await sleep(1800); + A.send({ t: 'in', et: 0 }); + ok(A.latest().you.fd > fd0, `eating carcass restores food (${fd0} -> ${A.latest().you.fd})`); + } else ok(false, 'carcass present for feasting test'); + + // 5) respawn works on same socket + const wB = B.waitFor(m => m.t === 'welcome', 6000); + B.send({ t: 'join', name: 'BetaRaptor', sp: 'raptor' }); + ok(await wB.then(() => true).catch(() => false), 'respawn re-joins successfully'); + + // 6) chat broadcast + const gotChat = B.waitFor(m => m.t === 'chat' && m.from === 'AlphaRex' && m.msg === 'hello isle', 5000); + A.send({ t: 'chat', msg: 'hello isle' }); + ok(await gotChat.then(() => true).catch(() => false), 'chat broadcasts between players'); + + // 7) developer mode: activate, instant grow, spawn AI, teleport + const devSnap = A.waitFor(m => m.t === 's' && m.you.dv === 1, 5000); + A.send({ t: 'dev', on: 1 }); + ok(await devSnap.then(() => true).catch(() => false), 'dev mode activates (you.dv=1)'); + + const stg0 = A.latest().you.stg; + if (stg0 < 3) { + A.send({ t: 'devact', act: 'grow' }); + await sleep(500); + ok(A.latest().you.stg === stg0 + 1, `dev grow raises stage (${stg0} -> ${A.latest().you.stg})`); + } else ok(true, 'dev grow skipped (already Apex)'); + + A.send({ t: 'devact', act: 'spawnai' }); + await sleep(800); + const youA = A.latest().you; + ok(A.latest().ents.some(e => e.k === 'd' && Math.hypot(e.x - youA.x, e.y - youA.y) < 900), + 'dev spawnai creates AI dino nearby'); + + A.send({ t: 'tp', x: 3000, y: 3000 }); + await sleep(400); + ok(Math.abs(A.latest().you.x - 3000) < 5 && Math.abs(A.latest().you.y - 3000) < 5, + 'dev Alt+Click-style tp works'); + + A.send({ t: 'dev', on: 0 }); + await sleep(400); + ok(A.latest().you.dv === 0, 'dev mode deactivates'); + + A.close(); B.close(); +} catch (e) { + ok(false, 'unexpected error: ' + e.message); +} + +srv.kill('SIGKILL'); +console.log(failures === 0 ? '\nALL SMOKE TESTS PASSED' : `\n${failures} SMOKE TEST(S) FAILED`); +process.exit(failures === 0 ? 0 : 1); diff --git a/test/visual.mjs b/test/visual.mjs new file mode 100644 index 0000000..01af73c --- /dev/null +++ b/test/visual.mjs @@ -0,0 +1,84 @@ +// Visual verification: load the real game in headless Chromium, capture +// console/page errors, join with two players, screenshot menu + gameplay. +'use strict'; +import { createRequire } from 'node:module'; +import { readdirSync, existsSync } from 'node:fs'; +const require = createRequire('/root/kidcraft/node_modules/'); +const { chromium } = require('playwright-core'); + +// auto-discover the headless shell binary (cache version changes over time) +function findBrowserExe() { + const root = '/root/.cache/ms-playwright'; + try { + for (const dir of readdirSync(root)) { + if (!dir.startsWith('chromium')) continue; + for (const sub of ['chrome-headless-shell-linux64/chrome-headless-shell', 'chrome-linux/headless_shell', 'chrome-linux/chrome']) { + const p = `${root}/${dir}/${sub}`; + if (existsSync(p)) return p; + } + } + } catch {} + return null; +} +const EXE = findBrowserExe(); +if (!EXE) { console.log('FAIL no chromium binary found in ms-playwright cache'); process.exit(1); } +const URL = 'http://127.0.0.1:8095/'; +let failures = 0; +const ok = (c, l) => { console.log(`${c ? 'PASS' : 'FAIL'} ${l}`); if (!c) failures++; }; + +const browser = await chromium.launch({ executablePath: EXE, args: ['--no-sandbox'] }); +const ctxA = await browser.newContext({ viewport: { width: 1280, height: 800 } }); +const pageA = await ctxA.newPage(); + +const errors = []; +pageA.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); +pageA.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); + +await pageA.goto(URL, { waitUntil: 'networkidle' }); +await pageA.waitForTimeout(1200); +ok((await pageA.title()) === 'Dino Isle Online', 'page title loads'); +ok(await pageA.isVisible('#menu'), 'menu overlay visible'); +const cardCount = await pageA.locator('.card').count(); +ok(cardCount === 4, `4 species cards rendered (got ${cardCount})`); +await pageA.screenshot({ path: '/tmp/shot-menu.png' }); + +// join as player A +await pageA.fill('#nameInput', 'VisRex'); +await pageA.click('.card[data-key="rex"]'); +await pageA.click('#playBtn'); +await pageA.waitForTimeout(2500); +ok(await pageA.isHidden('#menu'), 'menu hides after Play'); +ok(!(await pageA.evaluate(() => document.getElementById('hud').classList.contains('hidden'))), 'HUD visible in game'); +// canvas actually drawing? sample a pixel region via 2d readback +const painted = await pageA.evaluate(() => { + const cv = document.getElementById('game'); + const c = document.createElement('canvas'); + c.width = cv.width; c.height = cv.height; + c.getContext('2d').drawImage(cv, 0, 0); + const d = c.getContext('2d').getImageData(c.width >> 1, c.height >> 1, 1, 1).data; + return d[0] + d[1] + d[2] > 0; +}); +ok(painted, 'game canvas has painted pixels'); +await pageA.screenshot({ path: '/tmp/shot-game-a.png' }); + +// second player joins on another "tab" +const pageB = await (await browser.newContext({ viewport: { width: 1100, height: 700 } })).newPage(); +pageB.on('pageerror', (e) => errors.push('B pageerror: ' + e.message)); +await pageB.goto(URL, { waitUntil: 'networkidle' }); +await pageB.fill('#nameInput', 'VisRaptor'); +await pageB.click('.card[data-key="raptor"]'); +await pageB.click('#playBtn'); +await pageB.waitForTimeout(2000); + +// A walks a bit & bites; then screenshot again (multiplayer HUD state) +for (const key of ['KeyW', 'KeyW', 'Space']) { await pageA.keyboard.press(key); await pageA.waitForTimeout(350); } +await pageA.waitForTimeout(1200); +const online = await pageA.textContent('#onlineCount'); +ok(parseInt(online) >= 2, `server reports ${online} players online`); +await pageA.screenshot({ path: '/tmp/shot-game-mp.png' }); + +ok(errors.length === 0, 'no browser console/page errors' + (errors.length ? ' -> ' + errors.slice(0, 4).join(' | ') : '')); + +await browser.close(); +console.log(failures === 0 ? '\nALL VISUAL TESTS PASSED' : `\n${failures} VISUAL TEST(S) FAILED`); +process.exit(failures ? 1 : 0); diff --git a/worldgen.js b/worldgen.js new file mode 100644 index 0000000..3a3bdaf --- /dev/null +++ b/worldgen.js @@ -0,0 +1,121 @@ +// --------------------------------------------------------------- +// Dino Isle Online — world generation (deterministic, seed based) +// Tiles: 0 deep water, 1 shallow water, 2 sand, 3 grassland, 4 forest +// --------------------------------------------------------------- +'use strict'; + +const MAP_W = 160; +const MAP_H = 160; +const TILE = 48; +const WORLD_W = MAP_W * TILE; +const WORLD_H = MAP_H * TILE; + +function hash2(ix, iy, seed) { + let h = ix * 374761393 + iy * 668265263 + seed * 2246822519; + h = (h ^ (h >>> 13)) >>> 0; + h = Math.imul(h, 1274126177) >>> 0; + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +function smooth(t) { return t * t * (3 - 2 * t); } +function valueNoise(x, y, seed) { + const ix = Math.floor(x), iy = Math.floor(y); + const fx = x - ix, fy = y - iy; + const a = hash2(ix, iy, seed), b = hash2(ix + 1, iy, seed); + const c = hash2(ix, iy + 1, seed), d = hash2(ix + 1, iy + 1, seed); + const u = smooth(fx), v = smooth(fy); + return a * (1 - u) * (1 - v) + b * u * (1 - v) + c * (1 - u) * v + d * u * v; +} +function fbm(x, y, seed, octaves) { + let amp = 1, freq = 1, sum = 0, norm = 0; + for (let i = 0; i < octaves; i++) { + sum += amp * valueNoise(x * freq, y * freq, seed + i * 1013); + norm += amp; amp *= 0.5; freq *= 2.03; + } + return sum / norm; +} + +function generateWorld(seed) { + const tiles = new Uint8Array(MAP_W * MAP_H); + for (let ty = 0; ty < MAP_H; ty++) { + for (let tx = 0; tx < MAP_W; tx++) { + // normalized coords centered + const nx = tx / MAP_W - 0.5, ny = ty / MAP_H - 0.5; + const d = Math.sqrt(nx * nx * 1.15 + ny * ny * 1.35) * 2; // 0 center -> ~1.4 corners + const e = fbm(tx / 22, ty / 22, seed, 4) * 0.9 + 0.18 - Math.pow(Math.min(d, 1.45), 2.1) * 0.62; + const m = fbm(tx / 14 + 100, ty / 14 + 100, seed + 7777, 3); + let t; + if (e < 0.30) t = 0; // deep water + else if (e < 0.40) t = 1; // shallow water + else if (e < 0.45) t = 2; // sand + else if (m > 0.60 && e < 0.85) t = 4; // forest + else t = 3; // grassland + tiles[ty * MAP_W + tx] = t; + } + } + return tiles; +} + +function makeRng(seed) { + let s = (seed >>> 0) || 1; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +function tileAt(tiles, wx, wy) { + const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); + if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return 0; + return tiles[ty * MAP_W + tx]; +} + +// Static decoration: trees on forest, rocks scattered, shoreline foam points +function generateDecor(tiles, seed) { + const rng = makeRng(seed + 991); + const trees = [], rocks = [], flowers = [], foam = []; + for (let ty = 1; ty < MAP_H - 1; ty++) { + for (let tx = 1; tx < MAP_W - 1; tx++) { + const t = tiles[ty * MAP_W + tx]; + const cx = tx * TILE + TILE / 2, cy = ty * TILE + TILE / 2; + if (t === 4 && rng() < 0.16) { + trees.push({ x: cx + (rng() - 0.5) * 30, y: cy + (rng() - 0.5) * 30, s: 0.8 + rng() * 0.55 }); + } else if (t === 3 && rng() < 0.010) { + rocks.push({ x: cx + (rng() - 0.5) * 34, y: cy + (rng() - 0.5) * 34, s: 0.6 + rng() * 0.9, r: rng() }); + } else if ((t === 3 || t === 2) && rng() < 0.05) { + flowers.push({ x: cx + (rng() - 0.5) * 40, y: cy + (rng() - 0.5) * 40, c: Math.floor(rng() * 3) }); + } + } + } + // foam: shallow tiles adjacent to sand + for (let ty = 1; ty < MAP_H - 1; ty++) { + for (let tx = 1; tx < MAP_W - 1; tx++) { + if (tiles[ty * MAP_W + tx] !== 1) continue; + let adjSand = false; + for (let oy = -1; oy <= 1 && !adjSand; oy++) + for (let ox = -1; ox <= 1; ox++) { + if (tiles[(ty + oy) * MAP_W + tx + ox] === 2) { adjSand = true; break; } + } + if (adjSand) foam.push({ x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, p: rng() * Math.PI * 2 }); + } + } + // cap decor for perf + const cap = (arr, n) => arr.length > n ? arr.filter((_, i) => i % Math.ceil(arr.length / n) === 0) : arr; + return { trees: cap(trees, 900), rocks: cap(rocks, 260), flowers: cap(flowers, 700), foam: cap(foam, 1400) }; +} + +// Find land spawn points (grass/sand near coast preferred) +function findSpawnPoints(tiles, count) { + const rng = makeRng(4242); + const pts = []; + let guard = 0; + while (pts.length < count && guard++ < 20000) { + const tx = 4 + Math.floor(rng() * (MAP_W - 8)); + const ty = 4 + Math.floor(rng() * (MAP_H - 8)); + const t = tiles[ty * MAP_W + tx]; + if (t !== 2 && t !== 3) continue; + pts.push({ x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2 }); + } + return pts; +} + +module.exports = { MAP_W, MAP_H, TILE, WORLD_W, WORLD_H, generateWorld, generateDecor, findSpawnPoints, tileAt, makeRng }; diff --git a/ws.js b/ws.js new file mode 100644 index 0000000..8028606 --- /dev/null +++ b/ws.js @@ -0,0 +1,149 @@ +// --------------------------------------------------------------- +// Minimal RFC6455 WebSocket server — zero dependencies +// --------------------------------------------------------------- +'use strict'; +const crypto = require('crypto'); + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function acceptKey(key) { + return crypto.createHash('sha1').update(key + GUID).digest('base64'); +} + +class WSConn { + constructor(socket) { + this.socket = socket; + this.alive = true; + this.fragOp = 0; + this.fragBufs = []; + this.onmessage = null; + this.onclose = null; + socket.setNoDelay(true); + socket.on('data', (buf) => this._onData(buf)); + const end = () => this._closed(); + socket.on('close', end); + socket.on('error', end); + } + + get writable() { + return this.socket && this.socket.writable && this.alive; + } + + _closed() { + if (!this.alive) return; + this.alive = false; + if (this.onclose) this.onclose(); + try { this.socket.destroy(); } catch (_) {} + } + + _onData(buf) { + let off = 0; + try { + while (off + 2 <= buf.length) { + const b0 = buf[off]; + const b1 = buf[off + 1]; + const fin = (b0 & 0x80) !== 0; + const op = b0 & 0x0f; + const masked = (b1 & 0x80) !== 0; + let len = b1 & 0x7f; + off += 2; + if (len === 126) { + if (off + 2 > buf.length) break; + len = buf.readUInt16BE(off); off += 2; + } else if (len === 127) { + if (off + 8 > buf.length) break; + const big = buf.readBigUInt64BE(off); + off += 8; + len = Number(big); + } + if (len > 10 * 1024 * 1024) { this.close(); return; } // sanity cap + let maskKey = null; + if (masked) { + if (off + 4 > buf.length) break; + maskKey = buf.subarray(off, off + 4); off += 4; + } + if (off + len > buf.length) break; // wait for more data (rare; messages are small) + let payload = buf.subarray(off, off + len); + off += len; + if (maskKey) { + payload = Buffer.from(payload); // copy so we can unmask + for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i & 3]; + } + switch (op) { + case 0x0: // continuation + this.fragBufs.push(payload); + if (fin) { + const full = Buffer.concat(this.fragBufs); + this.fragBufs = []; + this._emit(this.fragOp, full); + } + break; + case 0x1: case 0x2: // text / binary + if (fin) this._emit(op, payload); + else { this.fragOp = op; this.fragBufs = [payload]; } + break; + case 0x8: this.close(); return; // close + case 0x9: this._sendFrame(0xA, payload); break; // ping -> pong + case 0xA: break; // pong + default: break; + } + } + } catch (_) { this.close(); } + } + + _emit(op, payload) { + if (op === 0x1) { + const str = payload.toString('utf8'); + if (this.onmessage) this.onmessage(str); + } + } + + _sendFrame(op, payload) { + if (!this.writable) return; + const len = payload.length; + let header; + if (len < 126) { + header = Buffer.from([0x80 | op, len]); + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = 0x80 | op; header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | op; header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + try { this.socket.write(Buffer.concat([header, payload])); } catch (_) {} + } + + send(str) { this._sendFrame(0x1, Buffer.from(str, 'utf8')); } + ping() { this._sendFrame(0x9, Buffer.alloc(0)); } + close() { + if (this.writable) { try { this._sendFrame(0x8, Buffer.alloc(0)); } catch (_) {} } + this._closed(); + } +} + +function attach(server, path, onConn) { + server.on('upgrade', (req, socket) => { + try { + const url = req.url.split('?')[0]; + if (url !== path) { socket.destroy(); return; } + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + const headers = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${acceptKey(key)}`, + '\r\n', + ].join('\r\n'); + socket.write(headers); + onConn(new WSConn(socket)); + } catch (_) { + try { socket.destroy(); } catch (_) {} + } + }); +} + +module.exports = { attach, WSConn };