- 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
912 lines
33 KiB
JavaScript
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 };
|