Загрузка данных


<!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> движение &nbsp; <span class="key">Space</span> прыжок<br>
  <span class="key">Shift</span> бег &nbsp; <span class="key">F</span> полёт<br>
  <span class="key">ЛКМ</span> сломать &nbsp; <span class="key">ПКМ</span> поставить<br>
  <span class="key">1-9</span> / колесо — хотбар<br>
  <span class="key">E</span> инвентарь &nbsp; <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>