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
This commit is contained in:
2026-08-23 07:00:08 +00:00
commit 764da2ee6c
14 changed files with 3509 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+73
View File
@@ -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.
+11
View File
@@ -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"
}
+1427
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Dino Isle Online</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='80' font-size='80'>🦖</text></svg>">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<canvas id="game"></canvas>
<!-- ============ MAIN MENU ============ -->
<div id="menu" class="overlay">
<div class="menu-box">
<h1 class="title">🦖 DINO ISLE <span>ONLINE</span></h1>
<p class="subtitle">Survive. Eat. Grow. Devour the others.</p>
<div class="row">
<input id="nameInput" maxlength="16" placeholder="Your dino name…" autocomplete="off">
</div>
<div id="dinoCards" class="cards"></div>
<button id="playBtn" class="btn-play">ENTER THE ISLAND</button>
<div class="controls-help">
<b>WASD / Arrows</b> move &nbsp;&nbsp; <b>Shift</b> sprint &nbsp;&nbsp;
<b>Space / Click</b> bite &nbsp;&nbsp; <b>E (hold)</b> eat • graze • drink &nbsp;&nbsp;
<b>R</b> rest &nbsp;&nbsp; <b>Enter</b> chat
</div>
<div id="menuStatus" class="menu-status"></div>
</div>
</div>
<!-- ============ HUD ============ -->
<div id="hud" class="hidden">
<!-- vitals bottom-left -->
<div id="vitals">
<div class="bar"><div class="fill hp" id="barHp"></div><span>❤ HP</span></div>
<div class="bar"><div class="fill stam" id="barStam"></div><span>⚡ Stamina</span></div>
<div class="bar"><div class="fill food" id="barFood"></div><span>🍖 Food</span></div>
<div class="bar"><div class="fill water" id="barWater"></div><span>💧 Water</span></div>
<div class="growth-row">
<span id="stageLabel">Hatchling</span>
<div class="bar growth"><div class="fill growth" id="barGrowth"></div></div>
</div>
</div>
<!-- minimap top-right -->
<canvas id="minimap" width="164" height="164"></canvas>
<div id="onlineTag">🌍 <span id="onlineCount">1</span> online</div>
<!-- leaderboard top-left -->
<div id="leaderboard">
<h3>🏆 Apex of the Isle</h3>
<ol id="lbList"></ol>
</div>
<!-- kill feed -->
<div id="killFeed"></div>
<!-- chat -->
<div id="chatBox">
<div id="chatLog"></div>
<input id="chatInput" maxlength="120" placeholder="Press Enter to chat…" autocomplete="off">
</div>
<div id="hint">Space: bite · E hold: eat/drink · R: rest · Shift: sprint · M: mute</div>
<div id="devBadge" class="hidden">🛠 DEV MODE — ` off · 1 heal · 2 grow · 3 spawn AI · Alt+Click tp</div>
<div id="banner" class="hidden"></div>
<div id="dmgVignette"></div>
</div>
<!-- ============ DEATH SCREEN ============ -->
<div id="deathScreen" class="overlay hidden">
<div class="death-box">
<h1>💀 YOU WERE DEVOURED</h1>
<p id="deathBy">by …</p>
<div id="deathStats"></div>
<button id="respawnBtn" class="btn-play">RESPAWN</button>
</div>
</div>
<script src="/client.js"></script>
</body>
</html>
+154
View File
@@ -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); }
+120
View File
@@ -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!`);
});
+911
View File
@@ -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 };
+82
View File
@@ -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);
+57
View File
@@ -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);
+230
View File
@@ -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);
+84
View File
@@ -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);
+121
View File
@@ -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 };
+149
View File
@@ -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 };