// --------------------------------------------------------------- // 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 };