The second add-on for BlockCraft, my Minecraft-style game for the kids' Fire tablets (the first was airplanes and helicopters). The kids asked for different kinds of castles with different treasure: diamonds, gold, silver, jewels, apples and other fruit, healing and power-up potions, diamond pickaxes and swords, and diamond shoes. All of it is now in the game, mostly to be found or earned. This post covers what the add-on does and how to add it to an existing BlockCraft. Every block has a Copy button.
What it adds
Five kinds of castles to explore
Castles are part of the land, like trees and ore, so every world has them, old worlds too (in land nobody has changed yet). Walking through the gate says "You found a Desert Palace!" Each castle has a golden treasure chest in the keep (the house in the middle), filled the first time anyone opens it. Rarer castles hold better treasure.
| Castle | Where | Looks like | Treasure |
|---|---|---|---|
| Wooden Fort | grassland and forest | log and plank walls, green flags | apples and other fruit, iron, torches, arrows, a healing potion, sometimes iron boots or silver |
| Stone Castle (the original) | grassland and forest, the most common | stone bricks and mossy bricks, red flags | diamonds, iron, gold, apples, torches, arrows, maybe a diamond sword or a diamond block, now also silver and a healing potion |
| Royal Castle (rare) | grassland and forest | stone bricks with gold battlements, purple flags | the best: 8 to 14 diamonds, every kind of jewel, golden apples, strength, shield, regeneration and healing potions, a diamond pickaxe or diamond sword, a diamond armor piece and a diamond block |
| Desert Palace | deserts | sandstone with gold battlements, orange flags | gold, silver, rubies, emeralds, oranges, bananas, grapes, a speed potion, healing potions, maybe a golden apple, a gold armor piece |
| Ice Castle | snowy land | ice bricks and snow with silver battlements, blue flags | diamonds, sapphires, amethysts, silver, watermelon, water breathing and night vision potions, often diamond boots |
The Stone Castles that were already in the family world are exactly where they were, and look the same (only their chest now has the golden treasure chest look). The new kinds show up in places that had no castle before.
Castle kits: earn your own castle
Make a kit at a crafting table in Survival, hold it and tap: the whole castle builds itself just in front of you (its gate faces north). It belongs to you: in the family world it flies a flag in your own colour (the same colour as your airplane) and has your name floating over it ("Layla's Royal Castle"). Nobody else can break or add blocks in it or open its treasure chest; they can still open and close the doors to visit. Explosions can't damage it either. Its treasure chest starts empty: it is yours to fill. Castles can't be built right next to someone else's.
| Kit | Made from |
|---|---|
| Wooden Fort Kit | 40 logs, 4 glass, 8 torches |
| Stone Castle Kit | 64 stone bricks, 16 glass, 4 iron ingots |
| Desert Palace Kit | 64 sandstone, 4 gold blocks, 8 glass |
| Ice Castle Kit | 64 glass, 4 sapphires, 16 silver ingots |
| Royal Castle Kit | 64 stone bricks, 8 gold blocks, 2 diamond blocks, 4 rubies |
A kit castle has no gold or silver battlements, so it can't be mined for more than it cost.
Treasure
- Silver: silver ore deep underground (below height 30, needs an iron pickaxe). Smelt it into silver ingots. 9 silver ingots make a silver block, and 9 gold ingots make a gold block.
- Jewels: ruby, emerald, sapphire and amethyst. Find them in treasure chests, or dig jewel ore very deep (below height 14, needs an iron pickaxe), which drops one jewel at random.
- Fruit: apples, pears, cherries and grapes sometimes drop from leaves; oranges, bananas and watermelon come from treasure chests. A golden apple (1 apple + 8 gold ingots) fills you up, heals 3 hearts and gives 10 seconds of regeneration.
- Silver Sword (2 silver ingots and a stick): a bit weaker than diamond, but does double damage to zombies and skeletons.
Potions
Make a glass bottle (3 from 1 glass), then mix it with something special at a crafting table. Tap a potion to drink it; you get the bottle back. Running potions are listed in pink in the top-left corner with the seconds left.
| Potion | Made from bottle + | What it does |
|---|---|---|
| Healing | 2 apples | +4 hearts straight away |
| Regeneration | 1 golden apple | heals over 30 seconds |
| Strength | 1 ruby | your hits do double damage for 90 seconds |
| Speed | 1 sapphire | run 60% faster for 90 seconds |
| Jump | 1 emerald | jump much higher, and longer falls don't hurt, for 90 seconds |
| Shield | 1 amethyst + 1 iron ingot | monsters hurt you half as much for 90 seconds |
| Night Vision | 1 glowstone | caves and nights look lit up for 2 minutes |
| Water Breathing | 2 grapes | no drowning for 2 minutes |
Armor
Iron, gold and diamond helmets, chestplates, leggings and boots, made at a crafting table (5, 8, 7 and 4 of the metal). Tap armor to put it on, or open the bag: the new WEARING row shows the four pieces; tap a piece in the bag and then a WEARING slot to put it on, and tap a worn piece to take it off. Blue diamonds next to the hearts show how protected you are, and the bag says how much less monsters hurt you. Armor helps against monsters, arrows and explosions: a full diamond set stops 80%. Diamond Boots also mean no fall damage at all.
Earned, not free
Jewels, potions, golden apples, diamond armor, castle kits and the treasure chest block are left out of the Creative block list, like the airplanes, so they have to be found in castles or crafted in Survival.
How it works
- The castle types are a table in the shared game rules (
engine.js): which blocks make the walls, floors, walkways and roofs, the flag colour and (for natural castles only) the gold or silver battlements. Which castle appears where comes from the same seed-based luck as before, so the original Stone Castles keep their exact spots, and the new kinds use regions that had no castle. - Each castle type has its own treasure list. The amounts come from the seed too, so the same castle always holds the same treasure, and it is put in the chest the first time anyone opens it.
- A castle kit sends one message (
kit) to the server (or, in single player, the page's own simulation). The server builds the castle block by block exactly like a natural one, notes who owns it, and tells everyone. From then on it refuses block changes, chest opening and explosions inside that castle from anyone but the owner, and puts back any block a tablet tried to change. Kit castles are saved in theownedlist of/opt/blockcraft/data/world.json. - Potions, golden apples and armor run on each tablet (that is where health is kept). Armor and the shield potion shrink the damage the server sends for monster hits, arrows and explosions. The night vision potion raises the darkest light level the 3D drawing uses. Worn armor is saved with each player's bag.
Containers involved
| Container | Role | LAN IP |
|---|---|---|
| CT 127 (blockcraft) | Node.js game server; gets the new game files | x.x.0.193 (DHCP) |
| CT 108 (Caddy reverse proxy) | Unchanged: already routes /blockcraft* to CT 127 |
x.x.0.122 |
No new container, port, DNS name or Caddy route 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).
- Everything below is run there.
Add it to an existing BlockCraft
This assumes BlockCraft is already built from the main BlockCraft post, with its files in /root/blockcraft/ on the host and 04-push.sh there. The files below also contain the airplanes and helicopters add-on, so this brings a BlockCraft without either add-on fully up to date. If you are building BlockCraft from scratch, the main post already has these same files, so you can skip this section.
1. Go to the working folder
cd /root/blockcraft
2. Replace the five changed files
Paste each block into the Proxmox host shell. Each one overwrites one file in /root/blockcraft/. The game files are long; let each paste finish before the next.
engine.js: the shared game rules. New: silver ore, jewel ore, gold and silver blocks, ice bricks, the treasure chest, all the new items and recipes, the five castle types and their treasure, castle kits and owned-castle protection.
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: the browser side. New: textures and bag icons for everything new, drinking potions, wearing armor, damage protection, the effects list, fruit from leaves, jewel drops, placing castle kits, castle name labels and the "You found a ..." message.
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
index.html: adds the WEARING row to the bag, the armor diamonds next to the hearts, and a line about treasure on the start screen.
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
server.js: lets the kit message through, sends and saves the list of kit castles.
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: the two-player check now also looks inside the nearest castle's treasure chest (it only looks; nothing is taken).
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
3. Push the files into CT 127 and restart
It copies the game files in, restarts the server and prints active. The family code, the name list and the saved world are not touched. Anyone playing gets disconnected and the game rejoins by itself:
bash /root/blockcraft/04-push.sh
4. Check it
Copy the new test into CT 127:
pct push 127 /root/blockcraft/test-client.js /opt/blockcraft/test-client.js
Run it. Healthy output ends with a treasure: line naming the nearest castle to the spawn point, its position and how many kinds of things its chest holds (more than 0):
pct exec 127 -- node /opt/blockcraft/test-client.js
Then on a tablet: open https://games.503mail.com/blockcraft/, open MAP to find your way, and walk to the castle the test named (turn on Show position in the pause menu to see your X and Z).
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 {...}after the restart. A JavaScript stack trace followed byblockcraft.service: Main process exitedmeans a file didn't paste completely; paste it again and rerun04-push.sh.
Kit castles and treasure chests
- Where:
/opt/blockcraft/data/world.jsonin CT 127:ownedlists every kit castle (corner position, type, owner) andchestsholds what is in every chest that has been opened. - Open: Proxmox host shell. Lists the kit castles:
pct exec 127 -- node -e "const w=require('/opt/blockcraft/data/world.json');console.log(w.owned||'no kit castles yet')"
- A new kit castle can take up to 30 seconds to show up in the file (the save interval).
In the game itself
- "This is Layla's castle" / "Only Layla can open this chest": working as intended; only the owner can change a kit castle.
- "Too close to Layla's castle": kit castles need a little space between them. The kit is given back.
- A recipe is grey: something is missing, or there is no crafting table within 4 blocks. Recipes show in Survival only.
- No new castles near home: castles appear in land nobody has changed yet, at most one every 128 blocks; use the map and explore further.
- A potion seems to do nothing: check the pink list in the top-left corner. Effects end when you die.
Notes
- Tested: in a single-player world in the browser (all five castle types generate, each chest holds its own treasure, a Desert Palace kit built itself with its owner's flag, the speed and healing potions, wearing diamond boots and a diamond chestplate, armor cutting a 10-point hit to 5.6, the bag icons), the ownership rules with two pretend players (the other kid can't break blocks, build next to it or open its chest; the owner can), and on the live family server with the test above. Not yet tried on the Fire 7 tablets.
- Kid-friendly choices: potions give the bottle back, effects are shown with a countdown, a kit that can't be built is returned, and you keep your armor when you die.
- Ideas for later: showing worn armor on the player model, castle guards that protect the Royal Castle treasure, a brewing stand, and more fruit trees.