My own Minecraft-style game, written from scratch for the kids' Fire tablets. Play it at https://games.503mail.com/blockcraft/ or pick BLOCKCRAFT from the menu at https://games.503mail.com. It runs in the Amazon Kids web browser, so there is nothing to install or approve. Single player worlds are saved on the tablet, and Play Together puts everyone in one shared family world that lives on its own container. This post is the complete rebuild guide; every block has a Copy button.
What's in it
- A blocky world that never ends: grassland, forest, desert and snowy biomes, hills, lakes and sea, caves with coal, iron, gold and diamond ore, trees, flowers and cactus. Each world comes from a seed, so the same seed always makes the same land.
- Survival and Creative. Survival has 10 hearts, a hunger bar, fall damage, drowning and cactus pricks. Creative has every block, flying (double-tap JUMP, or the FLY button) and no damage. You can switch modes from the pause menu.
- Digging takes the right tool: stone and ores need a pickaxe (iron needs stone or better, gold and diamond need iron or better). Wooden, stone, iron and diamond pickaxes, axes, shovels and swords all wear out.
- Crafting from a list: tap a recipe to make it. Some need a crafting table nearby, and smelting needs a furnace nearby plus fuel (coal or wood). You can make planks, sticks, torches, tools, a furnace, chests, doors, beds, ladders, glass, bricks, stone bricks, slabs, sandstone, bookshelves, glowstone, coloured wool, TNT, a bow and arrows, and cooked food.
- Monsters come out at night and in dark caves: zombies (arms out, they chase and hit), skeletons (they keep their distance and shoot arrows), creepers (they hiss, flash and blow up blocks) and spiders. Zombies and skeletons burn in sunlight. Pigs, cows and sheep wander in the daytime and drop food, leather and wool. Torches and glowstone light up the dark and keep monsters from spawning next to them.
- Dinosaurs (the kids asked for these): a T-Rex (60 health, roars when it spots you, bites for 3 hearts, drops T-Rex teeth), packs of fast raptors, triceratops (peaceful until you hit one, then it charges) and the giant long-necked brachiosaurus. They roam in the daytime and drop dino meat to cook into dino steak. Two T-Rex teeth and a stick make a T-Rex Tooth Sword. On Peaceful difficulty only the friendly ones appear.
- Castles: stone castles stand here and there in grassland and forest (up to one every 128 blocks). Each has walls with battlements and a gate, four towers with ladders, lookout tops and red flags, and a keep with a door, windows, a bed, a crafting table and a treasure chest full of diamonds, iron, gold, arrows and torches, sometimes with a diamond sword or a diamond block. Each castle's chest is filled the first time someone opens it.
- Dungeon crawler mode: a dungeon to go down floor by floor with safe rooms and bosses, levels, classes, pets, achievements and loot boxes: see the add-on post BlockCraft add-on: dungeon floors, bosses, pets, levels and loot boxes. The files below already include it.
- Castles, treasure, potions and armor: five kinds of castles to explore or earn as kits, treasure chests with jewels, silver, fruit and potions, and iron, gold and diamond armor: see the add-on post BlockCraft add-on: castles, treasure, potions and diamond armor. The files below already include it.
- Airplanes and helicopters that each kid earns in Survival and owns: see the add-on post BlockCraft add-on: airplanes and helicopters. The files below already include it.
- More diamonds: diamond ore shows up below height 16 more often than before, and 9 diamonds make a sparkly diamond block (it turns back into 9 diamonds).
- House building: doors you can open and close, beds (sleep through the night and set your respawn point), chests to store things, glass windows, ladders, slabs and nine wool colours. TNT can be lit by tapping it.
- A map of everywhere you've been, with your spawn point, your bed and the other players.
- Day and night (a full day is 10 minutes), with a sun, a moon and darker nights.
- Peaceful, Easy or Normal difficulty for single-player worlds. The family world is Normal.
Controls
Tablet: your left thumb moves you (a joystick appears wherever you touch). Drag on the right side to look around. Tap to place a block, open a door, chest, table or furnace, sleep in a bed, light TNT, eat food or shoot the bow. Hold to dig a block or keep hitting a monster. JUMP jumps and swims up. BAG opens your bag and crafting, MAP opens the map and II pauses. Tap a hotbar slot to pick it.
Flying controls are in the airplanes and helicopters add-on post.
Keyboard and mouse: WASD or arrow keys move, the mouse looks around (click the game first), left click digs or hits, right click places or uses, space jumps (double-tap for flying in Creative), Shift flies down, F gets out of an airplane or helicopter, E opens the bag, M opens the map, 1 to 9 pick a slot, and Esc pauses.
Why not real Minecraft or Luanti?
The first plan was a Luanti server (the free Minecraft-like engine that used to be called Minetest) running VoxeLibre. On these tablets, none of the ways to run it worked:
- Luanti isn't in the Amazon Appstore, so it has to be sideloaded. The sideloaded app does run inside a kid's profile, but the Amazon Kids home screen never shows a tile for it, so a kid can't open it.
- Links on an approved website that try to open the app (
intent://links) are silently blocked by the Amazon Kids browser. - The web version of Luanti stops right away on a Fire 7 with
RangeError: WebAssembly.Memory(): could not allocate memory. It wants more memory than a 2 GB tablet's browser can give.
So BlockCraft is a web game, like the other games on the site. If the kids ever get devices that can run Luanti, here are the options I noted for later:
- VoxeLibre (formerly MineClone2): the long-running, well-maintained Minecraft-like game. This was the first choice.
- Mineclonia: a fork of the same game that aims to look and play closer to real Minecraft.
- Both at once: one container can run two Luanti worlds, VoxeLibre on UDP port 30000 and Mineclonia on 30001, with a router port forward for each.
How it works
- The tablet opens
https://games.503mail.com/blockcraft/. CT 108 (Caddy reverse proxy) sends every/blockcraft…request to CT 127 (blockcraft). Everything else on games.503mail.com still goes to CT 125 (nibbles), which serves the menu and the other games. - CT 127 runs a small Node.js server. It serves the three game files (
index.html,engine.js,game.js), about 150 KB in total. All the textures, icons and sounds are drawn or synthesised in code, so there are no image or sound files to download. engine.jsholds the game rules: blocks, items, recipes, world generation, physics and the monsters. The same file runs in the browser and on the server. In single player the whole game, monsters included, runs inside the page, and the world is saved in the browser's own storage on that tablet: only your changes are saved, because the land itself is regenerated from the seed.- Play Together opens a WebSocket to
wss://games.503mail.com/blockcraft/ws. Caddy passes WebSockets through on its own. The server checks the family code and the name list, sends the world's seed and every change anyone has made, and from then on runs the monsters, arrows, explosions, chests and the time of day for everyone. Each tablet sends its position 10 times a second, and the server sends a snapshot of monsters and players 10 times a second. - The family world (all block changes, chests, and each player's bag, health and position) is saved to
/opt/blockcraft/data/world.jsonevery 30 seconds and when the service stops.
Containers involved
| Container | Role | LAN IP |
|---|---|---|
| CT 127 (blockcraft) | Node.js game server, port 8080 | x.x.0.193 (DHCP) |
| CT 108 (Caddy reverse proxy) | HTTPS for games.503mail.com, routes /blockcraft* to CT 127 |
x.x.0.122 |
| CT 125 (nibbles) | The games menu and the other games | x.x.0.108 |
CT 127 is a Debian 12 container with 2 cores, 768 MB RAM and a 4 GB disk. Node uses around 60 MB with nobody playing. games.503mail.com already resolves to the house through the wildcard DNS record, so no DNS change is needed.
Where to run each command
- Proxmox host shell: in the Proxmox web UI click pve → Shell (or SSH to the host as root).
- Inside CT 127 (blockcraft): Proxmox web UI → 127 (blockcraft) → Console, or
pct enter 127from the host shell. - Your Windows PC: PowerShell, only for the optional icons.
Rebuild, step by step
The games site comes first: Games site + Nibbles builds CT 125 (nibbles), the menu (which already has the BlockCraft tile), the edge route for games.503mail.com in CT 108 (Caddy reverse proxy) and make-icons.ps1.
1. Make a working folder on the host
Proxmox host shell:
mkdir -p /root/blockcraft && cd /root/blockcraft
2. Create every file
Paste each block into the Proxmox host shell. Each one writes one file into /root/blockcraft/. The three game files are long; let each paste finish before the next.
01-create-ct.sh: creates CT 127 and runs the setup inside it.
cat > /root/blockcraft/01-create-ct.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host. Creates CT 127 (blockcraft) and installs the game server.
# Stage first, all in one folder: /root/blockcraft/{server.js,config.json,02-setup-inside.sh,index.html,engine.js,game.js}
# plus the icon PNGs if you have them (optional).
set -euo pipefail
CTID=127
if pct status $CTID >/dev/null 2>&1; then echo "CT $CTID already exists"; exit 1; fi
pct create $CTID local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \
--hostname blockcraft --cores 2 --memory 768 --swap 512 \
--rootfs local-lvm:4 --net0 name=eth0,bridge=vmbr0,ip=dhcp \
--unprivileged 1 --features nesting=1 --onboot 1 --ostype debian
pct start $CTID
for i in $(seq 1 30); do
IP=$(pct exec $CTID -- hostname -I | awk '{print $1}')
[ -n "$IP" ] && break; sleep 2
done
echo "CT $CTID IP: $IP"
pct exec $CTID -- mkdir -p /root/stage/public
for f in server.js config.json 02-setup-inside.sh; do pct push $CTID /root/blockcraft/$f /root/stage/$f; done
for f in /root/blockcraft/index.html /root/blockcraft/engine.js /root/blockcraft/game.js /root/blockcraft/*.png; do
[ -f "$f" ] && pct push $CTID "$f" /root/stage/public/$(basename "$f")
done
pct exec $CTID -- bash /root/stage/02-setup-inside.sh
EOF
02-setup-inside.sh: runs inside CT 127. It installs Node.js and the ws WebSocket library from Debian (no npm needed), copies the game to /opt/blockcraft and starts the blockcraft service.
cat > /root/blockcraft/02-setup-inside.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs inside CT 127. Installs Node.js + ws from Debian, copies the game to /opt/blockcraft
# and runs it as a systemd service on port 8080.
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q nodejs node-ws
id blockcraft >/dev/null 2>&1 || useradd --system --home /opt/blockcraft --shell /usr/sbin/nologin blockcraft
mkdir -p /opt/blockcraft/public /opt/blockcraft/data
cp /root/stage/server.js /opt/blockcraft/server.js
[ -f /opt/blockcraft/config.json ] || cp /root/stage/config.json /opt/blockcraft/config.json
cp /root/stage/public/* /opt/blockcraft/public/
chown -R blockcraft:blockcraft /opt/blockcraft
chmod 640 /opt/blockcraft/config.json
cat > /etc/systemd/system/blockcraft.service <<'EOF'
[Unit]
Description=BlockCraft multiplayer game server
After=network-online.target
[Service]
User=blockcraft
WorkingDirectory=/opt/blockcraft
ExecStart=/usr/bin/node /opt/blockcraft/server.js
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now blockcraft
sleep 2
systemctl --no-pager status blockcraft | head -5
curl -s -o /dev/null -w "local page: %{http_code}\n" http://127.0.0.1:8080/blockcraft/ || true
EOF_OUTER1
03-edge-route.sh: runs inside CT 108. It turns the games.503mail.com block into two handle blocks, /blockcraft* to CT 127 and everything else to CT 125. It keeps a backup and puts it back if Caddy says the new file is wrong.
cat > /root/blockcraft/03-edge-route.sh <<'EOF'
#!/bin/bash
# Runs inside CT 108 (edge Caddy). Usage: bash 03-edge-route.sh <CT127_IP> <CT125_IP>
# Sends games.503mail.com/blockcraft* (page + WebSocket) to CT 127; everything else stays on CT 125.
set -euo pipefail
IP="$1"; GAMES_IP="$2"; F=/etc/caddy/Caddyfile
if grep -q 'handle /blockcraft\*' "$F"; then echo "blockcraft route already present"; exit 0; fi
BAK="$F.bak-blockcraft-$(date +%Y%m%d%H%M%S)"
cp "$F" "$BAK"
GRE=$(printf '%s' "$GAMES_IP" | sed 's/\./\\./g')
perl -0pi -e "s{games\.503mail\.com \{\n\treverse_proxy $GRE:80\n\}}{games.503mail.com {\n\t# BlockCraft (CT 127, page + multiplayer WebSocket) - added $(date +%F)\n\thandle /blockcraft* {\n\t\treverse_proxy $IP:8080\n\t}\n\thandle {\n\t\treverse_proxy $GAMES_IP:80\n\t}\n}}" "$F"
if ! grep -q 'handle /blockcraft\*' "$F"; then cp "$BAK" "$F"; echo "games block not in the expected shape - nothing changed"; exit 1; fi
if ! caddy validate --config "$F" --adapter caddyfile; then
cp "$BAK" "$F"; echo "validate failed - restored $BAK"; exit 1
fi
systemctl reload caddy
echo "edge reloaded"
EOF
04-push.sh: for later updates. It copies new game files into CT 127 and restarts the service, without touching the settings or the saved world.
cat > /root/blockcraft/04-push.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host to update an existing CT 127 after copying new files to /root/blockcraft/
# (server.js, index.html, engine.js, game.js, icon PNGs). config.json and the saved world are left alone.
set -euo pipefail
CTID=127
pct push $CTID /root/blockcraft/server.js /opt/blockcraft/server.js
for f in /root/blockcraft/index.html /root/blockcraft/engine.js /root/blockcraft/game.js /root/blockcraft/*.png; do
[ -f "$f" ] && pct push $CTID "$f" /opt/blockcraft/public/$(basename "$f")
done
pct exec $CTID -- chown -R blockcraft:blockcraft /opt/blockcraft
pct exec $CTID -- systemctl restart blockcraft
sleep 2
pct exec $CTID -- systemctl is-active blockcraft
EOF
config.example.json: the server settings. The code is the family code everyone types once to join, names is who may join (leave it as [] to allow any name), and difficulty is peaceful, easy or normal.
cat > /root/blockcraft/config.example.json <<'EOF'
{
"port": 8080,
"code": "CHANGE-ME",
"difficulty": "normal",
"names": ["Lavon", "Layla", "Lilly", "Luca", "Dietrich", "admin"]
}
EOF
server.js: the game server. It serves the page, checks the family code, runs the shared world and saves it.
cat > /root/blockcraft/server.js <<'EOF'
'use strict';
// BlockCraft multiplayer server (CT 127). Serves the game page at /blockcraft/ and runs the shared
// world over a WebSocket at /blockcraft/ws. The world simulation is the same engine.js the page uses.
const http = require('http'), fs = require('fs'), path = require('path');
const WebSocket = require('ws');
const ROOT = __dirname;
const PUB = fs.existsSync(path.join(ROOT, 'public')) ? path.join(ROOT, 'public') : path.join(ROOT, '..', 'public');
const BC = require(path.join(PUB, 'engine.js'));
const DATA = path.join(ROOT, 'data'), WORLD_FILE = path.join(DATA, 'world.json');
const cfg = Object.assign({ port: 8080, code: '', difficulty: 'normal', names: [] },
fs.existsSync(path.join(ROOT, 'config.json')) ? JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'), 'utf8')) : {});
fs.mkdirSync(DATA, { recursive: true });
// ---------------------------------------------------------------- world state
let saved = null;
try { saved = JSON.parse(fs.readFileSync(WORLD_FILE, 'utf8')); } catch (e) { saved = null; }
const seed = saved ? saved.seed : (Math.random() * 2147483647) | 0;
const conns = new Map(); // player id -> { ws, name }
const sim = new BC.Sim({ seed, difficulty: cfg.difficulty, time: saved ? saved.time : 0.02, chests: saved ? saved.chests : {},
vehicles: saved ? saved.vehicles : [], owned: saved ? saved.owned : [], dungeon: saved ? saved.dungeon : null, pets: saved ? saved.pets : {}, send });
sim.unloadFar = true;
if (saved && saved.edits) sim.world.decodeEdits(saved.edits);
const spawn = (saved && saved.spawn) || sim.spawnPoint();
sim.ensureEntrance(spawn); // the dungeon shrine next to spawn (built once, then it's part of the saved changes)
const playersData = (saved && saved.players) || {};
let nextId = 1, dirty = true;
const origSet = sim.world.set.bind(sim.world);
sim.world.set = (...a) => { const r = origSet(...a); if (r) dirty = true; return r; };
function send(msg, to) {
const s = JSON.stringify(msg);
if (to == null) { for (const c of conns.values()) if (c.ws.readyState === 1) c.ws.send(s); }
else { const c = conns.get(to); if (c && c.ws.readyState === 1) c.ws.send(s); }
}
function saveWorld() {
const data = { seed, time: sim.time, spawn, edits: sim.world.encodeEdits(), chests: sim.chests, vehicles: sim.saveVehicles(), owned: sim.owned, dungeon: sim.dungeon, pets: sim.petData, players: playersData };
const tmp = WORLD_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data));
fs.renameSync(tmp, WORLD_FILE);
dirty = false;
}
// ---------------------------------------------------------------- web server
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.png': 'image/png', '.css': 'text/css' };
const server = http.createServer((req, res) => {
let p = decodeURIComponent((req.url || '/').split('?')[0]);
if (p === '/' || p === '/blockcraft') { res.writeHead(301, { Location: '/blockcraft/' }); res.end(); return; }
if (!p.startsWith('/blockcraft/')) { res.writeHead(404); res.end('not found'); return; }
p = p.slice('/blockcraft/'.length) || 'index.html';
const file = path.normalize(path.join(PUB, p));
if (!file.startsWith(PUB + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); res.end('not found'); return; }
res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
fs.createReadStream(file).pipe(res);
});
// ---------------------------------------------------------------- players
const wss = new WebSocket.Server({ server, path: '/blockcraft/ws', maxPayload: 256 * 1024 });
const ALLOWED = new Set(['move', 'block', 'hit', 'shoot', 'ignite', 'chest', 'chestSet', 'sleep', 'chat',
'vplace', 'vride', 'vmove', 'vleave', 'vpickup', 'kit', 'stairs', 'pet', 'petname']);
wss.on('connection', (ws, req) => {
let id = null, name = null;
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
ws.on('message', raw => {
let m; try { m = JSON.parse(raw); } catch (e) { return; }
if (!m || typeof m.t !== 'string') return;
if (id === null) {
if (m.t !== 'join') return;
const nm = String(m.name || '').trim();
if (cfg.code && String(m.code || '') !== cfg.code) { ws.send(JSON.stringify({ t: 'error', text: 'Wrong family code.' })); return; }
if (!/^[A-Za-z0-9 _-]{1,16}$/.test(nm)) { ws.send(JSON.stringify({ t: 'error', text: 'Names can use letters and numbers (up to 16).' })); return; }
if (cfg.names.length && !cfg.names.map(n => n.toLowerCase()).includes(nm.toLowerCase())) { ws.send(JSON.stringify({ t: 'error', text: 'That name is not on the family list.' })); return; }
for (const [oid, c] of conns) if (c.name.toLowerCase() === nm.toLowerCase()) { c.ws.close(); conns.delete(oid); sim.removePlayer(oid); }
id = nextId++; name = nm;
const you = playersData[nm.toLowerCase()] || null;
conns.set(id, { ws, name });
sim.addPlayer(id, name, you ? { x: you.x, y: you.y, z: you.z } : spawn);
ws.send(JSON.stringify({ t: 'welcome', id, name, seed, time: sim.time, edits: sim.world.encodeEdits(), spawn, you, owned: sim.owned, pet: sim.petData[nm.toLowerCase()] || null, dungeon: sim.dungeon.entrance }));
send({ t: 'msg', text: name + ' joined the game' }, null);
console.log(new Date().toISOString(), 'join', name, ip);
return;
}
if (m.t === 'save') {
if (m.data && JSON.stringify(m.data).length < 30000) { playersData[name.toLowerCase()] = m.data; dirty = true; }
return;
}
if (!ALLOWED.has(m.t)) return;
if (m.t === 'block' && !(m.id >= 0 && m.id < BC.NBLOCKS && m.id !== 61 && m.id !== 62)) return; // stairwells come only from the dungeon
sim.handle(id, m);
if (m.t[0] === 'v' || m.t[0] === 'p' || m.t === 'kit' || m.t === 'stairs' || m.t === 'hit') dirty = true;
});
ws.on('close', () => {
if (id === null || !conns.has(id) || conns.get(id).ws !== ws) return;
conns.delete(id); sim.removePlayer(id);
send({ t: 'msg', text: name + ' left the game' }, null);
console.log(new Date().toISOString(), 'leave', name);
});
});
// ---------------------------------------------------------------- loop
let last = Date.now();
setInterval(() => {
const now = Date.now(), dt = Math.min(0.1, (now - last) / 1000); last = now;
if (conns.size) sim.tick(dt);
}, 50);
setInterval(() => { if (dirty) saveWorld(); }, 30000);
const stop = () => { try { saveWorld(); } catch (e) { console.error(e); } process.exit(0); };
process.on('SIGTERM', stop); process.on('SIGINT', stop);
server.listen(cfg.port, () => console.log('BlockCraft on port ' + cfg.port + ', seed ' + seed + ', spawn ' + JSON.stringify(spawn)));
EOF
test-client.js: a quick check that joins two pretend players and makes sure a block placed by one shows up for the other.
cat > /root/blockcraft/test-client.js <<'EOF'
'use strict';
// Smoke test, run inside CT 127: node test-client.js
// Two fake players join on 127.0.0.1, one places a block, the other must see it.
const WebSocket = require('ws');
const cfg = require('./config.json');
const url = 'ws://127.0.0.1:' + cfg.port + '/blockcraft/ws';
function player(name) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url), p = { ws, name, msgs: [] };
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name, code: cfg.code })));
ws.on('message', d => { const m = JSON.parse(d); p.msgs.push(m); if (m.t === 'welcome') { p.welcome = m; resolve(p); } if (m.t === 'error') reject(new Error(m.text)); });
ws.on('error', reject);
setTimeout(() => reject(new Error('timeout ' + name)), 5000);
});
}
(async () => {
const bad = await new Promise(res => {
const ws = new WebSocket(url);
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name: 'Luca', code: 'wrong' })));
ws.on('message', d => { res(JSON.parse(d)); ws.close(); });
});
console.log('wrong code ->', bad.t, bad.text);
const a = await player('admin'), b = await player('Luca');
console.log('joined', a.welcome.id, b.welcome.id, 'seed', a.welcome.seed, 'spawn', JSON.stringify(a.welcome.spawn));
const s = a.welcome.spawn, x = Math.floor(s.x) + 2, y = Math.floor(s.y) + 3, z = Math.floor(s.z);
a.ws.send(JSON.stringify({ t: 'move', x: s.x, y: s.y, z: s.z, yaw: 0, pitch: 0 }));
a.ws.send(JSON.stringify({ t: 'block', x, y, z, id: 49 }));
await new Promise(r => setTimeout(r, 600));
const seen = b.msgs.some(m => m.t === 'blocks' && m.l.some((v, i) => i % 4 === 0 && m.l[i] === x && m.l[i + 1] === y && m.l[i + 2] === z && m.l[i + 3] === 49));
const snaps = b.msgs.filter(m => m.t === 'snap');
console.log('B saw the block:', seen, '| snapshots:', snaps.length, '| players in last snap:', snaps.length ? snaps[snaps.length - 1].players.map(p => p[1]).join(',') : '-');
a.ws.send(JSON.stringify({ t: 'block', x, y, z, id: 0 }));
await new Promise(r => setTimeout(r, 300));
// vehicles: A sets down an airplane, B may not get in, A flies it, B sees it move, A picks it back up
const wait = ms => new Promise(r => setTimeout(r, ms)), last = p => p.msgs.filter(m => m.t === 'snap').pop();
a.ws.send(JSON.stringify({ t: 'vplace', kind: 'plane', x: s.x + 5, y: s.y + 20, z: s.z, yaw: 0 }));
await wait(400);
const pv = last(b).vehicles.find(v => v[9] === a.name);
b.ws.send(JSON.stringify({ t: 'vride', id: pv[0] })); a.ws.send(JSON.stringify({ t: 'vride', id: pv[0] }));
await wait(300);
const refused = b.msgs.some(m => m.t === 'msg' && /admin's/.test(m.text)), rideOk = a.msgs.some(m => m.t === 'vride');
a.ws.send(JSON.stringify({ t: 'vmove', id: pv[0], x: s.x + 9, y: s.y + 30, z: s.z + 3, yaw: 1, pitch: 0.2, roll: 0.1 }));
await wait(400);
const bv = last(b).vehicles.find(v => v[0] === pv[0]), flew = bv && bv[2] === s.x + 9 && bv[8] === a.welcome.id;
a.ws.send(JSON.stringify({ t: 'vleave', id: pv[0], x: s.x + 9, y: s.y, z: s.z + 3 }));
b.ws.send(JSON.stringify({ t: 'vpickup', id: pv[0] })); await wait(200);
a.ws.send(JSON.stringify({ t: 'vpickup', id: pv[0] })); await wait(400);
const back = a.msgs.some(m => m.t === 'drop' && m.items[0][0] === 144), gone = !last(b).vehicles.some(v => v[0] === pv[0]);
const vOK = refused && rideOk && flew && back && gone;
console.log('vehicle: placed', !!pv, '| other kid refused', refused, '| owner got in', rideOk, '| B saw it fly', flew, '| picked up', back, '| gone', gone);
// treasure: look inside the nearest castle's treasure chest (only looks, takes nothing)
const BC = require('./public/engine.js');
let near = null;
for (let r = -3; r <= 3; r++) for (let q = -3; q <= 3; q++) {
const c = BC.castleAt(Math.floor(s.x / BC.REG) + r, Math.floor(s.z / BC.REG) + q, a.welcome.seed);
if (c && (!near || Math.hypot(c.x0 - s.x, c.z0 - s.z) < Math.hypot(near.x0 - s.x, near.z0 - s.z))) near = c;
}
let tOK = !near;
if (near) {
const k = (near.x0 + 10) + ',' + (near.base + 1) + ',' + (near.z0 + 14);
a.ws.send(JSON.stringify({ t: 'chest', k })); await wait(400);
const ch = a.msgs.filter(m => m.t === 'chest' && m.k === k).pop();
tOK = !!ch && ch.slots.length > 0;
console.log('treasure: nearest is a', BC.CASTLES[near.type].name, 'at', near.x0 + 10, near.z0 + 10, '| chest has', ch ? ch.slots.length : 0, 'kinds of things | kit castles:', (a.welcome.owned || []).length);
}
// dungeon: walk to the shrine by spawn, take the stairs down to Floor 1, then the Way Up back
const e = a.welcome.dungeon;
let dOK = false;
if (e) {
a.ws.send(JSON.stringify({ t: 'move', x: e.x + 0.5, y: e.y + 1, z: e.z + 2.5, yaw: 0, pitch: 0 })); await wait(200);
a.ws.send(JSON.stringify({ t: 'stairs', x: e.x, y: e.y, z: e.z })); await wait(500);
const down = a.msgs.filter(m => m.t === 'tp').pop();
if (down && down.floor === 1) {
a.ws.send(JSON.stringify({ t: 'move', x: down.x, y: down.y, z: down.z, yaw: 0, pitch: 0 })); await wait(200);
a.ws.send(JSON.stringify({ t: 'stairs', x: Math.floor(down.x), y: down.y, z: Math.floor(down.z) - 2 })); await wait(500);
const up = a.msgs.filter(m => m.t === 'tp').pop();
dOK = up !== down && up.floor === 0;
}
console.log('dungeon: entrance at', e.x, e.y, e.z, '| went down to Floor', down ? down.floor : '-', '| came back up', dOK);
}
a.ws.close(); b.ws.close();
setTimeout(() => process.exit(seen && vOK && tOK && dOK ? 0 : 1), 300);
})().catch(e => { console.error('FAIL', e.message); process.exit(1); });
EOF
index.html: menus, the bag and crafting screens, the map, the touch buttons and the hotbar.
cat > /root/blockcraft/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>BlockCraft</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta property="og:title" content="BlockCraft">
<meta property="og:image" content="https://games.503mail.com/blockcraft/icon-512.png">
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden;font-family:"Lucida Console","Courier New",monospace;color:#fff;
-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
#hud{pointer-events:none;z-index:2}
button{font:inherit;cursor:pointer}
.hidden{display:none!important}
/* overlays */
.panel{position:fixed;inset:0;z-index:10;background:rgba(0,0,0,.82);display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:2vmin;padding:16px;box-sizing:border-box;font-size:clamp(13px,2.6vmin,20px);text-align:center;overflow:auto}
.panel h1{margin:0;color:#FFFF55;letter-spacing:.25em;font-size:2em;text-shadow:3px 3px #555}
.panel h2{margin:0;color:#55FFFF;letter-spacing:.2em;font-size:1.2em}
.panel .sub{color:#AAAAAA}
.btn{background:#0000AA;color:#fff;border:3px solid #55FFFF;padding:.5em 1.2em;min-width:11em;letter-spacing:.1em}
.btn:active,.btn.on{background:#5555FF;border-color:#FFFF55;color:#FFFF55}
.btn.red{border-color:#FF5555}
.btn.small{min-width:0;padding:.3em .8em}
.row{display:flex;gap:.8em;flex-wrap:wrap;justify-content:center;align-items:center}
input[type=text],input[type=password]{font:inherit;background:#000;color:#FFFF55;border:3px solid #55FFFF;padding:.35em .6em;width:12em}
.worlds{display:flex;flex-direction:column;gap:.6em;width:min(560px,100%)}
.world{display:flex;gap:.6em;align-items:center;background:#111a;border:2px solid #555;padding:.5em}
.world .wn{flex:1;text-align:left}
.world .wn small{color:#AAAAAA;display:block}
.help{color:#AAAAAA;font-size:.85em;line-height:1.6;max-width:44em}
.help kbd{color:#FFFF55}
a{color:#55FFFF}
#status{color:#FF5555;min-height:1.2em}
/* HUD */
#bar{position:fixed;left:50%;bottom:1vmin;transform:translateX(-50%);z-index:4;display:flex;flex-direction:column;align-items:center;gap:.4vmin;pointer-events:none}
#stats{display:flex;gap:4vmin;font-size:clamp(12px,3vmin,22px);text-shadow:1px 1px #000;pointer-events:none}
#hearts{color:#FF5555;letter-spacing:-.1em}#food{color:#d9a066;letter-spacing:-.1em}
#airbar{color:#55FFFF}
#armorbar{color:#5decf5;letter-spacing:-.1em}
#hotbar{display:flex;gap:.5vmin;background:rgba(0,0,0,.45);padding:.6vmin;border:2px solid #555;pointer-events:auto}
.slot{position:relative;width:min(8.5vmin,58px);height:min(8.5vmin,58px);border:2px solid #777;background:rgba(40,40,40,.65);
box-sizing:border-box;display:flex;align-items:center;justify-content:center}
.slot img{width:78%;height:78%;image-rendering:pixelated;pointer-events:none}
.slot .n{position:absolute;right:2px;bottom:0;font-size:clamp(10px,2.2vmin,15px);text-shadow:1px 1px #000;pointer-events:none}
.slot .dur{position:absolute;left:10%;right:10%;bottom:6%;height:3px;background:#0a0;pointer-events:none}
.slot.sel{border-color:#FFFF55;background:rgba(90,90,40,.7)}
.slot.pick{border-color:#55FF55;background:rgba(40,110,40,.8)}
#toast{position:fixed;left:50%;top:9vmin;transform:translateX(-50%);z-index:5;color:#fff;text-shadow:2px 2px #000;font-size:clamp(13px,2.8vmin,20px);
text-align:center;pointer-events:none;white-space:pre-line}
#coords{position:fixed;left:1vmin;top:1vmin;z-index:4;font-size:12px;text-shadow:1px 1px #000;pointer-events:none}
#hurt{position:fixed;inset:0;z-index:3;background:radial-gradient(transparent 40%,rgba(255,0,0,.55));opacity:0;pointer-events:none;transition:opacity .25s}
/* touch controls */
#touch{position:fixed;inset:0;z-index:1;touch-action:none;display:none}
body.touch #touch{display:block}
.tb{display:none;position:fixed;z-index:4;touch-action:none;background:rgba(0,0,120,.45);color:#FFFF55;border:3px solid rgba(85,255,255,.8);
border-radius:1.5vmin;font:bold clamp(12px,3vmin,22px) "Lucida Console","Courier New",monospace}
body.touch .tb{display:block}
.tb.held{background:rgba(85,85,255,.75)}
#bJump{right:3vmin;bottom:15vmin;width:15vmin;height:15vmin;border-radius:50%}
#bDown{right:20vmin;bottom:5vmin;width:12vmin;height:12vmin;border-radius:50%}
#bFly{right:20vmin;bottom:19vmin;width:12vmin;height:9vmin}
#bExit{position:fixed;z-index:4;left:50%;top:1.5vmin;transform:translateX(-50%);background:rgba(160,0,0,.6);color:#FFFF55;
border:3px solid #FF5555;border-radius:1.2vmin;font:bold clamp(14px,3.2vmin,24px) "Lucida Console","Courier New",monospace;padding:1vmin 3vmin;touch-action:manipulation}
#topbtns{position:fixed;right:1.5vmin;top:1.5vmin;z-index:4;display:flex;gap:1.2vmin}
#topbtns button{background:rgba(0,0,120,.55);color:#FFFF55;border:3px solid rgba(85,255,255,.8);border-radius:1.2vmin;
font:bold clamp(12px,2.8vmin,20px) "Lucida Console","Courier New",monospace;padding:1vmin 1.6vmin;touch-action:manipulation}
#stickRing{position:fixed;z-index:3;width:26vmin;height:26vmin;margin:-13vmin 0 0 -13vmin;border-radius:50%;border:3px solid rgba(85,255,255,.6);
background:rgba(0,0,80,.25);pointer-events:none;display:none}
#stickKnob{position:absolute;left:50%;top:50%;width:10vmin;height:10vmin;margin:-5vmin 0 0 -5vmin;border-radius:50%;background:rgba(255,255,85,.75)}
/* inventory */
#inv{justify-content:flex-start}
#invWrap{display:flex;gap:3vmin;flex-wrap:wrap;justify-content:center;align-items:flex-start;max-width:100%}
.grid{display:grid;grid-template-columns:repeat(9,auto);gap:.5vmin;justify-content:center}
.box{background:rgba(30,30,60,.9);border:3px solid #555;padding:1.2vmin}
.box h3{margin:0 0 1vmin;color:#55FFFF;font-size:1em;letter-spacing:.15em}
#recipes{display:flex;flex-direction:column;gap:.6vmin;max-height:62vh;overflow:auto;min-width:min(330px,90vw)}
.rec{display:flex;align-items:center;gap:.6em;background:#0000AA;border:2px solid #55FFFF;padding:.3em .5em;text-align:left;color:#fff}
.rec img{width:2em;height:2em;image-rendering:pixelated}
.rec .ing{color:#AAAAAA;font-size:.8em}
.rec.no{background:#222;border-color:#444;color:#888}
.rec.no img{opacity:.45}
#palette{display:grid;grid-template-columns:repeat(9,auto);gap:.5vmin;max-height:58vh;overflow:auto}
#itemName{min-height:1.3em;color:#FFFF55}
/* announcer banner, HERO screen */
#announce{position:fixed;left:50%;top:13vmin;transform:translateX(-50%);z-index:6;width:min(560px,90vw);background:rgba(10,0,30,.88);
border:3px solid #FFFF55;padding:1.2vmin 2vmin;text-align:center;pointer-events:none;font-size:clamp(12px,2.6vmin,18px);white-space:pre-line}
#announce b{display:block;font-size:1.25em;letter-spacing:.08em;margin-bottom:.4em}
#hero{justify-content:flex-start}
.xpbar{width:min(420px,80vw);height:14px;border:2px solid #55FFFF;background:#111}
.xpbar div{height:100%;background:#55FF55;width:0}
#hAch{display:grid;grid-template-columns:repeat(auto-fill,minmax(13em,1fr));gap:.6vmin;max-width:min(900px,92vw);text-align:left;font-size:.85em}
.ach{background:#111a;border:2px solid #333;padding:.4em;color:#777}
.ach.got{border-color:#555;color:#ddd}
/* map */
#mapCv{position:static;width:min(92vw,92vh*1.5);height:min(72vh,92vw/1.5);border:3px solid #55FFFF;background:#000;image-rendering:pixelated}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="hud"></canvas>
<div id="touch"></div>
<div id="stickRing"><div id="stickKnob"></div></div>
<div id="hurt"></div>
<div id="coords"></div>
<div id="toast"></div>
<div id="bar" class="hidden">
<div id="stats"><span id="armorbar"></span><span id="hearts"></span><span id="airbar"></span><span id="food"></span></div>
<div id="hotbar"></div>
</div>
<button id="bJump" class="tb hidden" aria-label="Jump">JUMP</button>
<button id="bDown" class="tb hidden" aria-label="Fly down">▼</button>
<button id="bFly" class="tb hidden" aria-label="Fly">FLY</button>
<button id="bExit" class="hidden" aria-label="Get out">OUT</button>
<div id="topbtns" class="hidden">
<button id="bHero" aria-label="Hero">HERO</button>
<button id="bInv" aria-label="Inventory">BAG</button>
<button id="bMap" aria-label="Map">MAP</button>
<button id="bPause" aria-label="Pause">II</button>
</div>
<div id="menu" class="panel">
<h1>BLOCKCRAFT</h1>
<div class="sub">BUILD · MINE · SURVIVE</div>
<button class="btn" id="mSingle">SINGLE PLAYER</button>
<button class="btn" id="mMulti">PLAY TOGETHER</button>
<div class="row"><label><input type="checkbox" id="optSound" checked> Sound</label></div>
<div id="nogl" class="hidden" style="color:#FF5555">This browser can't draw 3D (WebGL is off), so the game can't run here.</div>
<div class="help">
<b>Tablet:</b> left thumb moves, drag on the right to look. <b>Tap</b> to place or use, <b>hold</b> to dig or hit.<br>
<b>Keyboard:</b> <kbd>WASD</kbd> move · mouse look · <kbd>left click</kbd> dig/hit · <kbd>right click</kbd> place/use ·
<kbd>space</kbd> jump (double tap = fly in Creative) · <kbd>E</kbd> bag · <kbd>M</kbd> map · <kbd>1</kbd>–<kbd>9</kbd> pick<br>
<b>Airplanes & helicopters:</b> earn one in Survival (iron + diamonds at a crafting table), set it down, tap it to get in.
It is yours only. <kbd>F</kbd> or <b>OUT</b> gets out.<br>
<b>Treasure:</b> explore Wooden Forts, Stone and Royal Castles, Desert Palaces and Ice Castles for treasure chests.
Tap potions to drink them, tap armor to wear it, and earn castle kits to build a castle of your own.<br>
<b>Dungeon:</b> the glowing stairwell next to the start leads down to dungeon floors with bosses. <b>HERO</b> (<kbd>H</kbd>) shows your level,
class, points, pet and achievements. Tap loot boxes to open them.
</div>
<a href="/">Other games</a>
</div>
<div id="spMenu" class="panel hidden">
<h2>SINGLE PLAYER</h2>
<div class="worlds" id="worldList"></div>
<div class="box" style="display:flex;flex-direction:column;gap:1.2vmin;align-items:center">
<h3>NEW WORLD</h3>
<div class="row"><input type="text" id="nwName" maxlength="20" placeholder="World name"><input type="text" id="nwSeed" maxlength="12" placeholder="Seed (optional)"></div>
<div class="row" id="nwMode"><button class="btn small on" data-v="survival">SURVIVAL</button><button class="btn small" data-v="creative">CREATIVE</button></div>
<div class="row" id="nwDiff"><button class="btn small" data-v="peaceful">PEACEFUL</button><button class="btn small on" data-v="easy">EASY</button><button class="btn small" data-v="normal">NORMAL</button></div>
<button class="btn" id="nwGo">CREATE</button>
</div>
<button class="btn small" id="spBack">BACK</button>
</div>
<div id="mpMenu" class="panel hidden">
<h2>PLAY TOGETHER</h2>
<div class="sub">Everyone joins the same family world.</div>
<div class="row" id="mpNames"></div>
<div class="row"><input type="text" id="mpName" maxlength="16" placeholder="Your name"></div>
<div class="row"><input type="password" id="mpCode" maxlength="32" placeholder="Family code"></div>
<div class="row" id="mpMode"><button class="btn small on" data-v="survival">SURVIVAL</button><button class="btn small" data-v="creative">CREATIVE</button></div>
<button class="btn" id="mpGo">JOIN</button>
<div id="status"></div>
<button class="btn small hidden" id="mpSwitch">SWITCH PLAYER</button>
<button class="btn small" id="mpBack">BACK</button>
</div>
<div id="loading" class="panel hidden"><h2 id="loadText">BUILDING WORLD...</h2></div>
<div id="pause" class="panel hidden">
<h2>PAUSED</h2>
<button class="btn" id="pResume">BACK TO GAME</button>
<div class="row">View: <span class="row" id="pDist"><button class="btn small" data-v="2">NEAR</button><button class="btn small" data-v="3">MEDIUM</button><button class="btn small" data-v="5">FAR</button></span></div>
<div class="row"><label><input type="checkbox" id="pSound"> Sound</label><label><input type="checkbox" id="pCoords"> Show position</label></div>
<div class="row" id="pModeRow">Mode: <button class="btn small" id="pMode"></button></div>
<button class="btn red" id="pQuit">SAVE & QUIT</button>
</div>
<div id="dead" class="panel hidden">
<h1 style="color:#FF5555">YOU DIED!</h1>
<div class="sub" id="deadWhy"></div>
<button class="btn" id="dRespawn">RESPAWN</button>
</div>
<div id="inv" class="panel hidden">
<div id="itemName"></div>
<div id="invWrap">
<div class="box" id="chestBox"><h3>CHEST</h3><div class="grid" id="chestGrid"></div></div>
<div class="box" id="creativeBox"><h3>ALL BLOCKS & ITEMS</h3><div id="palette"></div></div>
<div class="box" id="armorBox"><h3>WEARING</h3><div class="grid" id="armorGrid" style="grid-template-columns:repeat(4,auto)"></div><div class="sub" id="armorInfo" style="font-size:.8em;margin-top:.6vmin"></div></div>
<div class="box"><h3>BAG</h3><div class="grid" id="invGrid"></div><div style="height:1vmin"></div><div class="grid" id="invHot"></div></div>
<div class="box" id="craftBox"><h3 id="craftTitle">CRAFTING</h3><div id="recipes"></div></div>
</div>
<button class="btn small" id="invClose">CLOSE</button>
</div>
<div id="announce" class="hidden"><b></b><span></span></div>
<div id="hero" class="panel hidden">
<h2 id="hLevel">HERO</h2>
<div class="xpbar"><div id="hXp"></div></div><div class="sub" id="hXpText"></div>
<div class="box" style="display:flex;flex-direction:column;gap:1vmin;align-items:center">
<h3>CLASS</h3><div class="row" id="hClass"></div><div class="sub" id="hClassAbout"></div>
<h3>POINTS</h3><div class="sub" id="hFree"></div><div id="hStats" style="display:flex;flex-direction:column;gap:.6vmin"></div>
<h3>PET</h3><div id="hPet"></div>
<div class="row hidden" id="hPetRow"><input type="text" id="hPetName" maxlength="16" placeholder="New pet name"><button class="btn small" id="hPetSave">NAME</button></div>
</div>
<div class="box"><h3 id="hAchTitle">ACHIEVEMENTS</h3><div id="hAch"></div></div>
<button class="btn small" id="hClose">CLOSE</button>
</div>
<div id="mapPanel" class="panel hidden">
<canvas id="mapCv" width="900" height="600"></canvas>
<div class="row"><button class="btn small" id="mapOut">−</button><button class="btn small" id="mapIn">+</button><button class="btn small" id="mapClose">CLOSE</button></div>
</div>
<script src="engine.js"></script>
<script src="game.js"></script>
</body>
</html>
EOF
engine.js: the shared game rules.
cat > /root/blockcraft/engine.js <<'EOF'
// BlockCraft shared engine: blocks, items, recipes, world generation, physics and the
// game simulation (mobs, arrows, explosions, chests, time of day). The same file runs in the
// browser (single-player runs the simulation in the page) and in Node on CT 127 (multiplayer).
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.BC = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const CS = 16, CH = 64, SEA = 24, DAY_SECONDS = 600;
// ---------------------------------------------------------------- textures (drawn by the client)
const TILES = ('grass_top grass_side dirt stone cobble planks log_side log_top leaves sand gravel water glass ' +
'coal_ore iron_ore gold_ore diamond_ore bedrock snow snow_side cactus_side cactus_top table_top table_side ' +
'table_front furnace_front furnace_side furnace_top chest_front chest_side chest_top torch door_bottom door_top ' +
'bed_top bed_side tnt_side tnt_top tnt_bottom bricks stone_bricks bookshelf flower_red flower_yellow tall_grass ' +
'wool_white wool_red wool_orange wool_yellow wool_lime wool_blue wool_purple wool_black wool_pink glowstone ' +
'sandstone_side sandstone_top slab_side slab_top snow_block ladder ' +
'zombie_face zombie_skin zombie_shirt zombie_pants skeleton_face bone creeper_face creeper_skin spider_face ' +
'spider_body pig_face pig_skin cow_face cow_skin sheep_face sheep_wool player_face player_hair player_skin ' +
'player_shirt player_pants arrow_tex tnt_lit diamond_block mossy_bricks trex_skin trex_face raptor_skin raptor_face ' +
'trike_skin trike_face brachio_skin brachio_face paint0 paint1 paint2 paint3 paint4 paint5 paint6 paint7 plane_wing metal cockpit rotor ' +
'silver_ore gem_ore gold_block silver_block ice_bricks treasure_front treasure_side treasure_top ' +
'stairs_down stairs_up stairs_side cat_skin cat_face dog_skin dog_face').split(' ');
const T = {}; TILES.forEach((n, i) => { T[n] = i; });
// ---------------------------------------------------------------- blocks
// shape: cube | cross | torch | door | slab | bed | cactus | water | ladder
// tex: [top, bottom, side] or [top, bottom, north, south, east, west]
const BLOCKS = [];
function B(id, name, o) {
const d = Object.assign({ id, name, solid: true, opaque: true, shape: 'cube', hard: 1, tool: '', lvl: 0,
drop: id, cutout: false, light: 0, h: 1, stack: 64, color: '#888' }, o);
if (d.tex && d.tex.length === 1) d.tex = [d.tex[0], d.tex[0], d.tex[0]];
BLOCKS[id] = d; return d;
}
const t = n => T[n];
B(0, 'Air', { solid: false, opaque: false, shape: 'none', drop: 0 });
B(1, 'Grass Block', { tex: [t('grass_top'), t('dirt'), t('grass_side')], hard: 0.6, tool: 'shovel', drop: 2, color: '#5d9c3a' });
B(2, 'Dirt', { tex: [t('dirt')], hard: 0.5, tool: 'shovel', color: '#86603f' });
B(3, 'Stone', { tex: [t('stone')], hard: 1.5, tool: 'pick', lvl: 1, drop: 4, color: '#7d7d7d' });
B(4, 'Cobblestone', { tex: [t('cobble')], hard: 2, tool: 'pick', lvl: 1, color: '#6e6e6e' });
B(5, 'Planks', { tex: [t('planks')], hard: 2, tool: 'axe', color: '#a8844f' });
B(6, 'Log', { tex: [t('log_top'), t('log_top'), t('log_side')], hard: 2, tool: 'axe', color: '#6b5130' });
B(7, 'Leaves', { tex: [t('leaves')], hard: 0.2, opaque: false, cutout: true, drop: -1, color: '#3d7a26' });
B(8, 'Sand', { tex: [t('sand')], hard: 0.5, tool: 'shovel', color: '#dccf9a' });
B(9, 'Gravel', { tex: [t('gravel')], hard: 0.6, tool: 'shovel', color: '#8a8380' });
B(10, 'Water', { tex: [t('water')], solid: false, opaque: false, shape: 'water', hard: -1, drop: 0, color: '#3f6fd8' });
B(11, 'Glass', { tex: [t('glass')], hard: 0.3, opaque: false, cutout: true, drop: 0, color: '#c8e4ec' });
B(12, 'Coal Ore', { tex: [t('coal_ore')], hard: 3, tool: 'pick', lvl: 1, drop: 101, color: '#5f5f5f' });
B(13, 'Iron Ore', { tex: [t('iron_ore')], hard: 3, tool: 'pick', lvl: 2, color: '#8f7d6e' });
B(14, 'Gold Ore', { tex: [t('gold_ore')], hard: 3, tool: 'pick', lvl: 3, color: '#a39461' });
B(15, 'Diamond Ore', { tex: [t('diamond_ore')], hard: 3, tool: 'pick', lvl: 3, drop: 104, color: '#6fa4a6' });
B(16, 'Bedrock', { tex: [t('bedrock')], hard: -1, drop: 0, color: '#333' });
B(17, 'Snowy Grass', { tex: [t('snow'), t('dirt'), t('snow_side')], hard: 0.6, tool: 'shovel', drop: 2, color: '#f0f4f4' });
B(18, 'Cactus', { tex: [t('cactus_top'), t('cactus_top'), t('cactus_side')], hard: 0.4, opaque: false, shape: 'cactus', color: '#3f7f2a' });
B(19, 'Crafting Table', { tex: [t('table_top'), t('planks'), t('table_front'), t('table_side'), t('table_front'), t('table_side')], hard: 2.5, tool: 'axe', color: '#9a6e3e' });
B(20, 'Furnace', { tex: [t('furnace_top'), t('furnace_top'), t('furnace_front'), t('furnace_side'), t('furnace_side'), t('furnace_side')], hard: 3.5, tool: 'pick', lvl: 1, color: '#5a5a5a' });
B(21, 'Chest', { tex: [t('chest_top'), t('chest_top'), t('chest_front'), t('chest_side'), t('chest_side'), t('chest_side')], hard: 2.5, tool: 'axe', color: '#a2702d' });
B(22, 'Torch', { tex: [t('torch')], solid: false, opaque: false, shape: 'torch', hard: 0, light: 1, cutout: true, color: '#ffd84a' });
// doors: 23-26 closed (X = panel along x, Z = panel along z; bottom/top), 27-30 open
B(23, 'Door', { tex: [t('door_bottom')], opaque: false, shape: 'door', axis: 'x', half: 0, open: false, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
B(24, 'Door', { tex: [t('door_top')], opaque: false, shape: 'door', axis: 'x', half: 1, open: false, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
B(25, 'Door', { tex: [t('door_bottom')], opaque: false, shape: 'door', axis: 'z', half: 0, open: false, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
B(26, 'Door', { tex: [t('door_top')], opaque: false, shape: 'door', axis: 'z', half: 1, open: false, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
B(27, 'Door', { tex: [t('door_bottom')], solid: false, opaque: false, shape: 'door', axis: 'x', half: 0, open: true, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
B(28, 'Door', { tex: [t('door_top')], solid: false, opaque: false, shape: 'door', axis: 'x', half: 1, open: true, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
B(29, 'Door', { tex: [t('door_bottom')], solid: false, opaque: false, shape: 'door', axis: 'z', half: 0, open: true, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
B(30, 'Door', { tex: [t('door_top')], solid: false, opaque: false, shape: 'door', axis: 'z', half: 1, open: true, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
B(31, 'Bed', { tex: [t('bed_top'), t('planks'), t('bed_side')], opaque: false, shape: 'bed', h: 0.5625, hard: 0.2, color: '#c43a3a' });
B(32, 'TNT', { tex: [t('tnt_top'), t('tnt_bottom'), t('tnt_side')], hard: 0, color: '#d44030' });
B(33, 'Bricks', { tex: [t('bricks')], hard: 2, tool: 'pick', lvl: 1, color: '#975a49' });
B(34, 'Stone Bricks', { tex: [t('stone_bricks')], hard: 1.5, tool: 'pick', lvl: 1, color: '#7a7a7a' });
B(35, 'Bookshelf', { tex: [t('planks'), t('planks'), t('bookshelf')], hard: 1.5, tool: 'axe', color: '#7d5a3a' });
B(36, 'Red Flower', { tex: [t('flower_red')], solid: false, opaque: false, shape: 'cross', hard: 0, cutout: true, color: '#d22' });
B(37, 'Yellow Flower', { tex: [t('flower_yellow')], solid: false, opaque: false, shape: 'cross', hard: 0, cutout: true, color: '#ee2' });
B(38, 'Tall Grass', { tex: [t('tall_grass')], solid: false, opaque: false, shape: 'cross', hard: 0, cutout: true, drop: 0, color: '#5d9c3a' });
const WOOL = [['White', 'wool_white', '#e8e8e8'], ['Red', 'wool_red', '#b02e26'], ['Orange', 'wool_orange', '#f07d19'],
['Yellow', 'wool_yellow', '#f8c627'], ['Lime', 'wool_lime', '#70b919'], ['Blue', 'wool_blue', '#3c44aa'],
['Purple', 'wool_purple', '#8932b8'], ['Black', 'wool_black', '#1d1d21'], ['Pink', 'wool_pink', '#f38baa']];
WOOL.forEach((w, i) => B(39 + i, w[0] + ' Wool', { tex: [t(w[1])], hard: 0.8, color: w[2] }));
B(48, 'Stone Slab', { tex: [t('slab_top'), t('slab_top'), t('slab_side')], opaque: false, shape: 'slab', h: 0.5, hard: 2, tool: 'pick', lvl: 1, color: '#a0a0a0' });
B(49, 'Glowstone', { tex: [t('glowstone')], hard: 0.3, light: 1, color: '#f4d27a' });
B(50, 'Sandstone', { tex: [t('sandstone_top'), t('sandstone_top'), t('sandstone_side')], hard: 0.8, tool: 'pick', lvl: 1, color: '#d8c98e' });
B(51, 'Snow Block', { tex: [t('snow_block')], hard: 0.2, tool: 'shovel', color: '#f6fafa' });
B(52, 'Ladder', { tex: [t('ladder')], solid: false, opaque: false, shape: 'ladder', hard: 0.4, tool: 'axe', cutout: true, color: '#9a7a48' });
B(53, 'Diamond Block', { tex: [t('diamond_block')], hard: 5, tool: 'pick', lvl: 3, color: '#5decf5' });
B(54, 'Mossy Stone Bricks', { tex: [t('mossy_bricks')], hard: 1.5, tool: 'pick', lvl: 1, color: '#6f7a62' });
B(55, 'Silver Ore', { tex: [t('silver_ore')], hard: 3, tool: 'pick', lvl: 2, color: '#9aa4ae' });
B(56, 'Jewel Ore', { tex: [t('gem_ore')], hard: 3, tool: 'pick', lvl: 3, drop: -2, color: '#8a6f9a' }); // drops a random jewel
B(57, 'Gold Block', { tex: [t('gold_block')], hard: 3, tool: 'pick', lvl: 2, color: '#f5d742' });
B(58, 'Silver Block', { tex: [t('silver_block')], hard: 3, tool: 'pick', lvl: 2, color: '#d8dee6' });
B(59, 'Ice Bricks', { tex: [t('ice_bricks')], hard: 1.5, tool: 'pick', lvl: 1, color: '#a8d8f0' });
B(60, 'Treasure Chest', { tex: [t('treasure_top'), t('treasure_top'), t('treasure_front'), t('treasure_side'), t('treasure_side'), t('treasure_side')], hard: 2.5, tool: 'axe', drop: 21, color: '#c89a2d' });
const isChest = id => id === 21 || id === 60;
// dungeon stairwells: tap to go down a floor / back up to the entrance. Unbreakable and they glow.
B(61, 'Dungeon Stairs', { tex: [t('stairs_down'), t('stairs_side'), t('stairs_side')], hard: -1, drop: 0, light: 1, color: '#8a40d0' });
B(62, 'Way Up', { tex: [t('stairs_up'), t('stairs_side'), t('stairs_side')], hard: -1, drop: 0, light: 1, color: '#40d0a0' });
const NBLOCKS = BLOCKS.length;
const blk = id => BLOCKS[id] || BLOCKS[0];
const isOpaque = new Uint8Array(256), isSolid = new Uint8Array(256);
for (let i = 0; i < NBLOCKS; i++) { isOpaque[i] = BLOCKS[i].opaque ? 1 : 0; isSolid[i] = BLOCKS[i].solid ? 1 : 0; }
// ---------------------------------------------------------------- items (ids 100+)
const ITEMS = {};
function I(id, name, o) { ITEMS[id] = Object.assign({ id, name, stack: 64 }, o); }
I(100, 'Stick', {}); I(101, 'Coal', { fuel: 8 }); I(102, 'Iron Ingot', {}); I(103, 'Gold Ingot', {});
I(104, 'Diamond', {}); I(105, 'Apple', { food: 4 }); I(106, 'Raw Porkchop', { food: 3 }); I(107, 'Cooked Porkchop', { food: 8 });
I(108, 'Raw Beef', { food: 3 }); I(109, 'Steak', { food: 8 }); I(110, 'Rotten Flesh', { food: 4 }); I(111, 'Bone', {});
I(112, 'Arrow', {}); I(113, 'Gunpowder', {}); I(114, 'Leather', {}); I(115, 'Door', { places: 23 }); I(116, 'String', {});
I(117, 'Bow', { stack: 1, bow: true, dur: 384 });
const MATS = [['Wooden', 1, 60, 2], ['Stone', 2, 132, 4], ['Iron', 3, 251, 6], ['Diamond', 4, 1562, 8]];
const KINDS = [['Pickaxe', 'pick'], ['Axe', 'axe'], ['Shovel', 'shovel'], ['Sword', 'sword']];
MATS.forEach((m, mi) => KINDS.forEach((k, ki) => I(120 + mi * 4 + ki, m[0] + ' ' + k[0],
{ stack: 1, tool: k[1], tier: m[1], dur: m[2], speed: m[3], dmg: k[1] === 'sword' ? 3 + mi * 1.5 : 2 + mi })));
I(140, 'Raw Dino Meat', { food: 3 }); I(141, 'Cooked Dino Steak', { food: 10 }); I(142, 'T-Rex Tooth', {});
I(143, 'T-Rex Tooth Sword', { stack: 1, tool: 'sword', tier: 4, dur: 900, speed: 8, dmg: 10 });
I(144, 'Airplane', { stack: 1, vehicle: 'plane', earn: true }); I(145, 'Helicopter', { stack: 1, vehicle: 'heli', earn: true });
// treasure add-on: silver, jewels, fruit, potions, armor, castle kits. earn: kept out of the Creative list
I(146, 'Silver Ingot', {});
I(147, 'Ruby', { earn: true }); I(148, 'Emerald', { earn: true }); I(149, 'Sapphire', { earn: true }); I(150, 'Amethyst', { earn: true });
I(151, 'Golden Apple', { food: 10, heal: 6, fx: 'regen', secs: 10, earn: true });
I(152, 'Pear', { food: 4 }); I(153, 'Orange', { food: 4 }); I(154, 'Banana', { food: 5 }); I(155, 'Cherries', { food: 3 });
I(156, 'Grapes', { food: 3 }); I(157, 'Watermelon Slice', { food: 3 });
I(159, 'Glass Bottle', {});
const POTIONS = [[160, 'Healing Potion', 'heal', 0, '#f03a5a'], [161, 'Regeneration Potion', 'regen', 30, '#f088c8'],
[162, 'Strength Potion', 'str', 90, '#b02020'], [163, 'Speed Potion', 'speed', 90, '#7ad0f0'], [164, 'Jump Potion', 'jump', 90, '#60e060'],
[165, 'Shield Potion', 'shield', 90, '#9a7ad8'], [166, 'Night Vision Potion', 'night', 120, '#3050d0'], [167, 'Water Breathing Potion', 'water', 120, '#30a0b0']];
POTIONS.forEach(([id, name, fx, secs, color]) => I(id, name, { stack: 16, potion: fx, secs, color, earn: true }));
// armor: 170 + material * 4 + piece. pts = how much it protects (all four diamond pieces stop 80% of monster damage)
const ARMOR_MATS = [['Iron', 102, [2, 6, 5, 2]], ['Gold', 103, [2, 5, 3, 1]], ['Diamond', 104, [3, 8, 6, 3]]];
const ARMOR_PIECES = [['Helmet', 5], ['Chestplate', 8], ['Leggings', 7], ['Boots', 4]];
ARMOR_MATS.forEach(([mat, , pts], mi) => ARMOR_PIECES.forEach(([piece], pi) => I(170 + mi * 4 + pi, mat + ' ' + piece,
{ stack: 1, armor: pi, pts: pts[pi], tier: mi, earn: mi === 2 })));
I(182, 'Silver Sword', { stack: 1, tool: 'sword', tier: 3, dur: 400, speed: 6, dmg: 7, undead: 2 }); // double damage to zombies and skeletons
// castle kits: place one and the whole castle builds itself, owned by whoever placed it
const KITS = [[183, 'fort'], [184, 'stone'], [185, 'royal'], [186, 'palace'], [187, 'ice']];
// castle styles. wall: [main, other] blocks mixed by `mix`; found: foundation; walk: walkway along the walls; roof: keep roof;
// flag: wool colour; rich: blocks only natural castles get (a kit castle must not be worth more than the kit)
const CASTLES = {
fort: { name: 'Wooden Fort', wall: [6, 5], mix: 0.3, found: 4, floor: 5, walk: 5, roof: 5, flag: 43 },
stone: { name: 'Stone Castle', wall: [34, 54], mix: 0.22, found: 34, floor: 4, walk: 48, roof: 48, flag: 40 },
royal: { name: 'Royal Castle', wall: [34, 34], mix: 0, found: 34, floor: 34, walk: 48, roof: 48, flag: 45, rich: 57 },
palace: { name: 'Desert Palace', wall: [50, 50], mix: 0, found: 50, floor: 50, walk: 48, roof: 50, flag: 41, rich: 57 },
ice: { name: 'Ice Castle', wall: [59, 51], mix: 0.25, found: 59, floor: 51, walk: 48, roof: 59, flag: 44, rich: 58 },
};
KITS.forEach(([id, type]) => I(id, CASTLES[type].name + ' Kit', { stack: 1, kit: type, earn: true }));
// dungeon add-on: loot boxes (tap to open) and pet items (tap to call your pet)
const BOXES = [[190, 'Bronze', '#c87a3a'], [191, 'Silver', '#d8dee6'], [192, 'Gold', '#f5d742'], [193, 'Legendary', '#c060ff']];
BOXES.forEach(([id, tier, color], ti) => I(id, tier + ' Loot Box', { stack: 16, box: ti, color, earn: true }));
I(194, 'Kitten Basket', { stack: 1, pet: 'cat', earn: true }); I(195, 'Puppy Basket', { stack: 1, pet: 'dog', earn: true });
I(196, 'Baby Dino Egg', { stack: 1, pet: 'babydino', earn: true });
const itemName = id => id < 100 ? blk(id).name : (ITEMS[id] ? ITEMS[id].name : '?');
const stackSize = id => id < 100 ? 64 : (ITEMS[id] ? ITEMS[id].stack : 64);
const isItem = id => id >= 100;
const allCreative = [];
for (let i = 1; i < NBLOCKS; i++) if (i !== 10 && i !== 16 && i < 60 && !(i >= 23 && i <= 30)) allCreative.push(i);
Object.keys(ITEMS).forEach(k => { if (!ITEMS[k].earn) allCreative.push(+k); }); // vehicles, jewels, potions, kits... have to be earned in survival
// ---------------------------------------------------------------- recipes
// st: '' anywhere, 'table' near a crafting table, 'furnace' near a furnace (uses fuel)
const R = (out, n, inp, st) => ({ out, n, inp, st: st || '' });
const RECIPES = [
R(5, 4, [[6, 1]]), R(100, 4, [[5, 2]]), R(19, 1, [[5, 4]]), R(22, 4, [[100, 1], [101, 1]]),
R(20, 1, [[4, 8]], 'table'), R(21, 1, [[5, 8]], 'table'), R(115, 1, [[5, 6]], 'table'),
R(31, 1, [[39, 3], [5, 3]], 'table'), R(32, 1, [[113, 5], [8, 4]], 'table'), R(52, 3, [[100, 7]], 'table'),
R(34, 4, [[3, 4]], 'table'), R(48, 6, [[4, 3]], 'table'), R(50, 1, [[8, 4]], 'table'), R(35, 1, [[5, 6], [114, 3]], 'table'),
R(117, 1, [[100, 3], [116, 3]], 'table'), R(112, 4, [[100, 1], [9, 1]], 'table'), R(39, 1, [[116, 4]], 'table'),
R(49, 1, [[103, 1], [101, 4]], 'table'), R(33, 1, [[2, 2], [8, 2]], 'furnace'),
R(102, 1, [[13, 1]], 'furnace'), R(103, 1, [[14, 1]], 'furnace'), R(11, 1, [[8, 1]], 'furnace'),
R(3, 1, [[4, 1]], 'furnace'), R(107, 1, [[106, 1]], 'furnace'), R(109, 1, [[108, 1]], 'furnace'), R(101, 1, [[6, 1]], 'furnace'),
R(141, 1, [[140, 1]], 'furnace'), R(53, 1, [[104, 9]], 'table'), R(104, 9, [[53, 1]]), R(143, 1, [[142, 2], [100, 1]], 'table'),
R(54, 1, [[34, 1], [7, 1]], 'table'),
// vehicles are the big goal: lots of iron plus diamonds
R(144, 1, [[102, 8], [104, 2], [5, 4], [11, 1]], 'table'), R(145, 1, [[102, 10], [104, 4], [11, 2]], 'table'),
// treasure add-on
R(146, 1, [[55, 1]], 'furnace'), R(57, 1, [[103, 9]], 'table'), R(103, 9, [[57, 1]]), R(58, 1, [[146, 9]], 'table'), R(146, 9, [[58, 1]]),
R(59, 4, [[11, 4], [146, 1]], 'table'), R(159, 3, [[11, 1]], 'table'), R(151, 1, [[105, 1], [103, 8]], 'table'),
R(160, 1, [[159, 1], [105, 2]], 'table'), R(161, 1, [[159, 1], [151, 1]], 'table'), R(162, 1, [[159, 1], [147, 1]], 'table'),
R(163, 1, [[159, 1], [149, 1]], 'table'), R(164, 1, [[159, 1], [148, 1]], 'table'), R(165, 1, [[159, 1], [150, 1], [102, 1]], 'table'),
R(166, 1, [[159, 1], [49, 1]], 'table'), R(167, 1, [[159, 1], [156, 2]], 'table'),
R(182, 1, [[146, 2], [100, 1]], 'table'),
R(183, 1, [[6, 40], [11, 4], [22, 8]], 'table'), R(184, 1, [[34, 64], [11, 16], [102, 4]], 'table'),
R(185, 1, [[34, 64], [57, 8], [53, 2], [147, 4]], 'table'), R(186, 1, [[50, 64], [57, 4], [11, 8]], 'table'),
R(187, 1, [[11, 64], [149, 4], [146, 16]], 'table'),
];
ARMOR_MATS.forEach(([, m], mi) => ARMOR_PIECES.forEach(([, n], pi) => RECIPES.push(R(170 + mi * 4 + pi, 1, [[m, n]], 'table'))));
const toolMat = [5, 4, 102, 104];
toolMat.forEach((m, mi) => {
RECIPES.push(R(120 + mi * 4, 1, [[m, 3], [100, 2]], 'table'), R(121 + mi * 4, 1, [[m, 3], [100, 2]], 'table'),
R(122 + mi * 4, 1, [[m, 1], [100, 2]], 'table'), R(123 + mi * 4, 1, [[m, 2], [100, 1]], 'table'));
});
// dyed wool: white wool plus something colourful
[[40, [36]], [41, [36, 37]], [42, [37]], [43, [18]], [44, [11, 9]], [45, [36, 11]], [46, [101]], [47, [36, 111]]]
.forEach(([out, dyes]) => RECIPES.push(R(out, 1, [[39, 1]].concat(dyes.map(d => [d, 1])))));
const FUEL = { 101: 8, 5: 1.5, 6: 1.5, 100: 0.5, 19: 1.5, 21: 1.5 };
// ---------------------------------------------------------------- noise
function hash(x, y, z, s) {
let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1) ^ Math.imul(z | 0, 0x1b873593) ^ Math.imul(s | 0, 0x85ebca6b);
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);
h = Math.imul(h ^ (h >>> 12), 0x297a2d39);
h ^= h >>> 15;
return (h >>> 0) / 4294967296;
}
const sm = f => f * f * (3 - 2 * f);
function noise2(x, z, s) {
const xi = Math.floor(x), zi = Math.floor(z), fx = sm(x - xi), fz = sm(z - zi);
const a = hash(xi, 0, zi, s), b = hash(xi + 1, 0, zi, s), c = hash(xi, 0, zi + 1, s), d = hash(xi + 1, 0, zi + 1, s);
return a + (b - a) * fx + (c - a) * fz + (a - b - c + d) * fx * fz;
}
function fbm2(x, z, s, oct) {
let v = 0, amp = 1, tot = 0, f = 1;
for (let o = 0; o < oct; o++) { v += noise2(x * f, z * f, s + o * 31) * amp; tot += amp; amp *= 0.5; f *= 2; }
return v / tot;
}
function noise3(x, y, z, s) {
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
const fx = sm(x - xi), fy = sm(y - yi), fz = sm(z - zi);
const l = (a, b, f) => a + (b - a) * f;
const c = (dx, dy, dz) => hash(xi + dx, yi + dy, zi + dz, s);
return l(l(l(c(0, 0, 0), c(1, 0, 0), fx), l(c(0, 1, 0), c(1, 1, 0), fx), fy),
l(l(c(0, 0, 1), c(1, 0, 1), fx), l(c(0, 1, 1), c(1, 1, 1), fx), fy), fz);
}
// ---------------------------------------------------------------- world generation
// biome: 0 plains, 1 forest, 2 desert, 3 snowy
function biomeAt(x, z, seed) {
const temp = fbm2(x / 220, z / 220, seed + 101, 2), wet = fbm2(x / 170, z / 170, seed + 202, 2);
if (temp > 0.62) return 2;
if (temp < 0.36) return 3;
return wet > 0.52 ? 1 : 0;
}
function heightAt(x, z, seed) {
const cont = fbm2(x / 110, z / 110, seed, 3), hill = fbm2(x / 36, z / 36, seed + 50, 3);
let h = 14 + cont * 20 + Math.pow(hill, 2) * 16 + (Math.max(0, cont - 0.62) * 40);
return Math.max(3, Math.min(CH - 10, Math.floor(h)));
}
function treeAt(x, z, seed, biome, h) {
if (h <= SEA || biome === 2) return 0;
const r = hash(x, 7, z, seed);
const dens = biome === 1 ? 0.035 : biome === 3 ? 0.012 : 0.004;
if (r >= dens) return 0;
return 4 + Math.floor(hash(x, 8, z, seed) * 3);
}
// ---------------------------------------------------------------- castles
// At most one castle per 128x128 region, 21x21 blocks, built on the ground level of its centre. The type depends on
// the land and luck: grassland/forest has Stone Castles (the original ones, unchanged), Wooden Forts and rare Royal
// Castles; deserts have Desert Palaces; snowy land has Ice Castles. Castle kits build the same castles (c.kit = true).
const REG = 128, CW = 21;
function castleAt(rx, rz, seed) {
const h = hash(rx, 99, rz, seed);
if (h >= 0.7) return null;
const cx = rx * REG + 24 + Math.floor(hash(rx, 98, rz, seed) * (REG - 48)), cz = rz * REG + 24 + Math.floor(hash(rx, 97, rz, seed) * (REG - 48));
const bm = biomeAt(cx, cz, seed), base = heightAt(cx, cz, seed);
if (base <= SEA + 1 || base > CH - 18) return null;
if (Math.abs(cx) < 30 && Math.abs(cz) < 30) return null; // keep the world spawn area free
const type = bm <= 1 ? (h < 0.45 ? 'stone' : h < 0.62 ? 'fort' : 'royal') : h < 0.5 ? (bm === 2 ? 'palace' : 'ice') : null;
if (!type) return null;
return { x0: cx - 10, z0: cz - 10, base, seed, type };
}
// block id for castle-local (lx, y, lz), or -1 to leave the land alone
function castleBlock(c, lx, y, lz) {
const b = c.base, edge = lx === 0 || lx === CW - 1 || lz === 0 || lz === CW - 1, P = CASTLES[c.type || 'stone'];
const wall = () => P.mix && hash(c.x0 + lx, y, c.z0 + lz, c.seed + 5) < P.mix ? P.wall[1] : P.wall[0];
const top = () => P.rich && !c.kit ? P.rich : wall(); // battlements: gold or silver on rich natural castles
if (y < b - 6) return -1;
if (y <= b) return y === b ? (edge ? P.found : P.floor) : P.found; // foundation and courtyard floor
if (y > b + 16) return -1;
// corner towers: 5x5 rings, 8 high, ladder inside, floor and battlements on top, a flag
const tx = lx < 5 ? 0 : lx >= CW - 5 ? CW - 5 : -1, tz = lz < 5 ? 0 : lz >= CW - 5 ? CW - 5 : -1;
if (tx >= 0 && tz >= 0) {
const ix = lx - tx, iz = lz - tz, ring = ix === 0 || ix === 4 || iz === 0 || iz === 4;
const inX = tx === 0 ? 4 : 0, inZ = tz === 0 ? 4 : 0; // the ring sides facing the courtyard
if (ring) {
if (y <= b + 8) {
if (y <= b + 2 && ix === 2 && iz === inZ) return 0; // doorway from the courtyard
if (y === b + 4 && (ix === 2 || iz === 2) && !(iz === inZ || ix === inX)) return 11; // arrow slits
return wall();
}
if (y === b + 9) return (ix + iz) % 2 === 0 ? top() : 0;
return 0;
}
if (ix === 2 && iz === (inZ === 4 ? 1 : 3)) return y <= b + 8 ? 52 : 0; // ladder beside the far wall
if (y === b + 8) return 5;
if (ix === 2 && iz === 2 && (y === b + 9 || y === b + 10)) return 6; // flag pole
if (ix === 3 && iz === 2 && y === b + 10) return c.flag || P.flag; // flag (a kit castle flies its owner's colour)
if (y === b + 2 && ix === 1 && iz === 2) return 22;
return 0;
}
// outer walls with a gate in the north wall and battlements
if (edge) {
if (lz === 0 && lx >= 9 && lx <= 11 && y <= b + 3) return 0;
if (y <= b + 5) return wall();
if (y === b + 6) return (lx + lz) % 2 === 0 ? top() : 0;
return 0;
}
// walkway along the inside of the walls
if ((lx === 1 || lx === CW - 2 || lz === 1 || lz === CW - 2) && y === b + 5 && !(lz === 1 && lx >= 8 && lx <= 12)) return P.walk;
// the keep: 7x7 house in the middle with a door, windows, a slab roof, chest, table and torches
if (lx >= 7 && lx <= 13 && lz >= 9 && lz <= 15) {
const kx = lx - 7, kz = lz - 9, kwall = kx === 0 || kx === 6 || kz === 0 || kz === 6;
if (kwall) {
if (kz === 0 && kx === 3 && y === b + 1) return 23;
if (kz === 0 && kx === 3 && y === b + 2) return 24;
if (y <= b + 6) return (y === b + 3 && (kx === 3 || kz === 3) && !(kz === 0 && kx === 3)) ? 11 : wall();
if (y === b + 7) return (kx + kz) % 2 === 0 ? wall() : 0;
return 0;
}
if (y === b + 7) return P.roof;
if (y === b + 1 && kx === 3 && kz === 5) return 60; // the treasure chest
if (y === b + 1 && kx === 1 && kz === 5) return 19;
if (y === b + 1 && kx === 5 && kz === 5) return 31;
if (y === b + 3 && (kx === 1 || kx === 5) && kz === 1) return 22;
return 0;
}
if (y === b + 1 && (lx === 6 || lx === 14) && lz === 4) return 22; // courtyard torches
return 0;
}
function castleLoot(x, y, z, seed) {
const c = castleAt(Math.floor(x / REG), Math.floor((z - 4) / REG), seed); // the chest sits at the castle centre + 4 south
if (!c || x !== c.x0 + 10 || z !== c.z0 + 14 || y !== c.base + 1) return null;
// each castle type has its own treasure; rarer castles hold better things
const n = (k, a, b) => a + Math.floor(hash(x, y + k, z, seed + 61) * (b - a + 1));
const luck = (k, p) => hash(x, y + k, z, seed + 62) < p;
const s = [], add = (id, cnt, d) => { if (cnt > 0) s.push(d ? { id, n: cnt, d } : { id, n: cnt }); };
const fruit = [105, 152, 153, 154, 155, 156, 157], jewel = [147, 148, 149, 150];
switch (c.type) {
case 'fort': // simple camp supplies
add(105, n(1, 3, 6)); add(fruit[n(2, 1, 6)], n(3, 2, 4)); add(102, n(4, 2, 5)); add(22, 12); add(112, n(5, 6, 14));
add(160, 1); if (luck(1, 0.5)) add(173, 1); if (luck(2, 0.3)) add(146, n(6, 2, 4)); break;
case 'palace': // gold, silver, jewels, desert fruit
add(103, n(1, 6, 12)); add(146, n(2, 4, 8)); add(147, n(3, 1, 3)); add(148, n(4, 1, 3)); add(153, n(5, 2, 5));
add(154, n(6, 2, 5)); add(156, n(7, 2, 4)); add(163, 1); add(160, n(8, 1, 2));
if (luck(1, 0.35)) add(151, 1); add(174 + n(9, 0, 3), 1); break;
case 'ice': // diamonds, cold jewels, silver
add(104, n(1, 4, 8)); add(149, n(2, 2, 4)); add(150, n(3, 1, 3)); add(146, n(4, 4, 8)); add(157, n(5, 2, 4));
add(167, 1); add(166, 1); add(160, 1); if (luck(1, 0.5)) add(181, 1); if (luck(2, 0.3)) add(53, 1); break;
case 'royal': // the best: lots of diamonds, every jewel, strong potions, diamond tools and armor
add(104, n(1, 8, 14)); jewel.forEach((j, i) => add(j, n(10 + i, 2, 4))); add(151, n(2, 1, 2)); add(103, n(3, 4, 8));
add(162, 1); add(165, 1); add(161, 1); add(160, 2); add(luck(1, 0.5) ? 132 : 135, 1, 1562);
add(178 + n(4, 0, 3), 1); add(53, 1); break;
default: // the original Stone Castle chest, plus a little silver and a healing potion
add(104, n(1, 3, 6)); add(102, n(2, 4, 9)); add(103, n(3, 2, 5)); add(105, n(4, 2, 5)); add(22, 16); add(112, n(5, 4, 12));
if (hash(x, y, z, seed + 62) < 0.4) add(135, 1, 1562);
if (hash(x, y, z, seed + 63) < 0.5) add(53, 1);
add(146, n(6, 2, 5)); add(160, 1);
}
return s;
}
// ---------------------------------------------------------------- dungeon floors
// The dungeon is far away to the east (x from DGX), sealed in bedrock. Floor f (1, 2, ...) is a 128x128 maze of 8x8 cells,
// one room per cell, joined by corridors. Cell (0,0) is the safe room with the Way Up; the cell farthest from it is the
// boss room, where the stairs to the next floor appear once its boss has been beaten.
const DGX = 1048576, FSTEP = 512, FW = 128, FY = 10, NC = 8;
const inDungeon = x => x >= DGX - 256;
const floorAt = x => inDungeon(x) ? Math.floor((x - DGX) / FSTEP) + 1 : 0;
const mazes = new Map();
function dungeonMaze(f, seed) {
const k = f + ':' + seed; let mz = mazes.get(k);
if (mz) return mz;
const open = new Uint8Array(NC * NC); // bits: 1 = +x, 2 = -x, 4 = +z, 8 = -z
const seen = new Uint8Array(NC * NC), stack = [0]; seen[0] = 1; let step = 0;
const DIRS = [[1, 0, 1, 2], [-1, 0, 2, 1], [0, 1, 4, 8], [0, -1, 8, 4]];
while (stack.length) { // random depth-first maze, then a few extra links so it isn't one long path
const c = stack[stack.length - 1], cx = c % NC, cz = (c / NC) | 0;
const opts = DIRS.filter(([dx, dz]) => { const x = cx + dx, z = cz + dz; return x >= 0 && z >= 0 && x < NC && z < NC && !seen[z * NC + x]; });
if (!opts.length) { stack.pop(); continue; }
const [dx, dz, a, b] = opts[Math.floor(hash(f, step++, c, seed + 71) * opts.length)], n = (cz + dz) * NC + cx + dx;
open[c] |= a; open[n] |= b; seen[n] = 1; stack.push(n);
}
for (let c = 0; c < NC * NC; c++) if (hash(f, c, 5, seed + 72) < 0.12 && c % NC < NC - 1) { open[c] |= 1; open[c + 1] |= 2; }
const dist = new Int16Array(NC * NC).fill(-1), q = [0]; dist[0] = 0; let boss = 0;
while (q.length) {
const c = q.shift();
if (dist[c] > dist[boss]) boss = c;
for (const [dx, dz, a] of DIRS) if (open[c] & a) { const n = c + dz * NC + dx; if (dist[n] < 0) { dist[n] = dist[c] + 1; q.push(n); } }
}
const rooms = [];
for (let c = 0; c < NC * NC; c++) {
const big = c === 0 || c === boss, w = big ? 12 : 6 + Math.floor(hash(f, c, 1, seed + 73) * 5), d = big ? 12 : 6 + Math.floor(hash(f, c, 2, seed + 73) * 5);
const cx = (c % NC) * 16 + 8, cz = ((c / NC) | 0) * 16 + 8;
rooms.push({ x0: cx - (w >> 1), z0: cz - (d >> 1), x1: cx - (w >> 1) + w - 1, z1: cz - (d >> 1) + d - 1, cx, cz,
chest: !big && hash(f, c, 3, seed + 74) < 0.3 });
}
mz = { open, boss, rooms, ox: DGX + (f - 1) * FSTEP };
mazes.set(k, mz); if (mazes.size > 12) mazes.delete(mazes.keys().next().value);
return mz;
}
// what to stand on / where things are, in world coordinates
const floorStart = (f, seed) => { const mz = dungeonMaze(f, seed), r = mz.rooms[0]; return { x: mz.ox + r.cx + 0.5, y: FY + 1, z: r.cz + 2.5 }; };
const bossRoom = (f, seed) => { const mz = dungeonMaze(f, seed), r = mz.rooms[mz.boss]; return { x0: mz.ox + r.x0, z0: r.z0, x1: mz.ox + r.x1, z1: r.z1, cx: mz.ox + r.cx, cz: r.cz }; };
function inSafeRoom(x, z, seed) {
const f = floorAt(x); if (f < 1) return false;
const r = dungeonMaze(f, seed).rooms[0], lx = x - (DGX + (f - 1) * FSTEP);
return lx >= r.x0 - 0.3 && lx <= r.x1 + 1.3 && z >= r.z0 - 0.3 && z <= r.z1 + 1.3;
}
// block for dungeon world position (x, y, z)
function dungeonBlock(x, y, z, seed) {
if (y === 0 || y > FY + 8) return 16;
const f = floorAt(x), lx = x - (DGX + (f - 1) * FSTEP);
if (f < 1 || lx < 0 || lx >= FW || z < 0 || z >= FW) return 16;
const mz = dungeonMaze(f, seed), c = (z >> 4) * NC + (lx >> 4), r = mz.rooms[c], o = mz.open[c];
const inRoom = lx >= r.x0 && lx <= r.x1 && z >= r.z0 && z <= r.z1;
const lcx = lx & 15, lcz = z & 15;
const inHall = (lcz >= 7 && lcz <= 9 && ((o & 1 && lcx >= 8) || (o & 2 && lcx <= 8))) || (lcx >= 7 && lcx <= 9 && ((o & 4 && lcz >= 8) || (o & 8 && lcz <= 8)));
const brick = hash(x, y, z, seed + 75) < 0.25 ? 54 : 34;
if (y < FY) return brick;
if (y === FY) return inRoom && c === 0 ? 44 : inRoom && c === mz.boss ? ((lx - r.x0) % 4 === 1 && (z - r.z0) % 4 === 1 ? 49 : 46) : brick; // safe room: blue floor; boss room: black with lights
if (y === FY + 6) return inRoom && lx === r.cx && z === r.cz ? 49 : brick; // a light in every room's ceiling
if (y > FY + 6) return brick;
if (!inRoom && !inHall) return brick;
if (inRoom && c === 0) { // the safe room
if (y === FY + 1 && lx === r.cx && z === r.cz) return 62;
if (y === FY + 1 && lx === r.x0 && z === r.z0) return 19;
if (y === FY + 1 && lx === r.x1 && z === r.z0) return 31;
if (y === FY + 3 && (lx === r.x0 || lx === r.x1) && z === r.cz) return 22;
}
if (inRoom && r.chest && y === FY + 1 && lx === r.x0 && z === r.z0) return 60;
if (inRoom && c !== 0 && c !== mz.boss && y === FY + 1 && lx === r.x1 && z === r.z1 && hash(x, y, z, seed + 76) < 0.5) return 35;
return 0;
}
function dungeonLoot(x, y, z, seed) {
const f = floorAt(x); if (f < 1 || y !== FY + 1) return null;
const mz = dungeonMaze(f, seed), lx = x - mz.ox, c = (z >> 4) * NC + (lx >> 4), r = mz.rooms[c];
if (!r || !r.chest || lx !== r.x0 || z !== r.z0) return null;
const n = (k, a, b) => a + Math.floor(hash(x, y + k, z, seed + 77) * (b - a + 1)), s = [];
s.push({ id: 190 + Math.min(3, Math.floor((f - 1) / 2) + (n(1, 0, 3) === 3 ? 1 : 0)), n: 1 }); // a loot box, better on deeper floors
s.push({ id: [105, 152, 153, 154, 155][n(2, 0, 4)], n: n(3, 2, 4) }, { id: 160, n: n(4, 1, 2) }, { id: 102, n: n(5, 2, 5) + f });
if (f >= 2) s.push({ id: 104, n: n(6, 1, 2) + (f >> 1) });
if (f >= 3) s.push({ id: 147 + n(7, 0, 3), n: n(8, 1, 3) });
s.push({ id: 112, n: n(9, 6, 14) }, { id: 22, n: 8 });
return s;
}
class Chunk {
constructor(cx, cz) {
this.cx = cx; this.cz = cz; this.b = new Uint8Array(CS * CS * CH); this.hm = new Uint8Array(CS * CS);
this.torches = []; this.mesh = null; this.dirty = true; this.map = null;
}
static idx(x, y, z) { return (y * CS + z) * CS + x; }
get(x, y, z) { return this.b[(y * CS + z) * CS + x]; }
updateColumn(x, z) {
let y = CH - 1;
while (y > 0 && !isOpaque[this.b[(y * CS + z) * CS + x]]) y--;
this.hm[z * CS + x] = y;
}
rebuildMeta() {
this.torches.length = 0;
for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) this.updateColumn(x, z);
for (let i = 0; i < this.b.length; i++) if (BLOCKS[this.b[i]] && BLOCKS[this.b[i]].light) {
const x = i % CS, z = ((i / CS) | 0) % CS, y = (i / (CS * CS)) | 0;
this.torches.push(this.cx * CS + x, y, this.cz * CS + z);
}
}
}
function generateChunk(cx, cz, seed) {
const c = new Chunk(cx, cz), b = c.b, X0 = cx * CS, Z0 = cz * CS;
if (inDungeon(X0)) {
for (let y = 0; y < CH; y++) for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) b[(y * CS + z) * CS + x] = dungeonBlock(X0 + x, y, Z0 + z, seed);
return c;
}
const P = 3, W = CS + P * 2;
const hts = new Int16Array(W * W), bio = new Uint8Array(W * W);
for (let z = 0; z < W; z++) for (let x = 0; x < W; x++) {
hts[z * W + x] = heightAt(X0 + x - P, Z0 + z - P, seed);
bio[z * W + x] = biomeAt(X0 + x - P, Z0 + z - P, seed);
}
const set = (x, y, z, id) => { if (x >= 0 && x < CS && z >= 0 && z < CS && y > 0 && y < CH) b[(y * CS + z) * CS + x] = id; };
for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) {
const wx = X0 + x, wz = Z0 + z, h = hts[(z + P) * W + x + P], bm = bio[(z + P) * W + x + P];
const beach = h <= SEA + 1 && bm !== 3;
for (let y = 0; y <= Math.max(h, SEA); y++) {
let id = 0;
if (y === 0) id = 16;
else if (y < h - 3) id = 3;
else if (y < h) id = (bm === 2 || beach) ? (y < h - 2 && bm === 2 ? 50 : 8) : 2;
else if (y === h) id = h < SEA ? (hash(wx, 3, wz, seed) < 0.3 ? 9 : 8) : (bm === 2 || beach) ? 8 : bm === 3 ? 17 : 1;
else if (y <= SEA) id = 10;
if (id === 3) {
const r = hash(wx, y, wz, seed + 9), cl = hash(wx >> 1, y >> 1, wz >> 1, seed + 10);
if (y < 16 && r < 0.01 && cl < 0.55) id = 15;
else if (y < 22 && r < 0.008 && cl < 0.5) id = 14;
else if (y < 44 && r < 0.02 && cl < 0.55) id = 13;
else if (r < 0.035 && cl < 0.6) id = 12;
else if (y < 30 && r < 0.043 && cl < 0.55) id = 55;
else if (y < 14 && r < 0.048 && cl < 0.6) id = 56;
}
if (id) b[(y * CS + z) * CS + x] = id;
}
// caves: two crossing noise "tubes"
for (let y = 2; y < h - 3; y++) {
const n1 = noise3(wx / 18, y / 11, wz / 18, seed + 300), n2 = noise3(wx / 18, y / 11, wz / 18, seed + 400);
if (Math.abs(n1 - 0.5) < 0.055 && Math.abs(n2 - 0.5) < 0.09) {
const i = (y * CS + z) * CS + x; if (b[i] !== 16) b[i] = 0;
}
}
// plants
const top = b[(h * CS + z) * CS + x];
if (h > SEA && b[((h + 1) * CS + z) * CS + x] === 0) {
const r = hash(wx, 11, wz, seed);
if (top === 1) {
if (r < 0.08) b[((h + 1) * CS + z) * CS + x] = 38;
else if (r < 0.088) b[((h + 1) * CS + z) * CS + x] = 36;
else if (r < 0.096) b[((h + 1) * CS + z) * CS + x] = 37;
} else if (top === 8 && bm === 2 && r < 0.006) {
const ch = 1 + Math.floor(hash(wx, 12, wz, seed) * 3);
for (let k = 1; k <= ch; k++) b[((h + k) * CS + z) * CS + x] = 18;
}
}
}
// trees can hang over chunk borders, so look a little outside
for (let z = -P; z < CS + P; z++) for (let x = -P; x < CS + P; x++) {
const h = hts[(z + P) * W + x + P], bm = bio[(z + P) * W + x + P];
const th = treeAt(X0 + x, Z0 + z, seed, bm, h);
if (!th) continue;
if (x >= 0 && x < CS && z >= 0 && z < CS) { const tp = b[(h * CS + z) * CS + x]; if (tp !== 1 && tp !== 17) continue; }
for (let y = h + th - 2; y <= h + th + 1; y++) {
const r = y > h + th - 1 ? 1 : 2;
for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) {
if (Math.abs(dx) === r && Math.abs(dz) === r && (r === 1 || hash(X0 + x + dx, y, Z0 + z + dz, seed) < 0.5)) continue;
const lx = x + dx, lz = z + dz;
if (lx >= 0 && lx < CS && lz >= 0 && lz < CS && y < CH && b[(y * CS + lz) * CS + lx] === 0) set(lx, y, lz, 7);
}
}
for (let k = 1; k <= th; k++) set(x, h + k, z, 6);
}
// castles that reach into this chunk
for (let rz = Math.floor((Z0 - CW) / REG); rz <= Math.floor((Z0 + CS) / REG); rz++)
for (let rx = Math.floor((X0 - CW) / REG); rx <= Math.floor((X0 + CS) / REG); rx++) {
const cs = castleAt(rx, rz, seed);
if (!cs || cs.x0 >= X0 + CS || cs.x0 + CW <= X0 || cs.z0 >= Z0 + CS || cs.z0 + CW <= Z0) continue;
for (let z = Math.max(0, cs.z0 - Z0); z < Math.min(CS, cs.z0 + CW - Z0); z++)
for (let x = Math.max(0, cs.x0 - X0); x < Math.min(CS, cs.x0 + CW - X0); x++)
for (let y = Math.max(1, cs.base - 6); y < Math.min(CH, cs.base + 17); y++) {
const id = castleBlock(cs, X0 + x - cs.x0, y, Z0 + z - cs.z0);
if (id >= 0) b[(y * CS + z) * CS + x] = id;
}
}
return c;
}
// ---------------------------------------------------------------- world
const key = (cx, cz) => cx + ',' + cz;
class World {
constructor(seed) { this.seed = seed | 0; this.chunks = new Map(); this.edits = new Map(); this.onChange = null; }
chunk(cx, cz, make) {
const k = key(cx, cz); let c = this.chunks.get(k);
if (!c && make) {
c = generateChunk(cx, cz, this.seed);
const e = this.edits.get(k);
if (e) e.forEach((id, i) => { c.b[i] = id; });
c.rebuildMeta(); this.chunks.set(k, c);
}
return c;
}
get(x, y, z) {
if (y < 0) return 16; if (y >= CH) return 0;
const c = this.chunk(Math.floor(x / CS), Math.floor(z / CS), true);
return c.b[(y * CS + (z - c.cz * CS)) * CS + (x - c.cx * CS)];
}
peek(x, y, z) { // like get, but never generates (0 when not loaded)
if (y < 0) return 16; if (y >= CH) return 0;
const c = this.chunks.get(key(Math.floor(x / CS), Math.floor(z / CS)));
return c ? c.b[(y * CS + (z - c.cz * CS)) * CS + (x - c.cx * CS)] : 0;
}
set(x, y, z, id) {
if (y < 1 || y >= CH) return false;
const cx = Math.floor(x / CS), cz = Math.floor(z / CS), c = this.chunk(cx, cz, true);
const lx = x - cx * CS, lz = z - cz * CS, i = Chunk.idx(lx, y, lz), old = c.b[i];
if (old === id) return false;
c.b[i] = id;
const k = key(cx, cz); let e = this.edits.get(k); if (!e) { e = new Map(); this.edits.set(k, e); }
e.set(i, id);
c.updateColumn(lx, lz);
if (blk(old).light || blk(id).light) c.rebuildMeta();
c.dirty = true;
if (this.onChange) this.onChange(x, y, z, old, id);
return true;
}
topY(x, z) { // highest solid block in a column
for (let y = CH - 1; y > 0; y--) if (isSolid[this.get(x, y, z)]) return y;
return 0;
}
skyAt(x, y, z) { // 1 in open air, falling off with depth below the sky line
const c = this.chunks.get(key(Math.floor(x / CS), Math.floor(z / CS)));
if (!c) return 1;
const d = c.hm[(z - c.cz * CS) * CS + (x - c.cx * CS)] - y;
return d < 0 ? 1 : Math.max(0.04, 0.85 - d * 0.14);
}
torchLightAt(x, y, z) {
let best = 0;
const cx = Math.floor(x / CS), cz = Math.floor(z / CS);
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
const c = this.chunks.get(key(cx + dx, cz + dz)); if (!c) continue;
const ts = c.torches;
for (let i = 0; i < ts.length; i += 3) {
const ddx = ts[i] + 0.5 - x, ddy = ts[i + 1] + 0.5 - y, ddz = ts[i + 2] + 0.5 - z;
const l = 1 - Math.sqrt(ddx * ddx + ddy * ddy + ddz * ddz) / 8;
if (l > best) best = l;
}
}
return best;
}
encodeEdits() {
const o = {};
this.edits.forEach((m, k) => { const a = []; m.forEach((id, i) => a.push(i.toString(36) + '.' + id.toString(36))); if (a.length) o[k] = a.join(','); });
return o;
}
decodeEdits(o) {
this.edits.clear();
for (const k in o) {
const m = new Map();
o[k].split(',').forEach(p => { const s = p.split('.'); if (s.length === 2) m.set(parseInt(s[0], 36), parseInt(s[1], 36)); });
this.edits.set(k, m);
}
}
}
// ---------------------------------------------------------------- physics
function blockBox(id) { // [y0, y1] of the collision box, or null
if (!isSolid[id]) return null;
const d = BLOCKS[id];
return d.h < 1 ? d.h : 1;
}
function boxHits(world, x0, y0, z0, x1, y1, z1) {
for (let y = Math.floor(y0); y <= Math.floor(y1 - 1e-6); y++)
for (let z = Math.floor(z0); z <= Math.floor(z1 - 1e-6); z++)
for (let x = Math.floor(x0); x <= Math.floor(x1 - 1e-6); x++) {
const h = blockBox(world.get(x, y, z));
if (h !== null && y + h > y0 + 1e-6) return true;
}
return false;
}
// b: {x,y,z (feet centre), vx,vy,vz, w, h}; sets b.onGround / b.hitWall / b.inWater
function moveBody(world, b, dt, canStep) {
const hw = b.w / 2;
b.hitWall = false;
const feetBlock = world.get(Math.floor(b.x), Math.floor(b.y + 0.2), Math.floor(b.z));
const midBlock = world.get(Math.floor(b.x), Math.floor(b.y + b.h * 0.6), Math.floor(b.z));
b.inWater = feetBlock === 10 || midBlock === 10;
b.onLadder = blk(feetBlock).shape === 'ladder' || blk(midBlock).shape === 'ladder';
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(b.vx), Math.abs(b.vy), Math.abs(b.vz)) * dt / 0.4));
const sdt = dt / steps;
let ground = false;
for (let s = 0; s < steps; s++) {
// y
const ny = b.y + b.vy * sdt;
if (b.vy !== 0 && boxHits(world, b.x - hw, ny, b.z - hw, b.x + hw, ny + b.h, b.z + hw)) {
const x0 = Math.floor(b.x - hw), x1 = Math.floor(b.x + hw - 1e-6), z0 = Math.floor(b.z - hw), z1 = Math.floor(b.z + hw - 1e-6);
if (b.vy < 0) { // land on the highest top below our feet
let top = ny;
for (let yy = Math.floor(ny); yy <= Math.floor(b.y + 0.01); yy++)
for (let zz = z0; zz <= z1; zz++) for (let xx = x0; xx <= x1; xx++) {
const h = blockBox(world.get(xx, yy, zz));
if (h !== null && yy + h <= b.y + 0.01 && yy + h > top) top = yy + h;
}
b.y = top; ground = true;
} else { // bump the head
let bottom = ny + b.h;
for (let yy = Math.floor(b.y + b.h - 0.01); yy <= Math.floor(ny + b.h); yy++)
for (let zz = z0; zz <= z1; zz++) for (let xx = x0; xx <= x1; xx++)
if (blockBox(world.get(xx, yy, zz)) !== null && yy >= b.y + b.h - 0.01 && yy < bottom) bottom = yy;
b.y = bottom - b.h;
}
b.vy = 0;
} else b.y = ny;
// x then z (with step-up for slabs, beds and other half blocks)
for (const ax of ['x', 'z']) {
const v = ax === 'x' ? b.vx : b.vz; if (!v) continue;
const nx = ax === 'x' ? b.x + v * sdt : b.x, nz = ax === 'z' ? b.z + v * sdt : b.z;
if (boxHits(world, nx - hw, b.y, nz - hw, nx + hw, b.y + b.h, nz + hw)) {
if (canStep && (ground || b.onGround) && !boxHits(world, nx - hw, b.y + 0.55, nz - hw, nx + hw, b.y + 0.55 + b.h, nz + hw)) {
// find the exact step height
let sy = b.y + 0.55;
while (sy - 0.05 > b.y && !boxHits(world, nx - hw, sy - 0.05, nz - hw, nx + hw, sy - 0.05 + b.h, nz + hw)) sy -= 0.05;
b.y = sy; b.x = nx; b.z = nz;
} else {
b.hitWall = true;
if (ax === 'x') b.vx = 0; else b.vz = 0;
}
} else { b.x = nx; b.z = nz; }
}
}
b.onGround = ground || (b.vy <= 0 && boxHits(world, b.x - hw, b.y - 0.05, b.z - hw, b.x + hw, b.y, b.z + hw));
}
// DDA ray through blocks. Returns {x,y,z, nx,ny,nz, id, d} of the first targetable block.
function raycast(world, ox, oy, oz, dx, dy, dz, maxD, hitWater) {
let x = Math.floor(ox), y = Math.floor(oy), z = Math.floor(oz);
const sx = dx > 0 ? 1 : -1, sy = dy > 0 ? 1 : -1, sz = dz > 0 ? 1 : -1;
const tdx = Math.abs(1 / dx), tdy = Math.abs(1 / dy), tdz = Math.abs(1 / dz);
let tx = (dx > 0 ? x + 1 - ox : ox - x) * tdx, ty = (dy > 0 ? y + 1 - oy : oy - y) * tdy, tz = (dz > 0 ? z + 1 - oz : oz - z) * tdz;
let nx = 0, ny = 0, nz = 0, d = 0;
for (let i = 0; i < 64 && d <= maxD; i++) {
const id = world.peek(x, y, z);
if (id && (id !== 10 || hitWater)) {
const def = BLOCKS[id];
let ok = true;
if (def.shape === 'slab' || def.shape === 'bed') { // only the lower part is there
const hy = oy + dy * d - y;
if (hy > def.h + 0.01 && dy >= 0) ok = false;
}
if (ok) return { x, y, z, nx, ny, nz, id, d };
}
if (tx < ty && tx < tz) { x += sx; d = tx; tx += tdx; nx = -sx; ny = 0; nz = 0; }
else if (ty < tz) { y += sy; d = ty; ty += tdy; nx = 0; ny = -sy; nz = 0; }
else { z += sz; d = tz; tz += tdz; nx = 0; ny = 0; nz = -sz; }
}
return null;
}
function rayBox(ox, oy, oz, dx, dy, dz, x0, y0, z0, x1, y1, z1) {
let tmin = 0, tmax = 1e9;
const o = [ox, oy, oz], d = [dx, dy, dz], lo = [x0, y0, z0], hi = [x1, y1, z1];
for (let i = 0; i < 3; i++) {
if (Math.abs(d[i]) < 1e-9) { if (o[i] < lo[i] || o[i] > hi[i]) return -1; continue; }
let a = (lo[i] - o[i]) / d[i], b = (hi[i] - o[i]) / d[i];
if (a > b) { const tt = a; a = b; b = tt; }
tmin = Math.max(tmin, a); tmax = Math.min(tmax, b);
if (tmin > tmax) return -1;
}
return tmin;
}
// ---------------------------------------------------------------- mobs
const MOBS = {
zombie: { hp: 20, w: 0.6, h: 1.9, speed: 1.9, hostile: true, burns: true, drops: [[110, 0, 2]] },
skeleton: { hp: 20, w: 0.6, h: 1.9, speed: 1.6, hostile: true, burns: true, drops: [[111, 0, 2], [112, 0, 2]] },
creeper: { hp: 20, w: 0.6, h: 1.7, speed: 1.7, hostile: true, drops: [[113, 0, 2]] },
spider: { hp: 16, w: 1.3, h: 0.9, speed: 2.8, hostile: true, drops: [[116, 0, 2]] },
pig: { hp: 10, w: 0.9, h: 0.9, speed: 1.2, drops: [[106, 1, 3]] },
cow: { hp: 10, w: 0.9, h: 1.4, speed: 1.1, drops: [[108, 1, 3], [114, 0, 2]] },
sheep: { hp: 8, w: 0.9, h: 1.3, speed: 1.1, drops: [[39, 1, 1]] },
trex: { hp: 60, w: 1.6, h: 3.0, speed: 3.0, hostile: true, dino: true, dmg: 6, reach: 2.2, drops: [[140, 2, 5], [142, 1, 2]] },
raptor: { hp: 16, w: 0.7, h: 1.3, speed: 4.0, hostile: true, dino: true, dmg: 2, drops: [[140, 1, 2]] },
trike: { hp: 30, w: 1.4, h: 1.9, speed: 1.2, dino: true, charges: true, dmg: 4, reach: 1.8, drops: [[140, 2, 4], [114, 1, 2]] },
brachio: { hp: 50, w: 2.0, h: 5.3, speed: 0.9, dino: true, drops: [[140, 3, 6]] },
// pets: follow their owner, fight monsters near them, can't be hurt
cat: { hp: 1, w: 0.5, h: 0.6, speed: 5, pet: true, drops: [] },
dog: { hp: 1, w: 0.6, h: 0.85, speed: 5, pet: true, drops: [] },
babydino: { hp: 1, w: 0.6, h: 1.0, speed: 5, pet: true, base: 'trex', scale: 0.35, drops: [] },
// dungeon floor bosses: a big version of a monster (base = how it looks and fights)
zking: { name: 'THE ZOMBIE KING', hp: 80, w: 1.1, h: 3.4, speed: 2.2, hostile: true, boss: true, base: 'zombie', scale: 1.8, dmg: 5, reach: 2.4, drops: [[104, 2, 4], [102, 4, 8]] },
squeen: { name: 'THE SPIDER QUEEN', hp: 100, w: 2.8, h: 1.9, speed: 3.2, hostile: true, boss: true, base: 'spider', scale: 2.2, dmg: 4, reach: 2.6, drops: [[104, 3, 5], [116, 6, 12]] },
slord: { name: 'THE SKELETON LORD', hp: 110, w: 1.0, h: 3.2, speed: 1.8, hostile: true, boss: true, base: 'skeleton', scale: 1.7, dmg: 4, drops: [[104, 3, 6], [112, 10, 20]] },
ralpha: { name: 'THE RAPTOR ALPHA', hp: 120, w: 1.3, h: 2.5, speed: 4.6, hostile: true, boss: true, dino: true, base: 'raptor', scale: 1.9, dmg: 5, reach: 2.2, drops: [[104, 4, 7], [140, 4, 8]] },
dlord: { name: 'THE DINO OVERLORD', hp: 220, w: 2.4, h: 4.5, speed: 3.2, hostile: true, boss: true, dino: true, base: 'trex', scale: 1.5, dmg: 8, reach: 3.2, drops: [[104, 6, 10], [142, 2, 4]] },
};
const BOSSES = ['zking', 'squeen', 'slord', 'ralpha', 'dlord'];
const MOB_TYPES = Object.keys(MOBS);
// ---------------------------------------------------------------- vehicles
// Placed from the Airplane / Helicopter items. The rider's page flies it and reports where it is;
// the sim only keeps the list, who is riding, and lets an empty one settle onto the ground.
// w/h: collision box while flying; bw/bh: the box you tap to get in.
const VEHICLES = {
plane: { item: 144, w: 1.2, h: 1.2, bw: 3.6, bh: 1.8 },
heli: { item: 145, w: 1.4, h: 1.6, bw: 2.4, bh: 2.4 },
};
const VEH_TYPES = Object.keys(VEHICLES);
// each player's colour, picked from their name: index into the page's 8 paint colours, and the matching wool for flags
const paintOf = owner => { if (!owner) return 0; const s = owner.toLowerCase(); let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return (h >>> 0) % 8; };
const PAINT_WOOL = [40, 44, 43, 45, 42, 41, 47, 39];
const fin = v => typeof v === 'number' && isFinite(v);
const dayLight = time => { const s = Math.sin(time * Math.PI * 2); return Math.max(0.12, Math.min(1, 0.55 + s * 1.6)); };
// ---------------------------------------------------------------- simulation
// One Sim per world. `send(msg, playerId|null)` delivers messages (null = everyone).
class Sim {
constructor(opts) {
this.world = new World(opts.seed);
this.diff = opts.difficulty || 'normal'; // peaceful | easy | normal
this.time = opts.time != null ? opts.time : 0.02;
this.players = new Map(); this.mobs = new Map(); this.arrows = []; this.tnts = [];
this.chests = opts.chests || {};
this.nextId = 1; this.send = opts.send || (() => {}); this.spawnTimer = 0; this.snapTimer = 0;
this.rand = Math.random;
this.pendingBlocks = [];
this.world.onChange = (x, y, z, old, id) => this.pendingBlocks.push(x, y, z, id);
this.vehicles = new Map();
for (const v of opts.vehicles || []) if (VEHICLES[v.kind]) this.addVehicle(v.kind, v.x, v.y, v.z, v.yaw, v.owner);
this.owned = opts.owned || []; // castles built from kits: { x0, z0, base, type, owner }
this.dungeon = Object.assign({ entrance: null, unlocked: {}, lastKill: {} }, opts.dungeon || {});
this.petData = opts.pets || {}; // lower-case owner name -> { kind, name, lvl, xp }
}
// the dungeon entrance: a small mossy shrine with the glowing stairwell, a few steps from the world spawn (built once)
ensureEntrance(spawn) {
if (this.dungeon.entrance) return;
const W = this.world, ex = Math.floor(spawn.x) + 7, ez = Math.floor(spawn.z) + 7;
let gy = W.topY(ex, ez);
while (gy > 2 && [6, 7, 18].includes(W.get(ex, gy, ez))) gy--;
for (let dz = -3; dz <= 3; dz++) for (let dx = -3; dx <= 3; dx++) {
const x = ex + dx, z = ez + dz, edge = Math.abs(dx) === 3 || Math.abs(dz) === 3, corner = Math.abs(dx) === 3 && Math.abs(dz) === 3;
for (let y = gy - 2; y <= gy; y++) W.set(x, y, z, 54); // platform
for (let y = gy + 1; y <= gy + 6; y++) {
let id = 0;
if (corner && y <= gy + 4) id = 34; // pillars
else if (y === gy + 5 && edge) id = 48; // roof rim
else if (y === gy + 5 && dx === 0 && dz === 0) id = 49; // light
W.set(x, y, z, id);
}
}
W.set(ex, gy, ez, 61);
W.set(ex - 2, gy + 1, ez - 2, 22); W.set(ex + 2, gy + 1, ez - 2, 22); W.set(ex - 2, gy + 1, ez + 2, 22); W.set(ex + 2, gy + 1, ez + 2, 22);
this.dungeon.entrance = { x: ex, y: gy, z: ez };
}
// ---- pets
spawnPet(pid) {
const p = this.players.get(pid); if (!p) return;
for (const m of [...this.mobs.values()]) if (m.owner === pid) this.mobs.delete(m.id);
const pd = this.petData[p.name.toLowerCase()]; if (!pd || !MOBS[pd.kind]) return;
const m = this.spawnMob(pd.kind, p.x + 1, p.y + 0.5, p.z + 1);
m.owner = pid; m.petName = pd.name; m.lvl = pd.lvl || 1;
}
petOf(pid) { for (const m of this.mobs.values()) if (m.owner === pid) return m; return null; }
ownerAt(x, z) { // whose kit castle (x, z) is in, or ''
for (const c of this.owned) if (x >= c.x0 && x < c.x0 + CW && z >= c.z0 && z < c.z0 + CW) return c.owner;
return '';
}
blockedFor(p, x, z) { const o = this.ownerAt(x, z); return o && (!p || o.toLowerCase() !== p.name.toLowerCase()) ? o : ''; }
buildKit(pid, type, cx, cz) {
const p = this.players.get(pid), W = this.world;
cx = Math.floor(cx); cz = Math.floor(cz);
const x0 = cx - 10, z0 = cz - 10;
let base = W.topY(cx, cz);
while (base > 2 && [6, 7, 18].includes(W.get(cx, base, cz))) base--; // stand on the ground, not on a treetop
for (const c of this.owned) if (x0 < c.x0 + CW + 2 && x0 + CW + 2 > c.x0 && z0 < c.z0 + CW + 2 && z0 + CW + 2 > c.z0) return 'Too close to ' + c.owner + "'s castle";
if (base > CH - 18 || base < 3) return 'The ground is too high or too low here';
const c = { x0, z0, base, seed: W.seed, type, kit: true, flag: PAINT_WOOL[paintOf(p.name)] };
for (let y = Math.max(1, base - 6); y < Math.min(CH, base + 17); y++)
for (let lz = 0; lz < CW; lz++) for (let lx = 0; lx < CW; lx++) {
const id = castleBlock(c, lx, y, lz);
if (id >= 0) W.set(x0 + lx, y, z0 + lz, id);
}
this.owned.push({ x0, z0, base, type, owner: p.name });
this.chests[(x0 + 10) + ',' + (base + 1) + ',' + (z0 + 14)] = []; // its treasure chest starts empty: fill it yourself
this.send({ t: 'owned', list: this.owned }, null);
this.send({ t: 'msg', text: p.name + ' built a ' + CASTLES[type].name + '!' }, null);
return '';
}
// owner: the name of the player who placed it; only they can fly it or pick it up
addVehicle(kind, x, y, z, yaw, owner) {
const d = VEHICLES[kind];
const v = { id: this.nextId++, kind, owner: String(owner || ''), x, y, z, yaw: yaw || 0, pitch: 0, roll: 0, vx: 0, vy: 0, vz: 0, w: d.w, h: d.h, rider: null, settle: true };
this.vehicles.set(v.id, v); return v;
}
ownsVehicle(p, v) { return !v.owner || (p && p.name.toLowerCase() === v.owner.toLowerCase()); }
saveVehicles() { return [...this.vehicles.values()].map(v => ({ kind: v.kind, owner: v.owner, x: v.x, y: v.y, z: v.z, yaw: v.yaw })); }
addPlayer(id, name, pos) {
const p = { id, name, x: pos.x, y: pos.y, z: pos.z, yaw: 0, pitch: 0, dead: false, sleepAt: 0, skin: hash(name.length, name.charCodeAt(0) || 1, name.charCodeAt(1) || 2, 77) };
this.players.set(id, p); this.spawnPet(id); return p;
}
removePlayer(id) {
this.players.delete(id);
for (const m of [...this.mobs.values()]) if (m.owner === id) this.mobs.delete(m.id);
for (const v of this.vehicles.values()) if (v.rider === id) { v.rider = null; v.settle = true; }
}
spawnPoint() {
const s = this.world.seed;
for (let r = 0; r < 400; r += 4) for (let a = 0; a < 8; a++) {
const x = Math.round(Math.cos(a) * r), z = Math.round(Math.sin(a) * r);
const h = heightAt(x, z, s), bm = biomeAt(x, z, s);
if (h > SEA + 1 && (bm === 0 || bm === 1)) {
const top = this.world.topY(x, z);
if (this.world.get(x, top, z) !== 1 || isSolid[this.world.get(x, top + 1, z)] || isSolid[this.world.get(x, top + 2, z)]) continue;
return { x: x + 0.5, y: top + 1, z: z + 0.5 };
}
}
return { x: 0.5, y: CH - 4, z: 0.5 };
}
handle(pid, m) {
const p = this.players.get(pid);
switch (m.t) {
case 'move': if (p) { p.x = m.x; p.y = m.y; p.z = m.z; p.yaw = m.yaw; p.pitch = m.pitch; p.dead = !!m.dead; } break;
case 'block': {
const x = m.x | 0, y = m.y | 0, z = m.z | 0, old = this.world.get(x, y, z), o = this.blockedFor(p, x, z);
if (old === 61 || old === 62 || (old === 16 && inDungeon(x))) { this.send({ t: 'blocks', l: [x, y, z, old] }, pid); break; }
// someone else's kit castle: only its doors may be opened and closed
if (o && !(blk(old).shape === 'door' && blk(m.id | 0).shape === 'door')) {
this.send({ t: 'msg', text: 'This is ' + o + "'s castle" }, pid);
this.send({ t: 'blocks', l: [x, y, z, old] }, pid); break;
}
this.world.set(x, y, z, m.id | 0); break;
}
case 'kit': {
const it = ITEMS[m.item];
if (!p || !it || !it.kit || !fin(m.x) || !fin(m.z)) break;
const why = this.buildKit(pid, it.kit, m.x, m.z);
if (why) { this.send({ t: 'msg', text: why }, pid); this.send({ t: 'drop', items: [[m.item, 1]] }, pid); }
break;
}
case 'hit': this.hitMob(pid, m.id, m.dmg, m.kx, m.kz); break;
case 'shoot': this.arrows.push({ x: m.x, y: m.y, z: m.z, vx: m.vx, vy: m.vy, vz: m.vz, owner: pid, life: 6, id: this.nextId++, dmg: Math.min(14, +m.dmg || 6) }); break;
case 'stairs': { // tap a stairwell: down a floor, or back up to the entrance
const x = m.x | 0, y = m.y | 0, z = m.z | 0, id = this.world.get(x, y, z);
if (!p || (id !== 61 && id !== 62) || Math.hypot(p.x - x - 0.5, p.z - z - 0.5) > 7) break;
let to, floor = 0;
if (id === 62) { const e = this.dungeon.entrance || this.spawnPoint(); to = { x: e.x + 0.5, y: e.y + 1, z: e.z + 2.5 }; }
else { floor = floorAt(x) + 1; to = floorStart(floor, this.world.seed); }
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) this.world.chunk(Math.floor(to.x / CS) + dx, Math.floor(to.z / CS) + dz, true);
p.x = to.x; p.y = to.y; p.z = to.z;
this.send({ t: 'tp', x: to.x, y: to.y, z: to.z, floor }, pid);
this.send({ t: 'msg', text: p.name + (floor ? ' went down to Floor ' + floor : ' climbed back up to the surface') }, null);
const pet = this.petOf(pid); if (pet) { pet.x = to.x + 1; pet.y = to.y; pet.z = to.z; }
break;
}
case 'pet': { // use a pet basket/egg: this player's pet (a new one replaces the old, whose item comes back)
const it = ITEMS[m.item]; if (!p || !it || !it.pet) break;
const k = p.name.toLowerCase(), old = this.petData[k];
if (old && old.kind !== it.pet) { const back = Object.keys(ITEMS).find(i => ITEMS[i].pet === old.kind); if (back) this.send({ t: 'drop', items: [[+back, 1]] }, pid); }
const names = { cat: 'Whiskers', dog: 'Buddy', babydino: 'Chomper' };
if (!old || old.kind !== it.pet) this.petData[k] = { kind: it.pet, name: names[it.pet], lvl: 1, xp: 0 };
this.spawnPet(pid);
this.send({ t: 'petinfo', pet: this.petData[k] }, pid); break;
}
case 'petname': {
const pd = p && this.petData[p.name.toLowerCase()], nm = String(m.name || '').replace(/[^A-Za-z0-9 _-]/g, '').trim().slice(0, 16);
if (!pd || !nm) break;
pd.name = nm; const pet = this.petOf(pid); if (pet) pet.petName = nm;
this.send({ t: 'petinfo', pet: pd }, pid); break;
}
case 'ignite': {
const x = m.x | 0, y = m.y | 0, z = m.z | 0;
if (this.world.get(x, y, z) === 32) { this.world.set(x, y, z, 0); this.tnts.push({ x: x + 0.5, y, z: z + 0.5, vx: 0, vy: 2, vz: 0, w: 0.98, h: 0.98, fuse: 3, id: this.nextId++ }); }
break;
}
case 'chest': {
const q0 = String(m.k).split(',').map(Number), o = this.blockedFor(p, q0[0], q0[2]);
if (o) { this.send({ t: 'msg', text: 'Only ' + o + ' can open this chest' }, pid); break; }
if (!this.chests[m.k]) { const q = String(m.k).split(',').map(Number); const loot = castleLoot(q[0], q[1], q[2], this.world.seed) || dungeonLoot(q[0], q[1], q[2], this.world.seed); if (loot) this.chests[m.k] = loot; }
this.send({ t: 'chest', k: m.k, slots: this.chests[m.k] || [] }, pid); break;
}
case 'chestSet': { const q1 = String(m.k).split(',').map(Number); if (!this.blockedFor(p, q1[0], q1[2])) this.chests[m.k] = m.slots; break; }
case 'sleep': if (p) { p.sleepAt = Date.now(); this.trySleep(pid); } break;
case 'chat': if (p) this.send({ t: 'msg', text: p.name + ': ' + String(m.text).slice(0, 80) }, null); break;
case 'vplace': {
if (!p || !VEHICLES[m.kind] || !fin(m.x) || !fin(m.y) || !fin(m.z) || this.vehicles.size >= 60) break;
this.addVehicle(m.kind, m.x, m.y, m.z, fin(m.yaw) ? m.yaw : 0, p.name); break;
}
case 'vride': {
const v = this.vehicles.get(m.id); if (!v || !p) break;
if (!this.ownsVehicle(p, v)) { this.send({ t: 'msg', text: 'This is ' + v.owner + "'s. Make your own at a crafting table!" }, pid); break; }
if (v.rider != null && v.rider !== pid && this.players.has(v.rider)) { this.send({ t: 'msg', text: 'Someone is already in it' }, pid); break; }
for (const o of this.vehicles.values()) if (o.rider === pid) o.rider = null;
v.rider = pid; v.settle = false;
this.send({ t: 'vride', id: v.id, kind: v.kind, x: v.x, y: v.y, z: v.z, yaw: v.yaw }, pid); break;
}
case 'vmove': {
const v = this.vehicles.get(m.id);
if (!v || v.rider !== pid || !fin(m.x) || !fin(m.y) || !fin(m.z)) break;
v.x = m.x; v.y = m.y; v.z = m.z; v.yaw = +m.yaw || 0; v.pitch = +m.pitch || 0; v.roll = +m.roll || 0; break;
}
case 'vleave': {
const v = this.vehicles.get(m.id); if (!v || v.rider !== pid) break;
if (fin(m.x) && fin(m.y) && fin(m.z)) { v.x = m.x; v.y = m.y; v.z = m.z; }
v.rider = null; v.pitch = 0; v.roll = 0; v.vy = 0; v.settle = true; break;
}
case 'vpickup': {
const v = this.vehicles.get(m.id);
if (!v || (v.rider != null && this.players.has(v.rider))) break;
if (!this.ownsVehicle(p, v)) { this.send({ t: 'msg', text: 'Only ' + v.owner + ' can pick that up' }, pid); break; }
this.vehicles.delete(v.id);
this.send({ t: 'drop', items: [[VEHICLES[v.kind].item, 1]] }, pid); break;
}
}
}
trySleep(pid) {
const night = dayLight(this.time) < 0.5;
if (!night) { this.send({ t: 'msg', text: 'You can only sleep at night' }, pid); return; }
const p = this.players.get(pid);
for (const mob of this.mobs.values()) if (MOBS[mob.type].hostile && Math.hypot(mob.x - p.x, mob.z - p.z) < 8) {
this.send({ t: 'msg', text: 'You may not rest now, there are monsters nearby' }, pid); return;
}
const now = Date.now();
for (const q of this.players.values()) if (now - q.sleepAt > 20000) {
this.send({ t: 'msg', text: p.name + ' is sleeping. Everyone needs to sleep to skip the night.' }, null); return;
}
this.time = 0.01;
for (const mob of [...this.mobs.values()]) if (MOBS[mob.type].hostile) this.mobs.delete(mob.id);
this.send({ t: 'time', time: this.time }, null);
this.send({ t: 'msg', text: 'Good morning!' }, null);
}
hitMob(pid, id, dmg, kx, kz) {
const m = this.mobs.get(id); if (!m || MOBS[m.type].pet) return; // pets can't be hurt
const boss = MOBS[m.type].boss;
m.hp -= Math.max(0.5, Math.min(40, +dmg || 1)); // up to 40: strength potion + warrior + points on a diamond sword
m.hurt = 0.35; m.vx += (kx || 0) * (boss ? 1.5 : 6); m.vz += (kz || 0) * (boss ? 1.5 : 6); if (!boss) m.vy = 4.5;
m.target = pid; m.angry = 20; m.panic = 4;
if (m.hp <= 0) this.killMob(m, pid);
}
// pid: who gets the drops and XP; byPet: the pet of pid did it
killMob(m, pid, byPet) {
this.mobs.delete(m.id);
if (pid == null) return;
const d = MOBS[m.type], items = [];
for (const q of d.drops) { const n = q[1] + Math.floor(this.rand() * (q[2] - q[1] + 1)); if (n > 0) items.push([q[0], n]); }
if (items.length) this.send({ t: 'drop', items }, pid);
this.send({ t: 'kill', type: m.type, pet: !!byPet, floor: m.floor || 0 }, pid);
if (d.boss) { // a floor boss: open the stairs down, and everyone nearby gets the reward
const f = m.floor || 1, br = bossRoom(f, this.world.seed);
this.dungeon.unlocked[f] = true; this.dungeon.lastKill[f] = Date.now();
this.world.set(br.cx, FY + 1, br.cz, 61);
for (const p of this.players.values()) if (p.id !== pid && Math.hypot(p.x - m.x, p.z - m.z) < 30) this.send({ t: 'kill', type: m.type, floor: f, helped: true }, p.id);
this.send({ t: 'msg', text: d.name + ' IS DEFEATED! The stairs to Floor ' + (f + 1) + ' are open.' }, null);
}
}
spawnMob(type, x, y, z) {
const d = MOBS[type];
const m = { id: this.nextId++, type, x, y, z, vx: 0, vy: 0, vz: 0, w: d.w, h: d.h, hp: d.hp, maxHp: d.hp, yaw: this.rand() * 6.28,
hurt: 0, fuse: 0, cool: 1, wander: 0, wx: 0, wz: 0, target: null, angry: 0, panic: 0, burning: false, onGround: false };
this.mobs.set(m.id, m); return m;
}
nearestPlayer(x, y, z, maxD) {
let best = null, bd = maxD;
for (const p of this.players.values()) {
if (p.dead) continue;
const d = Math.hypot(p.x - x, p.y - y, p.z - z);
if (d < bd) { bd = d; best = p; }
}
return best ? { p: best, d: bd } : null;
}
lineOfSight(x0, y0, z0, x1, y1, z1) {
const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0, len = Math.hypot(dx, dy, dz);
if (len < 0.01) return true;
const h = raycast(this.world, x0, y0, z0, dx / len, dy / len, dz / len, len, false);
return !h || h.d >= len - 0.5 || !isOpaque[h.id];
}
hurtPlayer(p, amt, fx, fz, src) {
if (p.dead) return;
const f = floorAt(p.x), scale = (this.diff === 'easy' ? 0.6 : 1) * (1 + Math.max(0, f - 1) * 0.15); // deeper floors hit harder
const dx = p.x - fx, dz = p.z - fz, l = Math.hypot(dx, dz) || 1;
this.send({ t: 'hurt', amt: Math.max(1, Math.round(amt * scale)), kx: dx / l, kz: dz / l, src: src || '' }, p.id);
}
explode(x, y, z, r, owner) {
const W = this.world;
for (let dy = -r; dy <= r; dy++) for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) {
const d = Math.hypot(dx, dy, dz);
if (d > r + 0.3 - this.rand() * 0.8) continue;
const bx = Math.floor(x + dx), by = Math.floor(y + dy), bz = Math.floor(z + dz), id = W.get(bx, by, bz);
if (!id || id === 16 || id === 10 || this.ownerAt(bx, bz)) continue; // kit castles can't be blown up
if (id === 32) { W.set(bx, by, bz, 0); this.tnts.push({ x: bx + 0.5, y: by, z: bz + 0.5, vx: (this.rand() - 0.5) * 6, vy: 5, vz: (this.rand() - 0.5) * 6, w: 0.98, h: 0.98, fuse: 0.6 + this.rand() * 1.2, id: this.nextId++ }); continue; }
W.set(bx, by, bz, 0);
}
const reach = r * 2;
for (const p of this.players.values()) {
const d = Math.hypot(p.x - x, p.y + 0.9 - y, p.z - z);
if (d < reach) this.hurtPlayer(p, (1 - d / reach) * (r >= 4 ? 20 : 16), x, z, 'boom');
}
for (const m of [...this.mobs.values()]) {
if (MOBS[m.type].pet) continue;
const d = Math.hypot(m.x - x, m.y - y, m.z - z);
if (d < reach) { m.hp -= (1 - d / reach) * 18; m.vy = 6; m.vx += (m.x - x) / (d || 1) * 8; m.vz += (m.z - z) / (d || 1) * 8; if (m.hp <= 0) this.killMob(m, typeof owner === 'number' ? owner : null); }
}
this.send({ t: 'boom', x, y, z, r }, null);
}
darkEnough(x, y, z) {
const sky = this.world.skyAt(x, y, z) * dayLight(this.time);
return sky < 0.35 && this.world.torchLightAt(x + 0.5, y + 0.5, z + 0.5) < 0.2;
}
trySpawn() {
const W = this.world;
let hostile = 0, passive = 0;
for (const m of this.mobs.values()) { if (MOBS[m.type].hostile) hostile++; else if (!MOBS[m.type].pet) passive++; }
const np = Math.max(1, this.players.size);
for (const p of this.players.values()) {
if (p.dead) continue;
if (inDungeon(p.x)) { this.dungeonTick(p); continue; }
const a = this.rand() * Math.PI * 2, r = 20 + this.rand() * 18;
const x = Math.floor(p.x + Math.cos(a) * r), z = Math.floor(p.z + Math.sin(a) * r);
if (!W.chunks.has(key(Math.floor(x / CS), Math.floor(z / CS)))) continue;
const top = W.topY(x, z);
if (this.diff !== 'peaceful' && hostile < 10 * np) {
// surface at night, or somewhere dark underground
let y = top + 1;
if (this.rand() < 0.5) y = 3 + Math.floor(this.rand() * Math.max(1, top - 6));
if (isSolid[W.get(x, y - 1, z)] && !isSolid[W.get(x, y, z)] && !isSolid[W.get(x, y + 1, z)] && W.get(x, y, z) !== 10 && this.darkEnough(x, y, z)) {
const r2 = this.rand();
const type = r2 < 0.38 ? 'zombie' : r2 < 0.62 ? 'skeleton' : r2 < 0.84 ? 'creeper' : 'spider';
this.spawnMob(type, x + 0.5, y, z + 0.5); hostile++;
continue;
}
}
if (passive < 6 * np && dayLight(this.time) > 0.6 && W.get(x, top, z) === 1 && !isSolid[W.get(x, top + 1, z)]) {
// dinosaurs: about one daytime spawn in four
if (this.rand() < 0.25) {
let dinos = 0, trex = false;
for (const mo of this.mobs.values()) if (MOBS[mo.type].dino) { dinos++; if (mo.type === 'trex') trex = true; }
if (dinos < 4 * np) {
const r3 = this.rand(), safe = this.diff === 'peaceful';
let type, n;
if (!safe && r3 < 0.12 && !trex) { type = 'trex'; n = 1; }
else if (!safe && r3 < 0.35) { type = 'raptor'; n = 2 + Math.floor(this.rand() * 2); }
else if (r3 < 0.75) { type = 'trike'; n = 1 + Math.floor(this.rand() * 2); }
else { type = 'brachio'; n = 1; }
for (let i = 0; i < n; i++) this.spawnMob(type, x + 0.5 + i * 1.5, top + 1, z + 0.5);
passive += n;
continue;
}
}
const type = ['pig', 'cow', 'sheep'][Math.floor(this.rand() * 3)];
const n = 1 + Math.floor(this.rand() * 3);
for (let i = 0; i < n; i++) this.spawnMob(type, x + 0.5 + i * 0.7, top + 1, z + 0.5);
passive += n;
}
}
}
// a pet: follow the owner (jump to them when far away) and fight monsters close to them
tickPet(m, dt) {
const p = this.players.get(m.owner), d = MOBS[m.type];
if (!p) { this.mobs.delete(m.id); return; }
m.cool -= dt;
const dist = Math.hypot(p.x - m.x, p.z - m.z);
if (dist > 20 || Math.abs(p.y - m.y) > 8) { m.x = p.x + 1; m.y = p.y + 0.3; m.z = p.z + 1; m.vx = m.vz = m.vy = 0; return; }
let foe = null, fd = 10;
for (const o of this.mobs.values()) {
if (!MOBS[o.type].hostile) continue;
const od = Math.hypot(o.x - m.x, o.z - m.z);
if (od < fd && Math.hypot(o.x - p.x, o.z - p.z) < 12) { fd = od; foe = o; }
}
let tx = p.x, tz = p.z, stop = 2.2;
if (foe) { tx = foe.x; tz = foe.z; stop = foe.w / 2 + 0.6; }
const dx = tx - m.x, dz = tz - m.z, hd = Math.hypot(dx, dz) || 1;
let mx = 0, mz = 0;
if (hd > stop) { mx = dx / hd; mz = dz / hd; m.yaw = Math.atan2(dx, dz); }
if (foe && hd < stop + 0.8 && m.cool <= 0) {
m.cool = 0.9; foe.hp -= 2 + (m.lvl || 1); foe.hurt = 0.35; foe.vx += mx * 3; foe.vz += mz * 3; foe.target = m.owner; foe.angry = 20;
this.send({ t: 'sfx', s: 'hit', x: m.x, y: m.y, z: m.z }, null);
if (foe.hp <= 0) {
this.killMob(foe, m.owner, true);
const pd = this.petData[p.name.toLowerCase()];
if (pd) { pd.xp = (pd.xp || 0) + 10; if (pd.xp >= 30 * pd.lvl) { pd.xp = 0; pd.lvl++; m.lvl = pd.lvl; this.send({ t: 'petinfo', pet: pd, up: true }, p.id); } }
}
}
const acc = m.onGround ? 12 : 3, sp = d.speed * (hd > 6 ? 1.3 : 1);
m.vx += (mx * sp - m.vx) * Math.min(1, acc * dt); m.vz += (mz * sp - m.vz) * Math.min(1, acc * dt);
m.vy -= (m.inWater ? 6 : 26) * dt; if (m.inWater) m.vy = Math.max(m.vy, 1.5);
moveBody(this.world, m, dt, true);
if (m.hitWall && m.onGround && (mx || mz)) m.vy = 7.6;
}
tickMob(m, dt) {
const d = MOBS[m.type], W = this.world, kind = d.base || m.type; // kind: how it fights (a boss fights like its base monster)
if (d.pet) { this.tickPet(m, dt); return; }
m.hurt = Math.max(0, m.hurt - dt); m.cool -= dt; m.angry -= dt; m.panic -= dt;
if (d.hostile && inSafeRoom(m.x, m.z, W.seed)) { this.mobs.delete(m.id); this.send({ t: 'sfx', s: 'pop', x: m.x, y: m.y, z: m.z }, null); return; } // safe rooms: monsters vanish
const near = this.nearestPlayer(m.x, m.y, m.z, d.hostile || (d.charges && m.angry > 0) ? (d.boss ? 40 : 22) : 10);
let mx = 0, mz = 0, speed = d.speed;
if (near && ((d.hostile && (kind !== 'spider' || d.boss || dayLight(this.time) < 0.5 || m.angry > 0 || inDungeon(m.x))) || (d.charges && m.angry > 0))) {
const p = near.p, dx = p.x - m.x, dz = p.z - m.z, hd = Math.hypot(dx, dz) || 1;
m.yaw = Math.atan2(dx, dz);
if (kind === 'trex' && !m.roared) { m.roared = true; this.send({ t: 'sfx', s: 'roar', x: m.x, y: m.y, z: m.z }, null); }
if (d.charges) speed = d.speed * 3;
if (kind === 'skeleton') {
const see = this.lineOfSight(m.x, m.y + 1.6, m.z, p.x, p.y + 1.5, p.z);
if (hd > 9 || !see) { mx = dx / hd; mz = dz / hd; } else if (hd < 5) { mx = -dx / hd; mz = -dz / hd; }
if (see && hd < 16 && m.cool <= 0) {
m.cool = d.boss ? 1.1 : 2.2;
const sy = m.y + (d.boss ? 2.6 : 1.5), ty = p.y + 1.4, tt = hd / 18;
this.arrows.push({ x: m.x + dx / hd * 0.6, y: sy, z: m.z + dz / hd * 0.6, vx: dx / hd * 18, vy: (ty - sy) / tt + 10 * tt, vz: dz / hd * 18, owner: 'mob', life: 5, id: this.nextId++, dmg: d.dmg });
this.send({ t: 'sfx', s: 'bow', x: m.x, y: m.y, z: m.z }, null);
}
} else if (kind === 'creeper') {
if (hd < 3 && Math.abs(p.y - m.y) < 2.5) {
if (m.fuse === 0) this.send({ t: 'sfx', s: 'hiss', x: m.x, y: m.y, z: m.z }, null);
m.fuse += dt;
if (m.fuse > 1.5) { this.mobs.delete(m.id); this.explode(m.x, m.y + 0.8, m.z, 3, 'creeper'); return; }
} else {
if (m.fuse > 0 && hd > 6) m.fuse = 0;
if (m.fuse > 0) m.fuse = Math.max(0, m.fuse - dt * 0.5);
mx = dx / hd; mz = dz / hd;
}
} else {
const reach = d.reach || 1.6;
if (hd > reach * 0.6) { mx = dx / hd; mz = dz / hd; }
if (hd < reach && Math.abs(p.y - m.y) < Math.max(1.6, d.h * 0.6) && m.cool <= 0) {
m.cool = kind === 'trex' ? 1.4 : 1;
this.hurtPlayer(p, d.dmg || (kind === 'spider' ? 2 : this.diff === 'normal' ? 3 : 2), m.x, m.z, m.type);
if (kind === 'trex') this.send({ t: 'sfx', s: 'roar', x: m.x, y: m.y, z: m.z }, null);
if (kind === 'spider' && m.onGround) m.vy = 5;
}
}
} else if (!d.hostile && !d.charges && m.panic > 0) {
if (m.wander <= 0 || m.wander > 1) { m.wander = 1; const a = this.rand() * 6.28; m.wx = Math.sin(a); m.wz = Math.cos(a); }
mx = m.wx; mz = m.wz; speed = d.speed * 2; m.yaw = Math.atan2(mx, mz);
} else {
m.roared = false;
m.wander -= dt;
if (m.wander <= 0) {
m.wander = 3 + this.rand() * 5;
if (this.rand() < 0.55) { const a = this.rand() * 6.28; m.wx = Math.sin(a); m.wz = Math.cos(a); } else { m.wx = 0; m.wz = 0; }
}
mx = m.wx; mz = m.wz; speed = d.speed * 0.6;
if (mx || mz) m.yaw = Math.atan2(mx, mz);
}
// movement + physics
const acc = m.onGround ? 10 : 2;
m.vx += (mx * speed - m.vx) * Math.min(1, acc * dt);
m.vz += (mz * speed - m.vz) * Math.min(1, acc * dt);
m.vy -= (m.inWater ? 6 : 26) * dt;
if (m.inWater) { m.vy = Math.max(m.vy, -2); if (mx || mz || d.hostile) m.vy += 14 * dt; }
moveBody(W, m, dt, true);
if (m.hitWall && m.onGround && (mx || mz)) m.vy = 7.6;
if (m.y < -10) { this.mobs.delete(m.id); return; }
// sunlight burns zombies and skeletons
m.burning = false;
if (d.burns && dayLight(this.time) > 0.6 && !m.inWater && W.skyAt(Math.floor(m.x), Math.floor(m.y + 1.5), Math.floor(m.z)) >= 1) {
m.burning = true; m.hp -= dt * 1.2; if (m.hp <= 0) { this.killMob(m, null); return; }
}
// despawn far away
const any = this.nearestPlayer(m.x, m.y, m.z, d.boss ? 60 : 90);
if (!any) this.mobs.delete(m.id);
}
// dungeon: monsters in the dark halls, and the floor boss when someone walks into the boss room
dungeonTick(p) {
const W = this.world, f = floorAt(p.x); if (f < 1) return;
const br = bossRoom(f, W.seed), inBoss = p.x >= br.x0 && p.x <= br.x1 + 1 && p.z >= br.z0 && p.z <= br.z1 + 1;
if (inBoss && Date.now() - (this.dungeon.lastKill[f] || 0) > 120000 && ![...this.mobs.values()].some(m => MOBS[m.type].boss && m.floor === f)) {
const type = BOSSES[(f - 1) % BOSSES.length], m = this.spawnMob(type, br.cx + 0.5, FY + 1, br.cz + 0.5);
m.floor = f; m.hp = m.maxHp = Math.round(MOBS[type].hp * (1 + Math.floor((f - 1) / BOSSES.length) * 0.6));
this.send({ t: 'msg', text: MOBS[type].name + ' HAS APPEARED!' }, null);
this.send({ t: 'sfx', s: 'roar', x: br.cx, y: FY + 1, z: br.cz }, null);
}
if (this.diff === 'peaceful') return;
let near = 0;
for (const m of this.mobs.values()) if (MOBS[m.type].hostile && Math.hypot(m.x - p.x, m.z - p.z) < 40) near++;
if (near >= 3 + f * 2) return;
const a = this.rand() * Math.PI * 2, r = 10 + this.rand() * 14;
const x = Math.floor(p.x + Math.cos(a) * r), z = Math.floor(p.z + Math.sin(a) * r), y = FY + 1;
if (inSafeRoom(x + 0.5, z + 0.5, W.seed) || (x >= br.x0 && x <= br.x1 && z >= br.z0 && z <= br.z1)) return;
if (!W.chunks.has(key(Math.floor(x / CS), Math.floor(z / CS))) || isSolid[W.get(x, y, z)] || isSolid[W.get(x, y + 1, z)] || !isSolid[W.get(x, y - 1, z)]) return;
const pool = ['zombie', 'spider', 'zombie'].concat(f >= 2 ? ['skeleton', 'skeleton'] : [], f >= 3 ? ['creeper', 'raptor'] : [], f >= 5 ? ['trike'] : []);
this.spawnMob(pool[Math.floor(this.rand() * pool.length)], x + 0.5, y, z + 0.5);
}
tickArrow(a, dt) {
a.life -= dt;
const steps = 4, sdt = dt / steps;
for (let s = 0; s < steps; s++) {
a.vy -= 20 * sdt;
a.x += a.vx * sdt; a.y += a.vy * sdt; a.z += a.vz * sdt;
if (isSolid[this.world.get(Math.floor(a.x), Math.floor(a.y), Math.floor(a.z))]) { a.life = Math.min(a.life, 0.01); a.vx = a.vy = a.vz = 0; return; }
if (a.owner === 'mob') {
for (const p of this.players.values()) {
if (!p.dead && Math.abs(p.x - a.x) < 0.4 && Math.abs(p.z - a.z) < 0.4 && a.y > p.y && a.y < p.y + 1.8) {
this.hurtPlayer(p, a.dmg || (this.diff === 'normal' ? 3 : 2), a.x - a.vx, a.z - a.vz, 'arrow'); a.life = 0; return;
}
}
} else {
for (const m of this.mobs.values()) {
if (MOBS[m.type].pet) continue;
const hw = m.w / 2 + 0.1;
if (Math.abs(m.x - a.x) < hw && Math.abs(m.z - a.z) < hw && a.y > m.y && a.y < m.y + m.h) {
const sp = Math.hypot(a.vx, a.vz) || 1;
this.hitMob(a.owner, m.id, a.dmg || 6, a.vx / sp, a.vz / sp); a.life = 0; return;
}
}
}
}
}
tick(dt) {
this.time = (this.time + dt / DAY_SECONDS) % 1;
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
this.spawnTimer = 1.5;
// keep the land around every player loaded (the server has no renderer doing it) and forget far chunks
for (const p of this.players.values()) {
const pcx = Math.floor(p.x / CS), pcz = Math.floor(p.z / CS);
for (let dz = -3; dz <= 3; dz++) for (let dx = -3; dx <= 3; dx++) this.world.chunk(pcx + dx, pcz + dz, true);
}
if (this.unloadFar) for (const [k, c] of this.world.chunks) {
let keep = false;
for (const p of this.players.values()) if (Math.abs(c.cx - Math.floor(p.x / CS)) <= 6 && Math.abs(c.cz - Math.floor(p.z / CS)) <= 6) keep = true;
if (!keep) this.world.chunks.delete(k);
}
this.trySpawn();
}
for (const m of [...this.mobs.values()]) this.tickMob(m, dt);
for (const a of this.arrows) this.tickArrow(a, dt);
this.arrows = this.arrows.filter(a => a.life > 0);
for (const tn of this.tnts) {
tn.fuse -= dt; tn.vy -= 20 * dt; tn.vx *= 0.9; tn.vz *= 0.9;
moveBody(this.world, tn, dt, false);
if (tn.fuse <= 0) { tn.dead = true; this.explode(tn.x, tn.y + 0.5, tn.z, 4, 'tnt'); }
}
this.tnts = this.tnts.filter(tn => !tn.dead);
// an empty vehicle left in the air drops down onto the ground (only where the land is loaded)
for (const v of this.vehicles.values()) {
if (v.rider != null || !v.settle || !this.world.chunks.has(key(Math.floor(v.x / CS), Math.floor(v.z / CS)))) continue;
v.vx = 0; v.vz = 0; v.vy = Math.max(-20, v.vy - 20 * dt);
moveBody(this.world, v, dt, false);
if (v.onGround || v.inWater || v.y < 1) { v.vy = 0; v.settle = false; }
}
if (this.pendingBlocks.length) { const l = this.pendingBlocks; this.pendingBlocks = []; this.send({ t: 'blocks', l }, null); }
this.snapTimer -= dt;
if (this.snapTimer <= 0) { this.snapTimer = 0.1; this.send(this.snapshot(), null); }
}
snapshot() {
const r = v => Math.round(v * 100) / 100;
const mobs = [];
for (const m of this.mobs.values()) {
const e = [m.id, MOB_TYPES.indexOf(m.type), r(m.x), r(m.y), r(m.z), r(m.yaw),
(m.hurt > 0 ? 1 : 0) | (m.fuse > 0 && (m.fuse * 6 | 0) % 2 ? 2 : 0) | (m.burning ? 4 : 0) | (Math.hypot(m.vx, m.vz) > 0.3 ? 8 : 0)];
if (MOBS[m.type].boss) e.push(Math.max(0, Math.round(m.hp / m.maxHp * 100)), m.floor || 1); // boss health bar
else if (m.owner != null) { const o = this.players.get(m.owner); e.push(m.petName || '', o ? o.name : '', m.lvl || 1); } // pet name tag
mobs.push(e);
}
const arrows = this.arrows.map(a => [a.id, r(a.x), r(a.y), r(a.z), r(a.vx), r(a.vy), r(a.vz)]);
const tnts = this.tnts.map(tn => [tn.id, r(tn.x), r(tn.y), r(tn.z), (tn.fuse * 4 | 0) % 2]);
const players = [];
for (const p of this.players.values()) players.push([p.id, p.name, r(p.x), r(p.y), r(p.z), r(p.yaw), r(p.pitch), p.dead ? 1 : 0, p.skin]);
const vehicles = [];
for (const v of this.vehicles.values()) vehicles.push([v.id, VEH_TYPES.indexOf(v.kind), r(v.x), r(v.y), r(v.z), r(v.yaw), r(v.pitch), r(v.roll), v.rider || 0, v.owner]);
return { t: 'snap', time: r(this.time * 1000) / 1000, mobs, arrows, tnts, players, vehicles };
}
}
return { CS, CH, SEA, DAY_SECONDS, TILES, T, BLOCKS, NBLOCKS, ITEMS, WOOL, blk, isOpaque, isSolid, itemName, stackSize,
isItem, allCreative, RECIPES, FUEL, hash, noise2, heightAt, biomeAt, Chunk, World, generateChunk, moveBody, raycast,
rayBox, MOBS, MOB_TYPES, VEHICLES, VEH_TYPES, dayLight, Sim, key,
CASTLES, castleAt, REG, CW, isChest, paintOf, inDungeon, floorAt, inSafeRoom, bossRoom, floorStart, FY };
});
EOF
game.js: everything else in the browser: textures, 3D drawing, controls, crafting, saving and talking to the server.
cat > /root/blockcraft/game.js <<'EOF'
// BlockCraft client: textures, WebGL rendering, controls, inventory/crafting, menus, saving and networking.
(() => {
'use strict';
const E = window.BC;
const { CS, CH, BLOCKS, ITEMS, blk, isOpaque, isSolid, T } = E;
const $ = id => document.getElementById(id);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const cv = $('gl'), hud = $('hud'), hx = hud.getContext('2d');
const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
if (isTouch) document.body.classList.add('touch');
const BASE = location.pathname.replace(/[^/]*$/, '');
// ---------------------------------------------------------------- settings (per device)
const cfg = { sound: true, dist: isTouch ? 3 : 4, coords: false, name: '', code: '', mpMode: 'survival' };
try { Object.assign(cfg, JSON.parse(localStorage.getItem('blockcraft_cfg') || '{}')); } catch (e) {}
const saveCfg = () => { try { localStorage.setItem('blockcraft_cfg', JSON.stringify(cfg)); } catch (e) {} };
// ---------------------------------------------------------------- sound (synthesised)
let actx = null, noiseBuf = null;
function audio() {
if (!cfg.sound) return null;
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
if (actx.state === 'suspended') actx.resume();
return actx;
}
function tone(f0, f1, dur, type, vol, delay) {
const a = audio(); if (!a) return;
const t0 = a.currentTime + (delay || 0), o = a.createOscillator(), g = a.createGain();
o.type = type || 'square'; o.frequency.setValueAtTime(f0, t0); o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t0 + dur);
g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.0005, t0 + dur);
o.connect(g).connect(a.destination); o.start(t0); o.stop(t0 + dur);
}
function noise(dur, vol, f0, f1, delay) {
const a = audio(); if (!a) return;
if (!noiseBuf) { noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
const t0 = a.currentTime + (delay || 0), s = a.createBufferSource(), f = a.createBiquadFilter(), g = a.createGain();
s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(f0, t0); f.frequency.exponentialRampToValueAtTime(Math.max(30, f1), t0 + dur);
g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
s.connect(f).connect(g).connect(a.destination); s.start(t0, Math.random() * 0.5); s.stop(t0 + dur);
}
function sfx(name, x, y, z) {
let v = 1;
if (x != null && pl) { const d = Math.hypot(x - pl.x, y - pl.y, z - pl.z); v = 1 - d / 28; if (v <= 0) return; }
switch (name) {
case 'dig': noise(0.12, 0.25 * v, 1400, 300); break;
case 'break': noise(0.22, 0.35 * v, 2200, 200); break;
case 'place': noise(0.09, 0.3 * v, 900, 200); break;
case 'step': noise(0.05, 0.06 * v, 700, 300); break;
case 'hurt': tone(420, 180, 0.22, 'sawtooth', 0.12 * v); break;
case 'hit': noise(0.08, 0.3 * v, 3000, 500); tone(260, 120, 0.12, 'square', 0.05 * v); break;
case 'pop': tone(500, 1100, 0.08, 'sine', 0.12 * v); break;
case 'eat': for (let i = 0; i < 3; i++) noise(0.07, 0.2 * v, 1800, 600, i * 0.12); break;
case 'fanfare': [523, 659, 784, 1046].forEach((f, i) => tone(f, f, 0.16, 'square', 0.05, i * 0.1)); break;
case 'drink': for (let i = 0; i < 3; i++) tone(300 + i * 60, 200, 0.1, 'sine', 0.12 * v, i * 0.13); tone(700, 1400, 0.3, 'triangle', 0.06, 0.4); break;
case 'boom': noise(1.4, 0.7 * v, 900, 40); break;
case 'hiss': noise(1.4, 0.18 * v, 6000, 3000); break;
case 'bow': tone(300, 900, 0.12, 'triangle', 0.1 * v); break;
case 'door': noise(0.1, 0.25 * v, 600, 150); tone(140, 90, 0.1, 'square', 0.05 * v); break;
case 'splash': noise(0.4, 0.2 * v, 2500, 300); break;
case 'groan': tone(140, 90, 0.7, 'sawtooth', 0.05 * v); tone(150, 80, 0.7, 'square', 0.02 * v, 0.05); break;
case 'tool': tone(900, 300, 0.25, 'square', 0.06); break;
case 'craft': tone(600, 900, 0.07, 'square', 0.05); tone(900, 1200, 0.07, 'square', 0.05, 0.08); break;
case 'die': tone(400, 60, 0.9, 'sawtooth', 0.15); break;
case 'roar': tone(110, 45, 1.3, 'sawtooth', 0.22 * v); tone(160, 60, 1.1, 'square', 0.08 * v, 0.05); noise(1.2, 0.35 * v, 900, 120); break;
}
}
// ---------------------------------------------------------------- textures
function rng(seed) { let s = seed >>> 0 || 1; return () => { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; return (s >>> 0) / 4294967296; }; }
const hashStr = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return h >>> 0; };
const hexRgb = h => { const n = parseInt(h.slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; };
const PAINTS = ['#d23a32', '#2f6fd8', '#3fae3a', '#8932b8', '#f0c020', '#f07d19', '#f38baa', '#20b8b0'];
const paintFor = E.paintOf;
const atlas = document.createElement('canvas'); atlas.width = atlas.height = 256;
const ac = atlas.getContext('2d');
function drawTile(name, i) {
const img = ac.createImageData(16, 16), d = img.data, r = rng(hashStr(name) + 7);
const P = (x, y, c, a) => { if (x < 0 || y < 0 || x > 15 || y > 15) return; const k = (y * 16 + x) * 4; d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = a == null ? 255 : a; };
const V = (c, f) => [clamp(c[0] * f, 0, 255) | 0, clamp(c[1] * f, 0, 255) | 0, clamp(c[2] * f, 0, 255) | 0];
const C = hexRgb;
const fillN = (hex, amt) => { const c = C(hex); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, 1 + (r() - 0.5) * amt)); };
const rect = (x0, y0, x1, y1, hex, amt) => { const c = C(hex); for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) P(x, y, V(c, 1 + (r() - 0.5) * (amt || 0))); };
const clear = () => { for (let k = 0; k < d.length; k++) d[k] = 0; };
const voronoi = (n, cols, edge, edgeW) => {
const pts = []; for (let k = 0; k < n; k++) pts.push([r() * 16, r() * 16, C(cols[k % cols.length]), 0.85 + r() * 0.3]);
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) {
let d1 = 99, d2 = 99, best = 0;
for (let k = 0; k < n; k++) for (let oy = -16; oy <= 16; oy += 16) for (let ox = -16; ox <= 16; ox += 16) {
const dd = Math.hypot(x + 0.5 - pts[k][0] - ox, y + 0.5 - pts[k][1] - oy);
if (dd < d1) { d2 = d1; d1 = dd; best = k; } else if (dd < d2) d2 = dd;
}
if (edge && d2 - d1 < edgeW) P(x, y, V(C(edge), 0.9 + r() * 0.2)); else P(x, y, V(pts[best][2], pts[best][3] + (r() - 0.5) * 0.12));
}
};
const ore = hex => { fillN('#7f7f7f', 0.22); for (let k = 0; k < 4; k++) { const cx = 2 + (r() * 12 | 0), cy = 2 + (r() * 12 | 0); for (let j = 0; j < 5; j++) P(cx + (r() * 3 | 0) - 1, cy + (r() * 3 | 0) - 1, V(C(hex), 0.8 + r() * 0.4)); } };
const planks = (hex) => {
const c = C(hex);
for (let y = 0; y < 16; y++) { const row = y >> 2, f = 0.88 + ((row * 7919) % 5) * 0.05; for (let x = 0; x < 16; x++) P(x, y, V(c, f + (r() - 0.5) * 0.1)); }
for (let x = 0; x < 16; x++) for (const y of [3, 7, 11, 15]) P(x, y, V(c, 0.62));
for (let row = 0; row < 4; row++) { const sx = (row * 5 + 3) % 16; for (let y = row * 4; y < row * 4 + 3; y++) P(sx, y, V(c, 0.7)); }
};
const face = (skin, eyes) => { fillN(skin, 0.15); for (const [x, y] of eyes) P(x, y, [20, 20, 20]); };
switch (name) {
case 'grass_top': fillN('#5f9e3c', 0.32); break;
case 'dirt': fillN('#866043', 0.42); for (let k = 0; k < 14; k++) P(r() * 16 | 0, r() * 16 | 0, V(C('#866043'), 0.65)); break;
case 'grass_side': fillN('#866043', 0.42); for (let x = 0; x < 16; x++) { const h = 3 + (r() < 0.45 ? 1 : 0) + (r() < 0.2 ? 1 : 0); for (let y = 0; y < h; y++) P(x, y, V(C('#5f9e3c'), 1 + (r() - 0.5) * 0.3)); } break;
case 'stone': fillN('#7f7f7f', 0.22); for (let k = 0; k < 6; k++) { const x = r() * 14 | 0, y = r() * 16 | 0; P(x, y, [98, 98, 98]); P(x + 1, y, [98, 98, 98]); } break;
case 'cobble': voronoi(9, ['#8a8a8a', '#7a7a7a', '#6c6c6c', '#949494'], '#4c4c4c', 1.1); break;
case 'planks': planks('#a8844f'); break;
case 'log_side': { const c = C('#6b5130'); for (let x = 0; x < 16; x++) { const f = 0.8 + r() * 0.35; for (let y = 0; y < 16; y++) P(x, y, V(c, f + (r() - 0.5) * 0.15)); } break; }
case 'log_top': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { const dd = Math.max(Math.abs(x - 7.5), Math.abs(y - 7.5)); P(x, y, V(C(dd > 6.5 ? '#6b5130' : (Math.floor(dd) % 2 ? '#b8945f' : '#a07c4a')), 1 + (r() - 0.5) * 0.12)); } break;
case 'leaves': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { if (r() < 0.16) P(x, y, [0, 0, 0], 0); else P(x, y, V(C('#3f7f28'), 0.7 + r() * 0.6)); } break;
case 'sand': fillN('#dbcf9c', 0.14); break;
case 'gravel': voronoi(18, ['#8f8a86', '#7a7470', '#a09a94', '#6a6460'], '#5a5450', 0.6); break;
case 'water': { const c = C('#3a66d0'); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, ((x + y * 3) % 8 < 2 ? 1.2 : 1) + (r() - 0.5) * 0.08)); break; }
case 'glass': clear(); for (let k = 0; k < 16; k++) { P(k, 0, [216, 240, 248]); P(k, 15, [216, 240, 248]); P(0, k, [216, 240, 248]); P(15, k, [216, 240, 248]); } for (let k = 3; k < 7; k++) P(k, k, [255, 255, 255], 200); P(9, 4, [255, 255, 255], 160); P(10, 5, [255, 255, 255], 160); break;
case 'coal_ore': ore('#222222'); break;
case 'iron_ore': ore('#d8af93'); break;
case 'gold_ore': ore('#fcee4b'); break;
case 'diamond_ore': ore('#5decf5'); break;
case 'bedrock': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C('#555555'), 0.4 + r() * 1.1)); break;
case 'snow': fillN('#f2f6f6', 0.06); break;
case 'snow_side': fillN('#866043', 0.42); for (let x = 0; x < 16; x++) { const h = 3 + (r() < 0.5 ? 1 : 0); for (let y = 0; y < h; y++) P(x, y, V(C('#f2f6f6'), 1 - r() * 0.06)); } break;
case 'cactus_side': { const c = C('#3f7f2a'); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, (x % 4 === 0 ? 0.75 : 1) + (r() - 0.5) * 0.15)); for (let k = 0; k < 10; k++) P(r() * 16 | 0, r() * 16 | 0, C('#d8eaa8')); break; }
case 'cactus_top': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { const dd = Math.max(Math.abs(x - 7.5), Math.abs(y - 7.5)); P(x, y, V(C(dd > 5.5 ? '#3f7f2a' : '#5a9a3a'), 1 + (r() - 0.5) * 0.12)); } break;
case 'table_top': planks('#a8844f'); for (let k = 0; k < 16; k++) { P(k, 0, [90, 60, 30]); P(k, 15, [90, 60, 30]); P(0, k, [90, 60, 30]); P(15, k, [90, 60, 30]); P(k, 7, [110, 76, 40]); P(7, k, [110, 76, 40]); } break;
case 'table_side': case 'table_front': planks('#a8844f'); rect(0, 0, 15, 2, '#6b4a26', 0.1);
if (name === 'table_front') { rect(3, 6, 7, 8, '#9a9a9a'); rect(3, 9, 4, 12, '#6b4a26'); rect(10, 5, 13, 6, '#9a9a9a'); rect(11, 7, 12, 13, '#6b4a26'); } break;
case 'furnace_front': fillN('#6f6f6f', 0.22); rect(4, 8, 11, 13, '#1a1a1a'); rect(5, 12, 10, 13, '#3a2a1a'); rect(3, 3, 12, 4, '#4f4f4f'); break;
case 'furnace_side': fillN('#707070', 0.22); for (let k = 0; k < 16; k++) { P(k, 0, [80, 80, 80]); P(k, 15, [80, 80, 80]); } break;
case 'furnace_top': fillN('#8a8a8a', 0.15); break;
case 'chest_front': case 'chest_side': case 'chest_top': {
const c = C('#a2702d');
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, (y % 4 === 3 ? 0.82 : 1) + (r() - 0.5) * 0.1));
for (let k = 0; k < 16; k++) { P(k, 0, [80, 50, 20]); P(k, 15, [80, 50, 20]); P(0, k, [80, 50, 20]); P(15, k, [80, 50, 20]); }
if (name !== 'chest_top') for (let k = 1; k < 15; k++) P(k, 5, [80, 50, 20]);
if (name === 'chest_front') rect(7, 4, 8, 7, '#c8c8c8');
break;
}
case 'torch': clear(); rect(7, 9, 8, 15, '#7a5a32'); rect(7, 6, 8, 6, '#ff9d2a'); rect(7, 7, 8, 8, '#ffe066'); break;
case 'door_bottom': case 'door_top': {
planks('#8a6234');
for (let k = 0; k < 16; k++) { P(0, k, [70, 45, 20]); P(15, k, [70, 45, 20]); P(k, name === 'door_top' ? 0 : 15, [70, 45, 20]); }
if (name === 'door_top') { for (let y = 3; y <= 8; y++) for (let x = 3; x <= 12; x++) if (x !== 7 && x !== 8 && y !== 5) P(x, y, [0, 0, 0], 0); }
else rect(12, 1, 13, 2, '#c8c8c8');
break;
}
case 'bed_top': fillN('#c43a3a', 0.12); rect(1, 1, 14, 4, '#e8e8e8', 0.05); break;
case 'bed_side': clear(); rect(0, 7, 15, 10, '#c43a3a', 0.1); rect(0, 11, 15, 12, '#e0e0e0', 0.05); rect(0, 13, 15, 15, '#8a6234', 0.1); break;
case 'tnt_side': fillN('#d44030', 0.12); rect(0, 6, 15, 9, '#eeeeee', 0.04);
for (const [x, y] of [[2, 6], [3, 6], [4, 6], [3, 7], [3, 8], [3, 9], [6, 6], [6, 7], [6, 8], [6, 9], [7, 7], [8, 6], [8, 7], [8, 8], [8, 9], [10, 6], [11, 6], [12, 6], [11, 7], [11, 8], [11, 9]]) P(x, y, [20, 20, 20]);
break;
case 'tnt_top': fillN('#d44030', 0.12); rect(7, 7, 8, 8, '#333333'); break;
case 'tnt_bottom': fillN('#a83020', 0.12); break;
case 'bricks': { fillN('#b5b0a8', 0.08); for (let row = 0; row < 4; row++) for (let b = 0; b < 3; b++) { const x0 = b * 8 - (row % 2) * 4; for (let y = row * 4; y < row * 4 + 3; y++) for (let x = x0; x < x0 + 7; x++) if (x >= 0 && x < 16) P(x, y, V(C('#975a49'), 1 + (r() - 0.5) * 0.2)); } break; }
case 'stone_bricks': fillN('#7a7a7a', 0.15); for (let k = 0; k < 16; k++) { P(k, 7, [88, 88, 88]); P(k, 15, [88, 88, 88]); } for (let y = 0; y < 7; y++) P(7, y, [88, 88, 88]); for (let y = 8; y < 15; y++) P(15, y, [88, 88, 88]); break;
case 'bookshelf': { planks('#a8844f'); const cols = ['#a83232', '#3250a8', '#32a850', '#a88a32', '#7a32a8']; for (const [y0, y1] of [[2, 6], [9, 13]]) for (let x = 1; x < 15; x += 2) { const c = cols[r() * 5 | 0]; rect(x, y0 + (r() < 0.3 ? 1 : 0), x + 1, y1, c, 0.15); } break; }
case 'flower_red': case 'flower_yellow': { clear(); rect(7, 8, 7, 15, '#3f8a28'); P(6, 11, C('#3f8a28')); const pc = name === 'flower_red' ? '#d42020' : '#f0e020'; rect(6, 4, 8, 6, pc); P(7, 3, C(pc)); P(5, 5, C(pc)); P(9, 5, C(pc)); P(7, 5, C(name === 'flower_red' ? '#f0e020' : '#c08010')); break; }
case 'tall_grass': clear(); for (let k = 0; k < 7; k++) { const x0 = 1 + (r() * 14 | 0), h = 6 + (r() * 9 | 0), lean = r() < 0.5 ? -1 : 1; for (let y = 0; y < h; y++) P(x0 + (y > h * 0.6 ? lean : 0), 15 - y, V(C('#5f9e3c'), 0.75 + r() * 0.5)); } break;
case 'glowstone': voronoi(10, ['#f4d27a', '#ffe9a6', '#e8b85a'], '#9c7a3a', 0.8); break;
case 'sandstone_side': fillN('#d8c98e', 0.1); rect(0, 0, 15, 2, '#e2d49c', 0.05); for (let k = 0; k < 16; k++) { P(k, 3, [180, 165, 115]); P(k, 12, [180, 165, 115]); } break;
case 'sandstone_top': fillN('#dccf96', 0.1); break;
case 'slab_side': fillN('#a8a8a8', 0.08); for (let k = 0; k < 16; k++) { P(k, 8, [120, 120, 120]); P(k, 15, [120, 120, 120]); } break;
case 'slab_top': fillN('#b0b0b0', 0.08); for (let k = 0; k < 16; k++) { P(k, 0, [130, 130, 130]); P(k, 15, [130, 130, 130]); P(0, k, [130, 130, 130]); P(15, k, [130, 130, 130]); } break;
case 'snow_block': fillN('#f6fafa', 0.05); break;
case 'ladder': clear(); rect(2, 0, 3, 15, '#7a5a32', 0.1); rect(12, 0, 13, 15, '#7a5a32', 0.1); for (const y of [2, 6, 10, 14]) rect(2, y, 13, y, '#9a7a48', 0.1); break;
case 'zombie_face': face('#5f9f4a', [[4, 6], [5, 6], [10, 6], [11, 6], [4, 7], [11, 7]]); rect(0, 0, 15, 2, '#3f7a30', 0.15); rect(7, 8, 8, 9, '#4a8038'); rect(5, 11, 10, 11, '#3a6a2a'); break;
case 'zombie_skin': fillN('#5f9f4a', 0.2); break;
case 'zombie_shirt': fillN('#2fa8a8', 0.15); break;
case 'zombie_pants': fillN('#3b3f9f', 0.15); break;
case 'skeleton_face': face('#c6c6c6', [[4, 6], [5, 6], [4, 7], [5, 7], [10, 6], [11, 6], [10, 7], [11, 7], [7, 9], [8, 9]]); for (let x = 4; x <= 11; x += 2) P(x, 12, [40, 40, 40]); break;
case 'bone': fillN('#c6c6c6', 0.12); for (let k = 0; k < 10; k++) P(r() * 16 | 0, r() * 16 | 0, [138, 138, 138]); break;
case 'creeper_face': case 'creeper_skin': {
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C(r() < 0.3 ? '#7cd36a' : r() < 0.5 ? '#3f9a2f' : '#4fae3a'), 1));
if (name === 'creeper_face') { rect(3, 4, 6, 7, '#101010'); rect(9, 4, 12, 7, '#101010'); rect(6, 8, 9, 11, '#101010'); rect(4, 10, 5, 13, '#101010'); rect(10, 10, 11, 13, '#101010'); }
break;
}
case 'spider_face': fillN('#2e2620', 0.3); for (const [x, y] of [[3, 6], [4, 6], [11, 6], [12, 6], [6, 8], [9, 8], [5, 5], [10, 5]]) P(x, y, [200, 20, 20]); break;
case 'spider_body': fillN('#2e2620', 0.35); for (let k = 0; k < 8; k++) P(r() * 16 | 0, r() * 16 | 0, [70, 60, 50]); break;
case 'pig_face': face('#f0a5a2', [[3, 5], [12, 5]]); P(4, 5, [240, 240, 240]); P(11, 5, [240, 240, 240]); rect(5, 8, 10, 11, '#e58c8a'); P(6, 9, [120, 60, 60]); P(9, 9, [120, 60, 60]); break;
case 'pig_skin': fillN('#f0a5a2', 0.1); break;
case 'cow_face': fillN('#4a3424', 0.15); rect(5, 0, 10, 5, '#e8e8e8', 0.05); P(3, 6, [10, 10, 10]); P(12, 6, [10, 10, 10]); rect(4, 10, 11, 15, '#c8a890', 0.08); P(6, 12, [60, 40, 30]); P(9, 12, [60, 40, 30]); break;
case 'cow_skin': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C(E.noise2(x / 5, y / 5, 9) > 0.55 ? '#4a3424' : '#e8e8e8'), 1 + (r() - 0.5) * 0.08)); break;
case 'sheep_face': face('#d6bfa3', [[4, 7], [11, 7]]); rect(7, 10, 8, 10, '#c08a80'); rect(0, 0, 15, 3, '#e8e8e8', 0.1); break;
case 'sheep_wool': fillN('#e8e8e8', 0.16); break;
case 'player_face': fillN('#c99a7a', 0.08); rect(0, 0, 15, 3, '#4a3220', 0.15); rect(0, 4, 0, 6, '#4a3220'); rect(15, 4, 15, 6, '#4a3220');
rect(3, 8, 4, 8, '#ffffff'); rect(11, 8, 12, 8, '#ffffff'); P(4, 8, [40, 70, 160]); P(11, 8, [40, 70, 160]); rect(6, 12, 9, 12, '#8a5040'); break;
case 'player_hair': fillN('#4a3220', 0.2); break;
case 'player_skin': fillN('#c99a7a', 0.08); break;
case 'player_shirt': fillN('#2fa8c8', 0.12); break;
case 'player_pants': fillN('#3b3f9f', 0.12); break;
case 'arrow_tex': fillN('#8a6a40', 0.2); break;
case 'tnt_lit': fillN('#ffffff', 0.04); break;
case 'diamond_block': fillN('#5decf5', 0.1); for (let k = 0; k < 16; k++) { P(k, 0, [30, 160, 170]); P(k, 15, [30, 160, 170]); P(0, k, [30, 160, 170]); P(15, k, [30, 160, 170]); } rect(3, 3, 5, 5, '#c8fbff'); rect(9, 8, 10, 9, '#c8fbff'); P(12, 3, [200, 250, 255]); break;
case 'mossy_bricks': fillN('#7a7a7a', 0.15); for (let k = 0; k < 16; k++) { P(k, 7, [88, 88, 88]); P(k, 15, [88, 88, 88]); } for (let y = 0; y < 7; y++) P(7, y, [88, 88, 88]); for (let y = 8; y < 15; y++) P(15, y, [88, 88, 88]);
for (let k = 0; k < 40; k++) { const x = r() * 16 | 0, y = r() * 16 | 0; if (E.noise2(x / 4, y / 4, 5) > 0.45) P(x, y, V(C('#4f7a2f'), 0.8 + r() * 0.4)); } break;
case 'trex_skin': case 'raptor_skin': case 'trike_skin': case 'brachio_skin': {
const base = { trex_skin: '#5a6b3a', raptor_skin: '#a8743a', trike_skin: '#7a8a5a', brachio_skin: '#7f907a' }[name];
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C(base), (name !== 'brachio_skin' && (x + y * 2) % 7 < 2 ? 0.75 : 1) + (r() - 0.5) * 0.2));
break;
}
case 'trex_face': case 'raptor_face': {
fillN(name === 'trex_face' ? '#5a6b3a' : '#a8743a', 0.2);
rect(2, 3, 4, 5, '#f0d020'); P(3, 4, [10, 10, 10]); rect(11, 3, 13, 5, '#f0d020'); P(12, 4, [10, 10, 10]);
rect(5, 2, 10, 3, '#3a4a20'); P(6, 8, [30, 20, 20]); P(9, 8, [30, 20, 20]);
rect(1, 11, 14, 12, '#401010'); for (let x = 1; x <= 14; x += 2) { P(x, 10, [245, 245, 230]); P(x + 1, 13, [245, 245, 230]); }
break;
}
case 'trike_face': fillN('#7a8a5a', 0.15); rect(3, 5, 4, 6, '#101010'); rect(11, 5, 12, 6, '#101010'); rect(5, 10, 10, 15, '#5a4a3a', 0.1); P(7, 12, [30, 20, 20]); P(8, 12, [30, 20, 20]); break;
case 'brachio_face': fillN('#7f907a', 0.12); rect(3, 5, 4, 6, '#101010'); rect(11, 5, 12, 6, '#101010'); rect(4, 12, 11, 12, '#4a5a40'); break;
case 'paint0': case 'paint1': case 'paint2': case 'paint3': case 'paint4': case 'paint5': case 'paint6': case 'paint7': {
// each player's vehicles get their own colour (PAINTS, picked from the owner's name)
const c = PAINTS[+name.slice(5)];
fillN(c, 0.08); rect(0, 6, 15, 8, '#f4f4f4', 0.03); for (let k = 0; k < 16; k += 4) P(k, 2, V(C(c), 0.7)); break;
}
case 'plane_wing': fillN('#e8e8e8', 0.06); for (let k = 0; k < 16; k++) P(k, 8, [190, 190, 190]); break;
case 'metal': fillN('#9aa0a6', 0.12); for (const [x, y] of [[2, 2], [13, 2], [2, 13], [13, 13]]) P(x, y, [70, 74, 80]); break;
case 'cockpit': clear(); for (let k = 0; k < 16; k++) { P(k, 0, [60, 64, 70]); P(k, 15, [60, 64, 70]); P(0, k, [60, 64, 70]); P(15, k, [60, 64, 70]); P(7, k, [60, 64, 70]); }
for (let k = 2; k < 6; k++) P(k, k - 1, [220, 245, 255], 255); P(10, 2, [220, 245, 255]); break;
case 'rotor': fillN('#2a2c30', 0.15); break;
case 'stairs_down': case 'stairs_up': { // a glowing stairwell seen from above: steps going down into the light
const up = name === 'stairs_up', c = C(up ? '#40d0a0' : '#8a40d0');
fillN('#3a3a44', 0.1);
for (let k = 0; k < 6; k++) { const v = up ? 0.55 + k * 0.12 : 1.25 - k * 0.12; rect(2, 2 + k * 2, 13, 3 + k * 2, '#000'); for (let x = 2; x <= 13; x++) for (let y = 2 + k * 2; y <= 3 + k * 2; y++) P(x, y, V(c, v)); }
for (let k = 0; k < 16; k++) { P(k, 0, [200, 200, 220]); P(k, 15, [200, 200, 220]); P(0, k, [200, 200, 220]); P(15, k, [200, 200, 220]); }
break;
}
case 'stairs_side': fillN('#4a4a55', 0.15); for (let k = 0; k < 16; k++) { P(k, 7, [70, 70, 80]); P(k, 15, [70, 70, 80]); } rect(5, 3, 10, 5, '#b080f0'); break;
case 'cat_skin': fillN('#e8964a', 0.12); for (let y = 1; y < 16; y += 4) for (let x = 0; x < 16; x++) if ((x + y) % 5 < 2) P(x, y, [190, 110, 40]); break;
case 'cat_face': fillN('#e8964a', 0.1); rect(2, 0, 4, 2, '#c87a3a'); rect(11, 0, 13, 2, '#c87a3a'); rect(3, 6, 5, 7, '#60c040'); P(4, 6, [10, 10, 10]); rect(10, 6, 12, 7, '#60c040'); P(11, 6, [10, 10, 10]);
rect(7, 9, 8, 10, '#f0a0b0'); P(6, 12, [60, 40, 30]); P(9, 12, [60, 40, 30]); for (const yy of [10, 11]) { P(1, yy, [250, 250, 250]); P(14, yy, [250, 250, 250]); } break;
case 'dog_skin': fillN('#c8a070', 0.12); for (let k = 0; k < 6; k++) rect(r() * 12 | 0, r() * 12 | 0, (r() * 12 | 0) + 3, (r() * 12 | 0) + 2, '#8a6038', 0.1); break;
case 'dog_face': fillN('#c8a070', 0.1); rect(0, 0, 3, 8, '#8a6038'); rect(12, 0, 15, 8, '#8a6038'); P(5, 5, [20, 20, 20]); P(10, 5, [20, 20, 20]); rect(5, 9, 10, 13, '#e0c8a0'); rect(7, 9, 8, 10, '#202020'); rect(7, 13, 8, 15, '#e05070'); break;
case 'silver_ore': ore('#eef2f8'); break;
case 'gem_ore': fillN('#7f7f7f', 0.22); for (const col of ['#e0115f', '#2ecc71', '#2f6fd8', '#9b59b6']) { const cx = 2 + (r() * 12 | 0), cy = 2 + (r() * 12 | 0); P(cx, cy, C(col)); P(cx + 1, cy, C(col)); P(cx, cy + 1, C(col)); P(cx + 1, cy + 1, V(C(col), 1.3)); } break;
case 'gold_block': case 'silver_block': { const c = name === 'gold_block' ? '#f5d742' : '#d8dee6', e = name === 'gold_block' ? [184, 150, 28] : [150, 158, 170];
fillN(c, 0.08); for (let k = 0; k < 16; k++) { P(k, 0, e); P(k, 15, e); P(0, k, e); P(15, k, e); } rect(3, 3, 5, 4, '#ffffff'); P(10, 9, [255, 255, 255]); break; }
case 'ice_bricks': fillN('#a8d8f0', 0.08); for (let k = 0; k < 16; k++) { P(k, 7, [120, 170, 210]); P(k, 15, [120, 170, 210]); } for (let y = 0; y < 7; y++) P(7, y, [120, 170, 210]); for (let y = 8; y < 15; y++) P(15, y, [120, 170, 210]);
for (let k = 1; k < 5; k++) P(k + 1, k, [230, 248, 255]); break;
case 'treasure_front': case 'treasure_side': case 'treasure_top': {
const c = C('#8a5a24');
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, (y % 4 === 3 ? 0.82 : 1) + (r() - 0.5) * 0.1));
for (let k = 0; k < 16; k++) { P(k, 0, [245, 200, 60]); P(k, 15, [245, 200, 60]); P(0, k, [245, 200, 60]); P(15, k, [245, 200, 60]); }
if (name !== 'treasure_top') for (let k = 1; k < 15; k++) P(k, 5, [245, 200, 60]); else { rect(7, 1, 8, 14, '#f5c83c'); }
if (name === 'treasure_front') { rect(6, 4, 9, 8, '#f5d742'); P(7, 6, [60, 40, 10]); P(8, 6, [60, 40, 10]); P(4, 10, [224, 17, 95]); P(11, 10, [46, 204, 113]); }
break;
}
default: {
const w = E.WOOL.find(q => q[1] === name);
if (w) { const c = C(w[2]); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, ((x + y) % 4 === 0 ? 0.9 : 1) + (r() - 0.5) * 0.1)); }
else fillN('#ff00ff', 0);
}
}
ac.putImageData(img, (i % 16) * 16, (i >> 4) * 16);
}
E.TILES.forEach(drawTile);
// ---------------------------------------------------------------- item icons (data URLs, 32x32)
const iconCache = {};
function iconFor(id) {
if (iconCache[id]) return iconCache[id];
const c = document.createElement('canvas'); c.width = c.height = 32;
const x = c.getContext('2d'); x.imageSmoothingEnabled = false;
const tileXY = tt => [(tt % 16) * 16, (tt >> 4) * 16];
const flat = tt => { const [sx, sy] = tileXY(tt); x.drawImage(atlas, sx, sy, 16, 16, 0, 0, 32, 32); };
if (id < 100) {
const d = blk(id);
if (['cross', 'torch', 'ladder'].includes(d.shape)) flat(d.tex[0]);
else {
const top = d.tex[0], side = d.tex.length === 6 ? d.tex[2] : d.tex[2];
const draw = (tt, a, b, cc, dd, e, f, dark) => {
const [sx, sy] = tileXY(tt);
x.setTransform(a, b, cc, dd, e, f); x.drawImage(atlas, sx, sy, 16, 16, 0, 0, 16, 16);
if (dark) { x.fillStyle = 'rgba(0,0,0,' + dark + ')'; x.fillRect(0, 0, 16, 16); }
};
draw(top, 1, 0.5, -1, 0.5, 16, 0, 0);
draw(side, 1, 0.5, 0, 1, 0, 8, 0.25);
draw(d.tex.length === 6 ? d.tex[4] : side, 1, -0.5, 0, 1, 16, 16, 0.42);
x.setTransform(1, 0, 0, 1, 0, 0);
}
} else if (id === 115) {
const [ax, ay] = tileXY(T.door_top), [bx, by] = tileXY(T.door_bottom);
x.drawImage(atlas, ax, ay, 16, 16, 8, 0, 16, 16); x.drawImage(atlas, bx, by, 16, 16, 8, 16, 16, 16);
} else drawItem(x, id);
return (iconCache[id] = c.toDataURL());
}
function drawItem(x, id) {
const P = (px, py, col) => { x.fillStyle = col; x.fillRect(px * 2, py * 2, 2, 2); };
const line = (x0, y0, x1, y1, col, th) => {
const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) || 1;
for (let i = 0; i <= n; i++) { const px = Math.round(x0 + (x1 - x0) * i / n), py = Math.round(y0 + (y1 - y0) * i / n); P(px, py, col); if (th) P(px + 1, py, col); }
};
const blob = (cx, cy, rx, ry, col, edge) => { for (let py = 0; py < 16; py++) for (let px = 0; px < 16; px++) { const q = ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2; if (q < 1) P(px, py, q > 0.7 && edge ? edge : col); } };
const it = ITEMS[id] || {};
// treasure add-on icons
const gem = (c, e) => { for (let py = 2; py <= 13; py++) for (let px = 2; px <= 13; px++) { const q = Math.abs(px - 7.5) * 1.1 + Math.abs(py - 8) * (py < 6 ? 2 : 0.9); if (q < 6.5) P(px, py, q > 5 ? e : c); } P(6, 5, '#fff'); P(5, 6, '#fff'); };
const fruitCol = { 151: ['#f5d742', '#b8961c'], 152: ['#b8d84a', '#7a9a2a'], 153: ['#f39c12', '#c0700a'], 155: ['#d01030', '#900818'], 156: ['#7a3aa8', '#4a1a70'] };
if (it.potion || id === 159) { // bottle
blob(8, 10, 5, 4.5, '#cfe8f0', '#7a9aa8'); for (let py = 3; py <= 6; py++) { P(7, py, '#7a9aa8'); P(8, py, '#cfe8f0'); } P(7, 2, '#8a6a40'); P(8, 2, '#8a6a40');
if (it.potion) { blob(8, 10.5, 4, 3.5, it.color, it.color); P(6, 9, '#ffffff'); }
return;
}
if (it.armor != null) {
const c = ['#e0e0e0', '#f5d742', '#5decf5'][it.tier], e = ['#9a9a9a', '#b8961c', '#1aa6b0'][it.tier];
const shapes = [ // helmet, chestplate, leggings, boots
[[3, 4, 12, 6], [3, 7, 4, 10], [11, 7, 12, 10]], [[2, 3, 5, 6], [10, 3, 13, 6], [4, 6, 11, 13]],
[[4, 3, 11, 5], [4, 6, 6, 13], [9, 6, 11, 13]], [[3, 8, 6, 12], [9, 8, 12, 12], [2, 12, 6, 13], [9, 12, 13, 13]]][it.armor];
for (const [x0, y0, x1, y1] of shapes) for (let py = y0; py <= y1; py++) for (let px = x0; px <= x1; px++) P(px, py, py === y1 || px === x0 ? e : c);
return;
}
if (it.kit) { // little castle in the kit's colours
const k = E.CASTLES[it.kit], wc = { fort: '#8a6234', stone: '#8a8a8a', royal: '#9a9aa8', palace: '#dccf96', ice: '#a8d8f0' }[it.kit];
for (let py = 6; py <= 14; py++) for (let px = 1; px <= 14; px++) P(px, py, (px === 7 || px === 8) && py > 10 ? '#3a2a1a' : wc);
for (const px of [1, 3, 5, 10, 12, 14]) P(px, 5, wc);
for (let py = 2; py <= 5; py++) P(7, py, '#5a4a3a');
const fl = E.blk(k.flag).color; P(8, 2, fl); P(9, 2, fl); P(8, 3, fl); P(9, 3, fl);
if (it.kit === 'royal') { P(4, 8, '#f5d742'); P(11, 8, '#f5d742'); }
return;
}
if (it.box != null) { // a gift box with a ribbon, and sparkles on the good ones
for (let py = 6; py <= 14; py++) for (let px = 2; px <= 13; px++) P(px, py, py === 14 || px === 2 ? '#00000060' : it.color);
for (let px = 1; px <= 14; px++) { P(px, 5, it.color); P(px, 6, it.color); }
for (let py = 5; py <= 14; py++) { P(7, py, '#d02030'); P(8, py, '#d02030'); }
for (let px = 1; px <= 14; px++) P(px, 9, '#d02030');
P(5, 3, '#d02030'); P(6, 4, '#d02030'); P(9, 4, '#d02030'); P(10, 3, '#d02030');
if (it.box >= 2) { P(1, 1, '#fff'); P(14, 2, '#fff'); P(13, 0, '#ffffaa'); }
if (it.box === 3) { P(0, 4, '#fff'); P(15, 7, '#ffccff'); P(3, 0, '#ffccff'); }
return;
}
if (it.pet) {
if (it.pet === 'babydino') { blob(8, 9, 5, 6, '#f0ead0', '#c8bc90'); for (const [sx, sy] of [[6, 6], [10, 9], [7, 12]]) blob(sx, sy, 1.2, 1, '#5a6b3a'); return; }
for (let py = 8; py <= 14; py++) for (let px = 2; px <= 13; px++) P(px, py, (px + py) % 3 === 0 ? '#8a6234' : '#b8945f');
for (let a = 0; a <= 12; a++) { const ang = Math.PI * a / 12; P(Math.round(8 + Math.cos(ang) * 6), Math.round(8 - Math.sin(ang) * 5), '#8a6234'); }
const c = it.pet === 'cat' ? '#e8964a' : '#c8a070'; blob(8, 7, 3, 2.5, c); P(7, 7, '#111'); P(9, 7, '#111');
if (it.pet === 'cat') { P(5, 4, c); P(11, 4, c); } else { P(5, 6, '#8a6038'); P(11, 6, '#8a6038'); }
return;
}
if (it.tool && id !== 143 && id !== 182) {
const mc = ['#a8844f', '#8a8a8a', '#e0e0e0', '#5decf5'][it.tier - 1], dk = ['#6b5130', '#555555', '#9a9a9a', '#1aa6b0'][it.tier - 1];
if (it.tool === 'sword') { line(5, 11, 13, 3, mc, true); P(13, 2, mc); line(3, 8, 8, 13, '#4a3220'); line(2, 14, 4, 12, '#6b5130', true); line(6, 11, 13, 4, dk); return; }
line(3, 13, 10, 6, '#6b5130', true);
if (it.tool === 'pick') { line(5, 3, 10, 3, mc, true); line(10, 3, 13, 6, mc, true); line(13, 6, 13, 11, mc, true); line(6, 4, 10, 4, dk); line(12, 7, 12, 10, dk); }
else if (it.tool === 'axe') { for (let py = 2; py <= 7; py++) for (let px = 8; px <= 13; px++) if (px - 8 + (7 - py) < 9) P(px, py, mc); line(8, 7, 13, 2, dk); }
else { blob(11.5, 4.5, 3, 3, mc, dk); }
return;
}
switch (id) {
case 100: line(3, 13, 12, 4, '#7a5a32', true); break;
case 101: blob(8, 8, 5, 4.5, '#2a2a2a', '#111'); P(6, 6, '#555'); P(9, 7, '#555'); break;
case 102: case 103: { const c = id === 102 ? '#d8d8d8' : '#f5d742', e = id === 102 ? '#9a9a9a' : '#b8961c'; for (let py = 6; py <= 10; py++) for (let px = 3 + (10 - py); px <= 13 - (10 - py) + 2; px++) P(px, py, py === 10 ? e : c); break; }
case 104: for (let py = 0; py < 16; py++) for (let px = 0; px < 16; px++) { const q = Math.abs(px - 7.5) + Math.abs(py - 7.5); if (q < 6) P(px, py, q > 4.5 ? '#1aa6b0' : '#5decf5'); } P(6, 5, '#fff'); break;
case 105: blob(8, 9.5, 5, 5, '#d42020', '#9a1010'); line(8, 2, 8, 4, '#6b4a26'); P(9, 3, '#3f8a28'); P(6, 7, '#ff8080'); break;
case 106: case 107: blob(8, 8.5, 6, 4.5, id === 106 ? '#f0a0a0' : '#b56a3a', id === 106 ? '#d07070' : '#7a4422'); line(4, 7, 11, 7, id === 106 ? '#ffe0e0' : '#d8a070'); break;
case 108: case 109: blob(8, 8.5, 6, 4.5, id === 108 ? '#c03030' : '#7a4422', id === 108 ? '#8a2020' : '#4a2a12'); P(6, 7, id === 108 ? '#fff' : '#a06030'); P(10, 9, id === 108 ? '#fff' : '#a06030'); break;
case 110: blob(8, 8.5, 6, 4.5, '#7a8a3a', '#4a5a2a'); P(6, 7, '#4a5a2a'); P(10, 9, '#4a5a2a'); break;
case 111: line(4, 12, 12, 4, '#e8e8e0', true); P(3, 12, '#e8e8e0'); P(4, 13, '#e8e8e0'); P(12, 3, '#e8e8e0'); P(13, 4, '#e8e8e0'); break;
case 112: line(3, 13, 12, 4, '#8a6a40'); P(12, 4, '#aaa'); P(13, 3, '#aaa'); P(11, 3, '#aaa'); P(13, 5, '#aaa'); P(2, 12, '#eee'); P(3, 14, '#eee'); P(2, 14, '#eee'); break;
case 113: for (let py = 8; py < 14; py++) for (let px = 8 - (py - 8); px < 8 + (py - 8); px++) P(px, py, Math.random() < 0.5 ? '#555' : '#888'); break;
case 114: blob(8, 8, 6, 5, '#9a5a2a', '#6b3a18'); break;
case 116: for (let px = 2; px < 14; px++) P(px, 8 + Math.round(Math.sin(px) * 1.5), '#eeeeee'); break;
case 140: case 141: blob(8, 9, 5.5, 4.5, id === 140 ? '#d05050' : '#8a4a22', id === 140 ? '#902a2a' : '#5a2a10'); line(11, 6, 14, 2, '#f0f0e0', true); P(14, 1, '#f0f0e0'); P(13, 1, '#f0f0e0'); break;
case 142: for (let py = 2; py <= 14; py++) { const w = Math.max(0, Math.round((py - 2) / 3)); for (let px = 8 - w; px <= 8 + w; px++) P(px, py, py > 11 ? '#c8b890' : '#f4f2e6'); } break;
case 143: line(5, 11, 13, 3, '#f4f2e6', true); P(13, 2, '#f4f2e6'); line(6, 11, 13, 4, '#c8b890'); line(3, 8, 8, 13, '#5a6b3a'); line(2, 14, 4, 12, '#6b5130', true); break;
case 146: for (let py = 6; py <= 10; py++) for (let px = 3 + (10 - py); px <= 13 - (10 - py) + 2; px++) P(px, py, py === 10 ? '#9aa4ae' : '#eef2f8'); break;
case 147: gem('#e0115f', '#8a0a3a'); break;
case 148: gem('#2ecc71', '#16804a'); break;
case 149: gem('#2f6fd8', '#183c80'); break;
case 150: gem('#a868d8', '#5a2a80'); break;
case 151: case 152: case 153: case 155: case 156: {
const [c, e] = fruitCol[id];
if (id === 155) { blob(5.5, 11, 3, 3, c, e); blob(10.5, 11, 3, 3, c, e); line(5, 8, 8, 3, '#3f8a28'); line(10, 8, 8, 3, '#3f8a28'); }
else if (id === 156) { for (const [gx, gy] of [[6, 6], [9, 6], [7.5, 8.5], [5, 9], [10, 9], [7.5, 11.5]]) blob(gx, gy, 1.9, 1.9, c, e); line(8, 2, 8, 4, '#3f8a28'); }
else if (id === 152) { blob(8, 10.5, 5, 4.5, c, e); blob(8, 6, 3, 3, c, e); line(8, 1, 8, 3, '#6b4a26'); }
else { blob(8, 9.5, 5, 5, c, e); line(8, 2, 8, 4, '#6b4a26'); P(9, 3, '#3f8a28'); P(6, 7, '#ffffff'); }
break;
}
case 154: for (let a = 0; a <= 10; a++) { const ang = Math.PI * (0.15 + a / 14); const px = Math.round(8 + Math.cos(ang) * 6), py = Math.round(4 + Math.sin(ang) * 8); P(px, py, '#f5d742'); P(px, py - 1, '#f5d742'); P(px + 1, py - 1, '#c8a820'); } P(13, 5, '#6b4a26'); break;
case 157: for (let py = 6; py <= 13; py++) for (let px = 2; px <= 13; px++) { const q = ((px - 7.5) / 6) ** 2 + ((py - 5) / 8) ** 2; if (q < 1 && py >= 6) P(px, py, q > 0.75 ? '#3f8a28' : q > 0.6 ? '#e8f0c0' : '#e8404a'); } P(6, 8, '#111'); P(9, 9, '#111'); P(7, 11, '#111'); break;
case 182: line(5, 11, 13, 3, '#eef2f8', true); P(13, 2, '#eef2f8'); line(6, 11, 13, 4, '#9aa4ae'); line(3, 8, 8, 13, '#2f6fd8'); line(2, 14, 4, 12, '#6b5130', true); break;
case 144: // airplane, seen from above
for (let py = 1; py <= 14; py++) { P(7, py, '#d23a32'); P(8, py, '#d23a32'); }
for (let px = 1; px <= 14; px++) { P(px, 6, '#e8e8e8'); P(px, 7, '#e8e8e8'); }
P(1, 6, '#d23a32'); P(14, 6, '#d23a32'); P(1, 7, '#d23a32'); P(14, 7, '#d23a32');
for (let px = 5; px <= 10; px++) P(px, 13, '#e8e8e8');
P(7, 4, '#8fd0f0'); P(8, 4, '#8fd0f0'); P(6, 0, '#333'); P(9, 0, '#333'); P(7, 0, '#9aa0a6'); P(8, 0, '#9aa0a6'); break;
case 145: // helicopter, side view
line(1, 2, 14, 2, '#2a2c30'); P(7, 3, '#9aa0a6');
blob(6, 7.5, 4.5, 3.5, '#f0c020', '#c89a10'); blob(4, 7, 2, 2, '#8fd0f0');
line(10, 7, 14, 6, '#f0c020', true); line(14, 4, 14, 7, '#2a2c30');
line(2, 13, 11, 13, '#333'); P(4, 12, '#333'); P(9, 12, '#333'); break;
case 117: for (let a = 0; a <= 12; a++) { const ang = Math.PI * (0.5 + a / 24); P(Math.round(13 + Math.cos(ang) * 10), Math.round(13 - Math.sin(ang) * 10), '#7a5a32'); }
line(3, 13, 13, 3, '#cccccc'); break;
default: blob(8, 8, 5, 5, '#ff00ff');
}
}
// ---------------------------------------------------------------- WebGL
const gl = cv.getContext('webgl', { antialias: false, alpha: false, powerPreference: 'high-performance' });
const glOK = !!gl;
let uintExt = null, prog, flat, atlasTex, quadIBO, maxQuads, dynVBO;
if (glOK) {
uintExt = gl.getExtension('OES_element_index_uint');
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s)); return s; };
const mk = (vs, fs) => { const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(p); return p; };
prog = mk(`attribute vec3 aP;attribute vec2 aT;attribute vec3 aL;
uniform mat4 uM;uniform vec3 uCam;uniform float uDay;uniform vec2 uFog;uniform float uMin;
varying vec2 vT;varying vec3 vC;varying float vF;
void main(){gl_Position=uM*vec4(aP,1.0);vT=aT;
vec3 c=max(vec3(aL.x*uDay),vec3(aL.y)*vec3(1.0,0.85,0.6));vC=max(c,vec3(uMin))*aL.z;
vF=clamp((length(aP-uCam)-uFog.x)/(uFog.y-uFog.x),0.0,1.0);}`,
`precision mediump float;uniform sampler2D uTex;uniform vec3 uFogC;uniform float uAlpha;uniform vec4 uTint;
varying vec2 vT;varying vec3 vC;varying float vF;
void main(){vec4 t=texture2D(uTex,vT);if(t.a<0.5)discard;vec3 c=mix(t.rgb*vC,uTint.rgb,uTint.a);
gl_FragColor=vec4(mix(c,uFogC,vF),uAlpha);}`);
prog.a = { p: gl.getAttribLocation(prog, 'aP'), t: gl.getAttribLocation(prog, 'aT'), l: gl.getAttribLocation(prog, 'aL') };
prog.u = {}; ['uM', 'uCam', 'uDay', 'uFog', 'uTex', 'uFogC', 'uAlpha', 'uTint', 'uMin'].forEach(n => { prog.u[n] = gl.getUniformLocation(prog, n); });
flat = mk('attribute vec3 aP;uniform mat4 uM;void main(){gl_Position=uM*vec4(aP,1.0);}',
'precision mediump float;uniform vec4 uC;void main(){gl_FragColor=uC;}');
flat.a = gl.getAttribLocation(flat, 'aP'); flat.uM = gl.getUniformLocation(flat, 'uM'); flat.uC = gl.getUniformLocation(flat, 'uC');
atlasTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, atlasTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
maxQuads = uintExt ? 65536 : 16383;
const idx = uintExt ? new Uint32Array(maxQuads * 6) : new Uint16Array(maxQuads * 6);
for (let q = 0; q < maxQuads; q++) { const b = q * 4, o = q * 6; idx[o] = b; idx[o + 1] = b + 1; idx[o + 2] = b + 2; idx[o + 3] = b; idx[o + 4] = b + 2; idx[o + 5] = b + 3; }
quadIBO = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, quadIBO); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
dynVBO = gl.createBuffer();
} else $('nogl').classList.remove('hidden');
const IDX_TYPE = () => uintExt ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT;
// ---------------------------------------------------------------- meshing
// vertex: x y z u v sky torch shade
const FACES = [
[1, 0, 0, [1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1], 4, 0.8],
[-1, 0, 0, [0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0], 5, 0.8],
[0, 1, 0, [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0], 0, 1.0],
[0, -1, 0, [0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1], 1, 0.5],
[0, 0, 1, [0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1], 3, 0.65],
[0, 0, -1, [1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0], 2, 0.65],
];
const faceTile = (def, f) => { const tx = def.tex; if (tx.length === 6) return tx[FACES[f][4]]; return f === 2 ? tx[0] : f === 3 ? tx[1] : tx[2]; };
const AO = [0.42, 0.6, 0.8, 1];
const PAD = CS + 2, PAD2 = PAD * PAD;
const pb = new Uint8Array(PAD2 * CH), ph = new Int16Array(PAD2);
let VB = new Float32Array(8 * 4 * 30000), WB = new Float32Array(8 * 4 * 8000);
let vn = 0, wn = 0;
function grow(water) {
if (water) { const n = new Float32Array(WB.length * 2); n.set(WB); WB = n; } else { const n = new Float32Array(VB.length * 2); n.set(VB); VB = n; }
}
function vert(water, x, y, z, u, v, s, tl, sh) {
if (water) { if (wn + 8 > WB.length) grow(true); WB[wn++] = x; WB[wn++] = y; WB[wn++] = z; WB[wn++] = u; WB[wn++] = v; WB[wn++] = s; WB[wn++] = tl; WB[wn++] = sh; }
else { if (vn + 8 > VB.length) grow(false); VB[vn++] = x; VB[vn++] = y; VB[vn++] = z; VB[vn++] = u; VB[vn++] = v; VB[vn++] = s; VB[vn++] = tl; VB[vn++] = sh; }
}
const tu = tt => (tt % 16) / 16, tv = tt => (tt >> 4) / 16, EPS = 0.0004, TS = 1 / 16 - EPS * 2;
let torchList = [];
function torchAt(x, y, z) {
let best = 0;
for (let i = 0; i < torchList.length; i += 3) {
const dx = torchList[i] - x, dy = torchList[i + 1] - y, dz = torchList[i + 2] - z;
const d2 = dx * dx + dy * dy + dz * dz; if (d2 > 64) continue;
const l = 1 - Math.sqrt(d2) / 8; if (l > best) best = l;
}
return best;
}
const G = (x, y, z) => y < 0 ? 16 : y >= CH ? 0 : pb[y * PAD2 + (z + 1) * PAD + x + 1];
const SKY = (x, y, z) => { if (y >= CH) return 1; const dd = ph[(z + 1) * PAD + x + 1] - y; return dd < 0 ? 1 : Math.max(0.04, 0.85 - dd * 0.14); };
// box with face culling at block edges, uv taken from the box position inside the tile
function emitBox(water, bx, by, bz, x0, y0, z0, x1, y1, z1, def, sky, tl, onlyFaces) {
const lim = [x0, y0, z0, x1, y1, z1];
for (let f = 0; f < 6; f++) {
if (onlyFaces && !(onlyFaces & (1 << f))) continue;
const F = FACES[f], nx = F[0], ny = F[1], nz = F[2];
const onEdge = (nx === 1 && x1 >= 1) || (nx === -1 && x0 <= 0) || (ny === 1 && y1 >= 1) || (ny === -1 && y0 <= 0) || (nz === 1 && z1 >= 1) || (nz === -1 && z0 <= 0);
if (onEdge && isOpaque[G(bx + nx, by + ny, bz + nz)]) continue;
const tt = faceTile(def, f), u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
for (let k = 0; k < 4; k++) {
const cx = F[3][k * 3] ? x1 : x0, cy = F[3][k * 3 + 1] ? y1 : y0, cz = F[3][k * 3 + 2] ? z1 : z0;
let u, v;
if (nx === 1) { u = 1 - cz; v = 1 - cy; } else if (nx === -1) { u = cz; v = 1 - cy; }
else if (nz === 1) { u = cx; v = 1 - cy; } else if (nz === -1) { u = 1 - cx; v = 1 - cy; }
else if (ny === 1) { u = cx; v = cz; } else { u = cx; v = 1 - cz; }
vert(water, CX0 + bx + cx, by + cy, CZ0 + bz + cz, u0 + u * TS, v0 + v * TS, sky, tl, F[5]);
}
}
}
let CX0 = 0, CZ0 = 0;
function buildMesh(c) {
const nb = [];
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) nb.push(world.chunks.get(E.key(c.cx + dx, c.cz + dz)));
for (let z = -1; z <= CS; z++) for (let x = -1; x <= CS; x++) {
const ncx = x < 0 ? -1 : x >= CS ? 1 : 0, ncz = z < 0 ? -1 : z >= CS ? 1 : 0, n = nb[(ncz + 1) * 3 + ncx + 1];
const lx = x - ncx * CS, lz = z - ncz * CS, pi = (z + 1) * PAD + x + 1;
ph[pi] = n.hm[lz * CS + lx];
for (let y = 0; y < CH; y++) pb[y * PAD2 + pi] = n.b[(y * CS + lz) * CS + lx];
}
CX0 = c.cx * CS; CZ0 = c.cz * CS;
torchList = [];
for (const n of nb) for (let i = 0; i < n.torches.length; i += 3) {
const tx = n.torches[i] + 0.5, tz = n.torches[i + 2] + 0.5;
if (tx > CX0 - 8 && tx < CX0 + CS + 8 && tz > CZ0 - 8 && tz < CZ0 + CS + 8) torchList.push(tx, n.torches[i + 1] + 0.5, tz);
}
vn = 0; wn = 0;
const mapPix = new Uint8ClampedArray(CS * CS * 4);
for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) {
let topSeen = false;
for (let y = CH - 1; y >= 0; y--) {
const id = pb[y * PAD2 + (z + 1) * PAD + x + 1];
if (!id) continue;
const def = BLOCKS[id];
if (!topSeen && def.shape !== 'cross' && def.shape !== 'torch') {
topSeen = true;
const col = hexRgb(def.color || '#888'), f = 0.7 + y / CH * 0.6, mi = (z * CS + x) * 4;
mapPix[mi] = col[0] * f; mapPix[mi + 1] = col[1] * f; mapPix[mi + 2] = col[2] * f; mapPix[mi + 3] = 255;
}
const shape = def.shape;
if (shape === 'cube') {
for (let f = 0; f < 6; f++) {
const F = FACES[f], nx = F[0], ny = F[1], nz = F[2], n = G(x + nx, y + ny, z + nz);
if (isOpaque[n] || (n === id && def.cutout)) continue;
const tt = faceTile(def, f), u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
const lx = x + nx, ly = y + ny, lz = z + nz;
const aos = [0, 0, 0, 0], skys = [0, 0, 0, 0], tls = [0, 0, 0, 0];
for (let k = 0; k < 4; k++) {
const ccx = F[3][k * 3], ccy = F[3][k * 3 + 1], ccz = F[3][k * 3 + 2];
let ax, ay, az, bx2, by2, bz2;
if (nx) { ax = 0; ay = ccy ? 1 : -1; az = 0; bx2 = 0; by2 = 0; bz2 = ccz ? 1 : -1; }
else if (ny) { ax = ccx ? 1 : -1; ay = 0; az = 0; bx2 = 0; by2 = 0; bz2 = ccz ? 1 : -1; }
else { ax = ccx ? 1 : -1; ay = 0; az = 0; bx2 = 0; by2 = ccy ? 1 : -1; bz2 = 0; }
const o1 = isOpaque[G(lx + ax, ly + ay, lz + az)], o2 = isOpaque[G(lx + bx2, ly + by2, lz + bz2)];
const o3 = isOpaque[G(lx + ax + bx2, ly + ay + by2, lz + az + bz2)];
aos[k] = (o1 && o2) ? 0 : 3 - (o1 + o2 + o3);
let s = SKY(lx, ly, lz), cnt = 1;
if (!o1) { s += SKY(lx + ax, ly + ay, lz + az); cnt++; }
if (!o2) { s += SKY(lx + bx2, ly + by2, lz + bz2); cnt++; }
if (!o3 && !(o1 && o2)) { s += SKY(lx + ax + bx2, ly + ay + by2, lz + az + bz2); cnt++; }
skys[k] = s / cnt;
tls[k] = torchList.length ? torchAt(CX0 + x + ccx, y + ccy, CZ0 + z + ccz) : 0;
}
const flip = aos[0] + aos[2] < aos[1] + aos[3];
for (let kk = 0; kk < 4; kk++) {
const k = flip ? (kk + 1) % 4 : kk;
const ccx = F[3][k * 3], ccy = F[3][k * 3 + 1], ccz = F[3][k * 3 + 2];
const uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
vert(false, CX0 + x + ccx, y + ccy, CZ0 + z + ccz, u0 + uu * TS, v0 + vv * TS, skys[k], tls[k], F[5] * AO[aos[k]]);
}
}
} else if (shape === 'water') {
const above = G(x, y + 1, z), topH = above === 10 ? 1 : 0.875;
for (let f = 0; f < 6; f++) {
const F = FACES[f], n = G(x + F[0], y + F[1], z + F[2]);
if (n === 10 || isOpaque[n] || (f === 3 && n !== 0)) continue;
const tt = def.tex[0], u0 = tu(tt) + EPS, v0 = tv(tt) + EPS, sky = SKY(x + F[0], y + F[1], z + F[2]);
for (let k = 0; k < 4; k++) {
const ccy = F[3][k * 3 + 1] ? topH : 0, uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
vert(true, CX0 + x + F[3][k * 3], y + ccy, CZ0 + z + F[3][k * 3 + 2], u0 + uu * TS, v0 + vv * TS, sky, 0, F[5]);
}
}
} else {
const sky = SKY(x, y, z), tl = torchList.length ? torchAt(CX0 + x + 0.5, y + 0.5, CZ0 + z + 0.5) : 0;
if (shape === 'cross') {
const tt = def.tex[0], u0 = tu(tt) + EPS, v0 = tv(tt) + EPS, X = CX0 + x, Z = CZ0 + z;
const quads = [[0.15, 0.15, 0.85, 0.85], [0.85, 0.85, 0.15, 0.15], [0.15, 0.85, 0.85, 0.15], [0.85, 0.15, 0.15, 0.85]];
for (const q of quads) {
vert(false, X + q[0], y, Z + q[1], u0, v0 + TS, sky, tl, 0.9); vert(false, X + q[2], y, Z + q[3], u0 + TS, v0 + TS, sky, tl, 0.9);
vert(false, X + q[2], y + 1, Z + q[3], u0 + TS, v0, sky, tl, 0.9); vert(false, X + q[0], y + 1, Z + q[1], u0, v0, sky, tl, 0.9);
}
} else if (shape === 'torch') emitBox(false, x, y, z, 7 / 16, 0, 7 / 16, 9 / 16, 10 / 16, 9 / 16, def, 1, 1, 0);
else if (shape === 'slab' || shape === 'bed') emitBox(false, x, y, z, 0, 0, 0, 1, def.h, 1, def, sky, tl, 0);
else if (shape === 'cactus') emitBox(false, x, y, z, 1 / 16, 0, 1 / 16, 15 / 16, 1, 15 / 16, def, sky, tl, 0);
else if (shape === 'door') {
const thin = 3 / 16, alongX = (def.axis === 'x') !== def.open;
if (alongX) emitBox(false, x, y, z, 0, 0, 0, 1, 1, thin, def, sky, tl, 0);
else emitBox(false, x, y, z, 0, 0, 0, thin, 1, 1, def, sky, tl, 0);
} else if (shape === 'ladder') {
let any = false;
const th = 1 / 16;
if (isOpaque[G(x - 1, y, z)]) { emitBox(false, x, y, z, 0, 0, 0, th, 1, 1, def, sky, tl, 1); any = true; }
if (isOpaque[G(x + 1, y, z)]) { emitBox(false, x, y, z, 1 - th, 0, 0, 1, 1, 1, def, sky, tl, 2); any = true; }
if (isOpaque[G(x, y, z - 1)]) { emitBox(false, x, y, z, 0, 0, 0, 1, 1, th, def, sky, tl, 16); any = true; }
if (isOpaque[G(x, y, z + 1)]) { emitBox(false, x, y, z, 0, 0, 1 - th, 1, 1, 1, def, sky, tl, 32); any = true; }
if (!any) emitBox(false, x, y, z, 0, 0, 0, 1, 1, th, def, sky, tl, 0);
}
}
}
}
// upload
const old = chunkGL.get(E.key(c.cx, c.cz));
const m = old || { cx: c.cx, cz: c.cz, vbo: gl.createBuffer(), wvbo: gl.createBuffer(), n: 0, wn: 0 };
gl.bindBuffer(gl.ARRAY_BUFFER, m.vbo); gl.bufferData(gl.ARRAY_BUFFER, VB.subarray(0, vn), gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, m.wvbo); gl.bufferData(gl.ARRAY_BUFFER, WB.subarray(0, wn), gl.STATIC_DRAW);
m.n = Math.min(maxQuads, vn / 32) * 6; m.wn = Math.min(maxQuads, wn / 32) * 6;
chunkGL.set(E.key(c.cx, c.cz), m);
c.dirty = false;
// map tile
const mc = document.createElement('canvas'); mc.width = mc.height = CS;
mc.getContext('2d').putImageData(new ImageData(mapPix, CS, CS), 0, 0);
mapTiles.set(E.key(c.cx, c.cz), mc);
}
// ---------------------------------------------------------------- mob / player models
const HUM = (faceT, headT, shirt, arms, pants, thin) => {
const a = thin ? 0.12 : 0.25, l = thin ? 0.12 : 0.25;
return [
{ b: [-0.25, 1.45, -0.25, 0.25, 1.95, 0.25], f: faceT, o: headT, head: true },
{ b: [-0.25, 0.7, -0.125, 0.25, 1.45, 0.125], f: shirt, o: shirt },
{ b: [-0.25 - a, 0.7, -a / 2, -0.25, 1.45, a / 2], f: arms, o: arms, py: 1.4, sw: 1, arm: true },
{ b: [0.25, 0.7, -a / 2, 0.25 + a, 1.45, a / 2], f: arms, o: arms, py: 1.4, sw: -1, arm: true },
{ b: [-0.25, 0, -l / 2, -0.25 + l, 0.7, l / 2], f: pants, o: pants, py: 0.7, sw: -1 },
{ b: [0.25 - l, 0, -l / 2, 0.25, 0.7, l / 2], f: pants, o: pants, py: 0.7, sw: 1 },
];
};
const QUAD = (bodyB, body, headB, faceT, headO, legH, lx, lz) => {
const legs = [];
[[-lx, lz, 1], [lx - 0.25, lz, -1], [-lx, -lz - 0.25, -1], [lx - 0.25, -lz - 0.25, 1]].forEach(([x0, z0, s]) => legs.push({ b: [x0, 0, z0, x0 + 0.25, legH, z0 + 0.25], f: body, o: body, py: legH, sw: s }));
return [{ b: bodyB, f: body, o: body }, { b: headB, f: faceT, o: headO, head: true }].concat(legs);
};
const MODELS = {
zombie: HUM(T.zombie_face, T.zombie_skin, T.zombie_shirt, T.zombie_skin, T.zombie_pants, false),
skeleton: HUM(T.skeleton_face, T.bone, T.bone, T.bone, T.bone, true),
player: HUM(T.player_face, T.player_hair, T.player_shirt, T.player_skin, T.player_pants, false),
creeper: [{ b: [-0.25, 1.2, -0.25, 0.25, 1.7, 0.25], f: T.creeper_face, o: T.creeper_skin, head: true },
{ b: [-0.25, 0.45, -0.125, 0.25, 1.2, 0.125], f: T.creeper_skin, o: T.creeper_skin },
{ b: [-0.25, 0, 0.125, 0, 0.45, 0.375], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: 1 },
{ b: [0, 0, 0.125, 0.25, 0.45, 0.375], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: -1 },
{ b: [-0.25, 0, -0.375, 0, 0.45, -0.125], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: -1 },
{ b: [0, 0, -0.375, 0.25, 0.45, -0.125], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: 1 }],
spider: (() => {
const m = [{ b: [-0.45, 0.25, -0.9, 0.45, 0.85, -0.1], f: T.spider_body, o: T.spider_body },
{ b: [-0.3, 0.3, -0.1, 0.3, 0.7, 0.3], f: T.spider_body, o: T.spider_body },
{ b: [-0.3, 0.25, 0.3, 0.3, 0.75, 0.75], f: T.spider_face, o: T.spider_body, head: true }];
for (let i = 0; i < 4; i++) { const z = 0.25 - i * 0.18; m.push({ b: [-1.0, 0.45, z - 0.05, -0.3, 0.55, z + 0.05], f: T.spider_body, o: T.spider_body, leg: (i % 2 ? 1 : -1) }); m.push({ b: [0.3, 0.45, z - 0.05, 1.0, 0.55, z + 0.05], f: T.spider_body, o: T.spider_body, leg: (i % 2 ? -1 : 1) }); }
return m;
})(),
pig: QUAD([-0.3, 0.35, -0.45, 0.3, 0.85, 0.45], T.pig_skin, [-0.25, 0.4, 0.45, 0.25, 0.9, 0.85], T.pig_face, T.pig_skin, 0.35, 0.3, 0.2),
cow: QUAD([-0.38, 0.6, -0.55, 0.38, 1.3, 0.55], T.cow_skin, [-0.25, 0.85, 0.55, 0.25, 1.35, 0.9], T.cow_face, T.cow_skin, 0.6, 0.38, 0.3),
sheep: QUAD([-0.38, 0.55, -0.5, 0.38, 1.15, 0.5], T.sheep_wool, [-0.2, 0.85, 0.5, 0.2, 1.25, 0.85], T.sheep_face, T.sheep_wool, 0.55, 0.38, 0.25),
cat: QUAD([-0.17, 0.22, -0.35, 0.17, 0.48, 0.3], T.cat_skin, [-0.16, 0.32, 0.28, 0.16, 0.62, 0.55], T.cat_face, T.cat_skin, 0.24, 0.17, 0.18)
.concat([{ b: [-0.04, 0.4, -0.7, 0.04, 0.48, -0.35], f: T.cat_skin, o: T.cat_skin }]),
dog: QUAD([-0.22, 0.38, -0.45, 0.22, 0.74, 0.35], T.dog_skin, [-0.18, 0.5, 0.33, 0.18, 0.86, 0.72], T.dog_face, T.dog_skin, 0.4, 0.22, 0.2)
.concat([{ b: [-0.04, 0.62, -0.7, 0.04, 0.7, -0.45], f: T.dog_skin, o: T.dog_skin }]),
trex: (() => { const S = T.trex_skin; return [
{ b: [-0.55, 1.4, -1.0, 0.55, 2.4, 0.9], f: S, o: S },
{ b: [-0.45, 2.1, 0.8, 0.45, 2.9, 2.0], f: T.trex_face, o: S, head: true },
{ b: [-0.4, 1.85, 0.9, 0.4, 2.1, 1.9], f: S, o: S },
{ b: [-0.3, 1.6, -2.6, 0.3, 2.2, -1.0], f: S, o: S },
{ b: [-0.15, 1.75, -3.6, 0.15, 2.05, -2.6], f: S, o: S },
{ b: [-0.55, 0, -0.3, -0.15, 1.5, 0.2], f: S, o: S, py: 1.5, sw: 1 },
{ b: [0.15, 0, -0.3, 0.55, 1.5, 0.2], f: S, o: S, py: 1.5, sw: -1 },
{ b: [-0.45, 1.55, 0.85, -0.3, 1.7, 1.25], f: S, o: S },
{ b: [0.3, 1.55, 0.85, 0.45, 1.7, 1.25], f: S, o: S }]; })(),
raptor: (() => { const S = T.raptor_skin; return [
{ b: [-0.25, 0.7, -0.5, 0.25, 1.1, 0.4], f: S, o: S },
{ b: [-0.18, 1.0, 0.35, 0.18, 1.3, 0.85], f: T.raptor_face, o: S, head: true },
{ b: [-0.1, 0.8, -1.3, 0.1, 1.0, -0.5], f: S, o: S },
{ b: [-0.22, 0, -0.1, -0.07, 0.75, 0.08], f: S, o: S, py: 0.75, sw: 1 },
{ b: [0.07, 0, -0.1, 0.22, 0.75, 0.08], f: S, o: S, py: 0.75, sw: -1 }]; })(),
trike: (() => { const S = T.trike_skin, H = T.bone; const m = [
{ b: [-0.65, 0.6, -1.0, 0.65, 1.5, 0.9], f: S, o: S },
{ b: [-0.45, 0.6, 0.9, 0.45, 1.3, 1.6], f: T.trike_face, o: S, head: true },
{ b: [-0.75, 0.9, 0.82, 0.75, 1.9, 0.97], f: S, o: S },
{ b: [-0.35, 1.15, 1.6, -0.25, 1.25, 2.15], f: H, o: H },
{ b: [0.25, 1.15, 1.6, 0.35, 1.25, 2.15], f: H, o: H },
{ b: [-0.05, 0.85, 1.6, 0.05, 0.95, 1.9], f: H, o: H },
{ b: [-0.2, 0.8, -1.8, 0.2, 1.1, -1.0], f: S, o: S }];
[[-0.65, 0.5, 1], [0.35, 0.5, -1], [-0.65, -0.9, -1], [0.35, -0.9, 1]].forEach(([x, z, s]) => m.push({ b: [x, 0, z, x + 0.3, 0.65, z + 0.3], f: S, o: S, py: 0.65, sw: s }));
return m; })(),
brachio: (() => { const S = T.brachio_skin; const m = [
{ b: [-0.7, 1.6, -1.2, 0.7, 2.8, 1.0], f: S, o: S },
{ b: [-0.25, 2.4, 0.8, 0.25, 5.0, 1.3], f: S, o: S },
{ b: [-0.3, 4.8, 1.0, 0.3, 5.3, 1.8], f: T.brachio_face, o: S, head: true },
{ b: [-0.25, 1.9, -3.0, 0.25, 2.4, -1.2], f: S, o: S }];
[[-0.7, 0.5, 1], [0.25, 0.5, -1], [-0.7, -1.1, -1], [0.25, -1.1, 1]].forEach(([x, z, s]) => m.push({ b: [x, 0, z, x + 0.45, 1.65, z + 0.45], f: S, o: S, py: 1.65, sw: s }));
return m; })(),
};
const MB = new Float32Array(8 * 4 * 6 * 20);
function drawModel(boxes, x, y, z, yaw, walk, pitch, tint, armsForward, scale) {
const sky = world.skyAt(Math.floor(x), Math.floor(y + 1), Math.floor(z)), tl = world.torchLightAt(x, y + 1, z);
let n = 0;
const cy = Math.cos(yaw), sy = Math.sin(yaw), s = scale || 1;
for (const bx of boxes) {
let ang = 0, piv = 0;
if (bx.sw) { ang = Math.sin(walk) * 0.7 * bx.sw; piv = bx.py; }
if (bx.arm && armsForward) ang = -1.45 + Math.sin(walk) * 0.15 * bx.sw;
if (bx.head && pitch) { ang = -pitch; piv = bx.b[1]; }
const ca = Math.cos(ang), sa = Math.sin(ang), b = bx.b;
for (let f = 0; f < 6; f++) {
const F = FACES[f], tt = f === 4 ? bx.f : bx.o, u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
for (let k = 0; k < 4; k++) {
let lx = F[3][k * 3] ? b[3] : b[0], ly = F[3][k * 3 + 1] ? b[4] : b[1], lz = F[3][k * 3 + 2] ? b[5] : b[2];
if (bx.leg) { lz += Math.sin(walk + bx.leg) * 0.12 * (Math.abs(lx) > 0.5 ? 1 : 0); }
if (ang) { const yy = ly - piv, zz = lz; ly = piv + yy * ca - zz * sa; lz = yy * sa + zz * ca; }
lx *= s; ly *= s; lz *= s;
const wx = lx * cy + lz * sy, wz = -lx * sy + lz * cy;
const uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
MB[n++] = x + wx; MB[n++] = y + ly; MB[n++] = z + wz; MB[n++] = u0 + uu * TS; MB[n++] = v0 + vv * TS; MB[n++] = sky; MB[n++] = tl; MB[n++] = F[5];
}
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, MB.subarray(0, n), gl.DYNAMIC_DRAW);
bindAttribs();
gl.uniform4fv(prog.u.uTint, tint || [0, 0, 0, 0]);
gl.drawElements(gl.TRIANGLES, n / 32 * 6, IDX_TYPE(), 0);
}
// ---------------------------------------------------------------- vehicle models (nose towards +z, drawn with yaw + PI like players)
// spin: 'x' | 'y' | 'z' turns that box around its own centre (propeller, rotors)
function vehicleBoxes(kind, paint, pilot) {
const S = T['paint' + paint], W = T.plane_wing, M = T.metal, G = T.cockpit, R = T.rotor;
const bx = (b, f, o, spin) => ({ b, f, o: o == null ? f : o, spin });
let m, seat;
if (kind === 'plane') {
m = [bx([-0.45, 0.5, -2.2, 0.45, 1.3, 1.6], S), bx([-0.35, 0.6, 1.6, 0.35, 1.2, 2.0], M),
bx([-0.95, 0.86, 2.02, 0.95, 0.94, 2.1], R, R, 'z'), bx([-0.38, 1.3, 0.0, 0.38, 1.8, 0.9], G),
bx([-2.7, 0.95, 0.1, 2.7, 1.1, 1.1], W), bx([-2.7, 0.96, 0.1, -2.2, 1.11, 1.1], S), bx([2.2, 0.96, 0.1, 2.7, 1.11, 1.1], S),
bx([-1.1, 1.1, -2.2, 1.1, 1.2, -1.6], W), bx([-0.06, 1.2, -2.2, 0.06, 2.0, -1.5], S),
bx([-0.85, 0, 0.6, -0.55, 0.5, 0.9], R, M), bx([0.55, 0, 0.6, 0.85, 0.5, 0.9], R, M), bx([-0.1, 0.25, -2.0, 0.1, 0.5, -1.8], R, M)];
seat = [0.15, 0.85];
} else {
m = [bx([-0.7, 0.4, -0.8, 0.7, 1.9, 1.0], S), bx([-0.65, 0.55, 1.0, 0.65, 1.8, 1.6], G),
bx([-0.15, 1.1, -3.4, 0.15, 1.4, -0.8], S), bx([-0.05, 1.1, -3.7, 0.05, 2.0, -3.3], S),
bx([0.08, 1.05, -3.75, 0.14, 2.05, -3.25], R, R, 'x'),
bx([-0.08, 1.9, -0.08, 0.08, 2.2, 0.08], M),
bx([-3.0, 2.2, -0.15, 3.0, 2.28, 0.15], R, R, 'y'), bx([-0.15, 2.2, -3.0, 0.15, 2.28, 3.0], R, R, 'y'),
bx([-0.8, 0, -0.9, -0.62, 0.1, 1.2], M), bx([0.62, 0, -0.9, 0.8, 0.1, 1.2], M),
bx([-0.72, 0.1, 0.4, -0.64, 0.4, 0.5], M), bx([0.64, 0.1, 0.4, 0.72, 0.4, 0.5], M)];
seat = [0.65, 1.05];
}
if (pilot) { const y = seat[0], z = seat[1];
m.push(bx([-0.22, y + 0.5, z - 0.22, 0.22, y + 0.94, z + 0.22], T.player_face, T.player_hair), bx([-0.25, y, z - 0.15, 0.25, y + 0.5, z + 0.15], T.player_shirt)); }
return m;
}
const VB2 = new Float32Array(8 * 4 * 6 * 24);
function drawVehicle(boxes, x, y, z, yaw, pitch, roll, spin) {
const sky = world.skyAt(Math.floor(x), Math.floor(y + 1), Math.floor(z)), tl = world.torchLightAt(x, y + 1, z);
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch), cr = Math.cos(roll), sr = Math.sin(roll);
const cs = Math.cos(spin), ss = Math.sin(spin), PY = 0.9; // tilt around the middle of the body
let n = 0;
for (const bx of boxes) {
const b = bx.b, mx = (b[0] + b[3]) / 2, my = (b[1] + b[4]) / 2, mz = (b[2] + b[5]) / 2;
for (let f = 0; f < 6; f++) {
const F = FACES[f], tt = f === 4 ? bx.f : bx.o, u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
for (let k = 0; k < 4; k++) {
let lx = F[3][k * 3] ? b[3] : b[0], ly = F[3][k * 3 + 1] ? b[4] : b[1], lz = F[3][k * 3 + 2] ? b[5] : b[2];
if (bx.spin === 'y') { const ax = lx - mx, az = lz - mz; lx = mx + ax * cs - az * ss; lz = mz + ax * ss + az * cs; }
else if (bx.spin === 'z') { const ax = lx - mx, ay = ly - my; lx = mx + ax * cs - ay * ss; ly = my + ax * ss + ay * cs; }
else if (bx.spin === 'x') { const ay = ly - my, az = lz - mz; ly = my + ay * cs - az * ss; lz = mz + ay * ss + az * cs; }
ly -= PY;
let ax = lx * cr - ly * sr, ay = lx * sr + ly * cr; // roll (around the nose axis)
const by = ay * cp + lz * sp, bz = -ay * sp + lz * cp; // pitch (nose up when > 0)
const wx = ax * cy + bz * sy, wz = -ax * sy + bz * cy; // yaw
const uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
VB2[n++] = x + wx; VB2[n++] = y + by + PY; VB2[n++] = z + wz; VB2[n++] = u0 + uu * TS; VB2[n++] = v0 + vv * TS; VB2[n++] = sky; VB2[n++] = tl; VB2[n++] = F[5];
}
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, VB2.subarray(0, n), gl.DYNAMIC_DRAW);
bindAttribs();
gl.uniform4fv(prog.u.uTint, [0, 0, 0, 0]);
gl.drawElements(gl.TRIANGLES, n / 32 * 6, IDX_TYPE(), 0);
}
function bindAttribs() {
gl.vertexAttribPointer(prog.a.p, 3, gl.FLOAT, false, 32, 0);
gl.vertexAttribPointer(prog.a.t, 2, gl.FLOAT, false, 32, 12);
gl.vertexAttribPointer(prog.a.l, 3, gl.FLOAT, false, 32, 20);
}
// ---------------------------------------------------------------- matrices
function perspective(fov, asp, n, f) { const t = 1 / Math.tan(fov / 2), nf = 1 / (n - f); return [t / asp, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) * nf, -1, 0, 0, 2 * f * n * nf, 0]; }
function mul(a, b) { const o = new Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s; } return o; }
function viewMat(yaw, pitch, ex, ey, ez) {
const r = [Math.cos(yaw), 0, -Math.sin(yaw)];
const f = [-Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch)];
const b = [-f[0], -f[1], -f[2]];
const u = [b[1] * r[2] - b[2] * r[1], b[2] * r[0] - b[0] * r[2], b[0] * r[1] - b[1] * r[0]];
return [r[0], u[0], b[0], 0, r[1], u[1], b[1], 0, r[2], u[2], b[2], 0,
-(r[0] * ex + r[1] * ey + r[2] * ez), -(u[0] * ex + u[1] * ey + u[2] * ez), -(b[0] * ex + b[1] * ey + b[2] * ez), 1];
}
const fwd = () => [-Math.sin(pl.yaw) * Math.cos(pl.pitch), Math.sin(pl.pitch), -Math.cos(pl.yaw) * Math.cos(pl.pitch)];
// ---------------------------------------------------------------- game state
let world = null, sim = null, link = null, pl = null, session = null, state = 'menu';
const chunkGL = new Map(), mapTiles = new Map();
let inv = new Array(36).fill(null), sel = 0;
let mobs = new Map(), arrows = [], tnts = [], others = new Map(), myId = 1, simTime = 0.1;
let target = null, breakT = 0, breakKey = '', attackCool = 0, useCool = 0, hurtFlash = 0, shake = 0, toastT = 0;
let moveSend = 0, saveTimer = 0, stepT = 0, groanT = 5, lastJumpTap = 0;
let vehicles = new Map(), ride = null, vSend = 0, myName = 'You', inSafe = false, myPet = null;
const SPIRAL = []; for (let dz = -8; dz <= 8; dz++) for (let dx = -8; dx <= 8; dx++) SPIRAL.push([dx, dz, dx * dx + dz * dz]);
SPIRAL.sort((a, b) => a[2] - b[2]);
function toast(msg) { $('toast').textContent = msg; toastT = 3; }
function newPlayer(pos, mode) {
return { x: pos.x, y: pos.y, z: pos.z, vx: 0, vy: 0, vz: 0, w: 0.6, h: 1.8, yaw: 0, pitch: -0.2, onGround: false, fly: false,
hp: 20, food: 20, air: 10, fallY: null, spawn: null, fuel: 0, mode: mode || 'survival', dead: false,
fx: {}, armor: [null, null, null, null] }; // fx: potion effects (seconds left); armor: helmet, chestplate, leggings, boots item ids
}
const creative = () => pl && pl.mode === 'creative';
const FX_NAMES = { regen: 'REGENERATION', str: 'STRENGTH', speed: 'SPEED', jump: 'JUMP', shield: 'SHIELD', night: 'NIGHT VISION', water: 'WATER BREATHING' };
const armorPts = () => pl.armor.reduce((a, id) => a + (id ? ITEMS[id].pts : 0), 0);
const hasBoots = id => pl.armor[3] === id;
function drink(it) { // potions and the golden apple
const healer = H().cls === 'healer';
if (it.heal) { pl.hp = Math.min(maxHp(), pl.hp + it.heal); }
if (it.potion === 'heal') pl.hp = Math.min(maxHp(), pl.hp + (healer ? 16 : 8));
const fx = it.potion && it.potion !== 'heal' ? it.potion : it.fx, secs = it.secs * (healer && it.potion ? 2 : 1);
if (fx) { pl.fx[fx] = Math.max(pl.fx[fx] || 0, secs); toast(FX_NAMES[fx] + ' for ' + secs + ' seconds!'); }
if (it.potion) earn('potion');
else if (it.potion) toast('+4 hearts');
hurtFlash = 0; lastStats = '';
}
// ---------------------------------------------------------------- hero: XP, levels, class, points, achievements, loot boxes
// Kept with the player (saved in the single-player world, or on the family server with the bag).
const H = () => pl.hero || (pl.hero = { xp: 0, lvl: 1, cls: '', pts: { str: 0, spd: 0, hp: 0 }, free: 0, ach: {}, cnt: {}, fruit: {}, castles: {}, deepest: 0 });
const CLASSES = {
warrior: { name: 'WARRIOR', about: 'Hits 50% harder and has 2 extra hearts' },
archer: { name: 'ARCHER', about: 'Arrows do double damage, and half the time the arrow is free' },
healer: { name: 'HEALER', about: 'Always heals slowly; potions heal double and last twice as long' },
explorer: { name: 'EXPLORER', about: 'Runs 25% faster, falls of 8 blocks don\'t hurt, sees a little in the dark' },
};
const maxHp = () => { if (!pl) return 20; const h = H(); return 20 + h.pts.hp * 2 + (h.cls === 'warrior' ? 4 : 0); };
const xpNeed = lvl => 20 + lvl * 15;
const meleeBoost = () => (1 + H().pts.str * 0.1) * (H().cls === 'warrior' ? 1.5 : 1);
const speedBoost = () => (1 + Math.min(10, H().pts.spd) * 0.06) * (H().cls === 'explorer' ? 1.25 : 1);
function addXp(n) {
const h = H(); if (!n) return;
h.xp += n;
while (h.xp >= xpNeed(h.lvl)) {
h.xp -= xpNeed(h.lvl); h.lvl++; h.free++;
pl.hp = maxHp(); lastStats = '';
announce('LEVEL ' + h.lvl + '!', 'You got a point to spend. Tap HERO to make yourself stronger, faster or tougher.', '#55FF55');
if (h.lvl === 3) { const pet = 194 + Math.floor(Math.random() * 3); giveDrops([[pet, 1]]); announce('A PET IS WAITING FOR YOU!', 'You got a ' + E.itemName(pet) + '. Tap it to meet your new best friend.', '#FFAA00'); }
if (h.lvl === 5) earn('level5'); if (h.lvl === 10) earn('level10');
}
if (state === 'hero') renderHero();
}
// achievements: [title, how to get it, tier 0 bronze .. 3 legendary, the announcer's comment]
const ACH = {
first_blood: ['Monster Masher', 'Defeat your first monster', 0, 'One down. Several thousand to go.'],
zombie10: ['Zombie Zapper', 'Defeat 10 zombies', 1, 'The zombies have started a support group about you.'],
skeleton10: ['Bone Collector', 'Defeat 10 skeletons', 1, 'That is a lot of spare ribs.'],
spider5: ['Web Wrecker', 'Defeat 5 spiders', 0, 'Eight legs were not enough to run away.'],
kaboom: ['Kaboom Club', 'Get caught in an explosion', 0, 'Your eyebrows will grow back. Probably.'],
dino_snack: ['Dino Snack', 'Get bitten by a T-Rex', 0, 'The T-Rex says you taste like chicken.'],
trex_down: ['Bigger Teeth', 'Defeat a T-Rex', 2, 'Who is the top of the food chain now?'],
shiny: ['Shiny!', 'Dig up your first diamond', 0, 'Ooooh. Shiny.'],
jewel: ['Jewel Thief', 'Find a jewel', 1, 'Finders keepers.'],
castle: ['Knock Knock', 'Find a castle', 0, 'Nobody was home. Their treasure is now your treasure.'],
royal: ['Royal Visitor', 'Find a Royal Castle', 1, 'Please wipe your feet on the royal carpet.'],
all_castles: ['Castle Collector', 'Visit all five kinds of castle', 2, 'You have seen more castles than most kings.'],
kit: ['Home Sweet Castle', 'Build a castle from a kit', 2, 'Property values in this area just went up.'],
fly: ['Up Up and Away', 'Fly an airplane or helicopter', 1, 'Please keep your arms inside the vehicle.'],
potion: ['Bottoms Up', 'Drink a potion', 0, 'It tastes like purple. Or maybe green.'],
boots: ['Shiny Shoes', 'Wear Diamond Boots', 1, 'Gravity is no longer your problem.'],
full_diamond: ['Diamond Hero', 'Wear a full set of diamond armor', 2, 'You sparkle when you walk. It is a little distracting.'],
oops: ['Oopsie Daisy', 'Get defeated for the first time', 0, 'It happens to the best of heroes. Get back up!'],
gravity: ['Gravity Wins', 'Get hurt by a fall', 0, 'The ground was closer than it looked.'],
level5: ['Getting Stronger', 'Reach level 5', 1, 'The monsters have started to whisper your name.'],
level10: ['Hero of the Realm', 'Reach level 10', 2, 'Statues are being built in your honor. Small ones.'],
pet: ['Best Friends', 'Get a pet', 1, 'Every hero needs a sidekick.'],
pet_win: ['Good Pet!', 'Your pet defeats a monster', 0, 'Your pet would like a treat now, please.'],
dungeon: ['Going Down', 'Enter the dungeon', 0, 'Welcome, crawler. Mind the monsters.'],
safe: ['Phew!', 'Rest in a safe room', 0, 'Nothing can get you in here. Probably.'],
boss: ['Floor Boss Down', 'Defeat a floor boss', 2, 'The crowd goes wild! Well, the bats do.'],
floor3: ['Deeper and Deeper', 'Reach Floor 3', 1, 'It is getting darker. And smellier.'],
floor5: ['Crawler Champion', 'Reach Floor 5', 3, 'Legends will be told about this. Mostly by you.'],
box: ['Box Opener', 'Open a loot box', 0, 'What is inside? Prizes! Mostly.'],
legendary: ['Legendary!', 'Open a Legendary Loot Box', 2, 'The rarest box of all. Show off a little.'],
fruit: ['Fruit Salad', 'Eat 5 different kinds of fruit', 1, 'An apple a day keeps the zombies away.'],
};
const TIER_NAMES = ['BRONZE', 'SILVER', 'GOLD', 'LEGENDARY'], TIER_COL = ['#d0884a', '#d8dee6', '#f5d742', '#d080ff'], TIER_XP = [10, 25, 60, 150];
function earn(id) {
const h = H(), a = ACH[id];
if (!a || h.ach[id] || !pl || pl.mode === 'creative') return;
h.ach[id] = Date.now();
announce('NEW ACHIEVEMENT: ' + a[0].toUpperCase() + '!', a[1] + '. ' + a[3] + '\nReward: ' + TIER_NAMES[a[2]] + ' Loot Box (tap it to open)', TIER_COL[a[2]]);
giveDrops([[190 + a[2], 1]]); addXp(TIER_XP[a[2]]);
}
const count = (k, n) => { const h = H(); h.cnt[k] = (h.cnt[k] || 0) + (n || 1); return h.cnt[k]; };
// the announcer: big banners at the top, one after another
const announceQ = []; let announceT = 0;
function announce(title, text, color) { announceQ.push([title, text, color]); if (announceQ.length === 1 && announceT <= 0) nextAnnounce(); }
function nextAnnounce() {
const a = announceQ[0], el = $('announce');
if (!a) { el.classList.add('hidden'); return; }
el.querySelector('b').textContent = a[0]; el.querySelector('b').style.color = a[2] || '#FFFF55';
el.querySelector('span').textContent = a[1]; el.style.borderColor = a[2] || '#FFFF55';
el.classList.remove('hidden'); announceT = 4.5; sfx('fanfare');
}
function tickAnnounce(dt) { if (announceT > 0) { announceT -= dt; if (announceT <= 0) { announceQ.shift(); nextAnnounce(); } } }
// loot boxes: random prizes, better boxes give better prizes
function openBox(tier) {
const R = (a, b) => a + Math.floor(Math.random() * (b - a + 1)), pick = a => a[Math.floor(Math.random() * a.length)];
const potion = () => 160 + R(0, 7), jewel = () => 147 + R(0, 3), fruit = () => pick([105, 152, 153, 154, 155, 156, 157]);
const pools = [
() => pick([[105, 3], [22, 8], [112, 8], [102, 3], [160, 1], [155, 3], [101, 5], [146, 2], [fruit(), 3]]),
() => pick([[103, 4], [102, 6], [104, R(1, 2)], [potion(), 1], [jewel(), 1], [146, 4], [151, 1], [fruit(), 4]]),
() => pick([[104, R(3, 5)], [jewel(), 2], [potion(), 2], [151, 1], [170 + R(0, 7), 1], [182, 1], [57, 1], [potion(), 1]]),
() => pick([[104, 8], [jewel(), 3], [178 + R(0, 3), 1], [183 + R(0, 4), 1], [194 + R(0, 2), 1], [162, 2], [165, 2], [53, 2], [151, 3]]),
];
const items = []; for (let i = 0; i < 2 + tier; i++) items.push(pools[tier]());
if (tier === 3 && Math.random() < 0.5) items.push([194 + R(0, 2), 1]);
if (tier >= 1 && Math.random() < 0.25) items.push(pools[tier - 1]());
giveDrops(items);
announce(TIER_NAMES[tier] + ' LOOT BOX!', 'You got: ' + items.map(([id, n]) => (n > 1 ? n + ' ' : '') + E.itemName(id)).join(', '), TIER_COL[tier]);
addXp([5, 15, 40, 100][tier]); earn('box'); if (tier === 3) earn('legendary');
}
function wearArmor(i) { // put the bag slot's armor on (swapping with what was worn)
const s = inv[i], it = s && ITEMS[s.id]; if (!it || it.armor == null) return false;
const old = pl.armor[it.armor]; pl.armor[it.armor] = s.id; inv[i] = old ? { id: old, n: 1 } : null;
sfx('door'); toast('Wearing ' + it.name + (s.id === 181 ? '\nNo more fall damage!' : '')); renderHotbar(); lastStats = '';
if (pl.armor[3] === 181) earn('boots');
if (pl.armor.every((a, k) => a === 178 + k)) earn('full_diamond');
return true;
}
function clientOnChange(x, y, z, old, id) {
const cx = Math.floor(x / CS), cz = Math.floor(z / CS), lx = x - cx * CS, lz = z - cz * CS;
const big = blk(old).light || blk(id).light;
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
if (!big && !((dx === 0 || (dx === -1 && lx === 0) || (dx === 1 && lx === CS - 1)) && (dz === 0 || (dz === -1 && lz === 0) || (dz === 1 && lz === CS - 1)))) continue;
const c = world.chunks.get(E.key(cx + dx, cz + dz)); if (c) c.dirty = true;
}
}
// ---------------------------------------------------------------- chunk loading
let lastUnload = 0;
function updateChunks(budget) {
const pcx = Math.floor(pl.x / CS), pcz = Math.floor(pl.z / CS), R = cfg.dist, t0 = performance.now();
let gens = 0;
for (const [dx, dz, d2] of SPIRAL) {
if (d2 > (R + 1.5) * (R + 1.5)) break;
if (!world.chunks.has(E.key(pcx + dx, pcz + dz))) { world.chunk(pcx + dx, pcz + dz, true); if (++gens >= 2 || performance.now() - t0 > budget) break; }
}
for (const [dx, dz, d2] of SPIRAL) {
if (d2 > (R + 0.5) * (R + 0.5)) break;
if (performance.now() - t0 > budget) break;
const c = world.chunks.get(E.key(pcx + dx, pcz + dz));
if (!c || !c.dirty) continue;
let ok = true;
for (let a = -1; a <= 1 && ok; a++) for (let b = -1; b <= 1; b++) if (!world.chunks.has(E.key(c.cx + a, c.cz + b))) { ok = false; break; }
if (ok) buildMesh(c);
}
const now = performance.now();
if (now - lastUnload > 2000) {
lastUnload = now;
for (const [k, m] of chunkGL) if (Math.abs(m.cx - pcx) > R + 2 || Math.abs(m.cz - pcz) > R + 2) { gl.deleteBuffer(m.vbo); gl.deleteBuffer(m.wvbo); chunkGL.delete(k); const c = world.chunks.get(k); if (c) c.dirty = true; }
for (const [k, c] of world.chunks) if (Math.abs(c.cx - pcx) > R + 4 || Math.abs(c.cz - pcz) > R + 4) world.chunks.delete(k);
}
}
function nearReady() {
const pcx = Math.floor(pl.x / CS), pcz = Math.floor(pl.z / CS);
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) if (!chunkGL.has(E.key(pcx + dx, pcz + dz))) return false;
return true;
}
// ---------------------------------------------------------------- inventory helpers
const maxDur = id => (ITEMS[id] && ITEMS[id].dur) || 0;
function addItem(id, n) {
const st = E.stackSize(id);
for (let i = 0; i < 36 && n > 0; i++) { const s = inv[i]; if (s && s.id === id && s.n < st) { const k = Math.min(n, st - s.n); s.n += k; n -= k; } }
for (let i = 0; i < 36 && n > 0; i++) if (!inv[i]) { const k = Math.min(n, st); inv[i] = { id, n: k }; if (maxDur(id)) inv[i].d = maxDur(id); n -= k; }
renderHotbar(); if (state === 'inv') renderInv();
return n;
}
const countItem = id => inv.reduce((a, s) => a + (s && s.id === id ? s.n : 0), 0);
function removeItem(id, n) {
for (let i = 35; i >= 0 && n > 0; i--) { const s = inv[i]; if (s && s.id === id) { const k = Math.min(n, s.n); s.n -= k; n -= k; if (!s.n) inv[i] = null; } }
renderHotbar();
}
function useHeld() { const s = inv[sel]; if (!s || creative()) return; s.n--; if (s.n <= 0) inv[sel] = null; renderHotbar(); }
function wearTool() {
const s = inv[sel]; if (!s || !maxDur(s.id) || creative()) return;
s.d = (s.d || maxDur(s.id)) - 1;
if (s.d <= 0) { inv[sel] = null; sfx('tool'); toast(E.itemName(s.id) + ' broke!'); }
renderHotbar();
}
function giveDrops(items) {
const names = [];
for (const [id, n] of items) { const left = addItem(id, n); if (left < n) names.push('+' + (n - left) + ' ' + E.itemName(id)); }
if (names.length) { sfx('pop'); toast(names.join('\n')); }
}
// ---------------------------------------------------------------- HUD
function slotHTML(s, extra) {
if (!s) return '<div class="slot ' + (extra || '') + '"></div>';
const md = maxDur(s.id), dur = md && s.d != null && s.d < md ? '<div class="dur" style="width:' + (80 * s.d / md) + '%;background:' + (s.d / md > 0.3 ? '#0c0' : '#e22') + '"></div>' : '';
return '<div class="slot ' + (extra || '') + '"><img src="' + iconFor(s.id) + '" alt="">' + (s.n > 1 ? '<span class="n">' + s.n + '</span>' : '') + dur + '</div>';
}
function renderHotbar() {
let h = '';
for (let i = 0; i < 9; i++) h += slotHTML(inv[i], i === sel ? 'sel' : '').replace('class="slot', 'data-i="' + i + '" class="slot');
$('hotbar').innerHTML = h;
}
$('hotbar').addEventListener('pointerdown', e => { const el = e.target.closest('.slot'); if (el) { sel = +el.dataset.i; renderHotbar(); showHeldName(); } e.stopPropagation(); });
function showHeldName() { const s = inv[sel]; if (s) toast(E.itemName(s.id)); }
let lastStats = '';
function renderStats() {
if (creative() || !pl) { if (lastStats !== 'c') { $('stats').style.visibility = 'hidden'; lastStats = 'c'; } return; }
const hp = Math.ceil(pl.hp), fd = Math.ceil(pl.food), air = pl.air < 10 ? Math.ceil(pl.air) : -1;
const ap = Math.ceil(armorPts() / 2);
const mh = maxHp() / 2;
const key = hp + ',' + fd + ',' + air + ',' + ap + ',' + mh; if (key === lastStats) return; lastStats = key;
$('armorbar').textContent = ap ? '◆'.repeat(ap) : '';
$('stats').style.visibility = 'visible';
let h = ''; for (let i = 0; i < mh; i++) h += hp >= (i + 1) * 2 ? '♥' : hp === i * 2 + 1 ? '♡' : '<span style="opacity:.3">♥</span>';
let f = ''; for (let i = 0; i < 10; i++) f += fd >= (i + 1) * 2 ? '●' : fd === i * 2 + 1 ? '◐' : '<span style="opacity:.3">●</span>';
$('hearts').innerHTML = h; $('food').innerHTML = f;
$('airbar').textContent = air >= 0 ? 'o'.repeat(air) : '';
}
// ---------------------------------------------------------------- inventory / crafting / chest UI
let picked = -1, openChest = null, chestSlots = [], station = '';
function nearBlock(id, r) {
const bx = Math.floor(pl.x), by = Math.floor(pl.y), bz = Math.floor(pl.z);
for (let y = -2; y <= 3; y++) for (let z = -r; z <= r; z++) for (let x = -r; x <= r; x++) if (world.peek(bx + x, by + y, bz + z) === id) return true;
return false;
}
function openInv(st, chestKey) {
station = st || ''; openChest = chestKey || null; picked = -1;
if (!openChest) chestSlots = [];
state = 'inv'; releasePointer();
$('inv').classList.remove('hidden');
renderInv();
}
function closeInv() {
if (openChest) link.send({ t: 'chestSet', k: openChest, slots: chestSlots });
openChest = null; $('inv').classList.add('hidden'); state = 'play'; renderHotbar();
}
function recipeOK(r) {
for (const [id, n] of r.inp) if (countItem(id) < n) return false;
if (r.st === 'table' && !(station === 'table' || nearBlock(19, 4))) return false;
if (r.st === 'furnace') { if (!(station === 'furnace' || nearBlock(20, 4))) return false; if (pl.fuel < 1 && !Object.keys(E.FUEL).some(k => countItem(+k) > 0)) return false; }
return true;
}
function craft(r) {
if (!recipeOK(r)) return;
if (r.st === 'furnace' && pl.fuel < 1) {
const fk = Object.keys(E.FUEL).map(Number).sort((a, b) => E.FUEL[b] - E.FUEL[a]).find(k => countItem(k) > 0 && !r.inp.some(q => q[0] === k && countItem(k) <= q[1]));
if (fk == null) { toast('Needs fuel (coal or wood)'); return; }
removeItem(fk, 1); pl.fuel += E.FUEL[fk];
}
if (r.st === 'furnace') pl.fuel -= 1;
for (const [id, n] of r.inp) removeItem(id, n);
const left = addItem(r.out, r.n);
if (left) toast('Bag is full!');
sfx('craft'); renderInv();
}
function renderInv() {
const cr = creative();
$('creativeBox').classList.toggle('hidden', !cr);
$('craftBox').classList.toggle('hidden', cr && !openChest ? true : !!openChest);
$('chestBox').classList.toggle('hidden', !openChest);
let g = '';
for (let i = 9; i < 36; i++) g += slotHTML(inv[i], picked === i ? 'pick' : '').replace('class="slot', 'data-s="' + i + '" class="slot');
$('invGrid').innerHTML = g;
let h = '';
for (let i = 0; i < 9; i++) h += slotHTML(inv[i], picked === i ? 'pick' : (i === sel ? 'sel' : '')).replace('class="slot', 'data-s="' + i + '" class="slot');
h += '<div class="slot" data-trash="1" title="Throw away" style="border-color:#FF5555;color:#FF5555">✖</div>';
$('invHot').innerHTML = h;
let a = '';
for (let i = 0; i < 4; i++) a += slotHTML(pl.armor[i] ? { id: pl.armor[i], n: 1 } : null).replace('class="slot', 'data-a="' + i + '" title="' + ['Helmet', 'Chestplate', 'Leggings', 'Boots'][i] + '" class="slot');
$('armorGrid').innerHTML = a;
$('armorInfo').textContent = armorPts() ? 'Monsters hurt you ' + Math.round(Math.min(0.8, armorPts() * 0.04) * 100) + '% less' + (hasBoots(181) ? ', no fall damage' : '') : 'Tap armor in your bag, then a slot here';
$('invHot').style.gridTemplateColumns = 'repeat(10,auto)';
if (openChest) {
let c = '';
for (let i = 0; i < 27; i++) c += slotHTML(chestSlots[i], '').replace('class="slot', 'data-c="' + i + '" class="slot');
$('chestGrid').innerHTML = c;
}
if (cr) {
if (!$('palette').childElementCount) $('palette').innerHTML = E.allCreative.map(id => slotHTML({ id, n: 1 }).replace('class="slot', 'data-p="' + id + '" class="slot')).join('');
} else if (!openChest) {
$('craftTitle').textContent = station === 'table' ? 'CRAFTING TABLE' : station === 'furnace' ? 'FURNACE' : 'CRAFTING';
const list = E.RECIPES.map((r, i) => ({ r, i, ok: recipeOK(r) })).sort((a, b) => (b.ok - a.ok));
$('recipes').innerHTML = list.map(({ r, i, ok }) => '<button class="rec' + (ok ? '' : ' no') + '" data-r="' + i + '"><img src="' + iconFor(r.out) + '" alt=""><span>' +
(r.n > 1 ? r.n + ' ' : '') + E.itemName(r.out) + '<br><span class="ing">' + r.inp.map(([id, n]) => n + ' ' + E.itemName(id)).join(' + ') +
(r.st ? ' · ' + (r.st === 'table' ? 'crafting table' : 'furnace + fuel') : '') + '</span></span></button>').join('');
}
}
$('inv').addEventListener('click', e => {
const s = e.target.closest('[data-s],[data-c],[data-p],[data-r],[data-trash],[data-a]');
if (!s) return;
if (s.dataset.a != null) { // armor slot: wear the picked piece, or take this one off
const ai = +s.dataset.a;
if (picked >= 0 && inv[picked] && ITEMS[inv[picked].id] && ITEMS[inv[picked].id].armor != null) { wearArmor(picked); picked = -1; }
else if (pl.armor[ai]) { const id = pl.armor[ai]; if (!addItem(id, 1)) { pl.armor[ai] = null; lastStats = ''; } else toast('Bag is full!'); }
renderInv(); return;
}
if (s.dataset.r != null) { craft(E.RECIPES[+s.dataset.r]); return; }
if (s.dataset.p != null) { const id = +s.dataset.p; inv[sel] = { id, n: E.stackSize(id) }; if (maxDur(id)) inv[sel].d = maxDur(id); $('itemName').textContent = E.itemName(id); renderInv(); renderHotbar(); return; }
if (s.dataset.trash) { if (picked >= 0) { inv[picked] = null; picked = -1; renderInv(); } else $('itemName').textContent = 'Pick an item, then tap here to throw it away'; return; }
if (s.dataset.c != null) { // chest -> bag
const i = +s.dataset.c, it = chestSlots[i]; if (!it) return;
const left = addItem(it.id, it.n);
chestSlots[i] = left ? Object.assign({}, it, { n: left }) : null;
renderInv(); return;
}
const i = +s.dataset.s;
if (inv[i]) $('itemName').textContent = E.itemName(inv[i].id);
if (openChest && inv[i]) { // bag -> chest
const it = inv[i], st = E.stackSize(it.id);
for (let k = 0; k < 27 && it.n > 0; k++) { const c = chestSlots[k]; if (c && c.id === it.id && c.n < st) { const m = Math.min(it.n, st - c.n); c.n += m; it.n -= m; } }
for (let k = 0; k < 27 && it.n > 0; k++) if (!chestSlots[k]) { chestSlots[k] = Object.assign({}, it); it.n = 0; }
if (it.n <= 0) inv[i] = null;
renderInv(); renderHotbar(); return;
}
if (picked < 0) { if (inv[i]) picked = i; }
else {
if (picked !== i) {
const a = inv[picked], b = inv[i];
if (b && a && a.id === b.id && b.n < E.stackSize(b.id)) { const m = Math.min(a.n, E.stackSize(b.id) - b.n); b.n += m; a.n -= m; if (!a.n) inv[picked] = null; }
else { inv[picked] = b; inv[i] = a; }
}
picked = -1;
}
renderInv(); renderHotbar();
});
$('invClose').onclick = closeInv;
// ---------------------------------------------------------------- pets and the HERO screen
function petTap(mo) {
const name = mo.ex ? mo.ex[0] : 'Your pet', owner = mo.ex ? mo.ex[1] : '';
toast((owner && owner.toLowerCase() !== myName.toLowerCase() && session.remote ? owner + "'s pet " : '') + name + ' loves you! ♥');
tone(880, 1320, 0.15, 'sine', 0.08); tone(1320, 990, 0.15, 'sine', 0.06, 0.15);
}
function openHero() { if (state !== 'play') return; state = 'hero'; releasePointer(); $('hero').classList.remove('hidden'); renderHero(); }
function closeHero() { state = 'play'; $('hero').classList.add('hidden'); }
function renderHero() {
const h = H(), need = xpNeed(h.lvl);
$('hLevel').textContent = (session.remote ? myName.toUpperCase() : 'HERO') + ' · LEVEL ' + h.lvl + (h.cls ? ' ' + CLASSES[h.cls].name : '');
$('hXp').style.width = Math.round(100 * h.xp / need) + '%'; $('hXpText').textContent = h.xp + ' / ' + need + ' XP';
$('hClass').innerHTML = Object.entries(CLASSES).map(([k, c]) => '<button class="btn small' + (h.cls === k ? ' on' : '') + '" data-cls="' + k + '">' + c.name + '</button>').join('');
$('hClassAbout').textContent = h.cls ? CLASSES[h.cls].about : 'Pick a class. You can change it any time.';
$('hFree').textContent = h.free ? h.free + ' point' + (h.free > 1 ? 's' : '') + ' to spend:' : 'Level up to get points.';
const st = [['str', 'STRENGTH', '+10% damage each'], ['spd', 'SPEED', '+6% speed each (up to 10)'], ['hp', 'HEALTH', '+1 heart each (up to 10)']];
$('hStats').innerHTML = st.map(([k, n, about]) => '<div class="row"><span style="min-width:8em;text-align:left">' + n + ' ' + h.pts[k] + '</span><button class="btn small" data-pt="' + k + '"' +
(h.free && !((k === 'spd' || k === 'hp') && h.pts[k] >= 10) ? '' : ' disabled style="opacity:.4"') + '>+</button><span class="sub" style="font-size:.8em">' + about + '</span></div>').join('');
$('hPet').innerHTML = myPet ? 'Pet: <b>' + myPet.name + '</b> the ' + { cat: 'cat', dog: 'dog', babydino: 'baby dino' }[myPet.kind] + ', level ' + myPet.lvl : 'No pet yet. You get one at level 3, or find one in a loot box.';
$('hPetRow').classList.toggle('hidden', !myPet);
const got = Object.keys(h.ach).length;
$('hAchTitle').textContent = 'ACHIEVEMENTS ' + got + ' / ' + Object.keys(ACH).length;
$('hAch').innerHTML = Object.entries(ACH).map(([k, a]) => '<div class="ach' + (h.ach[k] ? ' got' : '') + '"><b style="color:' + (h.ach[k] ? TIER_COL[a[2]] : '#777') + '">' + (h.ach[k] ? '★ ' : '☆ ') + a[0] + '</b><br><span>' + a[1] + '</span></div>').join('');
}
$('hero').addEventListener('click', e => {
const b = e.target.closest('[data-cls],[data-pt]'); if (!b) return;
const h = H();
if (b.dataset.cls) { h.cls = b.dataset.cls; lastStats = ''; pl.hp = Math.min(pl.hp, maxHp()); toast('You are now a ' + CLASSES[h.cls].name + '!'); sfx('craft'); }
if (b.dataset.pt && h.free > 0) { h.pts[b.dataset.pt]++; h.free--; lastStats = ''; sfx('craft'); }
renderHero();
});
$('hPetSave').onclick = () => { const n = $('hPetName').value.trim(); if (n) { link.send({ t: 'petname', name: n }); $('hPetName').value = ''; } };
$('hClose').onclick = closeHero;
$('bHero').addEventListener('click', openHero);
// ---------------------------------------------------------------- map
let mapZoom = 3;
function drawMap() {
const c = $('mapCv'), x = c.getContext('2d');
x.imageSmoothingEnabled = false; x.fillStyle = '#000'; x.fillRect(0, 0, c.width, c.height);
const s = mapZoom, cx = c.width / 2, cy = c.height / 2;
for (const [k, t] of mapTiles) {
const [a, b] = k.split(',').map(Number);
const px = cx + (a * CS - pl.x) * s, py = cy + (b * CS - pl.z) * s;
if (px > -CS * s && py > -CS * s && px < c.width && py < c.height) x.drawImage(t, px, py, CS * s, CS * s);
}
const mark = (wx, wz, col, label) => { const px = cx + (wx - pl.x) * s, py = cy + (wz - pl.z) * s; x.fillStyle = col; x.fillRect(px - 4, py - 4, 8, 8); x.fillStyle = '#fff'; x.font = '14px monospace'; x.fillText(label, px + 6, py + 4); };
if (session && session.spawn) mark(session.spawn.x, session.spawn.z, '#55FFFF', 'spawn');
if (pl.spawn) mark(pl.spawn.x, pl.spawn.z, '#FF5555', 'bed');
for (const o of others.values()) mark(o.x, o.z, '#FFFF55', o.name);
x.save(); x.translate(cx, cy); x.rotate(-pl.yaw);
x.fillStyle = '#fff'; x.beginPath(); x.moveTo(0, -9); x.lineTo(6, 7); x.lineTo(0, 3); x.lineTo(-6, 7); x.closePath(); x.fill(); x.restore();
x.fillStyle = '#FFFF55'; x.font = '15px monospace';
x.fillText('X ' + Math.floor(pl.x) + ' Y ' + Math.floor(pl.y) + ' Z ' + Math.floor(pl.z) + ' N is up', 10, 20);
}
function openMap() { state = 'map'; releasePointer(); $('mapPanel').classList.remove('hidden'); drawMap(); }
function closeMap() { state = 'play'; $('mapPanel').classList.add('hidden'); }
$('mapClose').onclick = closeMap;
$('mapIn').onclick = () => { mapZoom = Math.min(8, mapZoom + 1); drawMap(); };
$('mapOut').onclick = () => { mapZoom = Math.max(1, mapZoom - 1); drawMap(); };
// ---------------------------------------------------------------- input
const keys = {};
let stick = { id: null, x: 0, y: 0, ox: 0, oy: 0 }, look = { id: null, sx: 0, sy: 0, lx: 0, ly: 0, t0: 0, moved: false, hold: false };
let jumpHeld = false, downHeld = false, attackHeld = false, useHeld2 = false, locked = false;
const playing = () => state === 'play' && pl && !pl.dead;
addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
keys[e.code] = true;
if (state === 'play') {
if (e.code === 'Space') { const now = performance.now(); if (creative() && now - lastJumpTap < 300) { pl.fly = !pl.fly; toast(pl.fly ? 'Flying' : 'Walking'); } lastJumpTap = now; e.preventDefault(); }
if (e.code === 'KeyF' && ride) leaveRide();
if (e.code === 'KeyE') openInv();
if (e.code === 'KeyM') openMap();
if (e.code === 'KeyH') openHero();
if (e.code === 'Escape' || e.code === 'KeyP') openPause();
if (/^Digit[1-9]$/.test(e.code)) { sel = +e.code.slice(5) - 1; renderHotbar(); showHeldName(); }
} else if (state === 'inv' && (e.code === 'KeyE' || e.code === 'Escape')) closeInv();
else if (state === 'map' && (e.code === 'KeyM' || e.code === 'Escape')) closeMap();
else if (state === 'hero' && (e.code === 'KeyH' || e.code === 'Escape')) closeHero();
else if (state === 'pause' && e.code === 'Escape') resume();
});
addEventListener('keyup', e => { keys[e.code] = false; });
function releasePointer() { if (document.pointerLockElement) document.exitPointerLock(); }
document.addEventListener('pointerlockchange', () => { locked = document.pointerLockElement === cv; if (!locked && state === 'play' && !isTouch) openPause(); });
cv.addEventListener('mousedown', e => {
if (isTouch || !playing()) return;
if (!locked) { cv.requestPointerLock(); return; }
if (e.button === 0) { attackHeld = true; doAttackPress(); }
if (e.button === 2) { useHeld2 = true; doUse(); useCool = 0.3; }
});
addEventListener('mouseup', e => { if (e.button === 0) { attackHeld = false; breakT = 0; } if (e.button === 2) useHeld2 = false; });
cv.addEventListener('contextmenu', e => e.preventDefault());
addEventListener('mousemove', e => { if (locked && playing()) { pl.yaw -= e.movementX * 0.0025; pl.pitch = clamp(pl.pitch - e.movementY * 0.0025, -1.55, 1.55); } });
addEventListener('wheel', e => { if (state === 'play') { sel = (sel + (e.deltaY > 0 ? 1 : 8)) % 9; renderHotbar(); } }, { passive: true });
// touch: left = move stick, right = look / tap / hold
const tz = $('touch');
tz.addEventListener('touchstart', e => {
e.preventDefault(); audio();
for (const t of e.changedTouches) {
if (!playing()) continue;
if (t.clientX < innerWidth * 0.4 && stick.id === null) {
stick = { id: t.identifier, ox: t.clientX, oy: t.clientY, x: 0, y: 0 };
const r = $('stickRing'); r.style.left = t.clientX + 'px'; r.style.top = t.clientY + 'px'; r.style.display = 'block'; $('stickKnob').style.transform = '';
} else if (look.id === null) {
look = { id: t.identifier, sx: t.clientX, sy: t.clientY, lx: t.clientX, ly: t.clientY, t0: performance.now(), moved: false, hold: false };
}
}
}, { passive: false });
tz.addEventListener('touchmove', e => {
e.preventDefault();
for (const t of e.changedTouches) {
if (t.identifier === stick.id) {
const R = Math.min(innerWidth, innerHeight) * 0.13;
let dx = (t.clientX - stick.ox) / R, dy = (t.clientY - stick.oy) / R; const l = Math.hypot(dx, dy); if (l > 1) { dx /= l; dy /= l; }
stick.x = dx; stick.y = dy; $('stickKnob').style.transform = 'translate(' + dx * R + 'px,' + dy * R + 'px)';
} else if (t.identifier === look.id && pl) {
const dx = t.clientX - look.lx, dy = t.clientY - look.ly; look.lx = t.clientX; look.ly = t.clientY;
if (Math.hypot(t.clientX - look.sx, t.clientY - look.sy) > 12) look.moved = true;
const sens = 4.2 / Math.max(innerWidth, innerHeight);
pl.yaw -= dx * sens * 1.4; pl.pitch = clamp(pl.pitch - dy * sens * 1.4, -1.55, 1.55);
}
}
}, { passive: false });
const touchEnd = e => {
for (const t of e.changedTouches) {
if (t.identifier === stick.id) { stick = { id: null, x: 0, y: 0 }; $('stickRing').style.display = 'none'; }
else if (t.identifier === look.id) {
if (!look.moved && !look.hold && performance.now() - look.t0 < 300 && playing()) {
if (target && target.mob) doAttackPress(); else doUse();
}
look = { id: null }; attackHeld = false; breakT = 0;
}
}
};
tz.addEventListener('touchend', touchEnd); tz.addEventListener('touchcancel', touchEnd);
const holdBtn = (el, on, off) => {
el.addEventListener('touchstart', e => { e.preventDefault(); el.classList.add('held'); on(); }, { passive: false });
el.addEventListener('touchend', e => { e.preventDefault(); el.classList.remove('held'); off(); });
el.addEventListener('touchcancel', () => { el.classList.remove('held'); off(); });
el.addEventListener('mousedown', on); el.addEventListener('mouseup', off);
};
holdBtn($('bJump'), () => {
jumpHeld = true; const now = performance.now();
if (creative() && now - lastJumpTap < 300) { pl.fly = !pl.fly; toast(pl.fly ? 'Flying' : 'Walking'); }
lastJumpTap = now;
}, () => { jumpHeld = false; });
holdBtn($('bDown'), () => { downHeld = true; }, () => { downHeld = false; });
$('bFly').addEventListener('click', () => { if (creative()) { pl.fly = !pl.fly; toast(pl.fly ? 'Flying' : 'Walking'); } });
$('bExit').addEventListener('click', () => { if (ride) leaveRide(); });
$('bInv').addEventListener('click', () => { if (state === 'play') openInv(); });
$('bMap').addEventListener('click', () => { if (state === 'play') openMap(); });
$('bPause').addEventListener('click', () => { if (state === 'play') openPause(); });
// ---------------------------------------------------------------- vehicles: getting in, flying, getting out
const angDiff = (a, b) => { let d = a - b; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; };
function groundBelow(x, y, z) { // feet height of the first thing to stand on below (x, y, z)
const bx = Math.floor(x), bz = Math.floor(z);
for (let yy = Math.min(CH, Math.floor(y)); yy > 0; yy--) { const b = world.get(bx, yy - 1, bz); if (isSolid[b] || b === 10) return yy; }
return Math.floor(y);
}
let engine = null;
function engineSound(on, pitch) {
const a = on ? audio() : null;
if (!a) { if (engine) { try { engine.o.stop(); } catch (e) {} engine = null; } return; }
if (!engine) {
const o = a.createOscillator(), f = a.createBiquadFilter(), g = a.createGain();
o.type = 'sawtooth'; f.type = 'lowpass'; f.frequency.value = 500; g.gain.value = 0.035;
o.connect(f).connect(g).connect(a.destination); o.start(); engine = { o };
}
engine.o.frequency.setTargetAtTime(pitch, a.currentTime, 0.1);
}
function rideButtons() {
$('bExit').classList.toggle('hidden', !ride);
$('bJump').textContent = ride ? 'UP' : 'JUMP';
$('bDown').classList.toggle('hidden', !ride && !creative()); $('bFly').classList.toggle('hidden', !!ride || !creative());
}
function startRide(m) {
const d = E.VEHICLES[m.kind];
ride = { id: m.id, kind: m.kind, x: m.x, y: m.y, z: m.z, vx: 0, vy: 0, vz: 0, w: d.w, h: d.h, yaw: m.yaw, pitch: 0, roll: 0, speed: 0, fall: 0, spin: 0 };
pl.fly = false; pl.yaw = m.yaw; pl.pitch = 0;
rideButtons(); sfx('door'); earn('fly');
toast(m.kind === 'plane' ? 'Push forward to speed up, look up to take off!\nOUT to get out' : 'UP to fly up, move to fly around\nOUT to get out');
}
function leaveRide() {
if (!ride) return;
const r = ride, gy = groundBelow(r.x, r.y + 0.5, r.z);
link.send({ t: 'vleave', id: r.id, x: +r.x.toFixed(2), y: gy, z: +r.z.toFixed(2) });
// step out beside it, on the ground (falling out of the sky is no fun)
const sx = r.x + Math.cos(r.yaw) * 2.6, sz = r.z - Math.sin(r.yaw) * 2.6, sgy = groundBelow(sx, gy + 4, sz);
const free = !isSolid[world.get(Math.floor(sx), sgy, Math.floor(sz))] && !isSolid[world.get(Math.floor(sx), sgy + 1, Math.floor(sz))];
Object.assign(pl, free ? { x: sx, y: sgy, z: sz } : { x: r.x, y: gy + 2.2, z: r.z }, { vx: 0, vy: 0, vz: 0, fallY: null });
ride = null; engineSound(false); rideButtons(); sfx('door');
}
function updateRide(dt) {
const r = ride;
let mf = 0, ms = 0;
if (keys.KeyW || keys.ArrowUp) mf += 1; if (keys.KeyS || keys.ArrowDown) mf -= 1;
if (keys.KeyD || keys.ArrowRight) ms += 1; if (keys.KeyA || keys.ArrowLeft) ms -= 1;
if (stick.id !== null) { mf -= stick.y; ms += stick.x; }
const up = jumpHeld || keys.Space, down = downHeld || keys.ShiftLeft;
if (r.kind === 'heli') {
const sy = Math.sin(pl.yaw), cy = Math.cos(pl.yaw), sp = 12;
const tx = (-sy * mf + cy * ms) * sp, tz = (-cy * mf - sy * ms) * sp, ty = up ? 7 : down ? -7 : 0;
r.vx += (tx - r.vx) * Math.min(1, 2.5 * dt); r.vz += (tz - r.vz) * Math.min(1, 2.5 * dt); r.vy += (ty - r.vy) * Math.min(1, 4 * dt);
r.yaw += angDiff(pl.yaw, r.yaw) * Math.min(1, 4 * dt);
const fs = -r.vx * Math.sin(r.yaw) - r.vz * Math.cos(r.yaw), ss = r.vx * Math.cos(r.yaw) - r.vz * Math.sin(r.yaw);
r.pitch += (-fs * 0.025 - r.pitch) * Math.min(1, 5 * dt); r.roll += (ss * 0.03 - r.roll) * Math.min(1, 5 * dt);
r.spin += dt * 30;
engineSound(true, 70 + Math.abs(r.vy) * 4 + Math.hypot(r.vx, r.vz) * 2);
} else {
// arcade plane: stick forward = faster, back = slower; look (or UP/DOWN) to climb and dive; stick sideways or look to turn
if (mf > 0.2) r.speed += 9 * dt * mf; else if (mf < -0.2) r.speed += 14 * dt * mf;
r.speed = clamp(r.speed, 0, 20);
pl.yaw -= ms * 1.6 * dt;
if (up || down) pl.pitch = clamp(pl.pitch + (up ? 1 : -1) * 1.2 * dt, -1.55, 1.55);
const lift = clamp((r.speed - 6) / 6, 0, 1);
let tp = clamp(pl.pitch, -0.7, 0.7);
if (r.onGround && lift < 1) tp = Math.max(0, Math.min(tp, lift * 0.7));
const turn = clamp(angDiff(pl.yaw, r.yaw), -1.8 * dt, 1.8 * dt);
r.yaw += turn;
r.roll += (clamp(-turn / dt * 0.45, -0.7, 0.7) * lift - r.roll) * Math.min(1, 4 * dt);
r.pitch += (tp - r.pitch) * Math.min(1, 3 * dt);
r.fall = lift >= 1 ? 0 : Math.min(14, r.fall + 16 * dt * (1 - lift));
if (r.onGround) r.fall = Math.min(r.fall, 2);
const cp = Math.cos(r.pitch);
r.vx = -Math.sin(r.yaw) * cp * r.speed; r.vz = -Math.cos(r.yaw) * cp * r.speed;
r.vy = Math.sin(r.pitch) * r.speed * lift - r.fall;
r.spin += dt * (r.speed * 3 + 8);
engineSound(true, 60 + r.speed * 6);
}
if (r.inWater) { r.vy = Math.max(r.vy, 2); r.speed *= 0.97; }
const before = Math.hypot(r.vx, r.vz);
E.moveBody(world, r, dt, true);
if (r.hitWall && before > 6) { r.speed *= 0.3; sfx('hit', r.x, r.y, r.z); shake = 0.3; }
if (r.y > CH + 40) { r.y = CH + 40; r.vy = Math.min(r.vy, 0); }
if (r.y < 1) { r.y = groundBelow(r.x, CH, r.z); r.vy = 0; }
Object.assign(pl, { x: r.x, y: r.y + 0.3, z: r.z, vx: 0, vy: 0, vz: 0, fallY: null, air: 10 });
}
// ---------------------------------------------------------------- player update
function updatePlayer(dt) {
if (pl.dead) return;
for (const k in pl.fx) { pl.fx[k] -= dt; if (pl.fx[k] <= 0) { delete pl.fx[k]; toast(FX_NAMES[k] + ' wore off'); } }
if ((pl.fx.regen || H().cls === 'healer' || inSafe) && !creative()) pl.hp = Math.min(maxHp(), pl.hp + dt * (pl.fx.regen || inSafe ? 0.8 : 0.15));
if (ride) { updateRide(dt); return; }
let mf = 0, ms = 0;
if (keys.KeyW || keys.ArrowUp) mf += 1; if (keys.KeyS || keys.ArrowDown) mf -= 1;
if (keys.KeyD || keys.ArrowRight) ms += 1; if (keys.KeyA || keys.ArrowLeft) ms -= 1;
if (stick.id !== null) { mf -= stick.y; ms += stick.x; }
const ml = Math.hypot(mf, ms); if (ml > 1) { mf /= ml; ms /= ml; }
const sprint = (keys.ShiftLeft && !creative()) ? false : (ml > 0.95 && stick.id !== null) || keys.ControlLeft;
const jump = jumpHeld || keys.Space, down = downHeld || keys.ShiftLeft;
const sy = Math.sin(pl.yaw), cy = Math.cos(pl.yaw);
const dirx = -sy * mf + cy * ms, dirz = -cy * mf - sy * ms;
if (pl.fly) {
const sp = 10;
pl.vx = dirx * sp; pl.vz = dirz * sp; pl.vy = jump ? 8 : down ? -8 : 0;
E.moveBody(world, pl, dt, false);
if (pl.onGround && down) pl.fly = false;
pl.fallY = null;
} else {
const sp = (pl.inWater ? 2.2 : sprint ? 5.6 : 4.3) * (pl.fx.speed ? 1.6 : 1) * speedBoost();
const acc = pl.onGround ? 14 : pl.inWater ? 6 : 3;
pl.vx += (dirx * sp - pl.vx) * Math.min(1, acc * dt);
pl.vz += (dirz * sp - pl.vz) * Math.min(1, acc * dt);
if (pl.inWater) { pl.vy -= 9 * dt; pl.vy = Math.max(pl.vy, -2.5); if (jump) pl.vy = Math.min(pl.vy + 22 * dt, 3.2); }
else if (pl.onLadder) { pl.vy = jump || (ml > 0.1 && pl.hitWall) ? 2.6 : down ? -2.6 : Math.max(pl.vy - 28 * dt, -1.5); }
else { pl.vy -= 28 * dt; if (jump && pl.onGround) pl.vy = pl.fx.jump ? 12.5 : 8.4; }
pl.vy = Math.max(pl.vy, -40);
const wasGround = pl.onGround;
E.moveBody(world, pl, dt, true);
if (pl.inWater || pl.onLadder) pl.fallY = null;
else if (!pl.onGround) { if (pl.fallY === null || pl.y > pl.fallY) pl.fallY = pl.y; }
else if (pl.fallY !== null) {
const fall = pl.fallY - pl.y; pl.fallY = null;
const safe = hasBoots(181) ? Infinity : pl.fx.jump ? 6.4 : H().cls === 'explorer' ? 8 : 3.4; // diamond boots: no fall damage
if (fall > safe && !creative()) { hurt(Math.floor(fall - safe + 0.4), 'You fell from a high place'); earn('gravity'); }
if (fall > 1) sfx('step');
}
if (pl.onGround && ml > 0.1) { stepT -= dt * (sprint ? 1.4 : 1); if (stepT <= 0) { stepT = 0.4; sfx('step'); } }
if (!wasGround && pl.onGround && pl.inWater) sfx('splash');
}
if (pl.y < -10) { hurt(100, 'You fell out of the world'); }
// survival needs
if (!creative()) {
pl.food = Math.max(0, pl.food - dt * (sprint ? 1 / 25 : 1 / 60) - (jump && pl.onGround ? 0.02 : 0));
if (pl.food >= 16 && pl.hp < maxHp()) { pl.hp = Math.min(maxHp(), pl.hp + dt / 3); pl.food -= dt / 14; }
if (pl.food <= 0 && pl.hp > 2) pl.hp -= dt / 4;
const head = world.peek(Math.floor(pl.x), Math.floor(pl.y + 1.62), Math.floor(pl.z));
if (head === 10 && !pl.fx.water) { pl.air -= dt; if (pl.air < 0) { pl.air = 0; pl.drown = (pl.drown || 0) + dt; if (pl.drown > 1) { pl.drown = 0; hurt(2, 'You drowned'); } } }
else pl.air = Math.min(10, pl.air + dt * 4);
if (pl.onGround && world.peek(Math.floor(pl.x), Math.floor(pl.y - 0.2), Math.floor(pl.z)) === 18) { pl.cactusT = (pl.cactusT || 0) - dt; if (pl.cactusT <= 0) { pl.cactusT = 0.6; hurt(1, 'You were pricked to death'); } }
}
}
function hurt(n, why, mob) {
if (creative() || pl.dead || n <= 0) return;
if (mob) { // armor and the shield potion only help against monsters, arrows and explosions
n *= 1 - Math.min(0.8, armorPts() * 0.04);
if (pl.fx.shield) n *= 0.5;
n = Math.max(0.5, n);
}
pl.hp -= n; hurtFlash = 0.5; sfx('hurt'); shake = 0.25;
if (pl.hp <= 0) die(why);
}
function die(why) {
leaveRide();
pl.hp = 0; pl.dead = true; sfx('die'); earn('oops');
$('deadWhy').textContent = why || ''; $('dead').classList.remove('hidden'); state = 'dead'; releasePointer();
link.send({ t: 'move', x: pl.x, y: pl.y, z: pl.z, yaw: pl.yaw, pitch: pl.pitch, dead: true });
}
$('dRespawn').onclick = () => {
const sp = pl.spawn || session.spawn;
Object.assign(pl, { x: sp.x, y: sp.y + 0.1, z: sp.z, vx: 0, vy: 0, vz: 0, hp: maxHp(), food: 20, air: 10, dead: false, fallY: null, fx: {} });
$('dead').classList.add('hidden'); state = 'play'; renderStats();
};
// ---------------------------------------------------------------- targeting, digging, placing
function findTarget() {
const eye = [pl.x, pl.y + 1.62, pl.z], f = fwd(), reach = creative() ? 6 : 5;
const h = E.raycast(world, eye[0], eye[1], eye[2], f[0], f[1], f[2], reach, false);
let best = h ? { block: h, d: h.d } : null;
for (const m of mobs.values()) {
const d = E.MOBS[E.MOB_TYPES[m.type]], hw = d.w / 2;
const t = E.rayBox(eye[0], eye[1], eye[2], f[0], f[1], f[2], m.rx - hw, m.ry, m.rz - hw, m.rx + hw, m.ry + d.h, m.rz + hw);
if (t >= 0 && t < 3.5 && (!best || t < best.d)) best = { mob: m, d: t };
}
for (const v of vehicles.values()) {
if (v.rider && v.rider !== myId) continue;
const d = E.VEHICLES[E.VEH_TYPES[v.kind]], hw = d.bw / 2;
const t = E.rayBox(eye[0], eye[1], eye[2], f[0], f[1], f[2], v.rx - hw, v.ry, v.rz - hw, v.rx + hw, v.ry + d.bh, v.rz + hw);
if (t >= 0 && t < 5 && (!best || t < best.d)) best = { veh: v, d: t };
}
target = best;
}
function breakTime(id) {
const d = blk(id); if (d.hard < 0) return Infinity; if (creative()) return 0;
const s = inv[sel], it = s && ITEMS[s.id];
const can = !d.lvl || (it && it.tool === 'pick' && it.tier >= d.lvl);
const speed = it && it.tool && it.tool === d.tool ? it.speed : 1;
return d.hard * (can ? 1.5 : 5) / speed;
}
function doAttackPress() {
if (target && target.mob && attackCool <= 0) {
const s = inv[sel], it = s && ITEMS[s.id], f = fwd(), l = Math.hypot(f[0], f[2]) || 1;
const mt = E.MOB_TYPES[target.mob.type], md = E.MOBS[mt], base = md.base || mt, undead = base === 'zombie' || base === 'skeleton';
if (md.pet) { petTap(target.mob); attackCool = 0.6; return; }
const dmg = (it && it.dmg ? it.dmg : 1) * (it && it.undead && undead ? it.undead : 1) * (pl.fx.str ? 2 : 1) * meleeBoost();
link.send({ t: 'hit', id: target.mob.id, dmg, kx: f[0] / l, kz: f[2] / l });
sfx('hit', target.mob.rx, target.mob.ry, target.mob.rz); attackCool = 0.45; if (it && it.tool) wearTool();
}
}
function breakBlock(b) {
const d = blk(b.id), s = inv[sel], it = s && ITEMS[s.id];
const can = !d.lvl || (it && it.tool === 'pick' && it.tier >= d.lvl);
if (notMine(b.x, b.z)) return;
setBlock(b.x, b.y, b.z, 0);
if (d.shape === 'door') { const oy = d.half ? b.y - 1 : b.y + 1; if (blk(world.peek(b.x, oy, b.z)).shape === 'door') setBlock(b.x, oy, b.z, 0); }
sfx('break', b.x, b.y, b.z);
if (!creative()) {
if (can) {
let drop = d.drop;
if (d.shape === 'door') drop = 115;
if (drop === -1) { // leaves: sometimes fruit or a stick
const r = Math.random();
drop = r < 0.04 ? 105 : r < 0.055 ? 152 : r < 0.07 ? 155 : r < 0.08 ? 156 : r < 0.13 ? 100 : 0;
}
if (drop === -2) drop = 147 + Math.floor(Math.random() * 4); // jewel ore: ruby, emerald, sapphire or amethyst
if (drop) giveDrops([[drop, 1]]);
const oreXp = { 12: 1, 13: 2, 14: 3, 15: 5, 55: 3, 56: 6 }[b.id];
if (oreXp) addXp(oreXp);
if (b.id === 15) earn('shiny'); if (b.id === 56) earn('jewel');
}
if (it && it.tool) wearTool();
}
}
function setBlock(x, y, z, id) { world.set(x, y, z, id); link.send({ t: 'block', x, y, z, id }); }
// kit castles belong to whoever built them: others can't change their blocks (the server checks this too)
let owned = [];
const castleOwnerAt = (x, z) => { for (const c of owned) if (x >= c.x0 && x < c.x0 + E.CW && z >= c.z0 && z < c.z0 + E.CW) return c; return null; };
function notMine(x, z) {
const c = castleOwnerAt(x, z);
if (!c || c.owner.toLowerCase() === myName.toLowerCase()) return false;
toast('This is ' + c.owner + "'s castle"); return true;
}
function doUse() {
if (!playing() || useCool > 0 || ride) return;
useCool = 0.22;
const s = inv[sel], it = s && ITEMS[s.id];
if (target && target.mob) { doAttackPress(); return; }
if (target && target.veh) {
const v = target.veh;
if (v.owner && v.owner.toLowerCase() !== myName.toLowerCase()) toast('This is ' + v.owner + "'s " + (E.VEH_TYPES[v.kind] === 'plane' ? 'airplane' : 'helicopter') + '.\nMake your own at a crafting table!');
else link.send({ t: 'vride', id: v.id });
useCool = 0.6; return;
}
// airplane / helicopter: set it down on the block you tap (always uses up the item, even in creative)
if (it && it.vehicle && target && target.block) {
const b = target.block;
let px = b.x + b.nx + 0.5, pz = b.z + b.nz + 0.5, py = b.ny === 1 ? b.y + blk(b.id).h : b.y + b.ny;
if (Math.hypot(px - pl.x, pz - pl.z) < 2.5) { // too close: put it down a few steps ahead instead
px = pl.x - Math.sin(pl.yaw) * 3.5; pz = pl.z - Math.cos(pl.yaw) * 3.5; py = groundBelow(px, pl.y + 3, pz);
}
const vd = E.VEHICLES[it.vehicle], hw = vd.w / 2 + 0.3;
for (let y = Math.floor(py); y <= Math.floor(py + vd.h + 0.5); y++) for (let z = Math.floor(pz - hw); z <= Math.floor(pz + hw); z++) for (let x = Math.floor(px - hw); x <= Math.floor(px + hw); x++)
if (isSolid[world.peek(x, y, z)]) { toast('Not enough room here.\nFind an open spot!'); return; }
link.send({ t: 'vplace', kind: it.vehicle, x: px, y: py, z: pz, yaw: pl.yaw });
s.n--; if (s.n <= 0) inv[sel] = null; renderHotbar();
sfx('place', px, py, pz); toast('Tap your ' + (it.vehicle === 'plane' ? 'airplane' : 'helicopter') + ' to get in!\nHold on it to pick it back up');
useCool = 0.6; return;
}
// loot box: tap to open
if (it && it.box != null) { s.n--; if (s.n <= 0) inv[sel] = null; renderHotbar(); openBox(it.box); useCool = 0.8; return; }
// pet basket / egg: call your pet (it stays yours; a different one swaps)
if (it && it.pet) { link.send({ t: 'pet', item: s.id }); inv[sel] = null; renderHotbar(); sfx('pop'); useCool = 1; return; }
// castle kit: the castle builds itself with its gate 4 steps in front of you (always uses up the kit)
if (it && it.kit) {
const f = fwd(), l = Math.hypot(f[0], f[2]) || 1, dx = f[0] / l, dz = f[2] / l, k = 13 / Math.max(Math.abs(dx), Math.abs(dz));
const cx = Math.floor(pl.x + dx * k), cz = Math.floor(pl.z + dz * k); // far enough that you are outside its walls
link.send({ t: 'kit', item: s.id, x: cx, z: cz });
inv[sel] = null; renderHotbar(); sfx('craft'); shake = 0.4; useCool = 1; return;
}
// bow
if (it && it.bow) {
const archer = H().cls === 'archer';
if (!creative() && countItem(112) < 1) { toast('You need arrows'); return; }
if (!creative() && !(archer && Math.random() < 0.5)) removeItem(112, 1);
const f = fwd();
link.send({ t: 'shoot', x: pl.x + f[0] * 0.5, y: pl.y + 1.5 + f[1] * 0.5, z: pl.z + f[2] * 0.5, vx: f[0] * 32, vy: f[1] * 32, vz: f[2] * 32,
dmg: 6 * (archer ? 2 : 1) * (1 + H().pts.str * 0.05) });
sfx('bow'); wearTool(); useCool = 0.6; return;
}
const b = target && target.block;
if (b) {
const id = b.id, d = blk(id);
if (id === 19) { openInv('table'); return; }
if (id === 20) { openInv('furnace'); return; }
if (E.isChest(id)) { const k = b.x + ',' + b.y + ',' + b.z; openChestAt(k); return; }
if (id === 61 || id === 62) { link.send({ t: 'stairs', x: b.x, y: b.y, z: b.z }); useCool = 1.5; return; }
if (d.shape === 'door') {
const ny = d.open ? id - 4 : id + 4, oy = d.half ? b.y - 1 : b.y + 1, oid = world.peek(b.x, oy, b.z);
setBlock(b.x, b.y, b.z, ny);
if (blk(oid).shape === 'door') setBlock(b.x, oy, b.z, blk(oid).open ? oid - 4 : oid + 4);
sfx('door', b.x, b.y, b.z); return;
}
if (id === 31) { link.send({ t: 'sleep' }); pl.spawn = { x: b.x + 0.5, y: b.y + 1, z: b.z + 0.5 }; toast('Spawn point set'); return; }
if (id === 32) { link.send({ t: 'ignite', x: b.x, y: b.y, z: b.z }); sfx('hiss', b.x, b.y, b.z); return; }
}
// potions (the empty bottle comes back) and the golden apple
if (it && (it.potion || it.fx)) {
if (it.fx) pl.food = Math.min(20, pl.food + it.food);
drink(it); s.n--; if (s.n <= 0) inv[sel] = null; if (it.potion) addItem(159, 1);
renderHotbar(); sfx(it.potion ? 'drink' : 'eat'); return;
}
// armor: tap to wear it
if (it && it.armor != null) { wearArmor(sel); return; }
// eat
if (it && it.food) {
if (creative() || pl.food >= 20) { toast('You are not hungry'); return; }
pl.food = Math.min(20, pl.food + it.food); useHeld(); sfx('eat');
if ([105, 152, 153, 154, 155, 156, 157].includes(s.id)) { const h = H(); h.fruit[s.id] = 1; if (Object.keys(h.fruit).length >= 5) earn('fruit'); }
return;
}
if (!s || !b) return;
// place
let place = s.id < 100 ? s.id : (it && it.places) || 0;
if (!place) return;
let px = b.x + b.nx, py = b.y + b.ny, pz = b.z + b.nz;
if (b.id === 38 || b.id === 36 || b.id === 37) { px = b.x; py = b.y; pz = b.z; }
const cur = world.peek(px, py, pz);
if (cur && cur !== 10 && cur !== 38) return;
if (py < 1 || py >= CH - 1) return;
if (notMine(px, pz)) return;
if (place === 23) {
const f = fwd(); place = Math.abs(f[0]) > Math.abs(f[2]) ? 25 : 23;
const above = world.peek(px, py + 1, pz);
if (above && above !== 38) return;
setBlock(px, py, pz, place); setBlock(px, py + 1, pz, place + 1); useHeld(); sfx('place', px, py, pz); return;
}
if (isSolid[place]) {
const hw = pl.w / 2;
if (px + 1 > pl.x - hw && px < pl.x + hw && pz + 1 > pl.z - hw && pz < pl.z + hw && py + 1 > pl.y && py < pl.y + pl.h) return;
for (const o of others.values()) if (Math.abs(o.x - (px + 0.5)) < 0.8 && Math.abs(o.z - (pz + 0.5)) < 0.8 && py + 1 > o.y && py < o.y + 1.8) return;
}
if ((blk(place).shape === 'cross') && !isSolid[world.peek(px, py - 1, pz)]) return;
setBlock(px, py, pz, place); useHeld(); sfx('place', px, py, pz);
}
function openChestAt(k) { openChest = k; chestSlots = []; link.send({ t: 'chest', k }); }
function interact(dt) {
attackCool -= dt; useCool -= dt;
if (ride) { target = null; breakT = 0; return; }
findTarget();
const holding = attackHeld || (look.id != null && look.hold);
if (look.id != null && !look.hold && !look.moved && performance.now() - look.t0 > 300) look.hold = true;
if (holding && target && target.veh) { // hold on your own vehicle to put it back in the bag
const v = target.veh;
if (v.owner && v.owner.toLowerCase() !== myName.toLowerCase()) { if (breakT === 0) toast('Only ' + v.owner + ' can pick that up'); breakT = 0.001; return; }
if (breakKey !== 'v' + v.id) { breakKey = 'v' + v.id; breakT = 0; }
breakT += dt;
if (breakT >= 1) { link.send({ t: 'vpickup', id: v.id }); vehicles.delete(v.id); breakT = 0; breakKey = ''; }
return;
}
if (look.id != null && look.hold && target && target.mob) { doAttackPress(); return; }
if (holding && target && target.block && creative()) {
if (attackCool <= 0 && blk(target.block.id).hard >= 0) { breakBlock(target.block); attackCool = 0.25; }
} else if (holding && target && target.block) {
const b = target.block, k = b.x + ',' + b.y + ',' + b.z;
if (k !== breakKey) { breakKey = k; breakT = 0; }
const tt = breakTime(b.id);
if (tt === Infinity) return;
breakT += dt;
if (Math.floor(breakT / 0.25) !== Math.floor((breakT - dt) / 0.25)) sfx('dig', b.x, b.y, b.z);
if (breakT >= tt) { breakBlock(b); breakT = 0; breakKey = ''; }
} else if (holding && target && target.mob) doAttackPress();
else { breakT = 0; breakKey = ''; }
if (useHeld2 && useCool <= 0) doUse();
}
// ---------------------------------------------------------------- networking
function onMsg(m) {
switch (m.t) {
case 'welcome': cfg.signedIn = true; saveCfg(); retries = 0; startRemote(m); break;
case 'error': $('status').textContent = m.text; cfg.signedIn = false; saveCfg(); updateMultiBtn(); showMpForm(); if (link && link.ws) link.ws.close(); break;
case 'blocks': if (link && link.local) break; // single player shares the sim's world: already changed
for (let i = 0; i < m.l.length; i += 4) world.set(m.l[i], m.l[i + 1], m.l[i + 2], m.l[i + 3]); break;
case 'snap': {
simTime = m.time;
const seen = new Set();
for (const a of m.mobs) {
seen.add(a[0]); let mo = mobs.get(a[0]);
if (!mo) { mo = { id: a[0], rx: a[2], ry: a[3], rz: a[4], ryaw: a[5], walk: 0 }; mobs.set(a[0], mo); }
Object.assign(mo, { type: a[1], x: a[2], y: a[3], z: a[4], yaw: a[5], fl: a[6], ex: a.length > 7 ? a.slice(7) : null }); // ex: boss [hp%, floor] or pet [name, owner, level]
}
for (const k of [...mobs.keys()]) if (!seen.has(k)) mobs.delete(k);
arrows = m.arrows; tnts = m.tnts;
const ps = new Set();
for (const p of m.players) {
if (p[0] === myId) continue; ps.add(p[0]);
let o = others.get(p[0]);
if (!o) { o = { id: p[0], rx: p[2], ry: p[3], rz: p[4], walk: 0 }; others.set(p[0], o); }
Object.assign(o, { name: p[1], x: p[2], y: p[3], z: p[4], yaw: p[5], pitch: p[6], dead: p[7] });
}
for (const k of [...others.keys()]) if (!ps.has(k)) others.delete(k);
const vs = new Set();
for (const a of m.vehicles || []) {
vs.add(a[0]); let v = vehicles.get(a[0]);
if (!v) { v = { id: a[0], rx: a[2], ry: a[3], rz: a[4], ryaw: a[5], spin: 0 }; vehicles.set(a[0], v); }
Object.assign(v, { kind: a[1], x: a[2], y: a[3], z: a[4], yaw: a[5], pitch: a[6], roll: a[7], rider: a[8], owner: a[9] || '' });
}
for (const k of [...vehicles.keys()]) if (!vs.has(k)) vehicles.delete(k);
if (ride && (!vehicles.has(ride.id) || vehicles.get(ride.id).rider !== myId)) { ride = null; engineSound(false); rideButtons(); }
break;
}
case 'vride': if (pl && !pl.dead && state === 'play') startRide(m); break;
case 'owned': {
const mine = l => (l || []).filter(c => c.owner.toLowerCase() === myName.toLowerCase()).length;
if (mine(m.list) > mine(owned)) earn('kit');
owned = m.list || []; break;
}
case 'kill': { // a monster you (or your pet) defeated: XP and achievements
const d = E.MOBS[m.type], base = d.base || m.type;
if (d.boss) {
addXp(100 + 50 * (m.floor || 1)); earn('boss');
giveDrops([[m.floor >= 3 ? 193 : 192, 1]]);
announce(d.name + ' IS DEFEATED!', (m.helped ? 'You helped beat it! ' : '') + 'The stairs to Floor ' + ((m.floor || 1) + 1) + ' are open in the middle of the room. Reward: a ' + (m.floor >= 3 ? 'Legendary' : 'Gold') + ' Loot Box.', '#FF5555');
break;
}
addXp({ zombie: 5, skeleton: 6, spider: 5, creeper: 7, raptor: 6, trike: 10, trex: 30, brachio: 12 }[base] || 2);
earn('first_blood');
if (m.pet) earn('pet_win');
const n = count('kill_' + base);
if (base === 'zombie' && n >= 10) earn('zombie10'); if (base === 'skeleton' && n >= 10) earn('skeleton10');
if (base === 'spider' && n >= 5) earn('spider5'); if (base === 'trex') earn('trex_down');
break;
}
case 'tp': { // went down or up a stairwell
leaveRide();
Object.assign(pl, { x: m.x, y: m.y, z: m.z, vx: 0, vy: 0, vz: 0, fallY: null });
state = 'loading'; $('loading').classList.remove('hidden'); $('loadText').textContent = m.floor ? 'FLOOR ' + m.floor : 'BACK TO THE SURFACE';
if (m.floor) {
earn('dungeon'); if (m.floor >= 3) earn('floor3'); if (m.floor >= 5) earn('floor5');
const h = H(); if (m.floor > h.deepest) { h.deepest = m.floor; addXp(25 * m.floor); }
announce('FLOOR ' + m.floor, 'Start in the safe room (blue floor): no monsters, and you heal. Find the boss room to open the stairs down. The green stairwell takes you back up.', '#AA55FF');
}
break;
}
case 'petinfo': {
const had = myPet; myPet = m.pet;
if (!had) earn('pet');
if (m.up) announce(m.pet.name.toUpperCase() + ' LEVELED UP!', 'Your pet is now level ' + m.pet.lvl + ' and hits harder.', '#FFAA00');
if (state === 'hero') renderHero();
break;
}
case 'hurt': if (pl && !pl.dead && !creative()) { pl.vx += m.kx * 5; pl.vz += m.kz * 5; pl.vy = Math.max(pl.vy, 4); hurt(m.amt, 'You were slain by a monster', true);
if (m.src === 'trex' || m.src === 'dlord') earn('dino_snack'); if (m.src === 'boom') earn('kaboom'); } break;
case 'drop': giveDrops(m.items); break;
case 'chest': if (m.k === openChest) { chestSlots = m.slots.slice(); openInv('', m.k); } break;
case 'msg': toast(m.text); break;
case 'time': simTime = m.time; break;
case 'boom': sfx('boom', m.x, m.y, m.z); if (pl && Math.hypot(m.x - pl.x, m.z - pl.z) < 16) shake = 0.6; break;
case 'sfx': sfx(m.s, m.x, m.y, m.z); break;
}
}
function localLink() {
return { send: m => sim.handle(1, m), tick: dt => sim.tick(dt), local: true };
}
function remoteLink(name, code) {
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + BASE + 'ws');
const l = { ws, send: m => { if (ws.readyState === 1) ws.send(JSON.stringify(m)); }, tick: () => {}, local: false };
ws.onopen = () => { $('status').textContent = 'Connected, joining...'; ws.send(JSON.stringify({ t: 'join', name, code })); };
ws.onmessage = e => { try { onMsg(JSON.parse(e.data)); } catch (err) { console.error(err); } };
ws.onerror = () => { $('status').textContent = 'Could not reach the game server.'; };
ws.onclose = () => {
if (!(session && session.remote && state !== 'menu')) return;
saveGame(); quitToMenu(true);
// stay signed in: reconnect by itself a few times (server restarts, Wi-Fi blips)
if (cfg.signedIn && retries < 6) {
retries++; $('menu').classList.add('hidden'); $('mpMenu').classList.remove('hidden');
$('status').textContent = 'Lost the connection, trying again... (' + retries + ')';
setTimeout(() => { if (state === 'menu' && !$('mpMenu').classList.contains('hidden')) link = remoteLink(cfg.name, cfg.code); }, 3000);
} else $('status').textContent = 'Disconnected. Tap JOIN to try again.';
};
return l;
}
let retries = 0;
// ---------------------------------------------------------------- sessions, saving
const invSave = () => inv.map(s => s ? [s.id, s.n, s.d || 0] : 0);
const invLoad = a => { inv = new Array(36).fill(null); (a || []).forEach((s, i) => { if (s && i < 36) inv[i] = { id: s[0], n: s[1] }; if (s && s[2]) inv[i].d = s[2]; }); };
const playerData = () => ({ x: pl.x, y: ride ? groundBelow(pl.x, pl.y, pl.z) : pl.y, z: pl.z, yaw: pl.yaw, pitch: pl.pitch, hp: pl.hp, food: pl.food, inv: invSave(), sel, spawn: pl.spawn, fuel: pl.fuel, mode: pl.mode, armor: pl.armor, hero: H() });
function listWorlds() { try { return JSON.parse(localStorage.getItem('blockcraft_worlds') || '[]'); } catch (e) { return []; } }
function saveWorldList(l) { try { localStorage.setItem('blockcraft_worlds', JSON.stringify(l)); } catch (e) {} }
function saveGame() {
if (!session || !pl) return;
if (session.remote) { link.send({ t: 'save', data: playerData() }); return; }
const data = { seed: world.seed, time: sim.time, edits: world.encodeEdits(), chests: sim.chests, vehicles: sim.saveVehicles(), owned: sim.owned, dungeon: sim.dungeon, pets: sim.petData, spawn: session.spawn, player: playerData() };
try { localStorage.setItem('blockcraft_w_' + session.id, JSON.stringify(data)); }
catch (e) { toast('Could not save: this device is out of storage'); }
const l = listWorlds(), w = l.find(q => q.id === session.id); if (w) { w.played = Date.now(); saveWorldList(l); }
}
function resetSessionState() {
for (const m of chunkGL.values()) { gl.deleteBuffer(m.vbo); gl.deleteBuffer(m.wvbo); }
chunkGL.clear(); mapTiles.clear(); mobs.clear(); others.clear(); vehicles.clear(); arrows = []; tnts = [];
ride = null; engineSound(false); owned = []; myPet = null; inSafe = false; announceQ.length = 0; announceT = 0; $('announce').classList.add('hidden');
target = null; breakT = 0; picked = -1; openChest = null;
}
function beginPlay() {
$('menu').classList.add('hidden'); $('spMenu').classList.add('hidden'); $('mpMenu').classList.add('hidden');
$('loading').classList.remove('hidden'); $('loadText').textContent = 'BUILDING WORLD...';
state = 'loading'; lastStats = ''; renderHotbar();
}
function enterWorld() {
$('loading').classList.add('hidden'); $('bar').classList.remove('hidden'); $('topbtns').classList.remove('hidden');
for (const id of ['bJump', 'bDown', 'bFly']) $(id).classList.remove('hidden');
rideButtons();
state = 'play'; renderHotbar(); renderStats();
toast(session.remote ? 'Welcome, ' + session.name + '!' : session.name);
}
function startLocal(w) {
let data = null;
try { data = JSON.parse(localStorage.getItem('blockcraft_w_' + w.id) || 'null'); } catch (e) {}
resetSessionState();
sim = new E.Sim({ seed: w.seed, difficulty: w.diff, time: data ? data.time : 0.02, chests: data ? data.chests : {}, vehicles: data ? data.vehicles : [], owned: data && data.owned || [], dungeon: data && data.dungeon, pets: data && data.pets || {},
send: (m, to) => { if (to == null || to === 1) onMsg(m); } });
world = sim.world;
if (data && data.edits) world.decodeEdits(data.edits);
const simChange = world.onChange; world.onChange = (...a) => { simChange(...a); clientOnChange(...a); };
link = localLink(); myId = 1; myName = 'You'; owned = sim.owned;
const spawn = (data && data.spawn) || sim.spawnPoint();
session = { id: w.id, name: w.name, remote: false, spawn };
sim.ensureEntrance(spawn); myPet = sim.petData.you || null;
pl = newPlayer(spawn, w.mode);
if (data && data.player) { const p = data.player; Object.assign(pl, { x: p.x, y: p.y, z: p.z, yaw: p.yaw, pitch: p.pitch, hp: p.hp, food: p.food, spawn: p.spawn, fuel: p.fuel || 0 }); if (p.armor) pl.armor = p.armor.slice(0, 4); if (p.hero) pl.hero = p.hero; invLoad(p.inv); sel = p.sel || 0; }
else { inv = new Array(36).fill(null); sel = 0; if (w.mode === 'creative') [1, 4, 5, 6, 11, 22, 115, 21, 19].forEach((id, i) => { inv[i] = { id, n: E.stackSize(id) }; }); }
sim.addPlayer(1, 'You', pl);
beginPlay();
}
function startRemote(m) {
resetSessionState();
sim = null;
world = new E.World(m.seed);
world.decodeEdits(m.edits || {});
world.onChange = clientOnChange;
myId = m.id; myName = m.name; simTime = m.time; owned = m.owned || []; myPet = m.pet || null;
session = { remote: true, name: m.name, spawn: m.spawn };
pl = newPlayer(m.spawn, cfg.mpMode);
if (m.you) { const p = m.you; Object.assign(pl, { x: p.x, y: p.y, z: p.z, yaw: p.yaw || 0, pitch: p.pitch || 0, hp: p.hp || 20, food: p.food || 20, spawn: p.spawn || null, fuel: p.fuel || 0 }); if (p.armor) pl.armor = p.armor.slice(0, 4); if (p.hero) pl.hero = p.hero; invLoad(p.inv); sel = p.sel || 0; }
else { inv = new Array(36).fill(null); sel = 0; }
pl.mode = cfg.mpMode;
if (pl.mode === 'creative' && !inv.some(Boolean)) [1, 4, 5, 6, 11, 22, 115, 21, 19].forEach((id, i) => { inv[i] = { id, n: E.stackSize(id) }; });
beginPlay();
}
function quitToMenu(skipSave) {
if (!skipSave) { leaveRide(); saveGame(); }
if (link && link.ws) { const ws = link.ws; session = null; ws.close(); }
session = null; state = 'menu'; releasePointer();
for (const id of ['bar', 'topbtns', 'bJump', 'bDown', 'bFly', 'bExit', 'pause', 'dead', 'inv', 'mapPanel', 'loading', 'hero']) $(id).classList.add('hidden');
$('menu').classList.remove('hidden'); $('coords').textContent = '';
resetSessionState(); pl = null; world = null; sim = null;
}
// ---------------------------------------------------------------- menus
function pickRow(rowId, val) { for (const b of $(rowId).children) b.classList.toggle('on', b.dataset.v === val); }
const rowVal = rowId => { const b = [...$(rowId).children].find(q => q.classList.contains('on')); return b ? b.dataset.v : null; };
for (const r of ['nwMode', 'nwDiff', 'mpMode']) $(r).addEventListener('click', e => { if (e.target.dataset.v) pickRow(r, e.target.dataset.v); });
$('optSound').checked = cfg.sound;
$('optSound').onchange = () => { cfg.sound = $('optSound').checked; saveCfg(); };
function showWorlds() {
const l = listWorlds().sort((a, b) => (b.played || 0) - (a.played || 0));
$('worldList').innerHTML = l.length ? l.map(w => '<div class="world"><div class="wn">' + w.name.replace(/</g, '<') + '<small>' + (w.mode === 'creative' ? 'Creative' : 'Survival · ' + w.diff) +
' · seed ' + w.seed + '</small></div><button class="btn small" data-play="' + w.id + '">PLAY</button><button class="btn small red" data-del="' + w.id + '">DELETE</button></div>').join('')
: '<div class="sub">No worlds yet. Make one below!</div>';
}
$('mSingle').onclick = () => { audio(); if (!glOK) return; $('menu').classList.add('hidden'); $('spMenu').classList.remove('hidden'); showWorlds(); };
$('spBack').onclick = () => { $('spMenu').classList.add('hidden'); $('menu').classList.remove('hidden'); };
$('worldList').addEventListener('click', e => {
const p = e.target.dataset.play, d = e.target.dataset.del;
if (p) { const w = listWorlds().find(q => q.id === p); if (w) startLocal(w); }
if (d) {
if (e.target.dataset.sure) { saveWorldList(listWorlds().filter(q => q.id !== d)); try { localStorage.removeItem('blockcraft_w_' + d); } catch (er) {} showWorlds(); }
else { e.target.dataset.sure = '1'; e.target.textContent = 'SURE?'; }
}
});
$('nwGo').onclick = () => {
const name = $('nwName').value.trim() || 'My World';
const st = $('nwSeed').value.trim();
const seed = st ? (/^-?\d+$/.test(st) ? parseInt(st, 10) : hashStr(st) | 0) : (Math.random() * 2147483647) | 0;
const w = { id: 'w' + Date.now().toString(36), name, seed, mode: rowVal('nwMode'), diff: rowVal('nwDiff'), played: Date.now() };
const l = listWorlds(); l.push(w); saveWorldList(l);
startLocal(w);
};
const KIDS = ['Lavon', 'Layla', 'Lilly', 'Luca', 'Dietrich'];
$('mpNames').innerHTML = KIDS.map(n => '<button class="btn small" data-n="' + n + '">' + n.toUpperCase() + '</button>').join('');
$('mpNames').addEventListener('click', e => { if (e.target.dataset.n) { $('mpName').value = e.target.dataset.n; for (const b of $('mpNames').children) b.classList.toggle('on', b === e.target); } });
if (cfg.signedIn === undefined && cfg.name && cfg.code) cfg.signedIn = true; // joined before this option existed
function updateMultiBtn() { $('mMulti').textContent = cfg.signedIn && cfg.name ? 'PLAY TOGETHER AS ' + cfg.name.toUpperCase() : 'PLAY TOGETHER'; }
function showMpForm() { for (const el of $('mpMenu').querySelectorAll('.row, #mpGo')) el.classList.remove('hidden'); $('mpSwitch').classList.add('hidden'); }
updateMultiBtn();
$('mMulti').onclick = () => {
audio(); if (!glOK) return;
$('menu').classList.add('hidden'); $('mpMenu').classList.remove('hidden');
$('mpName').value = cfg.name || ''; $('mpCode').value = cfg.code || ''; pickRow('mpMode', cfg.mpMode || 'survival'); $('status').textContent = '';
for (const b of $('mpNames').children) b.classList.toggle('on', b.dataset.n === cfg.name);
if (cfg.signedIn && cfg.name) { // already signed in on this tablet: go straight in
for (const el of $('mpMenu').querySelectorAll('.row, #mpGo')) el.classList.add('hidden');
$('mpSwitch').classList.remove('hidden'); $('mpSwitch').textContent = 'NOT ' + cfg.name.toUpperCase() + '?';
$('status').textContent = 'Joining as ' + cfg.name + '...'; retries = 0;
link = remoteLink(cfg.name, cfg.code);
} else showMpForm();
};
$('mpSwitch').onclick = () => {
if (link && link.ws) link.ws.close();
cfg.signedIn = false; saveCfg(); updateMultiBtn(); showMpForm(); $('status').textContent = 'Pick a name and type the family code.';
};
for (const id of ['mpName', 'mpCode']) $(id).addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); $(id).blur(); $('mpGo').click(); } });
$('mpBack').onclick = () => { $('mpMenu').classList.add('hidden'); $('menu').classList.remove('hidden'); };
$('mpGo').onclick = () => {
const name = $('mpName').value.trim();
if (!name) { $('status').textContent = 'Pick your name first'; return; }
cfg.name = name; cfg.code = $('mpCode').value.trim(); cfg.mpMode = rowVal('mpMode'); saveCfg();
$('status').textContent = 'Connecting...';
link = remoteLink(name, cfg.code);
};
function openPause() {
if (state !== 'play') return;
state = 'pause'; releasePointer(); $('pause').classList.remove('hidden');
pickRow('pDist', String(cfg.dist)); $('pSound').checked = cfg.sound; $('pCoords').checked = cfg.coords;
$('pMode').textContent = creative() ? 'CREATIVE' : 'SURVIVAL';
saveGame();
}
function resume() { $('pause').classList.add('hidden'); state = 'play'; }
$('pResume').onclick = resume;
$('pDist').addEventListener('click', e => { if (e.target.dataset.v) { cfg.dist = +e.target.dataset.v; saveCfg(); pickRow('pDist', e.target.dataset.v); } });
$('pSound').onchange = () => { cfg.sound = $('pSound').checked; saveCfg(); };
$('pCoords').onchange = () => { cfg.coords = $('pCoords').checked; saveCfg(); if (!cfg.coords) $('coords').textContent = ''; };
$('pMode').onclick = () => {
pl.mode = creative() ? 'survival' : 'creative'; pl.fly = false;
if (session.remote) { cfg.mpMode = pl.mode; saveCfg(); }
else { const l = listWorlds(), w = l.find(q => q.id === session.id); if (w) { w.mode = pl.mode; saveWorldList(l); } }
$('pMode').textContent = creative() ? 'CREATIVE' : 'SURVIVAL';
rideButtons();
$('palette').innerHTML = ''; lastStats = '';
};
$('pQuit').onclick = () => quitToMenu(false);
document.addEventListener('visibilitychange', () => { if (document.hidden && session) { saveGame(); if (state === 'play') openPause(); } });
addEventListener('beforeunload', () => { if (session) saveGame(); });
// ---------------------------------------------------------------- rendering
function resize() {
const scale = isTouch ? Math.min(devicePixelRatio || 1, 1.25) : Math.min(devicePixelRatio || 1, 2);
cv.width = Math.round(innerWidth * scale); cv.height = Math.round(innerHeight * scale);
hud.width = innerWidth; hud.height = innerHeight;
}
addEventListener('resize', resize); resize();
const lerpC = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
let lastMVP = null;
function render(dt) {
const dl = E.dayLight(simTime);
const eye = [pl.x, pl.y + 1.62, pl.z];
if (ride) { // camera behind and above, pulled in if a wall is in the way
const f0 = fwd(), cx = ride.x, cy = ride.y + 1.6, cz = ride.z;
let d = ride.kind === 'plane' ? 8 : 7;
const h = E.raycast(world, cx, cy, cz, -f0[0], -f0[1], -f0[2], d, false);
if (h) d = Math.max(1.2, h.d - 0.4);
eye[0] = cx - f0[0] * d; eye[1] = cy - f0[1] * d + 0.8; eye[2] = cz - f0[2] * d;
}
if (shake > 0) { eye[0] += (Math.random() - 0.5) * shake * 0.3; eye[1] += (Math.random() - 0.5) * shake * 0.3; shake -= dt; }
const under = world.peek(Math.floor(eye[0]), Math.floor(eye[1]), Math.floor(eye[2])) === 10;
let sky = lerpC([0.03, 0.04, 0.12], [0.56, 0.74, 0.95], clamp((dl - 0.12) / 0.88, 0, 1));
const fogEnd = cfg.dist * CS, fogStart = fogEnd * 0.5;
let fog = [fogStart, fogEnd];
if (under) { sky = [0.08, 0.16, 0.45]; fog = [0, 12]; }
else if (E.inDungeon(pl.x)) { sky = [0.02, 0.015, 0.04]; fog = [10, 34]; } // the dungeon: dark, short sight
gl.viewport(0, 0, cv.width, cv.height);
gl.clearColor(sky[0], sky[1], sky[2], 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const proj = perspective(70 * Math.PI / 180, cv.width / cv.height, 0.05, fogEnd + 32);
const view = viewMat(pl.yaw, pl.pitch, eye[0], eye[1], eye[2]);
const mvp = mul(proj, view); lastMVP = mvp;
// sun and moon
gl.useProgram(flat); gl.disable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE);
gl.enableVertexAttribArray(flat.a);
const ang = simTime * Math.PI * 2;
for (const [s, col] of [[1, [1, 0.97, 0.75, 1]], [-1, [0.85, 0.88, 0.95, 1]]]) {
const dx = Math.cos(ang) * s, dy = Math.sin(ang) * s;
if (dy < -0.15) continue;
const cx = eye[0] + dx * 80, cy = eye[1] + dy * 80, cz = eye[2] + 10, r = s > 0 ? 7 : 5;
const ux = -dy * r, uy = dx * r;
const q = new Float32Array([cx - ux, cy - uy, cz - r, cx + ux, cy + uy, cz - r, cx + ux, cy + uy, cz + r, cx - ux, cy - uy, cz - r, cx + ux, cy + uy, cz + r, cx - ux, cy - uy, cz + r]);
gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, q, gl.DYNAMIC_DRAW);
gl.vertexAttribPointer(flat.a, 3, gl.FLOAT, false, 0, 0);
gl.uniformMatrix4fv(flat.uM, false, mvp); gl.uniform4fv(flat.uC, col);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
gl.disableVertexAttribArray(flat.a);
// world
gl.useProgram(prog); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.enableVertexAttribArray(prog.a.p); gl.enableVertexAttribArray(prog.a.t); gl.enableVertexAttribArray(prog.a.l);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, atlasTex);
gl.uniform1i(prog.u.uTex, 0); gl.uniformMatrix4fv(prog.u.uM, false, mvp); gl.uniform3fv(prog.u.uCam, eye);
gl.uniform1f(prog.u.uDay, 0.28 + dl * 0.72); gl.uniform2fv(prog.u.uFog, fog); gl.uniform3fv(prog.u.uFogC, sky);
gl.uniform1f(prog.u.uMin, pl.fx.night > 0 ? 0.75 : E.inDungeon(pl.x) ? (H().cls === 'explorer' ? 0.4 : 0.28) : H().cls === 'explorer' ? 0.2 : 0.04); // night vision potion: dark places look lit
gl.uniform1f(prog.u.uAlpha, 1); gl.uniform4fv(prog.u.uTint, [0, 0, 0, 0]);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, quadIBO);
const f = fwd(), visible = [];
for (const m of chunkGL.values()) {
const rx = m.cx * CS + 8 - eye[0], rz = m.cz * CS + 8 - eye[2];
if (rx * f[0] + rz * f[2] < -14 * Math.SQRT2 - 8 && Math.abs(f[1]) < 0.8) continue;
if (Math.hypot(rx, rz) > fogEnd + 24) continue;
visible.push(m);
if (!m.n) continue;
gl.bindBuffer(gl.ARRAY_BUFFER, m.vbo); bindAttribs();
gl.drawElements(gl.TRIANGLES, m.n, IDX_TYPE(), 0);
}
// mobs, players, arrows, tnt
const k = Math.min(1, dt * 12);
for (const mo of mobs.values()) {
const d0 = Math.hypot(mo.x - mo.rx, mo.z - mo.rz);
mo.rx += (mo.x - mo.rx) * k; mo.ry += (mo.y - mo.ry) * k; mo.rz += (mo.z - mo.rz) * k;
let dy = mo.yaw - mo.ryaw; while (dy > Math.PI) dy -= 2 * Math.PI; while (dy < -Math.PI) dy += 2 * Math.PI; mo.ryaw += dy * k;
if (mo.fl & 8) mo.walk += dt * 9; else mo.walk *= 0.9;
const type = E.MOB_TYPES[mo.type], md = E.MOBS[type], base = md.base || type;
const tint = mo.fl & 1 ? [1, 0, 0, 0.45] : mo.fl & 2 ? [1, 1, 1, 0.65] : mo.fl & 4 ? [1, 0.5, 0, 0.3] : md.boss ? [0.4, 0, 0.6, 0.18] : null;
drawModel(MODELS[base], mo.rx, mo.ry, mo.rz, mo.ryaw, mo.walk, 0, tint, base === 'zombie', (md.scale || 1) * (base === 'creeper' && mo.fl & 2 ? 1.08 : 1));
if (type === 'zombie' && d0 >= 0) { groanT -= dt * 0.15; }
}
if (groanT <= 0) { groanT = 6 + Math.random() * 8; const z = [...mobs.values()].find(q => E.MOB_TYPES[q.type] === 'zombie'); if (z) sfx('groan', z.rx, z.ry, z.rz); }
const riders = new Set();
for (const v of vehicles.values()) {
if (v.rider) riders.add(v.rider);
if (ride && v.id === ride.id) continue;
v.rx += (v.x - v.rx) * k; v.ry += (v.y - v.ry) * k; v.rz += (v.z - v.rz) * k; v.ryaw += angDiff(v.yaw, v.ryaw) * k;
v.rp = (v.rp || 0) + (v.pitch - (v.rp || 0)) * k; v.rr = (v.rr || 0) + (v.roll - (v.rr || 0)) * k;
v.spin += dt * (v.rider ? 30 : 0);
drawVehicle(vehicleBoxes(E.VEH_TYPES[v.kind], paintFor(v.owner), !!v.rider), v.rx, v.ry, v.rz, v.ryaw + Math.PI, v.rp, v.rr, v.spin);
}
if (ride) drawVehicle(vehicleBoxes(ride.kind, paintFor(myName), true), ride.x, ride.y, ride.z, ride.yaw + Math.PI, ride.pitch, ride.roll, ride.spin);
for (const o of others.values()) {
const d0 = Math.hypot(o.x - o.rx, o.z - o.rz);
o.rx += (o.x - o.rx) * k; o.ry += (o.y - o.ry) * k; o.rz += (o.z - o.rz) * k;
o.walk += d0 > 0.02 ? dt * 9 : 0;
if (!o.dead && !riders.has(o.id)) drawModel(MODELS.player, o.rx, o.ry, o.rz, o.yaw + Math.PI, o.walk, o.pitch, null, false);
}
for (const a of arrows) {
const yaw = Math.atan2(a[4], a[6]);
drawModel([{ b: [-0.04, -0.04, -0.3, 0.04, 0.04, 0.3], f: T.arrow_tex, o: T.arrow_tex }], a[1], a[2], a[3], yaw, 0, 0, null);
}
for (const tn of tnts) {
const def = blk(32);
drawModel([{ b: [-0.49, 0, -0.49, 0.49, 0.98, 0.49], f: def.tex[2], o: def.tex[2] }], tn[1], tn[2], tn[3], 0, 0, 0, tn[4] ? [1, 1, 1, 0.6] : null);
}
// water
gl.uniform4fv(prog.u.uTint, [0, 0, 0, 0]);
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); gl.disable(gl.CULL_FACE);
gl.uniform1f(prog.u.uAlpha, 0.72);
for (const m of visible) { if (!m.wn) continue; gl.bindBuffer(gl.ARRAY_BUFFER, m.wvbo); bindAttribs(); gl.drawElements(gl.TRIANGLES, m.wn, IDX_TYPE(), 0); }
gl.depthMask(true); gl.disable(gl.BLEND);
gl.disableVertexAttribArray(prog.a.p); gl.disableVertexAttribArray(prog.a.t); gl.disableVertexAttribArray(prog.a.l);
// block outline
if (target && target.block) {
const b = target.block, d = blk(b.id), e = 0.003, x0 = b.x - e, y0 = b.y - e, z0 = b.z - e, x1 = b.x + 1 + e, y1 = b.y + (d.h < 1 ? d.h : 1) + e, z1 = b.z + 1 + e;
const L = new Float32Array([x0, y0, z0, x1, y0, z0, x1, y0, z0, x1, y0, z1, x1, y0, z1, x0, y0, z1, x0, y0, z1, x0, y0, z0,
x0, y1, z0, x1, y1, z0, x1, y1, z0, x1, y1, z1, x1, y1, z1, x0, y1, z1, x0, y1, z1, x0, y1, z0,
x0, y0, z0, x0, y1, z0, x1, y0, z0, x1, y1, z0, x1, y0, z1, x1, y1, z1, x0, y0, z1, x0, y1, z1]);
gl.useProgram(flat); gl.enableVertexAttribArray(flat.a);
gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, L, gl.DYNAMIC_DRAW);
gl.vertexAttribPointer(flat.a, 3, gl.FLOAT, false, 0, 0);
gl.uniformMatrix4fv(flat.uM, false, mvp); gl.uniform4fv(flat.uC, [0, 0, 0, 0.8]);
gl.drawArrays(gl.LINES, 0, 24); gl.disableVertexAttribArray(flat.a);
}
drawHUD(under, mvp);
}
function project(mvp, x, y, z) {
const cx = mvp[0] * x + mvp[4] * y + mvp[8] * z + mvp[12], cy = mvp[1] * x + mvp[5] * y + mvp[9] * z + mvp[13], cw = mvp[3] * x + mvp[7] * y + mvp[11] * z + mvp[15];
if (cw <= 0.1) return null;
return [(cx / cw * 0.5 + 0.5) * hud.width, (1 - (cy / cw * 0.5 + 0.5)) * hud.height];
}
function drawHUD(under, mvp) {
const W = hud.width, H = hud.height;
hx.clearRect(0, 0, W, H);
if (under) { hx.fillStyle = 'rgba(20,50,160,.25)'; hx.fillRect(0, 0, W, H); }
const cx = W / 2, cy = H / 2;
hx.strokeStyle = 'rgba(255,255,255,.85)'; hx.lineWidth = 2;
if (!ride) { hx.beginPath(); hx.moveTo(cx - 9, cy); hx.lineTo(cx + 9, cy); hx.moveTo(cx, cy - 9); hx.lineTo(cx, cy + 9); hx.stroke(); }
// potion effects still running, top left
let fy = ride ? 80 : 30;
hx.font = 'bold 15px "Lucida Console",monospace'; hx.textAlign = 'left';
for (const k in pl.fx) { hx.fillStyle = '#000'; hx.fillText(FX_NAMES[k] + ' ' + Math.ceil(pl.fx[k]), 13, fy + 1); hx.fillStyle = '#FF77FF'; hx.fillText(FX_NAMES[k] + ' ' + Math.ceil(pl.fx[k]), 12, fy); fy += 20; }
if (ride) { // flying: speed and height instead of the crosshair
const sp = ride.kind === 'plane' ? ride.speed : Math.hypot(ride.vx, ride.vz);
hx.font = 'bold 18px "Lucida Console",monospace'; hx.textAlign = 'left'; hx.fillStyle = '#FFFF55';
hx.fillText('SPEED ' + Math.round(sp * 3.6) + ' HEIGHT ' + Math.max(0, Math.floor(ride.y - E.SEA)), 12, 30);
if (ride.kind === 'plane' && ride.speed < 12) { hx.fillStyle = '#fff'; hx.fillText(ride.onGround ? 'Go faster to take off' : 'Too slow!', 12, 54); }
}
else if (breakT > 0 && target && target.veh) { hx.strokeStyle = '#55FF55'; hx.lineWidth = 4; hx.beginPath(); hx.arc(cx, cy, 18, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, breakT)); hx.stroke(); }
else if (breakT > 0 && target && target.block) {
const tt = breakTime(target.block.id);
if (tt > 0 && tt < Infinity) { hx.strokeStyle = '#FFFF55'; hx.lineWidth = 4; hx.beginPath(); hx.arc(cx, cy, 18, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, breakT / tt)); hx.stroke(); }
}
hx.font = '14px "Lucida Console",monospace'; hx.textAlign = 'center';
for (const o of others.values()) {
if (o.dead) continue;
const p = project(mvp, o.rx, o.ry + 2.2, o.rz); if (!p) continue;
const w = hx.measureText(o.name).width + 8;
hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
hx.fillStyle = '#fff'; hx.fillText(o.name, p[0], p[1]);
}
// pet name tags, and the health bar of a floor boss you are near
let boss = null;
for (const mo of mobs.values()) {
const md = E.MOBS[E.MOB_TYPES[mo.type]];
if (md.boss && mo.ex && Math.hypot(mo.rx - pl.x, mo.rz - pl.z) < 40) boss = { md, hp: mo.ex[0] };
if (md.pet && mo.ex && Math.hypot(mo.rx - pl.x, mo.rz - pl.z) < 24) {
const p = project(mvp, mo.rx, mo.ry + md.h + 0.5, mo.rz); if (!p) continue;
const label = mo.ex[0] + ' (lv ' + mo.ex[2] + ')', w = hx.measureText(label).width + 8;
hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
hx.fillStyle = PAINTS[paintFor(mo.ex[1])]; hx.fillText(label, p[0], p[1]);
}
}
if (boss) {
const bw = Math.min(W * 0.6, 420), bx = (W - bw) / 2, by = H - Math.min(150, H * 0.24); // above the hotbar, clear of the announcer
hx.fillStyle = 'rgba(0,0,0,.6)'; hx.fillRect(bx - 4, by - 22, bw + 8, 38);
hx.fillStyle = '#FF5555'; hx.font = 'bold 15px "Lucida Console",monospace'; hx.fillText(boss.md.name, W / 2, by - 6);
hx.fillStyle = '#400'; hx.fillRect(bx, by, bw, 10); hx.fillStyle = '#e02020'; hx.fillRect(bx, by, bw * boss.hp / 100, 10);
hx.font = '14px "Lucida Console",monospace';
}
if (E.inDungeon(pl.x)) {
hx.font = 'bold 16px "Lucida Console",monospace'; hx.textAlign = 'right';
hx.fillStyle = inSafe ? '#55FFFF' : '#AA77FF';
hx.fillText('FLOOR ' + E.floorAt(pl.x) + (inSafe ? ' SAFE ROOM' : ''), W - 12, 72);
hx.textAlign = 'center'; hx.font = '14px "Lucida Console",monospace';
}
for (const c of owned) { // "Layla's Royal Castle" over kit castles nearby
const mx = c.x0 + 10.5, mz = c.z0 + 10.5; if (Math.hypot(mx - pl.x, mz - pl.z) > 48) continue;
const p = project(mvp, mx, c.base + 13, mz); if (!p) continue;
const label = (session.remote ? c.owner + "'s " : 'Your ') + E.CASTLES[c.type].name, w = hx.measureText(label).width + 8;
hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
hx.fillStyle = PAINTS[paintFor(c.owner)]; hx.fillText(label, p[0], p[1]);
}
if (session && session.remote) for (const v of vehicles.values()) { // "Layla's Airplane" over parked vehicles nearby
if (v.rider || !v.owner || Math.hypot(v.rx - pl.x, v.rz - pl.z) > 24) continue;
const p = project(mvp, v.rx, v.ry + 2.6, v.rz); if (!p) continue;
const label = v.owner + "'s " + (E.VEH_TYPES[v.kind] === 'plane' ? 'Airplane' : 'Helicopter'), w = hx.measureText(label).width + 8;
hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
hx.fillStyle = PAINTS[paintFor(v.owner)]; hx.fillText(label, p[0], p[1]);
}
hx.textAlign = 'left';
$('hurt').style.opacity = hurtFlash > 0 ? Math.min(1, hurtFlash * 2) : 0;
}
// walking into a castle says which kind it is (once per castle per visit)
let castleT = 0, inCastle = '';
function castleCheck(dt) {
castleT -= dt; if (castleT > 0) return; castleT = 0.5;
const x = Math.floor(pl.x), z = Math.floor(pl.z), mine = castleOwnerAt(x, z);
let k = '', msg = '';
if (mine) { k = 'k' + mine.x0 + ',' + mine.z0; msg = (mine.owner.toLowerCase() === myName.toLowerCase() ? 'Your ' : mine.owner + "'s ") + E.CASTLES[mine.type].name; }
else {
const c = E.castleAt(Math.floor(x / E.REG), Math.floor(z / E.REG), world.seed);
if (c && x >= c.x0 && x < c.x0 + E.CW && z >= c.z0 && z < c.z0 + E.CW) {
k = c.x0 + ',' + c.z0; msg = 'You found a ' + E.CASTLES[c.type].name + '!\nLook for the treasure chest in the keep';
const h = H(); h.castles[c.type] = 1; earn('castle'); if (c.type === 'royal') earn('royal'); if (Object.keys(h.castles).length >= 5) earn('all_castles');
}
}
inSafe = E.inSafeRoom(pl.x, pl.z, world.seed);
if (inSafe) earn('safe');
if (k && k !== inCastle) { toast(msg); sfx('craft'); }
inCastle = k;
}
// ---------------------------------------------------------------- main loop
let last = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (!pl || !world) return;
if (state === 'loading') {
updateChunks(40);
if (nearReady()) {
const top = world.topY(Math.floor(pl.x), Math.floor(pl.z));
if (pl.y < top + 1 && isSolid[world.get(Math.floor(pl.x), Math.floor(pl.y + 0.5), Math.floor(pl.z))]) pl.y = top + 1;
enterWorld();
}
return;
}
const active = state === 'play' || state === 'inv' || state === 'map' || state === 'hero' || state === 'dead' || (state === 'pause' && session && session.remote);
if (active) {
if (state === 'play') { updatePlayer(dt); interact(dt); castleCheck(dt); }
tickAnnounce(dt);
if (link.local) { simTime = sim.time; sim.players.get(1) && Object.assign(sim.players.get(1), { x: pl.x, y: pl.y, z: pl.z, yaw: pl.yaw, pitch: pl.pitch, dead: pl.dead }); }
link.tick(dt);
moveSend -= dt;
vSend -= dt;
if (ride && vSend <= 0) { vSend = 0.1; const r2 = v => +v.toFixed(2); link.send({ t: 'vmove', id: ride.id, x: r2(ride.x), y: r2(ride.y), z: r2(ride.z), yaw: r2(ride.yaw), pitch: r2(ride.pitch), roll: r2(ride.roll) }); }
if (moveSend <= 0 && !link.local) { moveSend = 0.1; link.send({ t: 'move', x: +pl.x.toFixed(2), y: +pl.y.toFixed(2), z: +pl.z.toFixed(2), yaw: +pl.yaw.toFixed(2), pitch: +pl.pitch.toFixed(2), dead: pl.dead }); }
saveTimer -= dt; if (saveTimer <= 0) { saveTimer = session.remote ? 10 : 30; saveGame(); }
}
hurtFlash = Math.max(0, hurtFlash - dt);
if (toastT > 0) { toastT -= dt; if (toastT <= 0) $('toast').textContent = ''; }
updateChunks(isTouch ? 6 : 10);
render(dt);
renderStats();
fpsN++; if (now - fpsT > 1000) { fps = Math.round(fpsN * 1000 / (now - fpsT)); fpsN = 0; fpsT = now; }
if (cfg.coords && state === 'play') $('coords').textContent = 'X ' + Math.floor(pl.x) + ' Y ' + Math.floor(pl.y) + ' Z ' + Math.floor(pl.z) + ' ' + (dl2txt(E.dayLight(simTime))) + ' ' + fps + ' fps';
}
let fps = 0, fpsN = 0, fpsT = 0;
const dl2txt = d => d > 0.6 ? 'day' : d > 0.3 ? 'dusk' : 'night';
requestAnimationFrame(frame);
window.BCdebug = () => ({ pl, world, sim, mobs, chunkGL, state, target, inv, addItem, openInv, closeInv, doUse,
dig: () => target && target.block && breakBlock(target.block), select: i => { sel = i; renderHotbar(); }, others, vehicles, ride: () => ride, leaveRide, owned: () => owned, giveDrops });
})();
EOF
3. Set the family code
Copy the example settings (Proxmox host shell):
cp /root/blockcraft/config.example.json /root/blockcraft/config.json
Open the copy and replace CHANGE-ME with your own family code (Ctrl+O, Enter, Ctrl+X to save and quit):
nano /root/blockcraft/config.json
4. Optional: the icons
The game works without them; they're the picture on the menu tile and in bookmarks. On your Windows PC, in the folder where you saved make-icons.ps1 from the games site post (example C:\nibbles), run it. It also writes BlockCraft's four PNGs into a BlockCraft\public folder next to that folder (C:\BlockCraft\public in the example):
powershell -ExecutionPolicy Bypass -File .\make-icons.ps1
Copy them to the host (this uses an SSH alias named proxmox; use root@<your host's IP> instead if you don't have one):
scp -o BatchMode=yes ..\BlockCraft\public\icon-512.png ..\BlockCraft\public\icon-192.png ..\BlockCraft\public\apple-touch-icon.png ..\BlockCraft\public\favicon-32.png proxmox:/root/blockcraft/
5. Create CT 127 and start the server
Proxmox host shell. It takes about a minute: making the container, then about 15 MB of Debian packages. It ends with the service status (active (running)) and local page: 200, or a harmless curl: command not found line, since the container has no curl:
bash /root/blockcraft/01-create-ct.sh
6. Route /blockcraft through the edge
Copy the script into CT 108 (Proxmox host shell):
pct push 108 /root/blockcraft/03-edge-route.sh /root/03-edge-route.sh
Run it. It looks up CT 127's and CT 125's addresses itself and ends with Valid configuration and edge reloaded:
pct exec 108 -- bash /root/03-edge-route.sh "$(pct exec 127 -- hostname -I | awk '{print $1}')" "$(pct exec 125 -- hostname -I | awk '{print $1}')"
7. Check it
Run the two-player test inside CT 127 (Proxmox host shell). Healthy output: wrong code -> error Wrong family code., then joined 1 2 ..., then B saw the block: true:
pct push 127 /root/blockcraft/test-client.js /opt/blockcraft/test-client.js
pct exec 127 -- node /opt/blockcraft/test-client.js
Then open https://games.503mail.com/blockcraft/ on a tablet, make a single-player world, and try PLAY TOGETHER with a name from the list and the family code.
8. Kids' tablets
Nothing to do if games.503mail.com is already an approved website for each kid. The kids open their Nibbles tile, scroll to BLOCKCRAFT and tap it. To approve the site, go to Settings → Profiles & Family Library → the child → Device Settings → Manage Web Content → Websites → + on the tablet, and add https://games.503mail.com.
Updating the game later
Paste the changed file blocks from this post into the Proxmox host shell again (they overwrite the files in /root/blockcraft/), then:
bash /root/blockcraft/04-push.sh
It prints active when the server is back. Players who are connected get "Disconnected from the server" and just tap JOIN again. The saved world and the settings stay as they are.
Troubleshooting: where the logs are
The game server
- Where: the systemd journal for
blockcraftinside CT 127 (blockcraft). - Open: Proxmox host shell.
pct exec 127 -- journalctl -u blockcraft -n 50 --no-pager
- Look for:
BlockCraft on port 8080, seed ..., spawn {...}at every start, and one line perjoinandleavewith the name and address. A crash shows a JavaScript stack trace followed byblockcraft.service: Main process exited, and systemd restarts it 3 seconds later.EADDRINUSEmeans something else is on port 8080.
Is the service running?
- Open: Proxmox host shell. Healthy is
active:
pct exec 127 -- systemctl is-active blockcraft
The saved family world
- Where:
/opt/blockcraft/data/world.jsonin CT 127. It's rewritten every 30 seconds while anything changes. The settings are in/opt/blockcraft/config.json. - Open: Proxmox host shell. Shows its size and when it was last written:
pct exec 127 -- ls -l /opt/blockcraft/data/
- Start the family world over (new seed, everything built so far is lost): stop the service, delete the file, start it again. Three commands:
pct exec 127 -- systemctl stop blockcraft
pct exec 127 -- rm /opt/blockcraft/data/world.json
pct exec 127 -- systemctl start blockcraft
The edge (HTTPS and the /blockcraft route)
- Where: the
caddyjournal inside CT 108 (Caddy reverse proxy). The route is in/etc/caddy/Caddyfileundergames.503mail.com. - Open: Proxmox host shell.
pct exec 108 -- grep -n -A9 '^games.503mail.com' /etc/caddy/Caddyfile
pct exec 108 -- journalctl -u caddy -n 50 --no-pager
- Look for:
dial tcp ...:8080: connect: connection refusedorno route to hostmeans CT 127 is down or got a new address from DHCP. Fix the address in thehandle /blockcraft*block (nano /etc/caddy/Caddyfileinside CT 108), thenpct exec 108 -- systemctl reload caddy. TheCaddyfile input is not formattedwarning is harmless.
In the game itself
- "Wrong family code." / "That name is not on the family list." come from the server's settings (step 3).
- "Could not reach the game server." means the WebSocket didn't connect. Check the service and the edge route above.
- For a speed check, turn on Show position in the pause menu: the top-left corner shows the position, day/night and frames per second.
Quick health check
Proxmox host shell. Prints 200 when CT 127 serves the page:
pct exec 127 -- node -e "require('http').get('http://127.0.0.1:8080/blockcraft/',r=>{console.log(r.statusCode);process.exit()})"
Notes
- Speed: on a Fire 7 (2 GB RAM, Mali-G52) in the Amazon Kids browser it runs at 60 frames per second at the MEDIUM view distance, which is the tablet default. NEAR and FAR are in the pause menu.
- What gets saved where: single-player worlds stay on that tablet, in that kid's browser storage, and aren't shared between tablets. The family world and each player's bag live on CT 127. A player's bag in the family world is separate from their single-player worlds.
- Kid-friendly choices: you keep your bag when you die, hunger never takes you below 1 heart, and nights are a bit brighter than in Minecraft so the kids can see what's coming.
- Testing on the kids' tablets: the Amazon Kids browser can't be inspected remotely (only the adult Silk browser has a debug port), so I tested with ADB taps,
uiautomator dumpto read the screen text, and the frames-per-second readout. - The world spawn is picked on open grass in grassland or forest. The first build sometimes put new players on a treetop or in a desert with no wood.
- Staying signed in: after a kid joins the family world once, the menu button says "PLAY TOGETHER AS" plus their name, and one tap joins. "NOT LAYLA?" (or whoever) switches players. If the connection drops, for example when the server restarts after an update, the game rejoins by itself (up to 6 tries). Pressing Enter in the code box also joins, which matters with an on-screen keyboard.
- Careful with the tablet's Back key while the kids browser has a form open: it goes back a page and drops what was typed.
- Castles and ore come from the seed, so existing worlds get them too, in land nobody has changed yet. Anything a player already built or dug stays as it was.
- Ideas for later: a bow that charges up, armor, farming, more monsters and a chat box.