Загрузка данных
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blockcraft - Minecraft Clone</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
html,body{margin:0;padding:0;overflow:hidden;background:#000;font-family:'Segoe UI',Tahoma,Verdana,sans-serif;user-select:none;}
#game-canvas{display:block;width:100vw;height:100vh;}
#crosshair{
position:fixed;top:50%;left:50%;width:22px;height:22px;transform:translate(-50%,-50%);
pointer-events:none;z-index:20;
}
#crosshair::before,#crosshair::after{content:'';position:absolute;background:rgba(255,255,255,0.85);box-shadow:0 0 2px rgba(0,0,0,0.8);}
#crosshair::before{top:50%;left:0;width:100%;height:2px;transform:translateY(-1px);}
#crosshair::after{left:50%;top:0;height:100%;width:2px;transform:translateX(-1px);}
.pixel-font{font-family:'Courier New', monospace;}
#overlay{
position:fixed;inset:0;background:rgba(10,15,10,0.85);
display:flex;flex-direction:column;align-items:center;justify-content:center;
z-index:60;color:white;text-align:center;
}
#hotbar{
position:fixed;bottom:14px;left:50%;transform:translateX(-50%);
display:flex;gap:4px;z-index:20;background:rgba(0,0,0,0.35);
padding:6px;border-radius:6px;
}
.slot{
width:46px;height:46px;border:2px solid #555;border-radius:4px;
background:#2b2b2b;display:flex;align-items:center;justify-content:center;
position:relative;box-shadow:inset 0 0 4px rgba(0,0,0,0.6);cursor:pointer;
}
.slot.selected{border-color:#fff;box-shadow:0 0 8px rgba(255,255,255,0.8);}
.slot canvas{width:30px;height:30px;image-rendering:pixelated;}
.slot .num{position:absolute;top:1px;left:3px;font-size:9px;color:#ddd;text-shadow:1px 1px 1px black;}
#hud-top{position:fixed;top:10px;left:10px;color:#0f0;z-index:20;font-size:12px;background:rgba(0,0,0,0.45);padding:6px 10px;border-radius:6px;display:none;white-space:pre;line-height:1.5;}
#hint{position:fixed;top:10px;right:10px;color:#eee;z-index:20;font-size:12px;background:rgba(0,0,0,0.35);padding:6px 10px;border-radius:6px;max-width:260px;line-height:1.5;}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:10;box-shadow:inset 0 0 140px rgba(0,0,0,0.35);}
.key{background:#333;border:1px solid #777;border-radius:3px;padding:1px 6px;margin:0 2px;font-size:11px;}
#stats{position:fixed;bottom:70px;left:16px;z-index:20;color:#fff;font-size:11px;}
.bar-row{display:flex;align-items:center;gap:6px;margin-bottom:4px;text-shadow:1px 1px 1px black;}
.bar-bg{width:140px;height:10px;border:1px solid #000;background:#1a1a1a;border-radius:2px;overflow:hidden;}
.bar-fill{height:100%;transition:width 0.3s;}
#minimap-wrap{position:fixed;top:160px;right:10px;z-index:20;border:2px solid #444;border-radius:6px;overflow:hidden;background:#111;box-shadow:0 0 8px rgba(0,0,0,0.6);}
#minimap{display:block;}
#inv-panel{
position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:55;
display:none;align-items:center;justify-content:center;
}
#inv-box{
background:#3b3b3b;border:4px solid #1e1e1e;border-radius:8px;padding:20px;
max-width:520px;box-shadow:0 0 30px rgba(0,0,0,0.8);
}
#inv-box h2{color:#fff;text-align:center;margin:0 0 14px;font-size:20px;letter-spacing:1px;}
#inv-grid{display:grid;grid-template-columns:repeat(6,64px);gap:8px;}
.inv-item{
background:#555;border:2px solid #2a2a2a;border-radius:5px;cursor:pointer;
display:flex;flex-direction:column;align-items:center;justify-content:center;padding:4px;
transition:background 0.15s;
}
.inv-item:hover{background:#6c6c6c;border-color:#fff;}
.inv-item canvas{width:36px;height:36px;image-rendering:pixelated;}
.inv-item span{color:#ddd;font-size:8px;margin-top:2px;text-align:center;}
#inv-hint{color:#bbb;font-size:11px;text-align:center;margin-top:12px;}
#death-msg{
position:fixed;top:40%;left:50%;transform:translate(-50%,-50%);
color:#ff4444;font-size:32px;font-weight:900;text-shadow:2px 2px 0 #000;
z-index:40;display:none;pixel-font;
}
</style>
</head>
<body>
<canvas id="game-canvas"></canvas>
<div id="crosshair"></div>
<div id="vignette"></div>
<div id="hud-top" class="pixel-font"></div>
<div id="death-msg" class="pixel-font">ВЫ ПОГИБЛИ</div>
<div id="hint" class="pixel-font">
<b>Управление</b><br>
<span class="key">WASD</span> движение <span class="key">Space</span> прыжок<br>
<span class="key">Shift</span> бег <span class="key">F</span> полёт<br>
<span class="key">ЛКМ</span> сломать <span class="key">ПКМ</span> поставить<br>
<span class="key">1-9</span> / колесо — хотбар<br>
<span class="key">E</span> инвентарь <span class="key">F3</span> отладка
</div>
<div id="stats" class="pixel-font">
<div class="bar-row">HP <div class="bar-bg"><div id="health-bar" class="bar-fill" style="background:#e33; width:100%"></div></div></div>
<div class="bar-row">Голод <div class="bar-bg"><div id="hunger-bar" class="bar-fill" style="background:#c8892f; width:100%"></div></div></div>
</div>
<div id="minimap-wrap"><canvas id="minimap" width="130" height="130"></canvas></div>
<div id="hotbar"></div>
<div id="inv-panel">
<div id="inv-box">
<h2>ИНВЕНТАРЬ</h2>
<div id="inv-grid"></div>
<div id="inv-hint">Нажмите на блок, чтобы положить его в выбранную ячейку хотбара (E — закрыть)</div>
</div>
</div>
<div id="overlay">
<h1 id="title-h1" style="font-size:48px;font-weight:900;letter-spacing:2px;text-shadow:3px 3px 0 #2e7d32;">BLOCKCRAFT</h1>
<p style="opacity:0.85;margin-top:4px;">мир, пещеры, руды, мобы, день и ночь — на Three.js</p>
<div style="margin-top:26px;display:flex;gap:14px;">
<button id="continue-btn" style="display:none;padding:14px 30px;font-size:18px;font-weight:bold;background:#3f7fd6;border:3px solid #2a5aa0;color:white;border-radius:6px;cursor:pointer;">ПРОДОЛЖИТЬ</button>
<button id="start-btn" style="padding:14px 30px;font-size:18px;font-weight:bold;background:#4caf50;border:3px solid #2e7d32;color:white;border-radius:6px;cursor:pointer;">НОВЫЙ МИР</button>
</div>
<p style="margin-top:22px;font-size:13px;opacity:0.75;max-width:460px;">Клик по кнопке захватит курсор мыши. WASD — ходьба, Shift — бег, мышь — обзор,<br>ЛКМ — ломать, ПКМ — ставить, E — инвентарь со всеми блоками, F — полёт.<br>Остерегайтесь зомби по ночам!</p>
</div>
<script>
(function(){
"use strict";
/* ============================= BASIC SETUP ============================= */
const canvas = document.getElementById('game-canvas');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const DAY_COLOR = new THREE.Color(0x8fd0f7);
const NIGHT_COLOR = new THREE.Color(0x070a1f);
const DAWN_COLOR = new THREE.Color(0xff9d5c);
scene.background = DAY_COLOR.clone();
scene.fog = new THREE.Fog(DAY_COLOR.getHex(), 16, 46);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.05, 160);
camera.rotation.order = 'YXZ';
window.addEventListener('resize', ()=>{
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* lighting */
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambient);
const hemi = new THREE.HemisphereLight(0xbde3ff, 0x4a3b2a, 0.35);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff3d0, 0.9);
sun.castShadow = true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 110;
sun.shadow.camera.left = -40;
sun.shadow.camera.right = 40;
sun.shadow.camera.top = 40;
sun.shadow.camera.bottom = -40;
sun.shadow.bias = -0.0025;
scene.add(sun);
const sunTarget = new THREE.Object3D();
scene.add(sunTarget);
sun.target = sunTarget;
/* stars */
const starCount = 900;
const starPositions = new Float32Array(starCount*3);
for(let i=0;i<starCount;i++){
const theta = Math.random()*Math.PI*2;
const phi = Math.acos(2*Math.random()-1);
const r = 110;
starPositions[i*3] = r*Math.sin(phi)*Math.cos(theta);
starPositions[i*3+1] = Math.abs(r*Math.cos(phi))*0.6+10;
starPositions[i*3+2] = r*Math.sin(phi)*Math.sin(theta);
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions,3));
const starMat = new THREE.PointsMaterial({color:0xffffff, size:1.3, transparent:true, opacity:0, depthWrite:false, sizeAttenuation:false});
const stars = new THREE.Points(starGeo, starMat);
scene.add(stars);
/* clouds */
const cloudGroup = new THREE.Group();
scene.add(cloudGroup);
const cloudMat = new THREE.MeshLambertMaterial({color:0xffffff, transparent:true, opacity:0.85});
for(let i=0;i<18;i++){
const w = 4+Math.random()*6, d = 3+Math.random()*4;
const geo = new THREE.BoxGeometry(w,1,d);
const mesh = new THREE.Mesh(geo, cloudMat);
mesh.position.set((Math.random()-0.5)*140, 30+Math.random()*4, (Math.random()-0.5)*140);
cloudGroup.add(mesh);
}
/* ============================= TEXTURES ============================= */
function makeTexture(draw){
const size = 16;
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
draw(ctx, size);
const tex = new THREE.CanvasTexture(c);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
return tex;
}
function speckle(ctx,size,base,variants){
ctx.fillStyle = base;
ctx.fillRect(0,0,size,size);
for(let y=0;y<size;y++){
for(let x=0;x<size;x++){
if(Math.random() < 0.55){
ctx.fillStyle = variants[Math.floor(Math.random()*variants.length)];
ctx.fillRect(x,y,1,1);
}
}
}
}
function blotch(ctx,size,base,variants,bs){
ctx.fillStyle = base; ctx.fillRect(0,0,size,size);
for(let y=0;y<size;y+=bs){
for(let x=0;x<size;x+=bs){
if(Math.random()<0.65){
ctx.fillStyle = variants[Math.floor(Math.random()*variants.length)];
ctx.fillRect(x,y,bs,bs);
}
}
}
}
function makeOreTexture(dots){
return makeTexture((ctx,s)=>{
speckle(ctx,s,'#8d8d8d',['#7d7d7d','#9c9c9c','#707070']);
for(let i=0;i<7;i++){
const x=Math.floor(Math.random()*(s-1)), y=Math.floor(Math.random()*(s-1));
ctx.fillStyle = dots[Math.floor(Math.random()*dots.length)];
ctx.fillRect(x,y,2,2);
}
});
}
const texDirt = makeTexture((ctx,s)=>speckle(ctx,s,'#7a5230',['#6b4726','#8a6038','#5e3c1f']));
const texStone = makeTexture((ctx,s)=>speckle(ctx,s,'#8d8d8d',['#7d7d7d','#9c9c9c','#707070','#a5a5a5']));
const texGrassTop = makeTexture((ctx,s)=>speckle(ctx,s,'#5fa53a',['#549132','#6fbf47','#4a8028']));
const texSand = makeTexture((ctx,s)=>speckle(ctx,s,'#ddd19a',['#d0c288','#e6dcae','#c7b878']));
const texWoodTop = makeTexture((ctx,s)=>{
ctx.fillStyle = '#a9814f'; ctx.fillRect(0,0,s,s);
ctx.strokeStyle = '#7a5a34';
for(let r=1;r<s/2;r+=2){ ctx.strokeRect(s/2-r, s/2-r, r*2, r*2); }
});
const texWoodSide = makeTexture((ctx,s)=>{
ctx.fillStyle = '#6b4a2b'; ctx.fillRect(0,0,s,s);
ctx.strokeStyle = '#543a20';
for(let x=0;x<s;x+=3){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,s); ctx.stroke(); }
});
const texLeaves = makeTexture((ctx,s)=>{
ctx.fillStyle = '#3d7a2a'; ctx.fillRect(0,0,s,s);
for(let y=0;y<s;y++) for(let x=0;x<s;x++){
const r = Math.random();
if(r<0.35){ ctx.fillStyle = '#336622'; ctx.fillRect(x,y,1,1); }
else if(r<0.55){ ctx.fillStyle = '#4f9236'; ctx.fillRect(x,y,1,1); }
}
});
const texWater = makeTexture((ctx,s)=>speckle(ctx,s,'#3a6fe0',['#3563c9','#4a80f0']));
const texBedrock = makeTexture((ctx,s)=>speckle(ctx,s,'#3a3a3a',['#2a2a2a','#454545','#1c1c1c']));
const texGrassSide = makeTexture((ctx,s)=>{
speckle(ctx,s,'#7a5230',['#6b4726','#8a6038','#5e3c1f']);
ctx.fillStyle = '#5fa53a';
ctx.fillRect(0,0,s,4);
for(let x=0;x<s;x++){ if(Math.random()<0.4){ ctx.fillStyle='#4a8028'; ctx.fillRect(x,3,1,1);} }
});
const texCobble = makeTexture((ctx,s)=>blotch(ctx,s,'#8a8a8a',['#767676','#9d9d9d','#6e6e6e','#b0b0b0'],3));
const texPlanks = makeTexture((ctx,s)=>{
ctx.fillStyle='#c19a5b'; ctx.fillRect(0,0,s,s);
ctx.strokeStyle='#a17a3e';
for(let y=3;y<s;y+=4){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(s,y); ctx.stroke(); }
for(let x=0;x<s;x+=8){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,s); ctx.stroke(); }
});
const texGlass = makeTexture((ctx,s)=>{
ctx.clearRect(0,0,s,s);
ctx.fillStyle='rgba(190,225,235,0.4)'; ctx.fillRect(0,0,s,s);
ctx.strokeStyle='rgba(255,255,255,0.7)';
ctx.strokeRect(0.5,0.5,s-1,s-1);
});
const texBrick = makeTexture((ctx,s)=>{
ctx.fillStyle='#8f3b2b'; ctx.fillRect(0,0,s,s);
ctx.strokeStyle='#5c2318';
for(let y=0;y<s;y+=4){
const offset = (y/4)%2===0?0:4;
for(let x=-4;x<s;x+=8){ ctx.strokeRect(x+offset,y,8,4); }
}
});
const texSnow = makeTexture((ctx,s)=>speckle(ctx,s,'#f5f8fa',['#e6edf2','#ffffff','#dbe6ec']));
const texIce = makeTexture((ctx,s)=>speckle(ctx,s,'#bfe3f0',['#a9d6e8','#d3edf5','#9bcbdf']));
const texCactus = makeTexture((ctx,s)=>{
speckle(ctx,s,'#3f8a3d',['#357530','#4a9d46']);
ctx.strokeStyle='#2d6b2a';
for(let x=2;x<s;x+=5){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,s); ctx.stroke(); }
});
const texCoalOre = makeOreTexture(['#1a1a1a','#000000']);
const texIronOre = makeOreTexture(['#c9a27a','#b5835a']);
const texGoldOre = makeOreTexture(['#f5d442','#e0b923']);
const texDiamondOre = makeOreTexture(['#63e6e0','#3ec9c2']);
const materials = {
grass_top: new THREE.MeshLambertMaterial({map:texGrassTop}),
grass_side: new THREE.MeshLambertMaterial({map:texGrassSide}),
dirt: new THREE.MeshLambertMaterial({map:texDirt}),
stone: new THREE.MeshLambertMaterial({map:texStone}),
sand: new THREE.MeshLambertMaterial({map:texSand}),
wood_side: new THREE.MeshLambertMaterial({map:texWoodSide}),
wood_top: new THREE.MeshLambertMaterial({map:texWoodTop}),
leaves: new THREE.MeshLambertMaterial({map:texLeaves, transparent:true, alphaTest:0.4}),
water: new THREE.MeshLambertMaterial({map:texWater, transparent:true, opacity:0.72, depthWrite:false}),
bedrock: new THREE.MeshLambertMaterial({map:texBedrock}),
cobblestone: new THREE.MeshLambertMaterial({map:texCobble}),
planks: new THREE.MeshLambertMaterial({map:texPlanks}),
glass: new THREE.MeshLambertMaterial({map:texGlass, transparent:true, alphaTest:0.05}),
brick: new THREE.MeshLambertMaterial({map:texBrick}),
snow: new THREE.MeshLambertMaterial({map:texSnow}),
ice: new THREE.MeshLambertMaterial({map:texIce, transparent:true, opacity:0.85}),
cactus: new THREE.MeshLambertMaterial({map:texCactus}),
coal_ore: new THREE.MeshLambertMaterial({map:texCoalOre}),
iron_ore: new THREE.MeshLambertMaterial({map:texIronOre}),
gold_ore: new THREE.MeshLambertMaterial({map:texGoldOre}),
diamond_ore: new THREE.MeshLambertMaterial({map:texDiamondOre}),
};
for(const k in materials){ materials[k].side = THREE.DoubleSide; }
const blockColor = {
grass:0x5fa53a, dirt:0x7a5230, stone:0x8d8d8d, cobblestone:0x8a8a8a, sand:0xddd19a,
wood:0x6b4a2b, planks:0xc19a5b, leaves:0x3d7a2a, water:0x3a6fe0, glass:0xbfe0ea,
brick:0x8f3b2b, snow:0xf5f8fa, ice:0xbfe3f0, cactus:0x3f8a3d, coal_ore:0x2b2b2b,
iron_ore:0xb5835a, gold_ore:0xe0b923, diamond_ore:0x3ec9c2, bedrock:0x2a2a2a
};
const BLOCK_NAMES = {
grass:'Трава', dirt:'Земля', stone:'Камень', cobblestone:'Булыжник', sand:'Песок',
wood:'Бревно', planks:'Доски', leaves:'Листва', water:'Вода', glass:'Стекло',
brick:'Кирпич', snow:'Снег', ice:'Лёд', cactus:'Кактус', coal_ore:'Уголь.руда',
iron_ore:'Жел.руда', gold_ore:'Зол.руда', diamond_ore:'Алм.руда'
};
const ALL_BLOCK_TYPES = ['grass','dirt','stone','cobblestone','sand','wood','planks','leaves','glass','brick','snow','ice','cactus','water','coal_ore','iron_ore','gold_ore','diamond_ore'];
function previewCanvas(type){
let matKey = type;
if(type==='grass') matKey='grass_top';
else if(type==='wood') matKey='wood_side';
const mat = materials[matKey];
const img = mat.map.image;
const c = document.createElement('canvas'); c.width=32; c.height=32;
const ctx = c.getContext('2d'); ctx.imageSmoothingEnabled=false;
ctx.drawImage(img,0,0,32,32);
return c;
}
/* ============================= NOISE (2D Perlin, seeded) ============================= */
let perm = new Uint8Array(512);
function initPerm(seedVal){
const p = new Uint8Array(256);
for(let i=0;i<256;i++) p[i]=i;
let seed = seedVal;
function rnd(){ seed = (seed*9301+49297) % 233280; return seed/233280; }
for(let i=255;i>0;i--){
const j = Math.floor(rnd()*(i+1));
const t = p[i]; p[i]=p[j]; p[j]=t;
}
for(let i=0;i<512;i++) perm[i]=p[i&255];
}
initPerm(1337);
function fade(t){ return t*t*t*(t*(t*6-15)+10); }
function lerp(a,b,t){ return a+t*(b-a); }
function clamp01(v){ return Math.max(0,Math.min(1,v)); }
function smoothstep(e0,e1,x){ const t=clamp01((x-e0)/(e1-e0)); return t*t*(3-2*t); }
function grad(hash,x,y){
const h = hash & 3;
const u = h<2 ? x : y;
const v = h<2 ? y : x;
return ((h&1)?-u:u) + ((h&2)?-2*v:2*v);
}
function perlin2(x,y){
const X = Math.floor(x)&255, Y = Math.floor(y)&255;
x -= Math.floor(x); y -= Math.floor(y);
const u = fade(x), v = fade(y);
const aa = perm[perm[X]+Y], ab = perm[perm[X]+Y+1];
const ba = perm[perm[X+1]+Y], bb = perm[perm[X+1]+Y+1];
return lerp(
lerp(grad(aa,x,y), grad(ba,x-1,y), u),
lerp(grad(ab,x,y-1), grad(bb,x-1,y-1), u),
v
);
}
/* ============================= WORLD DATA (chunked, infinite) ============================= */
const CHUNK_SIZE = 16;
const RENDER_DISTANCE = 3;
const WATER_LEVEL = 4;
const WORLD_HEIGHT = 26;
let world = {};
let heightMap = {};
let biomeMap = {};
let generatedChunks = new Set();
let chunkMeshes = new Map();
let editsLog = {};
let mobs = [];
let seed = 1337;
function key(x,y,z){ return x+','+y+','+z; }
function setBlock(x,y,z,type){ world[key(x,y,z)] = type; }
function getBlock(x,y,z){ return world[key(x,y,z)]; }
function removeBlock(x,y,z){ delete world[key(x,y,z)]; }
function isSolidType(t){ return !!t && t !== 'water'; }
function computeHeight(x,z){
const n1 = perlin2(x*0.045, z*0.045);
const n2 = perlin2(x*0.09+50, z*0.09+50)*0.5;
const n3 = perlin2(x*0.2+150, z*0.2+150)*0.2;
const n = (n1+n2+n3)/1.7;
return Math.floor(6 + n*5);
}
function getBiome(x,z){
const temp = perlin2(x*0.008+500, z*0.008+500);
const humidity = perlin2(x*0.01+1500, z*0.01+1500);
if(temp>0.25 && humidity<0) return 'desert';
if(temp<-0.28) return 'snow';
if(humidity>0.15) return 'forest';
return 'plains';
}
function isCave(x,y,z){
const n1 = perlin2(x*0.09+y*0.07, z*0.09-y*0.05);
const n2 = perlin2(y*0.11+z*0.04, x*0.11-z*0.04);
return (n1+n2) > 0.62;
}
function placeTree(x,h,z){
const trunkH = 4 + Math.floor(Math.random()*2);
for(let y=h+1; y<=h+trunkH; y++){ if(!getBlock(x,y,z)) setBlock(x,y,z,'wood'); }
const top = h+trunkH;
for(let dy=-2; dy<=-1; dy++){
for(let dx=-2; dx<=2; dx++){
for(let dz=-2; dz<=2; dz++){
if(Math.abs(dx)===2 && Math.abs(dz)===2) continue;
const bx=x+dx, by=top+dy, bz=z+dz;
if(!getBlock(bx,by,bz)) setBlock(bx,by,bz,'leaves');
}
}
}
for(let dx=-1; dx<=1; dx++){
for(let dz=-1; dz<=1; dz++){
const bx=x+dx, by=top, bz=z+dz;
if(!getBlock(bx,by,bz)) setBlock(bx,by,bz,'leaves');
}
}
if(!getBlock(x,top+1,z)) setBlock(x,top+1,z,'leaves');
}
function placeCactus(x,h,z){
const ht = 1+Math.floor(Math.random()*3);
for(let y=h+1; y<=h+ht; y++){ if(!getBlock(x,y,z)) setBlock(x,y,z,'cactus'); }
}
function generateChunk(cx,cz){
const startX = cx*CHUNK_SIZE, startZ = cz*CHUNK_SIZE;
for(let lx=0; lx<CHUNK_SIZE; lx++){
for(let lz=0; lz<CHUNK_SIZE; lz++){
const x = startX+lx, z = startZ+lz;
const biome = getBiome(x,z);
let h = computeHeight(x,z);
if(biome==='desert') h = Math.round(h*0.7)+2;
if(biome==='snow') h += 1;
heightMap[x+','+z] = h;
biomeMap[x+','+z] = biome;
let surface = 'grass';
if(biome==='desert') surface = 'sand';
else if(biome==='snow') surface = h<=WATER_LEVEL+1 ? 'sand' : 'snow';
else if(h<=WATER_LEVEL) surface = 'sand';
const maxY = Math.max(h, WATER_LEVEL);
for(let y=0; y<=maxY; y++){
const existing = getBlock(x,y,z);
if(existing==='leaves' || existing==='wood') continue;
if(y===0){ setBlock(x,y,z,'bedrock'); continue; }
if(y>h){
if(y<=WATER_LEVEL){
setBlock(x,y,z, (biome==='snow' && y===WATER_LEVEL) ? 'ice' : 'water');
}
continue;
}
if(y<h-3 && y>2 && h>WATER_LEVEL+3 && isCave(x,y,z)) continue;
let type;
if(y===h) type = surface;
else if(y>=h-3) type = (biome==='desert') ? 'sand' : 'dirt';
else {
type = 'stone';
const roll = Math.random();
if(y<=4 && roll<0.02) type='diamond_ore';
else if(y<=7 && roll<0.045) type='gold_ore';
else if(y<=10 && roll<0.07) type='iron_ore';
else if(roll<0.09) type='coal_ore';
}
setBlock(x,y,z,type);
}
}
}
for(let i=0;i<4;i++){
const lx = Math.floor(Math.random()*CHUNK_SIZE), lz = Math.floor(Math.random()*CHUNK_SIZE);
const x = startX+lx, z = startZ+lz;
const biome = biomeMap[x+','+z];
const h = heightMap[x+','+z];
if(biome==='desert'){
if(h>WATER_LEVEL+1 && getBlock(x,h,z)==='sand' && Math.random()<0.15) placeCactus(x,h,z);
} else {
const chance = biome==='forest'?0.35:0.12;
const top = getBlock(x,h,z);
if(h>WATER_LEVEL+1 && (top==='grass'||top==='snow') && Math.random()<chance) placeTree(x,h,z);
}
}
// apply persisted edits inside this chunk
for(const k in editsLog){
const parts = k.split(',');
const ex = +parts[0], ey = +parts[1], ez = +parts[2];
if(ex>=startX && ex<startX+CHUNK_SIZE && ez>=startZ && ez<startZ+CHUNK_SIZE){
const v = editsLog[k];
if(v==='air') delete world[k]; else world[k]=v;
}
}
maybeSpawnMobs(cx,cz,startX,startZ);
}
/* ============================= MESH BUILDING (per-chunk) ============================= */
const FACES = [
{name:'px', dir:[1,0,0], corners:[[1,1,1],[1,0,1],[1,0,0],[1,1,0]]},
{name:'nx', dir:[-1,0,0], corners:[[0,1,0],[0,0,0],[0,0,1],[0,1,1]]},
{name:'py', dir:[0,1,0], corners:[[0,1,1],[0,1,0],[1,1,0],[1,1,1]]},
{name:'ny', dir:[0,-1,0], corners:[[0,0,0],[0,0,1],[1,0,1],[1,0,0]]},
{name:'pz', dir:[0,0,1], corners:[[1,1,1],[0,1,1],[0,0,1],[1,0,1]]},
{name:'nz', dir:[0,0,-1], corners:[[0,1,0],[1,1,0],[1,0,0],[0,0,0]]},
];
function faceUV(f,c){
if(f.dir[1]!==0) return [c[0], c[2]];
if(f.dir[0]!==0) return [c[2], c[1]];
return [c[0], c[1]];
}
function getMatKey(type, faceName){
if(type==='grass'){
if(faceName==='py') return 'grass_top';
if(faceName==='ny') return 'dirt';
return 'grass_side';
}
if(type==='wood'){
if(faceName==='py' || faceName==='ny') return 'wood_top';
return 'wood_side';
}
return type;
}
function unloadChunkMesh(k){
const g = chunkMeshes.get(k);
if(g){ scene.remove(g); g.traverse(o=>{ if(o.geometry) o.geometry.dispose(); }); chunkMeshes.delete(k); }
}
function rebuildChunkMesh(chunkKey){
unloadChunkMesh(chunkKey);
const parts = chunkKey.split(',');
const cx = +parts[0], cz = +parts[1];
const startX = cx*CHUNK_SIZE, startZ = cz*CHUNK_SIZE;
const groups = {};
function getGroup(k){ if(!groups[k]) groups[k]={positions:[],normals:[],uvs:[],indices:[],idx:0}; return groups[k]; }
for(let x=startX; x<startX+CHUNK_SIZE; x++){
for(let z=startZ; z<startZ+CHUNK_SIZE; z++){
for(let y=0; y<=WORLD_HEIGHT; y++){
const type = getBlock(x,y,z);
if(!type) continue;
for(const f of FACES){
const nx=x+f.dir[0], ny=y+f.dir[1], nz=z+f.dir[2];
const neighbor = getBlock(nx,ny,nz);
let hide;
if(type==='water') hide = !!neighbor;
else if(type==='leaves') hide = false;
else hide = neighbor && neighbor!=='water' && neighbor!=='leaves';
if(hide) continue;
const matKey = getMatKey(type, f.name);
const g = getGroup(matKey);
const start = g.idx;
for(let ci=0; ci<4; ci++){
const c = f.corners[ci];
g.positions.push(x+c[0], y+c[1], z+c[2]);
g.normals.push(f.dir[0], f.dir[1], f.dir[2]);
const uv = faceUV(f,c);
g.uvs.push(uv[0], uv[1]);
}
g.indices.push(start,start+1,start+2, start,start+2,start+3);
g.idx += 4;
}
}
}
}
const group = new THREE.Group();
for(const matKey in groups){
const g = groups[matKey];
if(g.positions.length===0) continue;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(g.positions,3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(g.normals,3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(g.uvs,2));
geo.setIndex(g.indices);
const mat = materials[matKey] || materials.stone;
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = matKey !== 'water';
mesh.receiveShadow = true;
group.add(mesh);
}
scene.add(group);
chunkMeshes.set(chunkKey, group);
}
function chunkKeyAt(x,z){ return Math.floor(x/CHUNK_SIZE)+','+Math.floor(z/CHUNK_SIZE); }
function ensureChunksAround(px,pz){
const pcx = Math.floor(px/CHUNK_SIZE), pcz = Math.floor(pz/CHUNK_SIZE);
const needed = new Set();
const newlyGenerated = [];
for(let dx=-RENDER_DISTANCE; dx<=RENDER_DISTANCE; dx++){
for(let dz=-RENDER_DISTANCE; dz<=RENDER_DISTANCE; dz++){
if(dx*dx+dz*dz > RENDER_DISTANCE*RENDER_DISTANCE+2) continue;
const cx=pcx+dx, cz=pcz+dz, k=cx+','+cz;
needed.add(k);
if(!generatedChunks.has(k)){
generateChunk(cx,cz);
generatedChunks.add(k);
newlyGenerated.push(k);
}
}
}
for(const k of Array.from(chunkMeshes.keys())){
if(!needed.has(k)) unloadChunkMesh(k);
}
const toRebuild = new Set();
for(const k of newlyGenerated){
const p = k.split(',').map(Number);
toRebuild.add(k);
toRebuild.add((p[0]+1)+','+p[1]); toRebuild.add((p[0]-1)+','+p[1]);
toRebuild.add(p[0]+','+(p[1]+1)); toRebuild.add(p[0]+','+(p[1]-1));
}
for(const k of needed){ if(!chunkMeshes.has(k)) toRebuild.add(k); }
for(const k of toRebuild){ if(generatedChunks.has(k)) rebuildChunkMesh(k); }
}
function rebuildAffected(x,z){
const keys = [chunkKeyAt(x,z), chunkKeyAt(x+1,z), chunkKeyAt(x-1,z), chunkKeyAt(x,z+1), chunkKeyAt(x,z-1)];
const done = new Set();
for(const k of keys){
if(done.has(k)) continue; done.add(k);
if(generatedChunks.has(k)) rebuildChunkMesh(k);
}
}
/* ============================= MOBS ============================= */
function createMobMesh(type){
const g = new THREE.Group();
if(type==='pig'){
const bodyMat = new THREE.MeshLambertMaterial({color:0xf2b6c6});
const body = new THREE.Mesh(new THREE.BoxGeometry(0.9,0.55,0.5), bodyMat);
body.position.y = 0.55; body.castShadow=true;
const head = new THREE.Mesh(new THREE.BoxGeometry(0.4,0.4,0.4), bodyMat);
head.position.set(0,0.6,0.4); head.castShadow=true;
g.add(body,head);
} else {
const bodyMat = new THREE.MeshLambertMaterial({color:0x3f8f4a});
const body = new THREE.Mesh(new THREE.BoxGeometry(0.5,0.9,0.3), bodyMat);
body.position.y = 0.75; body.castShadow=true;
const head = new THREE.Mesh(new THREE.BoxGeometry(0.42,0.42,0.42), new THREE.MeshLambertMaterial({color:0x4caf50}));
head.position.set(0,1.35,0); head.castShadow=true;
g.add(body,head);
}
return g;
}
function spawnMob(type,x,y,z){
const mesh = createMobMesh(type);
mesh.position.set(x,y,z);
scene.add(mesh);
mobs.push({type, mesh, dir:Math.random()*Math.PI*2, timer:2+Math.random()*3, idle:false, speed:type==='pig'?1.3:1.9, lastHit:0});
}
function removeMob(m){
scene.remove(m.mesh);
m.mesh.traverse(o=>{ if(o.geometry) o.geometry.dispose(); });
mobs = mobs.filter(x=>x!==m);
}
function maybeSpawnMobs(cx,cz,startX,startZ){
if(Math.random()<0.45){
for(let a=0;a<5;a++){
const x = startX+Math.floor(Math.random()*CHUNK_SIZE);
const z = startZ+Math.floor(Math.random()*CHUNK_SIZE);
const biome = biomeMap[x+','+z];
const h = heightMap[x+','+z];
if((biome==='plains'||biome==='forest') && h>WATER_LEVEL && getBlock(x,h,z)==='grass'){
spawnMob('pig', x+0.5, h+1, z+0.5);
break;
}
}
}
}
function updateMobs(dt){
for(const m of mobs.slice()){
m.timer -= dt;
if(m.timer<=0){
m.dir = Math.random()*Math.PI*2;
m.timer = 2+Math.random()*4;
m.idle = Math.random()<0.3;
}
if(m.type==='zombie'){
const dx = player.position.x-m.mesh.position.x, dz = player.position.z-m.mesh.position.z;
const dist = Math.hypot(dx,dz);
if(dist<10){ m.dir = Math.atan2(dz,dx); m.idle=false; }
if(dist<1.3){
const now = performance.now();
if(now-m.lastHit>1200){ m.lastHit=now; damagePlayer(2); sfxHurt(); }
}
}
if(!m.idle){
const nx = m.mesh.position.x + Math.cos(m.dir)*m.speed*dt;
const nz = m.mesh.position.z + Math.sin(m.dir)*m.speed*dt;
const hk = Math.floor(nx)+','+Math.floor(nz);
const h = heightMap[hk];
if(h!==undefined && getBlock(Math.floor(nx),h,Math.floor(nz))!=='water'){
m.mesh.position.x = nx; m.mesh.position.z = nz; m.mesh.position.y = h+1;
m.mesh.rotation.y = -m.dir+Math.PI/2;
} else {
m.dir = Math.random()*Math.PI*2;
}
}
}
}
let zombieSpawnTimer = 0;
function updateNightSpawns(dt, dayFactor){
zombieSpawnTimer -= dt;
if(dayFactor<0.25 && zombieSpawnTimer<=0 && mobs.filter(m=>m.type==='zombie').length<6){
zombieSpawnTimer = 4+Math.random()*3;
for(let a=0;a<6;a++){
const ang = Math.random()*Math.PI*2, dist = 10+Math.random()*12;
const x = Math.floor(player.position.x + Math.cos(ang)*dist);
const z = Math.floor(player.position.z + Math.sin(ang)*dist);
const h = heightMap[x+','+z];
if(h!==undefined && h>WATER_LEVEL && getBlock(x,h,z)!=='water'){
spawnMob('zombie', x+0.5, h+1, z+0.5);
break;
}
}
}
if(dayFactor>0.65){
for(const m of mobs.filter(m=>m.type==='zombie')) removeMob(m);
}
}
/* ============================= PARTICLES ============================= */
let particles = [];
function spawnBreakParticles(x,y,z,type){
const color = blockColor[type] || 0x999999;
const mat = new THREE.MeshBasicMaterial({color});
for(let i=0;i<6;i++){
const geo = new THREE.BoxGeometry(0.12,0.12,0.12);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x+0.5+(Math.random()-0.5)*0.6, y+0.5+(Math.random()-0.5)*0.6, z+0.5+(Math.random()-0.5)*0.6);
scene.add(mesh);
particles.push({
mesh,
vel:new THREE.Vector3((Math.random()-0.5)*2, Math.random()*2.5, (Math.random()-0.5)*2),
life:0.5+Math.random()*0.3
});
}
}
function updateParticles(dt){
for(const p of particles.slice()){
p.vel.y -= 9*dt;
p.mesh.position.addScaledVector(p.vel, dt);
p.life -= dt;
if(p.life<=0){
scene.remove(p.mesh); p.mesh.geometry.dispose();
particles = particles.filter(x=>x!==p);
}
}
}
/* ============================= AUDIO ============================= */
let audioCtx = null;
function ensureAudio(){
if(!audioCtx){
try{ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){}
}
}
function playTone(freq,duration,type,vol,freqEnd){
if(!audioCtx) return;
const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain();
osc.type = type; osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
if(freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(1,freqEnd), audioCtx.currentTime+duration);
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime+duration);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime+duration);
}
function sfxBreak(){ playTone(140,0.15,'square',0.12,50); }
function sfxPlace(){ playTone(220,0.1,'square',0.1,320); }
function sfxJump(){ playTone(300,0.12,'sine',0.08,520); }
function sfxHurt(){ playTone(150,0.2,'sawtooth',0.16,70); }
/* ============================= PLAYER ============================= */
const player = {
position: new THREE.Vector3(0.5, 12, 0.5),
velocity: new THREE.Vector3(0,0,0),
width: 0.6,
height: 1.75,
eye: 1.62,
onGround: false,
flying: false,
health: 20,
hunger: 20,
lastDamageTime: 0,
};
let yaw = 0, pitch = 0;
let bobTimer = 0;
function collides(pos, w, h){
const minX = Math.floor(pos.x - w/2), maxX = Math.floor(pos.x + w/2);
const minY = Math.floor(pos.y), maxY = Math.floor(pos.y + h);
const minZ = Math.floor(pos.z - w/2), maxZ = Math.floor(pos.z + w/2);
for(let x=minX; x<=maxX; x++){
for(let y=minY; y<=maxY; y++){
for(let z=minZ; z<=maxZ; z++){
if(isSolidType(getBlock(x,y,z))) return true;
}
}
}
return false;
}
function damagePlayer(amount){
player.health = Math.max(0, player.health - amount);
player.lastDamageTime = performance.now();
updateStatsUI();
if(player.health<=0) respawnPlayer();
}
function respawnPlayer(){
const h = heightMap['0,0'] !== undefined ? heightMap['0,0'] : computeHeight(0,0);
player.position.set(0.5, h+3, 0.5);
player.velocity.set(0,0,0);
player.health = 20;
updateStatsUI();
const dm = document.getElementById('death-msg');
dm.style.display='block';
setTimeout(()=>{ dm.style.display='none'; }, 1500);
}
/* ============================= INPUT ============================= */
const keys = {};
let gameStarted = false;
let inventoryOpen = false;
let debugOn = false;
const hudTop = document.getElementById('hud-top');
const overlay = document.getElementById('overlay');
const startBtn = document.getElementById('start-btn');
const continueBtn = document.getElementById('continue-btn');
const invPanel = document.getElementById('inv-panel');
const invGrid = document.getElementById('inv-grid');
window.addEventListener('keydown', (e)=>{
keys[e.code] = true;
if(!gameStarted) return;
if(e.code==='KeyF' && !inventoryOpen){ player.flying = !player.flying; }
if(e.code==='F3'){ debugOn = !debugOn; hudTop.style.display = debugOn ? 'block' : 'none'; e.preventDefault(); }
if(e.code==='KeyE'){ if(inventoryOpen) closeInventory(); else if(locked) openInventory(); }
if(e.code==='Escape' && inventoryOpen){ closeInventory(); }
const num = parseInt(e.key);
if(num>=1 && num<=hotbar.length){ selectedSlot = num-1; updateHotbarUI(); }
});
window.addEventListener('keyup', (e)=>{ keys[e.code] = false; });
let locked = false;
function setStartLabel(){
const hasSave = !!localStorage.getItem('blockcraft_save');
continueBtn.style.display = hasSave ? 'inline-block' : 'none';
}
setStartLabel();
startBtn.addEventListener('click', ()=>{
ensureAudio();
initNewWorld();
gameStarted = true;
canvas.requestPointerLock();
});
continueBtn.addEventListener('click', ()=>{
ensureAudio();
loadSavedWorld();
gameStarted = true;
canvas.requestPointerLock();
});
canvas.addEventListener('click', ()=>{
if(!locked && gameStarted && !inventoryOpen) canvas.requestPointerLock();
});
document.addEventListener('pointerlockchange', ()=>{
locked = document.pointerLockElement === canvas;
if(locked){
overlay.style.display = 'none';
invPanel.style.display = 'none';
} else if(gameStarted){
if(inventoryOpen){ invPanel.style.display='flex'; overlay.style.display='none'; }
else { overlay.style.display = 'flex'; }
}
});
document.addEventListener('mousemove', (e)=>{
if(!locked) return;
const sensitivity = 0.0022;
yaw -= e.movementX * sensitivity;
pitch -= e.movementY * sensitivity;
const limit = Math.PI/2 - 0.01;
pitch = Math.max(-limit, Math.min(limit, pitch));
});
canvas.addEventListener('contextmenu', (e)=>e.preventDefault());
function openInventory(){
inventoryOpen = true;
buildInventoryGrid();
invPanel.style.display = 'flex';
document.exitPointerLock();
}
function closeInventory(){
inventoryOpen = false;
invPanel.style.display = 'none';
canvas.requestPointerLock();
}
function buildInventoryGrid(){
invGrid.innerHTML = '';
ALL_BLOCK_TYPES.forEach(type=>{
const item = document.createElement('div');
item.className = 'inv-item';
const c = previewCanvas(type);
item.appendChild(c);
const label = document.createElement('span');
label.textContent = BLOCK_NAMES[type] || type;
item.appendChild(label);
item.addEventListener('click', ()=>{
hotbar[selectedSlot] = type;
updateHotbarUI();
closeInventory();
});
invGrid.appendChild(item);
});
}
/* mining / placing */
const raycaster = new THREE.Raycaster();
const REACH = 6.5;
function getTargetedBlock(){
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
raycaster.set(camera.position, dir);
raycaster.far = REACH;
const meshes = [];
for(const g of chunkMeshes.values()) meshes.push(...g.children);
const hits = raycaster.intersectObjects(meshes, false);
for(const hit of hits){
if(hit.object.material === materials.water) continue;
const n = hit.face.normal.clone();
const point = hit.point.clone();
const breakPos = point.clone().addScaledVector(n, -0.5).floor();
const placePos = point.clone().addScaledVector(n, 0.5).floor();
return {breakPos, placePos};
}
return null;
}
canvas.addEventListener('mousedown', (e)=>{
if(!locked || inventoryOpen) return;
const target = getTargetedBlock();
if(!target) return;
if(e.button === 0){
const {breakPos} = target;
const t = getBlock(breakPos.x, breakPos.y, breakPos.z);
if(!t || t==='bedrock' || breakPos.y===0) return;
removeBlock(breakPos.x, breakPos.y, breakPos.z);
editsLog[key(breakPos.x,breakPos.y,breakPos.z)] = 'air';
spawnBreakParticles(breakPos.x, breakPos.y, breakPos.z, t);
sfxBreak();
rebuildAffected(breakPos.x, breakPos.z);
} else if(e.button === 2){
const {placePos} = target;
if(getBlock(placePos.x,placePos.y,placePos.z)) return;
const dx = Math.abs(placePos.x+0.5-player.position.x);
const dz = Math.abs(placePos.z+0.5-player.position.z);
const dy = placePos.y - player.position.y;
const overlapY = dy > -player.height-0.2 && dy < 1;
if(dx < player.width/2+0.5 && dz < player.width/2+0.5 && overlapY) return;
const type = hotbar[selectedSlot];
setBlock(placePos.x, placePos.y, placePos.z, type);
editsLog[key(placePos.x,placePos.y,placePos.z)] = type;
sfxPlace();
rebuildAffected(placePos.x, placePos.z);
}
});
/* ============================= HOTBAR ============================= */
let hotbar = ['grass','dirt','stone','cobblestone','sand','wood','planks','leaves','glass'];
let selectedSlot = 0;
const hotbarEl = document.getElementById('hotbar');
function buildHotbarUI(){
hotbarEl.innerHTML = '';
hotbar.forEach((type, i)=>{
const slot = document.createElement('div');
slot.className = 'slot' + (i===selectedSlot ? ' selected' : '');
const num = document.createElement('span'); num.className='num'; num.textContent = i+1;
slot.appendChild(num);
slot.appendChild(previewCanvas(type));
slot.addEventListener('click', ()=>{ selectedSlot = i; updateHotbarUI(); });
hotbarEl.appendChild(slot);
});
}
function updateHotbarUI(){
[...hotbarEl.children].forEach((el,i)=>{ el.classList.toggle('selected', i===selectedSlot); });
}
window.addEventListener('wheel', (e)=>{
if(!locked) return;
selectedSlot = (selectedSlot + (e.deltaY>0?1:-1) + hotbar.length) % hotbar.length;
updateHotbarUI();
});
/* ============================= STATS UI ============================= */
const healthBar = document.getElementById('health-bar');
const hungerBar = document.getElementById('hunger-bar');
function updateStatsUI(){
healthBar.style.width = (player.health/20*100)+'%';
hungerBar.style.width = (player.hunger/20*100)+'%';
}
/* ============================= MINIMAP ============================= */
const miniCanvas = document.getElementById('minimap');
const miniCtx = miniCanvas.getContext('2d');
function drawMinimap(){
const radius = 32, cell = 2;
miniCtx.clearRect(0,0,130,130);
const pcx = Math.floor(player.position.x), pcz = Math.floor(player.position.z);
for(let dx=-radius; dx<radius; dx++){
for(let dz=-radius; dz<radius; dz++){
const x = pcx+dx, z = pcz+dz;
const biome = getBiome(x,z);
let h = computeHeight(x,z);
if(biome==='desert') h = Math.round(h*0.7)+2;
if(biome==='snow') h += 1;
let color;
if(h<=WATER_LEVEL) color = '#3a6fe0';
else if(biome==='desert') color = '#ddd19a';
else if(biome==='snow') color = '#f5f8fa';
else color = '#5fa53a';
miniCtx.fillStyle = color;
miniCtx.fillRect((dx+radius)*cell, (dz+radius)*cell, cell, cell);
}
}
const cx=130/2, cy=130/2;
miniCtx.fillStyle = '#ff3333';
miniCtx.beginPath(); miniCtx.arc(cx,cy,4,0,Math.PI*2); miniCtx.fill();
miniCtx.strokeStyle = '#fff'; miniCtx.lineWidth=2;
miniCtx.beginPath(); miniCtx.moveTo(cx,cy);
miniCtx.lineTo(cx+Math.sin(yaw)*10, cy-Math.cos(yaw)*10);
miniCtx.stroke();
}
/* ============================= SAVE / LOAD ============================= */
function saveGame(){
if(!gameStarted) return;
const data = {
seed, time:gameTime, edits:editsLog,
pos:{x:player.position.x,y:player.position.y,z:player.position.z},
health:player.health, hunger:player.hunger, hotbar
};
try{ localStorage.setItem('blockcraft_save', JSON.stringify(data)); }catch(e){}
}
window.addEventListener('beforeunload', saveGame);
function resetWorldState(){
world = {}; heightMap = {}; biomeMap = {}; generatedChunks = new Set();
for(const k of Array.from(chunkMeshes.keys())) unloadChunkMesh(k);
chunkMeshes = new Map();
for(const m of mobs.slice()) removeMob(m);
mobs = [];
particles.forEach(p=>{ scene.remove(p.mesh); p.mesh.geometry.dispose(); });
particles = [];
}
function initNewWorld(){
seed = Math.floor(Math.random()*1000000);
initPerm(seed);
editsLog = {};
resetWorldState();
const h = computeHeight(0,0);
player.position.set(0.5, h+3, 0.5);
player.velocity.set(0,0,0);
player.health = 20; player.hunger = 20;
gameTime = 0.3*DAY_LENGTH;
hotbar = ['grass','dirt','stone','cobblestone','sand','wood','planks','leaves','glass'];
selectedSlot = 0;
buildHotbarUI(); updateStatsUI();
ensureChunksAround(0.5,0.5);
}
function loadSavedWorld(){
let save = null;
try{ save = JSON.parse(localStorage.getItem('blockcraft_save')); }catch(e){}
if(!save){ initNewWorld(); return; }
seed = save.seed;
initPerm(seed);
editsLog = save.edits || {};
resetWorldState();
player.position.set(save.pos.x, save.pos.y, save.pos.z);
player.velocity.set(0,0,0);
player.health = save.health!==undefined ? save.health : 20;
player.hunger = save.hunger!==undefined ? save.hunger : 20;
gameTime = save.time || 0;
hotbar = save.hotbar || ['grass','dirt','stone','cobblestone','sand','wood','planks','leaves','glass'];
selectedSlot = 0;
buildHotbarUI(); updateStatsUI();
ensureChunksAround(player.position.x, player.position.z);
}
setInterval(saveGame, 20000);
/* ============================= DAY/NIGHT ============================= */
const DAY_LENGTH = 300; // seconds per full cycle
let gameTime = 0.3*DAY_LENGTH;
const sunDir = new THREE.Vector3();
function updateSky(dt){
gameTime += dt;
const t = (gameTime % DAY_LENGTH) / DAY_LENGTH;
const angle = (t-0.25)*Math.PI*2;
sunDir.set(0.5, Math.sin(angle), Math.cos(angle)).normalize();
const sunHeight = sunDir.y;
const dayFactor = smoothstep(-0.25, 0.35, sunHeight);
const dawnFactor1 = smoothstep(-0.3, 0, sunHeight);
const dawnFactor2 = smoothstep(0, 0.3, sunHeight);
const colorA = NIGHT_COLOR.clone().lerp(DAWN_COLOR, dawnFactor1);
const finalColor = colorA.lerp(DAY_COLOR, dawnFactor2);
scene.background.copy(finalColor);
scene.fog.color.copy(finalColor);
ambient.intensity = lerp(0.15, 0.6, dayFactor);
hemi.intensity = lerp(0.1, 0.4, dayFactor);
sun.intensity = lerp(0.05, 0.95, dayFactor);
starMat.opacity = clamp01(1 - smoothstep(-0.05, 0.2, sunHeight));
sun.position.copy(player.position).addScaledVector(sunDir, 60);
sun.position.y += 10;
sunTarget.position.copy(player.position);
stars.position.copy(player.position);
updateNightSpawns(dt, dayFactor);
return dayFactor;
}
/* ============================= GAME LOOP ============================= */
const clock = new THREE.Clock();
const GRAVITY = 24;
const JUMP_SPEED = 8.2;
const WALK_SPEED = 5.2;
const FLY_SPEED = 9;
let chunkCheckTimer = 0;
let hungerTimer = 0, healthTimer = 0, statTimer = 0;
function updatePlayer(dt){
camera.rotation.y = yaw;
camera.rotation.x = pitch;
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0; forward.normalize();
const right = new THREE.Vector3();
right.crossVectors(forward, new THREE.Vector3(0,1,0)).negate();
let moveX = 0, moveZ = 0;
if(keys['KeyW']){ moveX += forward.x; moveZ += forward.z; }
if(keys['KeyS']){ moveX -= forward.x; moveZ -= forward.z; }
if(keys['KeyA']){ moveX -= right.x; moveZ -= right.z; }
if(keys['KeyD']){ moveX += right.x; moveZ += right.z; }
const len = Math.hypot(moveX, moveZ);
if(len>0){ moveX/=len; moveZ/=len; }
const sprinting = !!keys['ShiftLeft'] && len>0 && !player.flying;
const speed = player.flying ? FLY_SPEED : (sprinting ? WALK_SPEED*1.6 : WALK_SPEED);
if(player.flying){
player.velocity.set(moveX*speed, 0, moveZ*speed);
if(keys['Space']) player.velocity.y = speed;
else if(keys['ControlLeft']) player.velocity.y = -speed;
else player.velocity.y = 0;
} else {
player.velocity.x = moveX*speed;
player.velocity.z = moveZ*speed;
player.velocity.y -= GRAVITY*dt;
if(player.velocity.y < -40) player.velocity.y = -40;
if(keys['Space'] && player.onGround){
player.velocity.y = JUMP_SPEED;
player.onGround = false;
sfxJump();
}
}
const w = player.width, h = player.height;
player.position.x += player.velocity.x*dt;
if(collides(player.position, w, h)) player.position.x -= player.velocity.x*dt;
player.position.z += player.velocity.z*dt;
if(collides(player.position, w, h)) player.position.z -= player.velocity.z*dt;
player.onGround = false;
player.position.y += player.velocity.y*dt;
if(collides(player.position, w, h)){
player.position.y -= player.velocity.y*dt;
if(player.velocity.y < 0){
player.onGround = true;
if(player.velocity.y < -18){
const dmg = Math.floor((-player.velocity.y-18)*0.6);
if(dmg>0) damagePlayer(dmg);
}
}
player.velocity.y = 0;
}
if(player.position.y < -25){
const h0 = heightMap['0,0'] !== undefined ? heightMap['0,0'] : computeHeight(0,0);
player.position.set(0.5, h0+3, 0.5);
player.velocity.set(0,0,0);
}
camera.position.set(player.position.x, player.position.y + player.eye, player.position.z);
const moving = len>0 && player.onGround && !player.flying;
if(moving){
bobTimer += dt*8*(sprinting?1.5:1);
camera.position.y += Math.sin(bobTimer)*0.045;
} else {
bobTimer = 0;
}
const targetFov = sprinting ? 84 : 75;
camera.fov += (targetFov-camera.fov)*Math.min(1, dt*8);
camera.updateProjectionMatrix();
}
function updateDebug(dayFactor, fps){
if(!debugOn) return;
const biome = getBiome(Math.floor(player.position.x), Math.floor(player.position.z));
hudTop.textContent =
`FPS: ${fps.toFixed(0)}
XYZ: ${player.position.x.toFixed(2)}, ${player.position.y.toFixed(2)}, ${player.position.z.toFixed(2)}
Чанк: ${Math.floor(player.position.x/CHUNK_SIZE)}, ${Math.floor(player.position.z/CHUNK_SIZE)}
Биом: ${biome}
onGround: ${player.onGround} | fly: ${player.flying}
День: ${(dayFactor>0.3?'да':'нет')} (${dayFactor.toFixed(2)})
Загружено чанков: ${generatedChunks.size}
Мобов: ${mobs.length}`;
}
let fpsSmooth = 60;
let miniTimer = 0;
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
fpsSmooth = fpsSmooth*0.9 + (1/Math.max(dt,0.0001))*0.1;
let dayFactor = 0.8;
if(gameStarted){
dayFactor = updateSky(dt);
cloudGroup.children.forEach(c=>{
c.position.x += dt*0.6;
if(c.position.x - player.position.x > 90) c.position.x -= 180;
});
if(locked){
updatePlayer(dt);
updateMobs(dt);
updateParticles(dt);
chunkCheckTimer += dt;
if(chunkCheckTimer > 0.4){
chunkCheckTimer = 0;
ensureChunksAround(player.position.x, player.position.z);
}
statTimer += dt;
if(statTimer>1){
statTimer -= 1;
hungerTimer++; healthTimer++;
if(hungerTimer>=40){ hungerTimer=0; player.hunger = Math.max(0, player.hunger-1); updateStatsUI(); }
if(healthTimer>=4 && player.health<20 && player.hunger>0 && (performance.now()-player.lastDamageTime)>5000){
healthTimer=0; player.health = Math.min(20, player.health+1); updateStatsUI();
}
}
miniTimer += dt;
if(miniTimer>0.5){ miniTimer=0; drawMinimap(); }
}
updateDebug(dayFactor, fpsSmooth);
}
renderer.render(scene, camera);
}
animate();
})();
</script>
</body>
</html>