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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 • <b>Shift</b> sprint •
|
||||
<b>Space / Click</b> bite • <b>E (hold)</b> eat • graze • drink •
|
||||
<b>R</b> rest • <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>
|
||||
@@ -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); }
|
||||
@@ -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!`);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user