Files
dino-isle-online/sim.js
T
deepseek 764da2ee6c 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
2026-08-23 07:00:08 +00:00

912 lines
33 KiB
JavaScript

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