SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay';
- if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel';
-
- // руды: чем глубже, тем интереснее
- const depth = gy - sgy;
- const r = seededRandom(gx, gy);
- if(t==='stone'){
- if(r < 0.06) t='coal';
- else if(r < 0.10) t='copper_ore';
- else if(r < 0.13) t='iron_ore';
- else if(depth > 40 && r < 0.145) t='gold_ore';
- else if(depth > 70 && r < 0.152) t='diamond_ore';
- }
-
- setBlock(gx,gy,t);
- }
-
- // Деревья и цветы (только на траве, и не в воде)
- const top = getBlock(gx, sgy);
- if(top && top.t==='grass'){
- if(seededRandom(gx, sgy-1) < 0.10){
- setBlock(gx, sgy-1,'flower');
- }
- if(seededRandom(gx, sgy-2) < 0.12){
- // простое дерево
- setBlock(gx, sgy-1, 'wood');
- setBlock(gx, sgy-2, 'wood');
- setBlock(gx, sgy-3, 'leaves');
- setBlock(gx-1, sgy-3,'leaves');
- setBlock(gx+1, sgy-3,'leaves');
- }
- }
- }
-
- // Перегенерация видимых чанков (используется при загрузке сохранения)
- function regenerateVisibleChunks(){
- const gx0 = Math.floor(camX/TILE);
- for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
- // Принудительно перегенерируем колонну
- generated.delete(gx);
- genColumn(gx);
- }
- }
-
- function ensureGenAroundCamera(){
- const gx0 = Math.floor(camX/TILE);
- for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
- genColumn(gx);
- }
- }
-
- // Лут с дерева/листвы: дерево -> wood; листья -> leaves
- // (уже в mine добавляется inv[type] автоматически)
-
- // Рисование костра: огонь поверх текстуры
- function drawFire(wx,wy,now){
- const baseX = wx;
- const baseY = wy;
- const flick = 6 + (Math.sin(now/90)+1)*4;
- ctx.fillStyle = 'rgba(255,140,0,0.85)';
- ctx.beginPath();
- ctx.moveTo(baseX+10, baseY+30);
- ctx.lineTo(baseX+20, baseY+30-flick);
- ctx.lineTo(baseX+30, baseY+30);
- ctx.fill();
-
- ctx.fillStyle = 'rgba(255,230,150,0.75)';
- ctx.beginPath();
- ctx.moveTo(baseX+14, baseY+30);
- ctx.lineTo(baseX+20, baseY+30-(flick*0.7));
- ctx.lineTo(baseX+26, baseY+30);
- ctx.fill();
- }
-
- // Моб AI
- function mobAI(m, dt){
- updateWaterFlag(m);
-
- if(m.kind==='zombie'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp=0; return; }
- const dir = Math.sign((player.x) - m.x);
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
- // атака
- if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
- Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
- player.invuln <= 0){
- player.hp -= 15;
- player.invuln = 0.8;
- player.vx += dir*420;
- player.vy -= 260;
- playSound('hit1'); // Звук при атаке зомби
- }
- } else if(m.kind==='creeper'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp=0; return; }
- const dir = Math.sign((player.x) - m.x);
- const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
-
- // Движение к игроку
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
-
- // Взрыв если близко к игроку
- if(dist < 60){
- m.fuse -= dt;
- if(m.fuse <= 0){
- explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE));
- m.hp = 0;
- }
- } else {
- // Поджигаем если очень близко
- if(dist < 40){
- m.fuse = 0.5; // Быстрый взрыв
- }
- }
- } else if(m.kind==='skeleton'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp=0; return; }
- const dir = Math.sign((player.x) - m.x);
- const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
-
- // Движение к игроку
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
-
- // Стрельба стрелами с проверкой препятствий
- m.shootCooldown -= dt;
- if(dist < 200 && m.shootCooldown <= 0){
- m.shootCooldown = 1.5;
- // Создаём стрелу (упрощённо - просто урон)
- const dx = (player.x+player.w/2) - (m.x+m.w/2);
- const dy = (player.y+player.h/2) - (m.y+m.h/2);
- const angle = Math.atan2(dy, dx);
-
- // Проверяем препятствия (до 5 блоков)
- let blocked = false;
- const checkSteps = 5;
- const stepSize = dist / checkSteps;
- for(let i = 1; i <= checkSteps; i++){
- const checkX = m.x + m.w/2 + Math.cos(angle) * stepSize * i;
- const checkY = m.y + m.h/2 + Math.sin(angle) * stepSize * i;
- const checkGX = Math.floor(checkX / TILE);
- const checkGY = Math.floor(checkY / TILE);
- if(isSolid(checkGX, checkGY)){
- blocked = true;
- break;
- }
- }
-
- // Урон игроку если попали и нет препятствий
- if(!blocked && dist < 150 && player.invuln <= 0){
- player.hp -= 8;
- player.invuln = 0.5;
- player.vx += Math.cos(angle) * 300;
- player.vy -= 200;
- playSound('hit1');
- }
- }
- } else {
- // животные
- m.aiT -= dt;
- if(m.aiT <= 0){
- m.aiT = 1.8 + Math.random()*2.5;
- m.dir = Math.random()<0.5 ? -1 : 1;
- if(Math.random()<0.25) m.dir = 0;
- }
- m.vx = m.dir * (m.kind==='chicken' ? 55 : 40);
- if(m.inWater) m.vy = -120;
- }
-
- // физика моба
- const g = m.inWater ? GRAV_WATER : GRAV;
- m.vy += g*dt;
-
- m.y += m.vy*dt; m.grounded=false; resolveY(m);
- m.x += m.vx*dt; resolveX(m);
- }
-
- function isNight(){
- // Автоматический цикл: ночь когда worldTime > 0.5
- return worldTime > 0.5;
- }
-
- // Respawn
- document.getElementById('respawnBtn').onclick = async () => {
- playSound('click'); // Звук клика по кнопке
-
- console.log('=== RESPAWN CLICKED ===');
- console.log('isMultiplayer:', isMultiplayer);
- console.log('otherPlayers.size:', otherPlayers.size);
- console.log('player.hp before respawn:', player.hp);
-
- // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
- if (isMultiplayer && otherPlayers.size > 0) {
- console.log('Мультиплеер режим - возрождение в начальной точке');
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.fallStartY = player.y;
- console.log('Возрождение в начальной точке, HP:', player.hp);
- } else {
- console.log('Одиночный режим - загружаем последнее сохранение');
- // Одиночный режим - загружаем последнее сохранение
- const loadedSave = await loadGame();
- if(loadedSave){
- await applySave(loadedSave);
- console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
- } else {
- // Если сохранения нет, возрождаемся в начальной точке
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.fallStartY = player.y;
- console.log('Возрождение в начальной точке, HP:', player.hp);
- }
- }
-
- console.log('player.hp after respawn logic:', player.hp);
- console.log('Hiding death screen...');
- deathEl.style.display='none';
- console.log('=== RESPAWN END ===');
- };
-
- // Resize
- function resize(){
- W = gameEl.clientWidth;
- H = gameEl.clientHeight;
- canvas.width = W*dpr;
- canvas.height = H*dpr;
- lightC.width = W*dpr;
- lightC.height = H*dpr;
- ctx.setTransform(dpr,0,0,dpr,0,0);
- }
- window.addEventListener('resize', resize);
-
- // init
- resize();
- rebuildHotbar();
-
- // Инициализируем и загружаем сохранение
- initDB().then(async () => {
- // Пытаемся загрузить сохранённую игру
- const loadedSave = await loadGame();
- if(loadedSave){
- await applySave(loadedSave);
- console.log('Загружено сохранение, HP:', player.hp);
-
- // Проверяем HP после загрузки - если <= 0, возрождаемся
- if (player.hp <= 0) {
- console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.fallStartY = player.y;
- }
- } else {
- console.log('Сохранение не найдено, начинаем новую игру');
-
- // Инициализируем игрока для новой игры
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
-
- // старт — на поверхности (ровно на 1 тайл выше поверхности)
- const startGX = 6;
- genColumn(startGX);
- const surfaceY = surfaceGyAt(startGX);
- player.y = (surfaceY - 1) * TILE;
- player.x = startGX * TILE;
- player.fallStartY = player.y;
-
- // Обновляем spawnPoint, чтобы возрождение было на поверхности
- spawnPoint.x = player.x;
- spawnPoint.y = player.y;
-
- console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp);
- console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y);
-
- // Генерируем карту вокруг стартовой позиции при инициализации
- for(let gx = startGX - 50; gx <= startGX + 50; gx++){
- genColumn(gx);
- }
- }
-
- // Автосейв при скрытии страницы (защита от потери прогресса)
- document.addEventListener('visibilitychange', () => {
- if(document.hidden){
- saveGame();
- }
- });
-
- // Автосейв перед закрытием страницы (защита от потери прогресса)
- window.addEventListener('beforeunload', () => {
- saveGame();
- });
- }).catch(err => {
- console.error('Ошибка инициализации:', err);
- // При ошибке начинаем новую игру
- const startGX = 6;
- genColumn(startGX);
- player.y = (surfaceGyAt(startGX)-1)*TILE;
- player.fallStartY = player.y;
-
- for(let gx = startGX - 50; gx <= startGX + 50; gx++){
- genColumn(gx);
- }
- });
-
- // main loop
- let last = performance.now();
- let prevJump = false;
- function loop(now){
- const dt = Math.min(0.05, (now-last)/1000);
- last = now;
-
- const jumpPressed = inp.j && !prevJump;
- prevJump = inp.j;
-
- // Ускорение времени во время сна
- if(player.sleeping && isNight()){
- worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
- // Восстанавливаем здоровье во время сна
- player.hp = Math.min(100, player.hp + dt * 20);
- // Автоматическое пробуждение когда наступает день
- if(!isNight()){
- player.sleeping = false;
- }
- } else {
- worldTime += dt / DAY_LEN;
- }
- if(worldTime >= 1) worldTime -= 1;
-
- // камера следует за игроком по X/Y
- camX = Math.floor((player.x + player.w/2) - W/2);
- camY = Math.floor((player.y + player.h/2) - H/2);
-
- ensureGenAroundCamera();
-
- // clouds parallax
- for(const c of clouds){
- c.x -= c.s * dt;
- if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700;
- }
-
- // player
- updateWaterFlag(player);
-
- // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223]
- if(player.headInWater){
- player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза
- if(player.o2 === 0){
- player.hp -= 4*dt; // уменьшил урон от утопления
- }
- } else {
- player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза
- }
-
- // голод убывает, но HP не отнимает (как просили)
- player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза
-
- // Игрок не может двигаться во время сна
- if(player.sleeping){
- player.vx = 0;
- player.vy = 0;
- } else {
- const dir = (inp.r?1:0) - (inp.l?1:0);
- if(dir) player.vx = dir*MOVE;
- else player.vx *= 0.82;
- }
-
- // Звук шагов при движении по земле
- if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){
- const stepInterval = 0.35; // Интервал между шагами в секундах
- if(now/1000 - player.lastStepTime > stepInterval){
- playSound('step');
- player.lastStepTime = now/1000;
- }
- }
-
- // прыжок/плавание (новая логика)
- if(player.inBoat){
- // Игрок в лодке - лодка следует за игроком
- const dir = (inp.r?1:0) - (inp.l?1:0);
- if(dir) boat.vx = dir * MOVE;
- else boat.vx *= 0.95;
-
- // Лодка плавает на воде
- boat.vy = 0;
-
- // Игрок следует за лодкой (сидит внутри неё)
- player.x = boat.x + 2; // Игрок по центру лодки
- player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
- player.vx = boat.vx;
- player.vy = boat.vy;
- player.grounded = true;
- player.inWater = false; // Игрок не в воде когда в лодке
-
- // Прыжок из лодки (высадка)
- if(jumpPressed){
- // Возвращаем лодку в инвентарь
- inv.boat = (inv.boat || 0) + 1;
-
- player.inBoat = false;
- boat.active = false;
- player.y += TILE; // Прыгаем из лодки
- player.vy = -JUMP * 0.5;
- playSound('splash');
- }
-
- } else if(player.inWater){
- // сопротивление в воде
- player.vx *= 0.90;
- player.vy *= 0.92;
-
- // Если не нажимаем прыжок - тонем (гравитация в воде)
- if(!jumpPressed && !inp.j){
- // Применяем гравитацию в воде - игрок тонет
- player.vy += GRAV_WATER * dt;
- } else {
- // Если нажимаем прыжок - поднимаемся на поверхность
- if(jumpPressed){
- player.vy = Math.min(player.vy, -520); // рывок вверх
- } else if(inp.j){
- // если держим — мягкое всплытие
- player.vy = Math.min(player.vy, -260);
- }
- }
-
- } else {
- // обычный прыжок (только по нажатию)
- if(jumpPressed && player.grounded && !player.sleeping){
- player.vy = -JUMP;
- player.grounded = false;
- player.fallStartY = player.y;
- }
- }
-
- // Гравитация применяется только вне воды и вне лодки
- if(!player.inWater && !player.inBoat){
- player.vy += GRAV*dt;
- }
-
- // Обновляем позицию лодки
- if(boat.active){
- boat.x += boat.vx * dt;
- boat.y += boat.vy * dt;
-
- // Лодка не выходит за пределы воды
- const boatGX = Math.floor(boat.x / TILE);
- const boatGY = Math.floor(boat.y / TILE);
- const below = getBlock(boatGX, boatGY + 1);
-
- if(!below || below.t !== 'water'){
- // Если лодка вышла из воды - выкидываем игрока
- inv.boat = (inv.boat || 0) + 1;
- player.inBoat = false;
- boat.active = false;
- player.y += TILE;
- player.vy = -200;
- playSound('splash');
- }
- }
-
- // Проверяем, не доплыл ли игрок из лодки
- if(player.inBoat && !boat.active){
- inv.boat = (inv.boat || 0) + 1;
- player.inBoat = false;
- player.y += TILE;
- player.vy = -200;
- playSound('splash');
- }
-
- player.y += player.vy*dt;
- resolveY(player);
- player.x += player.vx*dt; resolveX(player);
-
- // Отправляем позицию на сервер (мультиплеер)
- sendPlayerPosition();
-
- // Обновляем физику воды
- updateWaterPhysics(dt);
-
- player.invuln = Math.max(0, player.invuln - dt);
-
- // TNT tick
- for(const key of Array.from(activeTNT)){
- const b = grid.get(key);
- if(!b || b.dead){ activeTNT.delete(key); continue; }
- b.fuse -= dt;
- if(b.fuse <= 0){
- explodeAt(b.gx,b.gy);
- }
- }
-
- // mobs spawn (с обеих сторон камеры)
- spawnT += dt;
- if(spawnT > 1.8 && mobs.length < 30){
- spawnT = 0;
-
- // Выбираем сторону спавна (левая или правая)
- const spawnLeft = Math.random() < 0.5;
- const gx = spawnLeft
- ? Math.floor((camX - 200)/TILE)
- : Math.floor((camX + W + 200)/TILE);
-
- genColumn(gx);
- const sgy = surfaceGyAt(gx);
- const wx = gx*TILE + 4;
- const wy = (sgy-2)*TILE;
-
- // не спавнить в воде
- const top = getBlock(gx, sgy);
- if(top && top.t==='water') {
- // skip
- } else {
- if(isNight()){
- // Ночью спавним больше враждебных мобов
- const rand = Math.random();
- if(rand < 0.35){
- mobs.push(new Zombie(wx, wy));
- } else if(rand < 0.55){
- mobs.push(new Creeper(wx, wy));
- } else {
- mobs.push(new Skeleton(wx, wy));
- }
- } else {
- // Днём только животные
- mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
- }
- }
- }
-
- // mobs update
- for(let i=mobs.length-1;i>=0;i--){
- const m = mobs[i];
- mobAI(m, dt);
- if(m.hp<=0) mobs.splice(i,1);
- }
-
- // particles
- for(let i=parts.length-1;i>=0;i--){
- const p = parts[i];
- p.t -= dt;
- p.x += p.vx*dt;
- p.y += p.vy*dt;
- p.vy += GRAV*dt;
- if(p.t <= 0) parts.splice(i,1);
- }
-
- // death
- if(player.hp <= 0){
- deathEl.style.display='flex';
- } else if(deathEl.style.display === 'flex') {
- // Если HP > 0 но экран смерти всё ещё показан - скрываем его
- deathEl.style.display='none';
- }
-
- // render
- const night = isNight();
-
- // sky
- ctx.fillStyle = night ? '#070816' : '#87CEEB';
- ctx.fillRect(0,0,W,H);
-
- // clouds (parallax x/y)
- ctx.save();
- ctx.translate(-camX*0.5, -camY*0.15);
- ctx.fillStyle = 'rgba(255,255,255,0.65)';
- for(const c of clouds){
- ctx.fillRect(c.x, c.y, c.w, 26);
- ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22);
- }
- ctx.restore();
-
- // world
- ctx.save();
- ctx.translate(-camX, -camY);
-
- const minGX = Math.floor(camX/TILE)-2;
- const maxGX = Math.floor((camX+W)/TILE)+2;
- const minGY = Math.floor(camY/TILE)-6;
- const maxGY = Math.floor((camY+H)/TILE)+6;
-
- // draw blocks (по массиву, но фильтруем диапазоном)
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
-
- const def = BLOCKS[b.t];
- if(def.alpha){
- ctx.save();
- ctx.globalAlpha = def.alpha;
- ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
- ctx.restore();
- } else {
- ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
- }
-
- // TNT мигает, если активирован
- if(b.t==='tnt' && b.active && Math.sin(now/60)>0){
- ctx.fillStyle='rgba(255,255,255,0.45)';
- ctx.fillRect(b.gx*TILE, b.gy*TILE, TILE, TILE);
- }
-
- // огонь костра
- if(b.t==='campfire'){
- drawFire(b.gx*TILE, b.gy*TILE, now);
- }
- }
-
- // mobs
- for(const m of mobs){
- if(m.kind==='zombie'){
- ctx.fillStyle = '#2ecc71';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#c0392b';
- ctx.fillRect(m.x+6, m.y+12, 6,6);
- ctx.fillRect(m.x+22, m.y+12, 6,6);
- } else if(m.kind==='pig'){
- ctx.fillStyle = '#ffb6c1';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+22, m.y+5, 3,3);
- ctx.fillStyle = '#ff69b4';
- ctx.fillRect(m.x+28, m.y+12, 6,6);
- } else if(m.kind==='chicken'){
- // chicken
- ctx.fillStyle = '#ecf0f1';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#f39c12';
- ctx.fillRect(m.x+18, m.y+10, 6,4);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+8, m.y+6, 3,3);
- } else if(m.kind==='creeper'){
- // creeper
- ctx.fillStyle = '#4CAF50';
- ctx.fillRect(m.x, m.y, m.w, m.h);
- // Глаза
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+8, m.y+8, 4,4);
- ctx.fillRect(m.x+22, m.y+8, 4,4);
- // Рот
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+12, m.y+20, 10,4);
- // Ноги
- ctx.fillStyle = '#4CAF50';
- ctx.fillRect(m.x+4, m.y+30, 6,20);
- ctx.fillRect(m.x+24, m.y+30, 6,20);
- } else if(m.kind==='skeleton'){
- // skeleton - детализированный
- // Тело
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(m.x+10, m.y+20, 14, 12);
- // Череп
- ctx.fillRect(m.x+8, m.y+0, 18, 18);
- // Глазницы
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+10, m.y+6, 4,4);
- ctx.fillRect(m.x+20, m.y+6, 4,4);
- // Нос
- ctx.fillRect(m.x+15, m.y+12, 4,2);
- // Руки
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(m.x+2, m.y+20, 6,14);
- ctx.fillRect(m.x+26, m.y+20, 6,14);
- // Ноги
- ctx.fillRect(m.x+10, m.y+32, 6, 18);
- ctx.fillRect(m.x+18, m.y+32, 6, 18);
- }
- }
-
- // boat (рисуем первой, чтобы игрок был внутри неё)
- if(boat.active){
- ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE);
- }
-
- // other players (multiplayer)
- for(const [socketId, p] of otherPlayers){
- if(heroImg.complete){
- ctx.drawImage(heroImg, p.x - (TILE-player.w)/2, p.y - (TILE-player.h)/2, TILE, TILE);
- } else {
- ctx.fillStyle = p.color;
- ctx.fillRect(p.x, p.y, 34, 34);
- }
- // Имя игрока (мелко над персонажем)
- ctx.fillStyle = '#fff';
- ctx.font = '12px system-ui';
- ctx.textAlign = 'center';
- ctx.fillText(p.name, p.x + 17, p.y - 8);
- }
-
- // player
- if(heroImg.complete){
- ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE);
- } else {
- ctx.fillStyle='#fff';
- ctx.fillRect(player.x, player.y, player.w, player.h);
- }
-
- // particles
- for(const p of parts){
- ctx.fillStyle = p.c;
- ctx.fillRect(p.x-2, p.y-2, 4, 4);
- }
-
- // Стрелы скелета
- for(const m of mobs){
- if(m.kind==='skeleton' && m.shootCooldown > 0.5){
- // Рисуем стрелу
- const arrowX = m.x + m.w/2;
- const arrowY = m.y + 15;
- const targetX = player.x + player.w/2;
- const targetY = player.y + player.h/2;
- const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
- const speed = 400;
-
- // Проверяем, попала ли стрела
- const dx = targetX - arrowX;
- const dy = targetY - arrowY;
- const dist = Math.hypot(dx, dy);
-
- // Рисуем стрелу
- ctx.save();
- ctx.translate(arrowX, arrowY);
- ctx.rotate(angle);
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(0, -1, 16, 2);
- ctx.restore();
-
- // Урон игроку если попали
- if(dist < 150 && player.invuln <= 0){
- player.hp -= 8;
- player.invuln = 0.5;
- player.vx += Math.cos(angle) * 300;
- player.vy -= 200;
- playSound('hit1');
- }
- }
- }
-
- // build ghost
- if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){
- const wx = mouse.x + camX;
- const wy = mouse.y + camY;
- const gx = Math.floor(wx/TILE);
- const gy = Math.floor(wy/TILE);
- ctx.strokeStyle = 'rgba(255,255,255,0.9)';
- ctx.lineWidth = 2;
- ctx.strokeRect(gx*TILE, gy*TILE, TILE, TILE);
- }
-
- ctx.restore();
-
- // lighting overlay at night
- if(night){
- // Рисуем полупрозрачный тёмный оверлей
- ctx.save();
- ctx.fillStyle = 'rgba(0,0,0,0.50)';
- ctx.fillRect(0, 0, W, H);
- ctx.restore();
-
- // Освещение от факелов и костров
- ctx.save();
- ctx.globalCompositeOperation = 'lighter';
-
- // Функция для рисования света
- function drawLight(x, y, radius, intensity) {
- const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
- gradient.addColorStop(0, `rgba(255, 255, 200, ${intensity})`);
- gradient.addColorStop(1, `rgba(255, 255, 200, 0)`);
- ctx.fillStyle = gradient;
- ctx.beginPath();
- ctx.arc(x, y, radius, 0, Math.PI * 2);
- ctx.fill();
- }
-
- // Освещение от факелов (1/3 яркости)
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
- const def = BLOCKS[b.t];
- if(def.lightRadius){
- const wx = b.gx*TILE + TILE/2 - camX;
- const wy = b.gy*TILE + TILE/2 - camY;
- drawLight(wx, wy, def.lightRadius, 0.33);
- }
- }
-
- ctx.restore();
- }
-
- // UI tick
- if(Math.random()<0.25){
- hpEl.textContent = Math.max(0, Math.ceil(player.hp));
- foodEl.textContent = Math.ceil(player.hunger);
- document.getElementById('o2').textContent = Math.ceil(player.o2);
- sxEl.textContent = Math.floor(player.x/TILE);
- syEl.textContent = Math.floor(player.y/TILE);
- todEl.textContent = night ? 'Ночь' : 'День';
- worldIdEl.textContent = worldId;
- if(isMultiplayer){
- document.getElementById('multiplayerStatus').style.display = 'flex';
- playerCountEl.textContent = otherPlayers.size + 1; // +1 = мы сами
- } else {
- document.getElementById('multiplayerStatus').style.display = 'none';
- }
- }
-
- // Индикатор сна
- if(player.sleeping){
- ctx.fillStyle = 'rgba(0,0,0,0.7)';
- ctx.fillRect(0, 0, W, H);
- ctx.fillStyle = '#fff';
- ctx.font = 'bold 32px system-ui';
- ctx.textAlign = 'center';
- ctx.fillText('💤 Спим...', W/2, H/2);
- ctx.font = '18px system-ui';
- ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
- }
-
- requestAnimationFrame(loop);
- }
-
- requestAnimationFrame(loop);
-})();
diff --git a/backup/20260103_082922/index.html b/backup/20260103_082922/index.html
deleted file mode 100644
index 3313464..0000000
--- a/backup/20260103_082922/index.html
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
-GrechkaCraft: Multiplayer
-
-
-
-
-
-
-
-
-
-
-
-
❤️ 100 🍗 100
-
🫁 100
-
📍 X:0 Y:0
-
🕒 День
-
🌐 default
-
👥 0
-
-
-
🏃
-
💾
-
🔨
-
🔄
-
-
-
-
-
-
-
ВЫ ПОГИБЛИ
-
-
-
-
-
🎒
-
-
-
-
-
💬
-
-
-
-
-
-
-
diff --git a/backup/20260103_082922/style.css b/backup/20260103_082922/style.css
deleted file mode 100644
index af8325b..0000000
--- a/backup/20260103_082922/style.css
+++ /dev/null
@@ -1,72 +0,0 @@
-/* Minegrechka Game Styles - v1.2 */
-html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; background:#111; font-family: system-ui, sans-serif; user-select:none; -webkit-user-select:none; touch-action:none; }
-#game { position:absolute; top:0; left:0; right:0; bottom:140px; background:#87CEEB; overflow:hidden; }
-canvas { display:block; width:100%; height:100%; image-rendering:pixelated; }
-#controls { position:absolute; left:0; right:0; bottom:0; height:140px; background:#222; border-top:4px solid #444; z-index:10; }
-
-.ui { position:absolute; inset:0; pointer-events:none; z-index:20; }
-.pe { pointer-events:auto; }
-
-#stats { position:absolute; left:10px; top:10px; color:#fff; font-weight:800; font-size:14px;
- background: rgba(0,0,0,0.55); padding:8px; border-radius:10px; text-shadow:1px 1px 0 #000; }
-#stats .row{ display:flex; gap:10px; align-items:center; }
-
-.rbtn { position:absolute; right:10px; width:52px; height:52px; border-radius:12px;
- display:flex; align-items:center; justify-content:center; border:2px solid rgba(255,255,255,0.9);
- font-size:24px; cursor:pointer; pointer-events:auto; box-shadow:0 4px 0 rgba(0,0,0,0.5); }
-.rbtn:active { transform: translateY(4px); box-shadow:none; }
-#modeBtn { top:10px; background:#f39c12; }
-#saveBtn { top:10px; right:70px !important; background:#27ae60; }
-#resetBtn { top:10px; right:130px !important; background:#e74c3c; }
-#craftBtn { top:74px; right:10px !important; background:#9b59b6; }
-#chatToggle { top:10px; right:190px !important; background:#9b59b6; }
-
-#hotbar { position:absolute; left:50%; transform:translateX(-50%); bottom:10px; display:flex; gap:6px;
- background: rgba(0,0,0,0.60); padding:6px; border-radius:12px; pointer-events:auto;
- overflow-x: auto; overflow-y: hidden; max-width: 80%; }
-.slot { width:38px; height:38px; border:2px solid rgba(255,255,255,0.22); border-radius:10px;
- position:relative; overflow:hidden; cursor:pointer; background: rgba(255,255,255,0.07);
- display:flex; align-items:center; justify-content:center; font-size:18px; }
-.slot.sel { border-color:#f1c40f; box-shadow: 0 0 0 2px rgba(241,196,15,0.18) inset; }
-.count { position:absolute; right:3px; bottom:1px; font-size:10px; color:#fff; font-weight:900; text-shadow:1px 1px 0 #000; }
-
-/* Craft modal */
-#craftPanel { display:none; position:absolute; left:14px; right:14px; top:14px; bottom:14px;
- background: rgba(10,10,12,0.92); border:2px solid rgba(255,255,255,0.85); border-radius:14px;
- pointer-events:auto; padding:12px; overflow:auto; }
-
-/* Inventory modal */
-#inventoryPanel { display:none; position:absolute; left:50%; top:50%; transform: translate(-50%, -50%);
- width: 420px; max-width: 90%; background: rgba(10,10,12,0.92); border:2px solid rgba(255,255,255,0.85);
- border-radius:14px; pointer-events:auto; padding:12px; z-index: 100; }
-#inventoryPanel .top { display:flex; justify-content:space-between; align-items:center; color:#fff; margin-bottom:10px; }
-#inventoryGrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-top: 10px; }
-.inv-slot { width: 48px; height: 48px; border: 2px solid rgba(255,255,255,0.22); border-radius: 8px;
- position: relative; overflow: hidden; cursor: pointer; background: rgba(255,255,255,0.07);
- display: flex; align-items: center; justify-content: center; font-size: 24px; }
-.inv-slot.sel { border-color: #f1c40f; box-shadow: 0 0 0 2px rgba(241,196,15,0.18) inset; }
-.inv-count { position: absolute; right: 3px; bottom: 1px; font-size: 12px; color: #fff;
- font-weight: 900; text-shadow: 1px 1px 0 #000; background: rgba(0,0,0,0.5); padding: 1px 3px; border-radius: 4px; }
-#craftPanel .top { display:flex; justify-content:space-between; align-items:center; color:#fff; margin-bottom:10px; }
-#craftPanel .close { background:#c0392b; border:none; color:#fff; font-weight:900; padding:8px 10px; border-radius:10px; cursor:pointer; }
-.recipe { display:flex; align-items:center; gap:10px; padding:10px; border-radius:12px;
- background: rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.10); margin-bottom:8px; }
-.ricon { width:32px; height:32px; border-radius:8px; background-size:cover; image-rendering:pixelated; }
-.rinfo { flex:1; }
-.rname { color:#fff; font-weight:900; font-size:14px; }
-.rcost { color:#bbb; font-size:11px; line-height:1.25; }
-.rcraft { background:#2ecc71; border:none; color:#fff; font-weight:900; padding:8px 10px; border-radius:10px; cursor:pointer; }
-.rcraft:disabled { background:#444; color:#888; cursor:not-allowed; }
-
-/* Controls buttons */
-.cbtn { position:absolute; top:50%; transform:translateY(-50%); width:74px; height:74px; border-radius:14px;
- background:#333; border:3px solid #555; color:#fff; font-size:32px; display:flex; align-items:center; justify-content:center;
- box-shadow:0 7px 0 #111; pointer-events:auto; }
-.cbtn:active { transform:translateY(-46%); box-shadow:0 3px 0 #111; background:#444; }
-#left { left:18px; }
-#right { left:102px; }
-#jump { right:18px; background:#d35400; border-color:#e67e22; }
-
-#death { display:none; position:absolute; inset:0; background: rgba(60,0,0,0.88);
- z-index:200; color:#fff; pointer-events:auto; align-items:center; justify-content:center; flex-direction:column; gap:12px; }
-#death button { padding:12px 18px; font-size:18px; font-weight:900; border:none; border-radius:12px; cursor:pointer; }
diff --git a/build3d.js b/build3d.js
new file mode 100644
index 0000000..1f84169
--- /dev/null
+++ b/build3d.js
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+/**
+ * Simple ES-module → IIFE bundler for GrechkaCraft
+ * Resolves import/export, wraps in single IIFE, no external deps.
+ * Usage: node build.js > game.js
+ */
+const fs = require('fs');
+const path = require('path');
+
+const SRC = path.join(__dirname, 'srcv3');
+const ENTRY = 'main3d.js';
+
+const visited = new Set();
+const chunks = [];
+
+const fileExports = {};
+const fileImports = {};
+
+function resolveImportPath(fromPath, importerDir) {
+ let resolved = path.resolve(importerDir, fromPath);
+ if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.js')) {
+ resolved += '.js';
+ }
+ return resolved;
+}
+
+function collectExports(content) {
+ const exports = [];
+ const re1 = /export\s*\{([^}]+)\}/g;
+ let m;
+ while ((m = re1.exec(content)) !== null) {
+ const names = m[1].split(',').map(s => {
+ const parts = s.trim().split(/\s+as\s+/);
+ return { local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() };
+ });
+ exports.push(...names);
+ }
+ const re2 = /export\s+(?:async\s+)?(?:const|let|var|function|class)\s+(\w+)/g;
+ while ((m = re2.exec(content)) !== null) {
+ exports.push({ local: m[1], exported: m[1] });
+ }
+ if (/export\s+default\s+/.test(content)) {
+ exports.push({ local: '__default__', exported: 'default' });
+ }
+ return exports;
+}
+
+function collectImports(content) {
+ const imports = [];
+ const re = /import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
+ let m;
+ while ((m = re.exec(content)) !== null) {
+ const names = m[1].split(',').map(s => {
+ const parts = s.trim().split(/\s+as\s+/);
+ return { local: (parts[1] || parts[0]).trim(), imported: parts[0].trim() };
+ });
+ imports.push({ names, from: m[2] });
+ }
+ const re2 = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
+ while ((m = re2.exec(content)) !== null) {
+ imports.push({ names: [{ local: m[1], imported: 'default' }], from: m[2] });
+ }
+ return imports;
+}
+
+function processFile(filePath) {
+ const relPath = path.relative(SRC, filePath);
+ if (visited.has(relPath)) return;
+ visited.add(relPath);
+
+ let content = fs.readFileSync(filePath, 'utf8');
+ const dir = path.dirname(filePath);
+
+ fileExports[relPath] = collectExports(content);
+ fileImports[relPath] = collectImports(content);
+
+ for (const imp of fileImports[relPath]) {
+ const depPath = resolveImportPath(imp.from, dir);
+ processFile(depPath);
+ }
+
+ // Strip import statements
+ content = content.replace(/import\s*\{[^}]+\}\s*from\s*['"][^'"]+['"];?/g, '');
+ content = content.replace(/import\s+\w+\s+from\s*['"][^'"]+['"];?/g, '');
+ // Strip export keyword but keep declarations
+ content = content.replace(/export\s+default\s+/, 'const __default__ = ');
+ content = content.replace(/export\s+(?:async\s+)?(const|let|var|function|class)\s/g, '$1 ');
+ content = content.replace(/export\s*\{[^}]*\};?/g, '');
+
+ chunks.push({ relPath, content });
+}
+
+processFile(path.join(SRC, ENTRY));
+
+let output = '// GrechkaCraft — auto-bundled from ES modules\n';
+output += '(function() {\n';
+output += '"use strict";\n\n';
+
+for (const chunk of chunks) {
+ output += `// === ${chunk.relPath} ===\n`;
+ output += chunk.content.trim();
+ output += '\n\n';
+}
+
+output += '})();\n';
+
+process.stdout.write(output);
\ No newline at end of file
diff --git a/game.js b/game.js
index 7f1bd07..1cda237 100644
--- a/game.js
+++ b/game.js
@@ -1,1560 +1,1865 @@
(() => {
- // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
-// === Custom modal functions ===
-function customAlert(msg) {
- const overlay = document.createElement("div");
- overlay.className = "custom-modal-overlay";
- const box = document.createElement("div");
- box.className = "custom-modal-box";
- const text = document.createElement("div");
- text.textContent = msg;
- text.style.marginBottom = "16px";
- const btn = document.createElement("button");
- btn.className = "btn-ok";
- btn.textContent = "OK";
- btn.onclick = () => overlay.remove();
- box.appendChild(text);
- box.appendChild(btn);
- overlay.appendChild(box);
- document.querySelector("#game").appendChild(overlay);
-}
-function customConfirm(msg, onYes) {
- const overlay = document.createElement("div");
- overlay.className = "custom-modal-overlay";
- const box = document.createElement("div");
- box.className = "custom-modal-box";
- const text = document.createElement("div");
- text.textContent = msg;
- text.style.marginBottom = "16px";
- const btns = document.createElement("div");
- btns.className = "modal-btns";
- const yesBtn = document.createElement("button");
- yesBtn.className = "btn-yes";
- yesBtn.textContent = "Да";
- yesBtn.onclick = () => { overlay.remove(); onYes(); };
- const noBtn = document.createElement("button");
- noBtn.className = "btn-no";
- noBtn.textContent = "Отмена";
- noBtn.onclick = () => overlay.remove();
- btns.appendChild(yesBtn);
- btns.appendChild(noBtn);
- box.appendChild(text);
- box.appendChild(btns);
- overlay.appendChild(box);
- document.querySelector("#game").appendChild(overlay);
-}
- // Возможность переопределить сервер через query string
- const urlParams = new URLSearchParams(window.location.search);
- const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
- const TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; // Имя бота для share-ссылки
- const TELEGRAM_APP_SHORT_NAME = 'minegrechka'; // Короткое имя Mini App
-
- // Защита от mixed content
- if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
- console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
- alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
- }
-
- // ==================== TELEGRAM MINI APP ====================
- const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
- let tgUser = null;
- let tgSaveLoaded = false;
-
- if (isTgWebApp) {
- try {
- Telegram.WebApp.ready();
- Telegram.WebApp.expand();
- tgUser = Telegram.WebApp.initDataUnsafe?.user || null;
- console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username);
- // Set player name from TG
- if (tgUser && tgUser.first_name) {
- const tgName = tgUser.username || tgUser.first_name;
- localStorage.setItem('minegrechka_playerName', tgName);
- }
- } catch(e) {
- console.warn('[TG] WebApp init error:', e);
- }
- }
+ // src/core/constants.js
+ var TILE = 40;
+ var SEA_GY = 14;
+ var BEDROCK_GY = 140;
+ var GEN_MARGIN_X = 40;
+ var GRAV = 2200;
+ var GRAV_WATER = 550;
+ var MOVE = 320;
+ var JUMP = 760;
+ var DAY_LEN = 360;
+ var SAVE_KEY = "minegrechka_save";
+ var MOVE_SEND_INTERVAL = 0.05;
+ var MAX_RAINDROPS = 200;
+ var MAX_CHAT_MESSAGES = 50;
+ var VOICE_SERVER = "wss://voicegrech.mkn8n.ru";
- // ==================== WORLD ID И ИГРОКА ====================
- let worldId = null;
- let playerName = localStorage.getItem('minegrechka_playerName') || null;
-
- // Priority: URL param > TG saved world > new world
- const worldParam = urlParams.get('world');
-
- // ==================== START MENU ====================
- let startMenuResolved = false;
-
- function generateShortWorldCode() {
- // Generate readable 6-char code: consonants + digits (easy to copy/share)
- const c = 'bcdfghjkmnpqrstvwxyz';
- const d = '23456789';
- let code = '';
- for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)];
- for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)];
- return code;
- }
-
- function showStartMenu() {
- return new Promise((resolve) => {
- // If world is in URL — skip menu, auto-join
- if (worldParam && worldParam.trim() !== '') {
- worldId = worldParam.trim();
- console.log('[Menu] Joining world from URL:', worldId);
- resolve(worldId);
- return;
- }
-
- // ===== Minecraft-style CSS =====
- const mcCSS = `
- .mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;
- display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;
- image-rendering:pixelated; }
- .mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; }
- .mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%;
- background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; }
- .mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000;
- padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000; }
- .mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center;
- border-bottom:4px solid #000; }
- .mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000;
- letter-spacing:2px; }
- .mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; }
- .mc-body { padding:16px;background:#C6C6C6; }
- .mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px;
- font-family:'Courier New',monospace;font-size:16px;font-weight:bold;
- cursor:pointer;text-align:center;border:3px solid #000;
- image-rendering:pixelated;box-shadow:3px 3px 0 #000;
- transition:transform 0.05s,box-shadow 0.05s; }
- .mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; }
- .mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; }
- .mc-btn-green:hover { background:#6DAF4E; }
- .mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; }
- .mc-btn-blue:hover { background:#4B73BE; }
- .mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; }
- .mc-btn-purple:hover { background:#9555BE; }
- .mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; }
- .mc-btn-orange:hover { background:#DA8500; }
- .mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; }
- .mc-btn-red:hover { background:#BE3B3B; }
- .mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; }
- .mc-btn-gray:hover { background:#666;color:#DDD; }
- .mc-btn-outline { background:transparent;color:#555;border-color:#555;
- text-shadow:none;box-shadow:2px 2px 0 #333; }
- .mc-btn-outline:hover { background:#DDD;color:#222; }
- .mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace;
- font-size:16px;background:#FFF;border:3px solid #000;color:#222;
- box-shadow:inset 2px 2px 0 #999;margin:4px 0; }
- .mc-input:focus { outline:none;border-color:#FFAA00; }
- .mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; }
- .mc-divider { border:none;border-top:2px solid #999;margin:12px 0; }
- .mc-friend-row { display:flex;justify-content:space-between;align-items:center;
- padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999;
- box-shadow:2px 2px 0 #999; }
- .mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace;
- font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000;
- box-shadow:1px 1px 0 #000; }
- .mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; }
- .mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; }
- .mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; }
- .mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; }
- .mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; }
- `;
-
- const styleEl = document.createElement('style');
- styleEl.textContent = mcCSS;
- document.head.appendChild(styleEl);
-
- const overlay = document.createElement('div');
- overlay.className = 'mc-overlay';
- overlay.id = 'startMenu';
- overlay.innerHTML = '';
-
- const box = document.createElement('div');
- box.className = 'mc-box';
-
- // Title bar
- const titleBar = document.createElement('div');
- titleBar.className = 'mc-titlebar';
- titleBar.innerHTML = '⛏️ GrechkaCraft
' +
- '' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
';
- box.appendChild(titleBar);
-
- const body = document.createElement('div');
- body.className = 'mc-body';
-
- // === Buttons ===
- // New World
- const btnNew = document.createElement('button');
- btnNew.className = 'mc-btn mc-btn-green';
- btnNew.textContent = '🌍 Новый мир';
- btnNew.onclick = () => {
- worldId = generateShortWorldCode();
- overlay.remove();
- styleEl.remove();
- resolve(worldId);
- };
- body.appendChild(btnNew);
-
- // Join by code
- const btnJoin = document.createElement('button');
- btnJoin.className = 'mc-btn mc-btn-blue';
- btnJoin.textContent = '🔗 Вступить по коду';
- btnJoin.onclick = () => {
- body.innerHTML = '';
- const lbl = document.createElement('div');
- lbl.className = 'mc-label';
- lbl.textContent = 'Код мира:';
- body.appendChild(lbl);
- const codeInput = document.createElement('input');
- codeInput.className = 'mc-input';
- codeInput.type = 'text';
- codeInput.placeholder = 'bkh237';
- codeInput.maxLength = 12;
- body.appendChild(codeInput);
- const btnGo = document.createElement('button');
- btnGo.className = 'mc-btn mc-btn-blue';
- btnGo.textContent = 'Войти →';
- btnGo.onclick = () => {
- const code = codeInput.value.trim().toLowerCase();
- if (code.length >= 3) {
- worldId = code;
- overlay.remove();
- styleEl.remove();
- resolve(worldId);
- }
- };
- body.appendChild(btnGo);
- const btnBack = document.createElement('button');
- btnBack.className = 'mc-btn mc-btn-gray';
- btnBack.textContent = '← Назад';
- btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
- body.appendChild(btnBack);
- codeInput.focus();
- codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
- };
- body.appendChild(btnJoin);
-
- // Continue saved game
- const hasLocalSave = !!localStorage.getItem('minegrechka_save');
- if (isTgWebApp || hasLocalSave) {
- const btnHistory = document.createElement('button');
- btnHistory.className = 'mc-btn mc-btn-purple';
- btnHistory.textContent = '💾 Продолжить игру';
- btnHistory.onclick = async () => {
- if (isTgWebApp && tgUser) {
- try {
- const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
- const data = await resp.json();
- if (data.ok && data.world_id) {
- worldId = data.world_id;
- tgSaveLoaded = true;
- overlay.remove();
- styleEl.remove();
- resolve(worldId);
- return;
- }
- } catch(e) { console.warn('[TG] Load save failed:', e); }
- }
- if (hasLocalSave) {
- worldId = generateShortWorldCode();
- overlay.remove();
- styleEl.remove();
- resolve(worldId);
- return;
- }
- customAlert('Сохранений не найдено');
- };
- body.appendChild(btnHistory);
- }
-
- // === TG-only buttons ===
- if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
- // Invite friends — with margin
- const btnInvite = document.createElement('button');
- btnInvite.className = 'mc-btn mc-btn-outline';
- btnInvite.style.marginTop = '16px';
- btnInvite.textContent = '📤 Пригласить друзей';
- btnInvite.onclick = () => {
- const code = worldId || generateShortWorldCode();
- const url = 'https://t.me/Grechkacraft_bot/app?startapp=' + encodeURIComponent(code);
- if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) {
- Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code));
- } else {
- navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url));
- }
- };
- body.appendChild(btnInvite);
-
- // Friends — with margin
- const btnFriends = document.createElement('button');
- btnFriends.className = 'mc-btn mc-btn-orange';
- btnFriends.style.marginTop = '8px';
- btnFriends.textContent = '👥 Друзья';
- btnFriends.onclick = async () => {
- try {
- const resp = await fetch(SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id);
- const data = await resp.json();
- body.innerHTML = '';
-
- // Back button at top
- const btnBackTop = document.createElement('button');
- btnBackTop.className = 'mc-btn mc-btn-gray';
- btnBackTop.textContent = '← Назад';
- btnBackTop.style.marginBottom = '12px';
- btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
- body.appendChild(btnBackTop);
-
- // Nickname section
- if (data.nickname) {
- const nickDiv = document.createElement('div');
- nickDiv.className = 'mc-friend-row';
- nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + '';
- nickDiv.style.background = '#D5E8C5';
- body.appendChild(nickDiv);
- } else {
- const nickLabel = document.createElement('div');
- nickLabel.className = 'mc-label';
- nickLabel.style.color = '#9E2B2B';
- nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:';
- body.appendChild(nickLabel);
- const nickRow = document.createElement('div');
- nickRow.style.display = 'flex';
- nickRow.style.gap = '4px';
- nickRow.style.marginBottom = '8px';
- const nickInput = document.createElement('input');
- nickInput.className = 'mc-input';
- nickInput.style.flex = '1';
- nickInput.type = 'text';
- nickInput.maxLength = 16;
- nickInput.placeholder = 'Твой ник';
- const nickBtn = document.createElement('button');
- nickBtn.className = 'mc-btn mc-btn-green';
- nickBtn.style.width = 'auto';
- nickBtn.style.marginBottom = '0';
- nickBtn.style.padding = '8px 16px';
- nickBtn.textContent = '✓';
- nickBtn.onclick = async () => {
- const n = nickInput.value.trim().toLowerCase();
- if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; }
- const check = await fetch(SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n));
- const cd = await check.json();
- if (!cd.available) { showToast('❌ Ник занят'); return; }
- const set = await fetch(SERVER_URL + '/api/nick/set', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,nickname:n})});
- const sd = await set.json();
- if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
- };
- nickRow.appendChild(nickInput);
- nickRow.appendChild(nickBtn);
- body.appendChild(nickRow);
- }
-
- // Friends list
- if (data.friends && data.friends.length) {
- const listLabel = document.createElement('div');
- listLabel.className = 'mc-label';
- listLabel.textContent = '👥 Друзья:';
- body.appendChild(listLabel);
- for (const f of data.friends) {
- const fDiv = document.createElement('div');
- fDiv.className = 'mc-friend-row';
- const statusDot = f.online ? '🟢' : '⚫';
- let info = statusDot + ' ' + f.nickname + '';
- if (f.online && f.world_id) info += ' — мир ' + f.world_id;
- const fInfo = document.createElement('div');
- fInfo.innerHTML = info;
- fDiv.appendChild(fInfo);
- if (f.online && f.world_id) {
- const joinBtn = document.createElement('button');
- joinBtn.className = 'mc-friend-btn mc-friend-btn-green';
- joinBtn.textContent = '🎮 Вступить';
- joinBtn.onclick = () => { worldId = f.world_id; overlay.remove(); styleEl.remove(); resolve(worldId); };
- fDiv.appendChild(joinBtn);
- }
- body.appendChild(fDiv);
- }
- } else if (data.friends && !data.friends.length) {
- const noFriends = document.createElement('div');
- noFriends.className = 'mc-label';
- noFriends.style.color = '#888';
- noFriends.textContent = 'Пока нет друзей';
- body.appendChild(noFriends);
- }
-
- // Pending requests
- if (data.pending && data.pending.length) {
- const pLabel = document.createElement('div');
- pLabel.className = 'mc-label';
- pLabel.style.color = '#B86E00';
- pLabel.textContent = '📨 Запросы в друзья:';
- body.appendChild(pLabel);
- for (const p of data.pending) {
- const pItem = document.createElement('div');
- pItem.className = 'mc-friend-row';
- const span = document.createElement('span');
- span.textContent = p.from;
- pItem.appendChild(span);
- const btns = document.createElement('div');
- btns.style.display = 'flex';
- btns.style.gap = '4px';
- const acceptBtn = document.createElement('button');
- acceptBtn.className = 'mc-friend-btn mc-friend-btn-green';
- acceptBtn.textContent = '✓';
- acceptBtn.onclick = async () => {
- const r = await fetch(SERVER_URL + '/api/friends/accept', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,request_id:p.id})});
- const d = await r.json();
- if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
- };
- const declineBtn = document.createElement('button');
- declineBtn.className = 'mc-friend-btn mc-friend-btn-red';
- declineBtn.textContent = '✕';
- declineBtn.onclick = async () => {
- await fetch(SERVER_URL + '/api/friends/decline', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,request_id:p.id})});
- showToast('Отклонено');
- btnFriends.onclick();
- };
- btns.appendChild(acceptBtn);
- btns.appendChild(declineBtn);
- pItem.appendChild(btns);
- body.appendChild(pItem);
- }
- }
-
- // Add friend
- body.appendChild(document.createElement('hr'));
- body.className = 'mc-label';
- const addLabel = document.createElement('div');
- addLabel.className = 'mc-label';
- addLabel.textContent = '➕ Добавить друга по нику:';
- body.appendChild(addLabel);
- const addRow = document.createElement('div');
- addRow.style.display = 'flex';
- addRow.style.gap = '4px';
- const addInput = document.createElement('input');
- addInput.className = 'mc-input';
- addInput.style.flex = '1';
- addInput.type = 'text';
- addInput.maxLength = 16;
- addInput.placeholder = 'Ник друга';
- const addBtn = document.createElement('button');
- addBtn.className = 'mc-btn mc-btn-blue';
- addBtn.style.width = 'auto';
- addBtn.style.marginBottom = '0';
- addBtn.style.padding = '8px 16px';
- addBtn.textContent = '+';
- addBtn.onclick = async () => {
- const nick = addInput.value.trim().toLowerCase();
- if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; }
- const r = await fetch(SERVER_URL + '/api/friends/request', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,nickname:nick})});
- const d = await r.json();
- if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); }
- else { showToast('❌ ' + (d.error || 'Ошибка')); }
- };
- addRow.appendChild(addInput);
- addRow.appendChild(addBtn);
- body.appendChild(addRow);
-
- // Privacy checkboxes
- const settings = data.settings || { allow_requests: 1, notify_online: 1 };
- const settingsDiv = document.createElement('div');
- settingsDiv.style.marginTop = '12px';
- const reqLabel = document.createElement('label');
- reqLabel.className = 'mc-checkbox-row';
- const reqCb = document.createElement('input');
- reqCb.className = 'mc-checkbox';
- reqCb.type = 'checkbox';
- reqCb.checked = settings.allow_requests;
- reqCb.onchange = async () => {
- await fetch(SERVER_URL + '/api/friends/settings', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,allow_requests:reqCb.checked?1:0,notify_online:settings.notify_online})});
- };
- reqLabel.appendChild(reqCb);
- reqLabel.appendChild(document.createTextNode(' Запросы в друзья'));
- settingsDiv.appendChild(reqLabel);
- body.appendChild(settingsDiv);
- } catch(e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); }
- };
- body.appendChild(btnFriends);
- }
-
- // Player name input
- if (!playerName) {
- const nameDivider = document.createElement('hr');
- nameDivider.className = 'mc-divider';
- body.appendChild(nameDivider);
- const nameLabel = document.createElement('div');
- nameLabel.className = 'mc-label';
- nameLabel.textContent = 'Твоё имя:';
- body.appendChild(nameLabel);
- const nameInput = document.createElement('input');
- nameInput.className = 'mc-input';
- nameInput.type = 'text';
- nameInput.placeholder = 'Ваше имя';
- nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : '';
- nameInput.maxLength = 20;
- nameInput.addEventListener('input', () => {
- if (nameInput.value.trim()) {
- playerName = nameInput.value.trim();
- localStorage.setItem('minegrechka_playerName', playerName);
- }
- });
- body.appendChild(nameInput);
- }
-
- box.appendChild(body);
- overlay.appendChild(box);
- document.body.appendChild(overlay);
-
- // Auto-load TG save
- if (isTgWebApp && tgUser && !worldParam) {
- fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
- .then(r => r.json())
- .then(data => {
- if (data.ok && data.world_id) {
- // could update UI but menu is already shown
- }
- })
- .catch(() => {});
- }
- });
- }
-
- // ==================== GAME LAUNCH ====================
- // Override the old auto-join logic
- let gameLaunched = false;
-
- async function launchGame() {
- if (gameLaunched) return;
- gameLaunched = true;
-
- if (!playerName) {
- playerName = tgUser ? (tgUser.username || tgUser.first_name || 'Игрок') : 'Игрок';
- localStorage.setItem('minegrechka_playerName', playerName);
- }
-
- // Validate TG initData if in TG context
- if (isTgWebApp && Telegram.WebApp.initData) {
- try {
- const resp = await fetch(SERVER_URL + '/api/tg/auth', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ initData: Telegram.WebApp.initData })
- });
- const data = await resp.json();
- if (data.ok && data.tg_id) {
- console.log('[TG] Authenticated, tg_id:', data.tg_id);
- tgUser = tgUser || {};
- tgUser.id = data.tg_id;
- }
- } catch(e) { console.warn('[TG] Auth failed:', e); }
- }
-
- // Update URL with world code
- try {
- const newUrl = new URL(window.location.href);
- newUrl.searchParams.set('world', worldId);
- if (typeof history !== 'undefined' && history.replaceState) {
- history.replaceState(null, '', newUrl.toString());
- }
- } catch(e) {}
-
- console.log('[Launch] worldId:', worldId, 'player:', playerName, 'tg:', !!tgUser);
-
- // The rest of the game init will pick up worldId and playerName
- // Signal that menu is done
- startMenuResolved = true;
- window.dispatchEvent(new Event('startMenuDone'));
- }
-
- // ==================== SOCKET.IO КЛИЕНТ ====================
- let socket = null;
- let isMultiplayer = false; // Флаг для мультиплеерного режима
- const otherPlayers = new Map(); // socket_id -> {x, y, color}
- const serverMobs = new Map(); // id -> mob (server-spawned, client-authoritative physics)
-
- // Helper to get all mobs (local + server-spawned in MP)
- function getAllMobs() {
- return isMultiplayer ? mobs.concat(Array.from(serverMobs.values())) : mobs;
- }
-
- // Create a client-side mob object from server spawn data with correct properties matching client constructors
- function createMobFromServer(data) {
- const kindProps = {
- zombie: { w: 34, h: 50, hp: 4, speed: 80 + Math.random() * 40, hostile: true, fuse: 0, shootCooldown: 2 },
- creeper: { w: 34, h: 50, hp: 4, speed: 60 + Math.random() * 30, hostile: true, fuse: 3.2, shootCooldown: 2 },
- skeleton: { w: 34, h: 50, hp: 4, speed: 70 + Math.random() * 30, hostile: true, fuse: 0, shootCooldown: 0 },
- pig: { w: 34, h: 24, hp: 2, speed: 40, hostile: false, fuse: 0, shootCooldown: 2 },
- chicken: { w: 26, h: 22, hp: 1, speed: 55, hostile: false, fuse: 0, shootCooldown: 2 },
- scorpion: { w: 26, h: 26, hp: 3, speed: 90, hostile: true, fuse: 0, shootCooldown: 0, biome:'desert' },
- polar_bear:{ w: 40, h: 34, hp: 8, speed: 50, hostile: false, fuse: 0, shootCooldown: 2, biome:'tundra' },
- slime: { w: 24, h: 24, hp: 2, speed: 30, hostile: true, fuse: 0, shootCooldown: 2, biome:'swamp' },
- eagle: { w: 30, h: 22, hp: 3, speed: 120, hostile: true, fuse: 0, shootCooldown: 0, biome:'mountains' }
- };
- const props = kindProps[data.kind] || kindProps['pig']; // fallback
- return {
- id: data.id,
- kind: data.kind,
- x: data.x,
- y: data.y,
- w: props.w,
- h: props.h,
- hp: data.hp || props.hp,
- maxHp: data.maxHp || data.hp || props.hp,
- speed: props.speed,
- hostile: props.hostile,
+ // src/core/state.js
+ var state = {
+ // Константы (для доступа из модулей)
+ TILE,
+ SEA_GY,
+ BEDROCK_GY,
+ GEN_MARGIN_X,
+ GRAV,
+ GRAV_WATER,
+ MOVE,
+ JUMP,
+ DAY_LEN,
+ SAVE_KEY,
+ MOVE_SEND_INTERVAL,
+ MAX_RAINDROPS,
+ MAX_CHAT_MESSAGES,
+ VOICE_SERVER,
+ // Камера
+ camX: 0,
+ camY: 0,
+ W: 0,
+ H: 0,
+ dpr: 1,
+ // Canvas/DOM (заполняется в main.js)
+ gameEl: null,
+ canvas: null,
+ ctx: null,
+ lightC: null,
+ lightCtx: null,
+ // День/ночь
+ worldTime: 0,
+ isNightTime: false,
+ // Мультиплеер
+ isMultiplayer: false,
+ mySocketId: null,
+ socket: null,
+ voiceSocket: null,
+ voicePosT: 0,
+ // Сервер
+ SERVER_URL: "",
+ TELEGRAM_BOT_USERNAME: "",
+ TELEGRAM_APP_SHORT_NAME: "",
+ // Игрок
+ playerName: null,
+ worldId: null,
+ // Инвентарь/UI
+ selected: 0,
+ showFullInventory: false,
+ craftOpen: false,
+ inventoryOpen: false,
+ chatOpen: false,
+ minimapOpen: false,
+ modeIdx: 0,
+ currentFurnaceKey: null,
+ // Мир
+ worldSeed: Math.floor(Math.random() * 1e6),
+ // Погода
+ isRaining: false,
+ rainIntensity: 0,
+ weatherTimer: 0,
+ weatherChangeInterval: 60 + Math.random() * 120,
+ // Мобы/спавн
+ spawnT: 0,
+ // Цикл
+ last: 0,
+ prevJump: false,
+ // Сеть — throttle отправки позиции
+ lastMoveSendTime: 0,
+ lastSentX: 0,
+ lastSentY: 0,
+ // Mining progress
+ mining: { gx: null, gy: null, progress: 0, hardness: 0 },
+ // Trade/Market
+ myTgId: null,
+ tradePanel: null,
+ tradeContent: null,
+ marketPanel: null,
+ marketContent: null,
+ // DOM-ссылки (заполняются в main.js)
+ hpEl: null,
+ foodEl: null,
+ sxEl: null,
+ syEl: null,
+ todEl: null,
+ worldIdEl: null,
+ playerCountEl: null,
+ deathEl: null,
+ hotbarEl: null,
+ craftPanel: null,
+ recipesEl: null,
+ inventoryPanel: null,
+ inventoryGrid: null,
+ multiplayerStatus: null,
+ // Игрок
+ player: {
+ x: 6 * TILE,
+ y: 0 * TILE,
+ w: 34,
+ h: 34,
vx: 0,
vy: 0,
grounded: false,
inWater: false,
- aiT: 0,
- dir: data.dir || 1,
- dead: false,
- fuse: props.fuse,
- shootCooldown: props.shootCooldown
- };
+ headInWater: false,
+ hp: 100,
+ hunger: 100,
+ o2: 100,
+ invuln: 0,
+ fallStartY: 0,
+ lastStepTime: 0,
+ sleeping: false,
+ inBoat: false,
+ armor: 0,
+ equippedArmor: null
+ },
+ // Точка спавна
+ spawnPoint: { x: 6 * TILE, y: 0 * TILE },
+ // Инвентарь
+ inv: {
+ dirt: 6,
+ stone: 0,
+ sand: 0,
+ gravel: 0,
+ clay: 0,
+ wood: 0,
+ planks: 0,
+ ladder: 0,
+ leaves: 0,
+ coal: 0,
+ copper_ore: 0,
+ iron_ore: 0,
+ gold_ore: 0,
+ diamond_ore: 0,
+ brick: 0,
+ glass: 0,
+ tnt: 1,
+ campfire: 0,
+ torch: 0,
+ meat: 0,
+ cooked: 0,
+ arrow: 0,
+ wood_pickaxe: 0,
+ stone_pickaxe: 0,
+ iron_pickaxe: 0,
+ wood_sword: 0,
+ stone_sword: 0,
+ iron_sword: 0,
+ wood_axe: 0,
+ stone_axe: 0,
+ iron_axe: 0,
+ wood_shovel: 0,
+ stone_shovel: 0,
+ iron_shovel: 0,
+ iron_armor: 0,
+ bow: 0,
+ furnace: 0,
+ bed: 0,
+ boat: 0,
+ iron_ingot: 0,
+ gold_ingot: 0,
+ copper_ingot: 0,
+ cobblestone: 0
+ },
+ // Лодка
+ boat: {
+ x: 0,
+ y: 0,
+ w: 34,
+ h: 34,
+ vx: 0,
+ vy: 0,
+ active: false,
+ inWater: false
+ },
+ // Ввод
+ inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false, l: false, r: false, j: false, s: false },
+ // Мышь
+ mouse: { x: 0, y: 0 },
+ // Другие игроки (MP)
+ otherPlayers: /* @__PURE__ */ new Map(),
+ // Серверные мобы (MP)
+ serverMobs: /* @__PURE__ */ new Map(),
+ // Мобы
+ mobs: [],
+ // Снаряды
+ projectiles: [],
+ // Отслеживание изменений мира
+ placedBlocks: [],
+ removedBlocks: [],
+ // Серверные изменения
+ serverOverrides: /* @__PURE__ */ new Map(),
+ // Чат
+ chatMessages: [],
+ // Погода — капли
+ raindrops: [],
+ // Облака
+ clouds: Array.from({ length: 10 }, () => ({
+ x: Math.random() * 2e3,
+ y: -200 - Math.random() * 260,
+ w: 80 + Math.random() * 120,
+ s: 12 + Math.random() * 20
+ })),
+ // Частицы
+ parts: [],
+ // Активный TNT
+ activeTNT: /* @__PURE__ */ new Set(),
+ // Прочность инструментов
+ toolDurability: /* @__PURE__ */ new Map(),
+ // Последние выбранные предметы
+ recentItems: [],
+ // Активные печи
+ activeFurnaces: /* @__PURE__ */ new Map(),
+ // Сгенерированные колонны
+ generated: /* @__PURE__ */ new Set(),
+ // Изображение героя
+ heroImg: null,
+ // Сетка мира
+ grid: /* @__PURE__ */ new Map(),
+ blocks: [],
+ // In-memory save fallback
+ inMemorySave: null
+ };
+
+ // src/core/canvas.js
+ function initCanvas() {
+ const gameEl = document.getElementById("game");
+ const canvas = document.getElementById("c");
+ const ctx = canvas.getContext("2d");
+ const lightC = document.createElement("canvas");
+ const lightCtx = lightC.getContext("2d");
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
+ function resize() {
+ state.W = gameEl.clientWidth;
+ state.H = gameEl.clientHeight;
+ canvas.width = state.W * dpr;
+ canvas.height = state.H * dpr;
+ lightC.width = state.W * dpr;
+ lightC.height = state.H * dpr;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ }
+ window.addEventListener("resize", resize);
+ resize();
+ state.gameEl = gameEl;
+ state.canvas = canvas;
+ state.ctx = ctx;
+ state.lightC = lightC;
+ state.lightCtx = lightCtx;
+ state.dpr = dpr;
}
- let mySocketId = null;
-
- // Throttle для отправки позиции (10-20 раз в секунду)
- let lastMoveSendTime = 0;
- const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
- let lastSentX = 0, lastSentY = 0;
-
- function initSocket() {
- try {
- socket = io(SERVER_URL, {
- path: '/socket.io/',
- transports: ['websocket', 'polling']
- });
-
- socket.on('connect', () => {
- console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
- mySocketId = socket.id;
- isMultiplayer = true;
-
- // Присоединяемся к миру
- socket.emit('join_world', { world_id: worldId, player_name: playerName, tg_id: (tgUser && tgUser.id) ? tgUser.id : null });
-
- // TG: отправляем tg_auth + онлайн статус для уведомления друзей
- if (tgUser && tgUser.id) {
- socket.emit('tg_auth', { tg_id: tgUser.id });
- try {
- fetch(SERVER_URL + '/api/tg/online', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,world_id:worldId,player_name:playerName||'Игрок'})});
- } catch(e) { console.warn('[TG] online notify error:', e); }
- }
-
- // Показываем в UI
- worldIdEl.textContent = worldId;
- // XP/Level display
- const lvXpNext = xpForLevel(player.level + 1);
- const lvXpCur = xpForLevel(player.level);
- const xpInLevel = player.xp - lvXpCur;
- const xpNeeded = lvXpNext - lvXpCur;
- document.getElementById('xplevel').textContent = player.level;
- document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
- multiplayerStatus.style.display = 'block';
- });
-
- socket.on('connect_error', (error) => {
- console.error('Socket connection error:', error);
- isMultiplayer = false;
- });
-
- socket.on('disconnect', () => {
- console.log('Disconnected from server');
- isMultiplayer = false;
- otherPlayers.clear();
- multiplayerStatus.style.display = 'none';
- // TG: убираем онлайн статус
- if (tgUser && tgUser.id) {
- try { fetch(SERVER_URL + '/api/tg/offline', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id})}); } catch(e) {}
- }
- });
-
- // Обработка world_state
- socket.on('world_state', (data) => {
- console.log('Received world_state:', data);
-
- // Устанавливаем seed и перегенерируем мир если он изменился
- if (data.seed !== undefined && data.seed !== worldSeed) {
- const oldSeed = worldSeed;
- worldSeed = data.seed;
- console.log('World seed set from', oldSeed, 'to', worldSeed);
- // Full regeneration with correct seed
- grid.clear();
- blocks.length = 0;
- placedBlocks = [];
- removedBlocks = [];
- generated.clear();
- for (let gx = spawnPoint.gx - 25; gx <= spawnPoint.gx + 25; gx++) {
- genColumn(gx);
- decorations(gx);
- }
- console.log('World regenerated with seed:', worldSeed);
+
+ // src/config.js
+ var urlParams = new URLSearchParams(window.location.search);
+ var SERVER_URL = urlParams.get("server") || "https://apigrech.mkn8n.ru";
+ if (location.protocol === "https:" && SERVER_URL.startsWith("http://")) {
+ console.warn("\u26A0\uFE0F Mixed content warning: page is HTTPS but server URL is HTTP");
+ alert("\u26A0\uFE0F \u041F\u0440\u0435\u0434\u0443\u043F\u0440\u0435\u0436\u0434\u0435\u043D\u0438\u0435: \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430 \u043F\u043E HTTPS, \u043D\u043E \u0441\u0435\u0440\u0432\u0435\u0440 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u0442 HTTP. \u042D\u0442\u043E \u043C\u043E\u0436\u0435\u0442 \u0432\u044B\u0437\u0432\u0430\u0442\u044C \u043F\u0440\u043E\u0431\u043B\u0435\u043C\u044B.");
+ }
+
+ // src/data/blocks.js
+ var BLOCKS = {
+ air: { n: "\u0412\u043E\u0437\u0434\u0443\u0445", solid: false, hardness: 0 },
+ grass: { n: "\u0422\u0440\u0430\u0432\u0430", c: "#7cfc00", solid: true, hardness: 3, toolType: "shovel" },
+ dirt: { n: "\u0413\u0440\u044F\u0437\u044C", c: "#8b4513", solid: true, hardness: 3, toolType: "shovel" },
+ stone: { n: "\u041A\u0430\u043C\u0435\u043D\u044C", c: "#7f8c8d", solid: true, hardness: 6, toolType: "pickaxe" },
+ sand: { n: "\u041F\u0435\u0441\u043E\u043A", c: "#f4d06f", solid: true, hardness: 2, toolType: "shovel" },
+ gravel: { n: "\u0413\u0440\u0430\u0432\u0438\u0439", c: "#95a5a6", solid: true, hardness: 3, toolType: "shovel" },
+ clay: { n: "\u0413\u043B\u0438\u043D\u0430", c: "#74b9ff", solid: true, hardness: 3, toolType: "shovel" },
+ wood: { n: "\u0414\u0435\u0440\u0435\u0432\u043E", c: "#d35400", solid: true, hardness: 4, toolType: "axe" },
+ planks: { n: "\u0414\u043E\u0441\u043A\u0438", c: "#e67e22", solid: true, hardness: 3, toolType: "axe" },
+ ladder: { n: "\u041B\u0435\u0441\u0442\u043D\u0438\u0446\u0430", c: "#d35400", solid: false, climbable: true, hardness: 1, toolType: null },
+ leaves: { n: "\u041B\u0438\u0441\u0442\u0432\u0430", c: "#2ecc71", solid: true, hardness: 1, toolType: "axe", minTool: null },
+ glass: { n: "\u0421\u0442\u0435\u043A\u043B\u043E", c: "rgba(200,240,255,0.25)", solid: true, alpha: 0.55, hardness: 1, toolType: null },
+ water: { n: "\u0412\u043E\u0434\u0430", c: "rgba(52,152,219,0.55)", solid: false, fluid: true, hardness: 0 },
+ coal: { n: "\u0423\u0433\u043E\u043B\u044C", c: "#2c3e50", solid: true, hardness: 8, toolType: "pickaxe", minTool: "wood" },
+ copper_ore: { n: "\u041C\u0435\u0434\u044C", c: "#e17055", solid: true, hardness: 10, toolType: "pickaxe", minTool: "wood" },
+ iron_ore: { n: "\u0416\u0435\u043B\u0435\u0437\u043E", c: "#dcdde1", solid: true, hardness: 12, toolType: "pickaxe", minTool: "stone" },
+ iron_armor: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u0431\u0440\u043E\u043D\u044F", c: "#95a5a6", solid: false, armor: 0.5, hardness: 0 },
+ gold_ore: { n: "\u0417\u043E\u043B\u043E\u0442\u043E", c: "#f1c40f", solid: true, hardness: 15, toolType: "pickaxe", minTool: "stone" },
+ diamond_ore: { n: "\u0410\u043B\u043C\u0430\u0437", c: "#00a8ff", solid: true, hardness: 20, toolType: "pickaxe", minTool: "iron" },
+ brick: { n: "\u041A\u0438\u0440\u043F\u0438\u0447", c: "#c0392b", solid: true, hardness: 8, toolType: "pickaxe" },
+ tnt: { n: "TNT", c: "#e74c3c", solid: true, explosive: true, hardness: 1, toolType: null },
+ campfire: { n: "\u041A\u043E\u0441\u0442\u0451\u0440", c: "#e67e22", solid: true, lightRadius: 190, hardness: 2, toolType: null },
+ torch: { n: "\u0424\u0430\u043A\u0435\u043B", c: "#f9ca24", solid: true, lightRadius: 140, hardness: 1, toolType: null },
+ bedrock: { n: "\u0411\u0435\u0434\u0440\u043E\u043A", c: "#2d3436", solid: true, unbreakable: true, hardness: Infinity },
+ flower: { n: "\u0426\u0432\u0435\u0442\u043E\u043A", c: "#ff4757", solid: false, decor: true, hardness: 0 },
+ bed: { n: "\u041A\u0440\u043E\u0432\u0430\u0442\u044C", c: "#e91e63", solid: true, bed: true, hardness: 2, toolType: "axe" },
+ boat: { n: "\u041B\u043E\u0434\u043A\u0430", c: "#8B4513", solid: false, hardness: 0 },
+ furnace: { n: "\u041F\u0435\u0447\u044C", c: "#696969", solid: true, smelting: true, hardness: 8, toolType: "pickaxe", minTool: "wood" },
+ cobblestone: { n: "\u0411\u0443\u043B\u044B\u0436\u043D\u0438\u043A", c: "#7f8c8d", solid: true, hardness: 6, toolType: "pickaxe" }
+ };
+
+ // src/world/world-storage.js
+ var grid = state.grid;
+ var blocks = state.blocks;
+ function k(gx, gy) {
+ return gx + "," + gy;
+ }
+ function getBlock(gx, gy) {
+ return grid.get(k(gx, gy));
+ }
+ function isSolid(gx, gy) {
+ const b = getBlock(gx, gy);
+ if (!b || b.dead) return false;
+ const def = BLOCKS[b.t];
+ return !!def.solid && !def.fluid && !def.decor;
+ }
+ function setBlock(gx, gy, t, isPlayerPlaced = false) {
+ const key = k(gx, gy);
+ if (grid.has(key)) return false;
+ const b = { gx, gy, t, dead: false, active: false, fuse: 0 };
+ grid.set(key, b);
+ blocks.push(b);
+ if (isPlayerPlaced) {
+ state.placedBlocks.push({ gx, gy, t });
+ }
+ return true;
+ }
+ function removeBlock(gx, gy) {
+ const key = k(gx, gy);
+ const b = grid.get(key);
+ if (!b) return null;
+ if (BLOCKS[b.t].unbreakable) return null;
+ grid.delete(key);
+ b.dead = true;
+ const wasPlayerPlaced = state.placedBlocks.some((pb) => pb.gx === gx && pb.gy === gy);
+ if (wasPlayerPlaced) {
+ state.placedBlocks = state.placedBlocks.filter((pb) => !(pb.gx === gx && pb.gy === gy));
+ } else {
+ state.removedBlocks.push({ gx, gy });
+ }
+ return b;
+ }
+
+ // src/world/generation.js
+ var generated = state.generated;
+ function surfaceGyAt(gx) {
+ const n1 = Math.sin(gx * 0.025 + state.worldSeed * 1e-3) * 8;
+ const n2 = Math.sin(gx * 0.012 + state.worldSeed * 2e-3) * 12;
+ const n3 = Math.sin(gx * 6e-3 + state.worldSeed * 3e-3) * 6;
+ const n4 = Math.sin(gx * 0.045 + state.worldSeed * 4e-3) * 4;
+ const n5 = Math.cos(gx * 0.018 + state.worldSeed * 5e-3) * 5;
+ const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5);
+ return h;
+ }
+ function genColumn(gx) {
+ if (generated.has(gx)) return;
+ generated.add(gx);
+ const sgy = surfaceGyAt(gx);
+ if (sgy > SEA_GY) {
+ for (let gy = SEA_GY; gy < sgy; gy++) {
+ setBlock(gx, gy, "water");
+ }
+ setBlock(gx, sgy, "sand");
+ } else {
+ if (sgy < SEA_GY - 10) setBlock(gx, sgy, "stone");
+ else setBlock(gx, sgy, "grass");
+ }
+ for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) {
+ if (gy === BEDROCK_GY) {
+ setBlock(gx, gy, "bedrock");
+ continue;
+ }
+ let t = "stone";
+ if (gy <= sgy + 3) t = "dirt";
+ if (sgy > SEA_GY && gy === sgy + 1 && seededRandom(gx, gy) < 0.25) t = "clay";
+ if (gy > sgy + 6 && seededRandom(gx, gy) < 0.07) t = "gravel";
+ const depth = gy - sgy;
+ const r = seededRandom(gx, gy);
+ if (t === "stone") {
+ if (r < 0.06) t = "coal";
+ else if (r < 0.1) t = "copper_ore";
+ else if (r < 0.13) t = "iron_ore";
+ else if (depth > 40 && r < 0.145) t = "gold_ore";
+ else if (depth > 70 && r < 0.152) t = "diamond_ore";
+ }
+ setBlock(gx, gy, t);
+ }
+ const top = getBlock(gx, sgy);
+ if (top && top.t === "grass") {
+ if (seededRandom(gx, sgy - 1) < 0.1) {
+ setBlock(gx, sgy - 1, "flower");
+ }
+ if (seededRandom(gx, sgy - 2) < 0.12) {
+ setBlock(gx, sgy - 1, "wood");
+ setBlock(gx, sgy - 2, "wood");
+ setBlock(gx, sgy - 3, "leaves");
+ setBlock(gx - 1, sgy - 3, "leaves");
+ setBlock(gx + 1, sgy - 3, "leaves");
+ }
+ }
+ const colPrefix = gx + ",";
+ for (const [key, ov] of state.serverOverrides) {
+ if (!key.startsWith(colPrefix)) continue;
+ if (ov.op === "remove") {
+ const b = grid.get(key);
+ if (b) {
+ grid.delete(key);
+ b.dead = true;
}
-
- // Применяем блоки — сохраняем в serverOverrides для применения после genColumn
- if (data.blocks && Array.isArray(data.blocks)) {
- for (const block of data.blocks) {
- const key = k(block.gx, block.gy);
- serverOverrides.set(key, { op: block.op, t: block.t });
- // Также пробуем применить сразу (если колонна уже сгенерирована)
- if (block.op === 'set') {
- setBlock(block.gx, block.gy, block.t, false);
- } else if (block.op === 'remove') {
- removeBlock(block.gx, block.gy);
- }
- }
- }
-
- // Устанавливаем время
- if (data.time !== undefined) {
- worldTime = data.time;
- isNightTime = worldTime > 0.5;
- }
-
- // Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt
- // Это гарантирует совпадение с terrain generation
- {
- const startGX = 6;
- // Генерируем колонну и соседние для безопасного спавна
- for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
- const surfaceY = surfaceGyAt(startGX);
- // Ищем ближайшую небудущую позицию сверху вниз от поверхности
- let safeGY = surfaceY - 1;
- // Проверяем что над поверхностью воздух (не в воде)
- const aboveBlock = getBlock(startGX, surfaceY - 1);
- if (aboveBlock && aboveBlock.t === 'water') {
- // Если в воде — ищем поверхность выше уровня моря
- for (let gy = SEA_GY - 1; gy >= 0; gy--) {
- const b = getBlock(startGX, gy);
- if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
- safeGY = gy - 1;
- break;
- }
- }
- spawnPoint.x = startGX * TILE;
- spawnPoint.y = safeGY * TILE;
- console.log('Client-side spawn point:', spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
+ } else if (ov.op === "set") {
+ if (!grid.has(key)) {
+ const gy = parseInt(key.split(",")[1]);
+ const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
+ grid.set(key, nb);
+ blocks.push(nb);
}
-
- // Устанавливаем игрока в точку спавна
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.vx = 0;
- player.vy = 0;
- player.fallStartY = player.y;
- console.log('Player moved to spawn point:', player.x, player.y);
-
- // Устанавливаем HP на 100% при каждом подключении к миру
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.invuln = 0;
- console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
-
- // Обновляем список игроков
- if (data.players && Array.isArray(data.players)) {
- otherPlayers.clear();
- for (const p of data.players) {
- if (p.socket_id !== mySocketId) {
- otherPlayers.set(p.socket_id, {
- x: p.x,
- y: p.y,
- color: getRandomPlayerColor(p.socket_id),
- name: p.player_name || 'Игрок'
- });
- }
- }
- // Обновляем счётчик игроков
- playerCountEl.textContent = data.players.length;
- }
- // Server mobs — client-authoritative: create with full client-side properties
- if (data.mobs && Array.isArray(data.mobs)) {
- serverMobs.clear();
- for (const m of data.mobs) {
- const sm = createMobFromServer(m);
- // Пересчитываем Y по клиентской surfaceGyAt
- const spawnGX = Math.floor(sm.x / TILE);
- genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1);
- const clientSurfaceY = surfaceGyAt(spawnGX);
- sm.y = (clientSurfaceY - 1) * TILE;
- sm.vy = 0;
- serverMobs.set(m.id, sm);
- }
- }
- });
-
- // Игрок присоединился
- socket.on('player_joined', (data) => {
- console.log('Player joined:', data.socket_id);
- if (data.socket_id !== mySocketId) {
- // Генерируем безопасную позицию для нового игрока
- const spawnGX = 6;
- genColumn(spawnGX);
- const surfaceY = surfaceGyAt(spawnGX);
- const safeSpawnX = spawnGX * TILE;
- const safeSpawnY = (surfaceY - 1) * TILE;
-
- otherPlayers.set(data.socket_id, {
- x: safeSpawnX,
- y: safeSpawnY,
- tx: safeSpawnX,
- ty: safeSpawnY,
- color: getRandomPlayerColor(data.socket_id),
- name: data.player_name || 'Игрок'
- });
- addChatMessage('Система', `Игрок присоединился`);
- // Обновляем видимость кнопки сохранения
- updateSaveButtonVisibility();
- }
- });
-
- // Игрок переместился
- socket.on('player_moved', (data) => {
- if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) {
- const p = otherPlayers.get(data.socket_id);
- // Lerp: запоминаем целевую позицию, рисуем в рендере с интерполяцией
- p.tx = data.x;
- p.ty = data.y;
- // Обновляем имя, если оно пришло
- if (data.player_name) {
- p.name = data.player_name;
- }
- }
- });
-
- // Игрок покинул
- socket.on('player_left', (data) => {
- console.log('Player left:', data.socket_id);
- otherPlayers.delete(data.socket_id);
- addChatMessage('Система', `Игрок покинул игру`);
- // Обновляем видимость кнопки сохранения
- updateSaveButtonVisibility();
- });
-
- // === MOB SYNC (multiplayer) ===
-
- socket.on('mob_spawned', (data) => {
- const sm = createMobFromServer(data);
- // Пересчитываем Y по клиентской surfaceGyAt — сервер может ошибаться
- const spawnGX = Math.floor(sm.x / TILE);
- // Убеждаемся что чанк сгенерирован (иначе getBlock=null → моб падает сквозь землю)
- genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1);
- const clientSurfaceY = surfaceGyAt(spawnGX);
- sm.y = (clientSurfaceY - 1) * TILE; // 1 блок выше поверхности (под ногами)
- sm.vy = 0;
- sm.grounded = false; // resolveY в mobAI поставит grounded=true
- serverMobs.set(data.id, sm);
- });
-
- socket.on('mob_positions', (arr) => {
- for (const u of arr) {
- let sm = serverMobs.get(u.id);
- if (!sm) {
- sm = createMobFromServer({ id: u.id, kind: u.kind || 'zombie', x: u.x, y: u.y,
- hp: u.hp || 1, maxHp: u.hp || 1, dir: u.dir || 1 });
- serverMobs.set(u.id, sm);
- }
- sm.x += (u.x - sm.x) * 0.35;
- sm.y += (u.y - sm.y) * 0.35;
- sm.dir = u.dir != null ? u.dir : sm.dir;
- sm.hp = u.hp != null ? u.hp : sm.hp;
- sm.fuse = u.fuse != null ? u.fuse : sm.fuse;
- sm.grounded = !!u.grounded;
- }
- });
-
- socket.on('mob_despawned', (data) => { serverMobs.delete(data.id); });
-
- socket.on('mob_died', (data) => {
- const sm = serverMobs.get(data.id);
- if (sm && data.killer === mySocketId) {
- // Give loot to the killer
- if (sm.kind === 'chicken') playSound('hurt_chicken');
- spawnDrops(sm.x, sm.y, sm.kind);
- rebuildHotbar();
- }
- serverMobs.delete(data.id);
- });
-
- socket.on('mob_hurt_ack', (data) => {
- const sm = serverMobs.get(data.id);
- if (sm) sm.hp = data.hp;
- });
-
- socket.on('mob_explode', (data) => {
- explodeAt(data.gx, data.gy);
- serverMobs.delete(data.id);
- });
-
- socket.on('mob_shoot', (data) => {
- projectiles.push({
- x: data.x, y: data.y, vx: data.vx, vy: data.vy,
- dmg: data.dmg, owner: 'mob', life: data.life
- });
- });
-
- // Блок изменён
- socket.on('block_changed', (data) => {
- const key = k(data.gx, data.gy);
- serverOverrides.set(key, { op: data.op, t: data.t });
- if (data.op === 'set') {
- setBlock(data.gx, data.gy, data.t, false);
- } else if (data.op === 'remove') {
- removeBlock(data.gx, data.gy);
- }
- });
-
- // Сообщение в чат
- socket.on('chat_message', (data) => {
- const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
- addChatMessage(senderName, data.message);
- });
-
- // Обновление времени
- socket.on('time_update', (data) => {
- if (data.time !== undefined) {
- worldTime = data.time;
- isNightTime = worldTime > 0.5;
- }
- });
-
- } catch (e) {
- console.error('Error initializing socket:', e);
- isMultiplayer = false;
- }
- }
-
- // Генерация случайного цвета для игрока на основе socket_id
- function getRandomPlayerColor(socketId) {
- const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
- let hash = 0;
- for (let i = 0; i < socketId.length; i++) {
- hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
- hash = hash & hash;
- }
- return colors[Math.abs(hash) % colors.length];
- }
-
- // Отправка позиции игрока (с throttle)
- function sendPlayerPosition() {
- if (!isMultiplayer || !socket || !socket.connected) return;
-
- const now = performance.now() / 1000;
- if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
-
- // Отправляем только если позиция изменилась
- const dx = Math.abs(player.x - lastSentX);
- const dy = Math.abs(player.y - lastSentY);
- if (dx < 1 && dy < 1) return;
-
- lastMoveSendTime = now;
- lastSentX = player.x;
- lastSentY = player.y;
-
- socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName });
- }
-
- // Отправка изменения блока
- function sendBlockChange(gx, gy, t, op) {
- if (!isMultiplayer || !socket || !socket.connected) return;
-
- socket.emit('block_change', { gx, gy, t, op });
- }
-
- // ==================== ЧАТ ====================
- const chatMessages = [];
- const MAX_CHAT_MESSAGES = 20;
-
- function addChatMessage(sender, message) {
- const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
- chatMessages.push({ sender, message, time });
- if (chatMessages.length > MAX_CHAT_MESSAGES) {
- chatMessages.shift();
- }
- renderChatMessages();
- }
-
- function renderChatMessages() {
- const chatMessagesEl = document.getElementById('chatMessages');
- if (!chatMessagesEl) return;
-
- chatMessagesEl.innerHTML = chatMessages.map(m =>
- `${m.time} ${m.sender}: ${m.message}
`
- ).join('');
-
- // Прокручиваем вниз
- chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
- }
-
- function sendChatMessage(message) {
- if (!message || message.trim() === '') return;
-
- if (isMultiplayer && socket && socket.connected) {
- socket.emit('chat_message', { message: message.trim() });
- } else {
- addChatMessage('Вы', message.trim());
- }
- }
-
- // ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
- function shareWorld() {
- const shareUrl = new URL(window.location.href);
- shareUrl.searchParams.set('world', worldId);
- const shareUrlString = shareUrl.toString();
-
- // Копируем в буфер обмена
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(shareUrlString).then(() => {
- alert('Ссылка скопирована!');
- }).catch(() => {
- alert('Ссылка на мир:\n' + shareUrlString);
- });
- } else {
- alert('Ссылка на мир:\n' + shareUrlString);
- }
- }
-
- // ==================== ИНИЦИАЛИЗАЦИЯ UI ====================
- let chatOpen = false;
-
- document.getElementById('chatToggle').onclick = () => {
- playSound('click');
- chatOpen = !chatOpen;
- document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none';
- if (chatOpen) {
- document.getElementById('chatInput').focus();
- }
- };
-
- document.getElementById('chatClose').onclick = () => {
- playSound('click');
- chatOpen = false;
- document.getElementById('chatPanel').style.display = 'none';
- };
-
- document.getElementById('chatSend').onclick = () => {
- const input = document.getElementById('chatInput');
- sendChatMessage(input.value);
- input.value = '';
- };
-
- document.getElementById('chatInput').addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- sendChatMessage(e.target.value);
- e.target.value = '';
- }
- });
-
- // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ====================
- // Show start menu first, then init socket
- showStartMenu().then((wid) => {
- worldId = wid;
- // Send tg_id if TG user
- initSocket();
- launchGame();
- });
-
- // ==================== ЗВУКОВОЙ ДВИЖОК ====================
- const sounds = {};
+ }
+ }
+ }
+ function regenerateVisibleChunks() {
+ const gx0 = Math.floor(state.camX / TILE);
+ for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
+ generated.delete(gx);
+ genColumn(gx);
+ }
+ }
+ function ensureGenAroundCamera() {
+ const gx0 = Math.floor(state.camX / TILE);
+ for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
+ genColumn(gx);
+ }
+ }
+ function seededRandom(gx, gy) {
+ const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453;
+ return n - Math.floor(n);
+ }
+
+ // src/audio/sound-engine.js
+ var sounds = {};
function loadSound(id, src) {
const audio = new Audio();
audio.src = src;
audio.volume = 0.3;
sounds[id] = audio;
}
-
- // Загрузка звуков
- loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
- loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
- loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
- loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
- loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
- loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
- loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
- loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
- loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
- loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
- loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
- loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
- loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
- loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
- loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
- loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
- loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
-
+ loadSound("splash", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3");
+ loadSound("sand1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3");
+ loadSound("snow1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3");
+ loadSound("stone1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3");
+ loadSound("wood1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3");
+ loadSound("cloth1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3");
+ loadSound("fire", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3");
+ loadSound("hit1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3");
+ loadSound("attack", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3");
+ loadSound("hurt_chicken", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3");
+ loadSound("stone_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3");
+ loadSound("wood_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3");
+ loadSound("click", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3");
+ loadSound("explode1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3");
+ loadSound("glass1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3");
+ loadSound("eat1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3");
+ loadSound("step", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3");
function playSound(id) {
- if(sounds[id]) {
+ if (sounds[id]) {
sounds[id].currentTime = 0;
- sounds[id].play().catch(e => console.error('Sound error:', e));
+ sounds[id].play().catch((e) => console.error("Sound error:", e));
}
}
-
- // Играем звук при прыжке
- const gameEl = document.getElementById('game');
- const canvas = document.getElementById('c');
- const ctx = canvas.getContext('2d');
-
- // offscreen light map (не вставляем в DOM)
- const lightC = document.createElement('canvas');
- const lightCtx = lightC.getContext('2d');
-
- const dpr = Math.max(1, window.devicePixelRatio || 1);
- let W=0, H=0;
-
- const TILE = 40;
-
- // Мир
- const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
- const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
- const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
-
- const heroImg = new Image();
- heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
-
- // Состояние инвентаря
- let showFullInventory = false;
- let recentItems = []; // Последние 5 выбранных предметов
-
- const BLOCKS = {
- air: { n:'Воздух', solid:false },
- grass: { n:'Трава', c:'#7cfc00', solid:true },
- dirt: { n:'Грязь', c:'#8b4513', solid:true },
- stone: { n:'Камень', c:'#7f8c8d', solid:true },
- sand: { n:'Песок', c:'#f4d06f', solid:true },
- gravel: { n:'Гравий', c:'#95a5a6', solid:true },
- clay: { n:'Глина', c:'#74b9ff', solid:true },
- wood: { n:'Дерево', c:'#d35400', solid:true },
- planks: { n:'Доски', c:'#e67e22', solid:true },
- ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
- leaves: { n:'Листва', c:'#2ecc71', solid:true },
- glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
- water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
- coal: { n:'Уголь', c:'#2c3e50', solid:true },
- copper_ore:{ n:'Медь', c:'#e17055', solid:true },
- iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
- iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
- gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
- diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
- brick: { n:'Кирпич', c:'#c0392b', solid:true },
- tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
- campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280 },
- torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220 },
- bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
- flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
- bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
- boat: { n:'Лодка', c:'#8B4513', solid:false },
- furnace: { n:'Печь', c:'#696969', solid:true, smelting:true },
- // === BIOME BLOCKS ===
- snow: { n:'Снег', c:'#ecf0f1', solid:true },
- ice: { n:'Лёд', c:'#74b9ff', solid:true, slip:true },
- cactus: { n:'Кактус', c:'#27ae60', solid:true, hurt:true },
- mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true },
- moss: { n:'Мох', c:'#0a6640', solid:true },
- swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true },
- farmland: { n:'Грядка', c:'#8B6914', solid:true, farmable:true },
- dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true },
- spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true },
- // === CROP STAGES ===
- wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'wheat_stage1' },
- wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'wheat_stage2' },
- wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'wheat_stage3' },
- wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, harvestable:true, harvestItem:'wheat', harvestQty:2 },
- carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'carrot_stage1' },
- carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'carrot_stage2' },
- carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'carrot_stage3' },
- carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, harvestable:true, harvestItem:'carrot', harvestQty:3 },
- potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'potato_stage1' },
- potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'potato_stage2' },
- potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'potato_stage3' },
- potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, harvestable:true, harvestItem:'potato', harvestQty:2 }
- };
-
- const ITEMS = {
- meat: { n:'Сырое мясо', icon:'🥩', food:15 },
- cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
- arrow: { n:'Стрела', icon:'➡️', stack:64 },
- chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
- feather: { n:'Перо', icon:'🪶', stack:64 },
- bone: { n:'Кость', icon:'🦴', stack:64 },
- gunpowder: { n:'Порох', icon:'💥', stack:64 },
- // === FARMING ITEMS ===
- wheat: { n:'Пшеница', icon:'🌾', stack:64 },
- bread: { n:'Хлеб', icon:'🍞', food:30 },
- carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 },
- potato: { n:'Картофель', icon:'🥔', stack:64 },
- baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 },
- mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 },
- // === MOB DROP ITEMS ===
- scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 },
- polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 },
- slime_ball: { n:'Слизь', icon:'🟢', stack:64 },
- eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 },
- // === NEW ARMOR & TOOLS ===
- gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 }
- };
-
- // Seed мира для детерминированной генерации
- // Seed: null in MP until server sends it; SP uses random seed
- let worldSeed = isMultiplayer ? null : Math.floor(Math.random() * 1000000);
-
- // Отслеживание изменений мира (для оптимизированного сохранения)
- let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком
- let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком
- // Серверные изменения — применяются после genColumn чтобы не перезатирались
- const serverOverrides = new Map(); // key "gx,gy" => {op:'set'|'remove', t?:string}
-
- // Инструменты
- const TOOLS = {
- wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 }, requiredLevel: 1 },
- stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 }, requiredLevel: 2 },
- iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 }, requiredLevel: 3 },
- wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 }, requiredLevel: 1 },
- stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 }, requiredLevel: 2 },
- iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 }, requiredLevel: 3 },
- bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, planks: 2 }, requiredLevel: 4 },
- diamond_pickaxe: { n:'Алмазная кирка', icon:'⛏️', durability: 500, miningPower: 5, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 },
- diamond_sword: { n:'Алмазный меч', icon:'⚔️', durability: 400, damage: 18, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 },
- hoe: { n:'Мотыга', icon:'🔨', durability: 80, tillTo: 'farmland', craft: { wood: 2, planks: 1 }, requiredLevel: 1 }
+ // src/ui/chat.js
+ var chatMessages = [];
+ var MAX_CHAT_MESSAGES2 = 20;
+ function addChatMessage(sender, message) {
+ const time = (/* @__PURE__ */ new Date()).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
+ chatMessages.push({ sender, message, time });
+ if (chatMessages.length > MAX_CHAT_MESSAGES2) {
+ chatMessages.shift();
+ }
+ renderChatMessages();
+ }
+ function renderChatMessages() {
+ const chatMessagesEl = document.getElementById("chatMessages");
+ if (!chatMessagesEl) return;
+ chatMessagesEl.innerHTML = chatMessages.map(
+ (m) => `${m.time} ${m.sender}: ${m.message}
`
+ ).join("");
+ chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
+ }
+ function sendChatMessage(message) {
+ if (!message || message.trim() === "") return;
+ if (state.isMultiplayer && state.socket && state.socket.connected) {
+ state.socket.emit("chat_message", { message: message.trim() });
+ } else {
+ addChatMessage("\u0412\u044B", message.trim());
+ }
+ }
+ function initChat() {
+ document.getElementById("chatToggle").onclick = () => {
+ playSound("click");
+ state.chatOpen = !state.chatOpen;
+ document.getElementById("chatPanel").style.display = state.chatOpen ? "block" : "none";
+ if (state.chatOpen) {
+ document.getElementById("chatInput").focus();
+ }
+ };
+ document.getElementById("chatClose").onclick = () => {
+ playSound("click");
+ state.chatOpen = false;
+ document.getElementById("chatPanel").style.display = "none";
+ };
+ document.getElementById("chatSend").onclick = () => {
+ const input = document.getElementById("chatInput");
+ sendChatMessage(input.value);
+ input.value = "";
+ };
+ document.getElementById("chatInput").addEventListener("keypress", (e) => {
+ if (e.key === "Enter") {
+ sendChatMessage(e.target.value);
+ e.target.value = "";
+ }
+ });
+ }
+
+ // src/entities/player.js
+ function calculateDamage(baseDamage) {
+ const reduction = state.player.armor;
+ const actualDamage = baseDamage * (1 - reduction);
+ console.log("[DAMAGE] Base:", baseDamage, "- Armor:", reduction, "- Actual:", actualDamage.toFixed(1));
+ return actualDamage;
+ }
+
+ // src/data/items.js
+ var ITEMS = {
+ meat: { n: "\u0421\u044B\u0440\u043E\u0435 \u043C\u044F\u0441\u043E", icon: "\u{1F969}", food: 15 },
+ cooked: { n: "\u0416\u0430\u0440\u0435\u043D\u043E\u0435 \u043C\u044F\u0441\u043E", icon: "\u{1F356}", food: 45 },
+ arrow: { n: "\u0421\u0442\u0440\u0435\u043B\u0430", icon: "\u27A1\uFE0F", stack: 64 }
};
-
- // Текстуры блоков (простые)
- const tex = {};
+ ITEMS.iron_ingot = { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1F529}" };
+ ITEMS.gold_ingot = { n: "\u0417\u043E\u043B\u043E\u0442\u043E\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1FA99}" };
+ ITEMS.copper_ingot = { n: "\u041C\u0435\u0434\u043D\u044B\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1F7E4}" };
+ ITEMS.wood_axe = { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}" };
+ ITEMS.stone_axe = { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}" };
+ ITEMS.iron_axe = { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}" };
+ ITEMS.wood_shovel = { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}" };
+ ITEMS.stone_shovel = { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}" };
+ ITEMS.iron_shovel = { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}" };
+
+ // src/data/tools.js
+ var TOOLS = {
+ wood_pickaxe: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", damage: 1, durability: 60, toolType: "pickaxe", toolTier: "wood", speed: 2 },
+ stone_pickaxe: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", damage: 2, durability: 132, toolType: "pickaxe", toolTier: "stone", speed: 4 },
+ iron_pickaxe: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", damage: 3, durability: 250, toolType: "pickaxe", toolTier: "iron", speed: 6 },
+ wood_sword: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u{1F5E1}\uFE0F", damage: 4, durability: 60 },
+ stone_sword: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u{1F5E1}\uFE0F", damage: 5, durability: 132 },
+ iron_sword: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u{1F5E1}\uFE0F", damage: 7, durability: 250 },
+ wood_axe: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}", damage: 3, durability: 60, toolType: "axe", toolTier: "wood", speed: 2 },
+ stone_axe: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}", damage: 4, durability: 132, toolType: "axe", toolTier: "stone", speed: 4 },
+ iron_axe: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u0442\u043E\u043F\u043E\u0440", icon: "\u{1FA93}", damage: 5, durability: 250, toolType: "axe", toolTier: "iron", speed: 6 },
+ wood_shovel: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}", damage: 1, durability: 60, toolType: "shovel", toolTier: "wood", speed: 2 },
+ stone_shovel: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}", damage: 2, durability: 132, toolType: "shovel", toolTier: "stone", speed: 4 },
+ iron_shovel: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043B\u043E\u043F\u0430\u0442\u0430", icon: "\u{1F531}", damage: 3, durability: 250, toolType: "shovel", toolTier: "iron", speed: 6 },
+ bow: { n: "\u041B\u0443\u043A", icon: "\u{1F3F9}", damage: 10, durability: 200 }
+ };
+ function addTool(type) {
+ const maxDur = TOOLS[type]?.durability || 60;
+ const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
+ state.toolDurability.set(key, { type, current: maxDur, max: maxDur });
+ }
+ function useTool(type) {
+ for (const [tid, dur] of state.toolDurability) {
+ if (dur.type === type && dur.current > 0) {
+ dur.current--;
+ if (dur.current <= 0) {
+ state.toolDurability.delete(tid);
+ state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
+ return true;
+ }
+ return false;
+ }
+ }
+ return false;
+ }
+ function getBestTool(blockType) {
+ const block = BLOCKS[blockType];
+ if (!block || !block.toolType) return null;
+ const toolType = block.toolType;
+ for (const tier of ["iron", "stone", "wood"]) {
+ const toolId = `${tier}_${toolType}`;
+ if (TOOLS[toolId] && state.inv[toolId] > 0) return toolId;
+ }
+ return null;
+ }
+ function toolTierLessThan(toolTier, minTier) {
+ const tiers = { wood: 1, stone: 2, iron: 3 };
+ return (tiers[toolTier] || 0) < (tiers[minTier] || 0);
+ }
+ function getMiningSpeed(blockType) {
+ const block = BLOCKS[blockType];
+ if (!block) return 1;
+ const toolId = getBestTool(blockType);
+ if (toolId) {
+ const tool = TOOLS[toolId];
+ if (tool.toolType === block.toolType) return tool.speed;
+ }
+ return 1;
+ }
+
+ // src/render/textures.js
+ var tex = {};
+ var itemTex = {};
+ function drawArrow(ctx, color) {
+ ctx.fillStyle = "#8B4513";
+ ctx.fillRect(8, 14, 16, 3);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.moveTo(24, 15);
+ ctx.lineTo(18, 10);
+ ctx.lineTo(18, 20);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = "#e74c3c";
+ ctx.beginPath();
+ ctx.moveTo(8, 15);
+ ctx.lineTo(4, 10);
+ ctx.lineTo(10, 14);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(8, 16);
+ ctx.lineTo(4, 21);
+ ctx.lineTo(10, 17);
+ ctx.closePath();
+ ctx.fill();
+ }
+ function drawPickaxe(ctx, headColor, handleColor) {
+ ctx.fillStyle = handleColor || "#8B4513";
+ ctx.save();
+ ctx.translate(16, 16);
+ ctx.rotate(Math.PI / 4);
+ ctx.fillRect(-2, 0, 4, 18);
+ ctx.fillStyle = headColor;
+ ctx.fillRect(-8, -3, 16, 5);
+ ctx.fillRect(-8, -3, 3, 8);
+ ctx.fillRect(5, -3, 3, 8);
+ ctx.restore();
+ }
+ function drawAxe(ctx, headColor, handleColor) {
+ ctx.fillStyle = handleColor || "#8B4513";
+ ctx.save();
+ ctx.translate(16, 16);
+ ctx.rotate(Math.PI / 4);
+ ctx.fillRect(-2, 0, 4, 18);
+ ctx.fillStyle = headColor;
+ ctx.fillRect(-2, -8, 10, 8);
+ ctx.fillRect(-2, -8, 4, 12);
+ ctx.restore();
+ }
+ function drawShovel(ctx, headColor, handleColor) {
+ ctx.fillStyle = handleColor || "#8B4513";
+ ctx.save();
+ ctx.translate(16, 16);
+ ctx.rotate(Math.PI / 4);
+ ctx.fillRect(-2, 0, 4, 18);
+ ctx.fillStyle = headColor;
+ ctx.fillRect(-6, -8, 12, 8);
+ ctx.fillRect(-4, -12, 8, 4);
+ ctx.restore();
+ }
+ function drawSword(ctx, bladeColor, guardColor) {
+ ctx.fillStyle = "#8B4513";
+ ctx.fillRect(14, 18, 4, 10);
+ ctx.fillStyle = guardColor || "#f1c40f";
+ ctx.fillRect(10, 16, 12, 3);
+ ctx.fillStyle = bladeColor;
+ ctx.beginPath();
+ ctx.moveTo(16, 2);
+ ctx.lineTo(12, 16);
+ ctx.lineTo(20, 16);
+ ctx.closePath();
+ ctx.fill();
+ }
+ function drawBow(ctx) {
+ ctx.strokeStyle = "#8B4513";
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.arc(12, 16, 12, -Math.PI * 0.7, Math.PI * 0.7);
+ ctx.stroke();
+ ctx.strokeStyle = "#ddd";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(12, 4);
+ ctx.lineTo(12, 28);
+ ctx.stroke();
+ }
+ function drawIngot(ctx, color) {
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.moveTo(6, 12);
+ ctx.lineTo(26, 12);
+ ctx.lineTo(24, 24);
+ ctx.lineTo(8, 24);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = "rgba(255,255,255,0.3)";
+ ctx.fillRect(8, 8, 16, 4);
+ }
+ function drawMeat(ctx, cooked) {
+ ctx.fillStyle = cooked ? "#8B4513" : "#e74c3c";
+ ctx.beginPath();
+ ctx.ellipse(16, 18, 10, 8, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = "#f5f5dc";
+ ctx.fillRect(8, 6, 3, 12);
+ ctx.fillRect(14, 4, 3, 8);
+ }
+ function makeItemTex(id) {
+ const c = document.createElement("canvas");
+ c.width = 32;
+ c.height = 32;
+ const g = c.getContext("2d");
+ const tierColors = {
+ wood: { head: "#a0522d", blade: "#a0522d" },
+ stone: { head: "#7f8c8d", blade: "#95a5a6" },
+ iron: { head: "#bdc3c7", blade: "#ecf0f1" }
+ };
+ switch (id) {
+ case "arrow":
+ drawArrow(g, "#7f8c8d");
+ break;
+ case "bow":
+ drawBow(g);
+ break;
+ case "wood_pickaxe":
+ drawPickaxe(g, tierColors.wood.head, tierColors.wood.blade);
+ break;
+ case "stone_pickaxe":
+ drawPickaxe(g, tierColors.stone.head);
+ break;
+ case "iron_pickaxe":
+ drawPickaxe(g, tierColors.iron.head);
+ break;
+ case "wood_axe":
+ drawAxe(g, tierColors.wood.head);
+ break;
+ case "stone_axe":
+ drawAxe(g, tierColors.stone.head);
+ break;
+ case "iron_axe":
+ drawAxe(g, tierColors.iron.head);
+ break;
+ case "wood_shovel":
+ drawShovel(g, tierColors.wood.head);
+ break;
+ case "stone_shovel":
+ drawShovel(g, tierColors.stone.head);
+ break;
+ case "iron_shovel":
+ drawShovel(g, tierColors.iron.head);
+ break;
+ case "wood_sword":
+ drawSword(g, tierColors.wood.blade, "#a0522d");
+ break;
+ case "stone_sword":
+ drawSword(g, tierColors.stone.blade, "#7f8c8d");
+ break;
+ case "iron_sword":
+ drawSword(g, tierColors.iron.blade, "#bdc3c7");
+ break;
+ case "iron_ingot":
+ drawIngot(g, "#bdc3c7");
+ break;
+ case "gold_ingot":
+ drawIngot(g, "#f1c40f");
+ break;
+ case "copper_ingot":
+ drawIngot(g, "#e67e22");
+ break;
+ case "meat":
+ drawMeat(g, false);
+ break;
+ case "cooked":
+ drawMeat(g, true);
+ break;
+ default:
+ g.fillStyle = "#7f8c8d";
+ g.fillRect(4, 4, 24, 24);
+ g.fillStyle = "rgba(255,255,255,0.2)";
+ g.fillRect(4, 4, 24, 4);
+ }
+ return c;
+ }
function makeTex(type) {
const t = BLOCKS[type];
- const c = document.createElement('canvas');
- c.width = 32; c.height = 32;
- const g = c.getContext('2d');
-
- if (type === 'tnt') {
- g.fillStyle='#c0392b'; g.fillRect(0,0,32,32);
- g.fillStyle='#fff'; g.fillRect(0,12,32,8);
- g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20);
- return c;
- }
- if (type === 'campfire') {
- g.fillStyle='#5d4037'; g.fillRect(4,26,24,6);
- g.fillStyle='#3e2723'; g.fillRect(7,23,18,4);
- return c;
- }
- if (type === 'torch') {
- g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18);
- g.fillStyle='#f39c12'; g.fillRect(12,6,8,8);
- return c;
- }
- if (type === 'glass') {
- g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32);
- g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28);
- g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke();
- return c;
- }
- if (type === 'water') {
- g.fillStyle = t.c; g.fillRect(0,0,32,32);
- g.fillStyle = 'rgba(255,255,255,0.08)';
- g.fillRect(0,6,32,2);
- return c;
- }
- if (type === 'bed') {
- // Основание кровати
- g.fillStyle = '#e91e63';
+ const c = document.createElement("canvas");
+ c.width = 32;
+ c.height = 32;
+ const g = c.getContext("2d");
+ if (type === "tnt") {
+ g.fillStyle = "#c0392b";
g.fillRect(0, 0, 32, 32);
- // Подушка
- g.fillStyle = '#f8bbd0';
+ g.fillStyle = "#fff";
+ g.fillRect(0, 12, 32, 8);
+ g.fillStyle = "#000";
+ g.font = "bold 10px sans-serif";
+ g.fillText("TNT", 6, 20);
+ return c;
+ }
+ if (type === "campfire") {
+ g.fillStyle = "#5d4037";
+ g.fillRect(4, 26, 24, 6);
+ g.fillStyle = "#3e2723";
+ g.fillRect(7, 23, 18, 4);
+ return c;
+ }
+ if (type === "torch") {
+ g.fillStyle = "#6d4c41";
+ g.fillRect(14, 10, 4, 18);
+ g.fillStyle = "#f39c12";
+ g.fillRect(12, 6, 8, 8);
+ return c;
+ }
+ if (type === "glass") {
+ g.fillStyle = "rgba(200,240,255,0.25)";
+ g.fillRect(0, 0, 32, 32);
+ g.strokeStyle = "rgba(255,255,255,0.65)";
+ g.strokeRect(2, 2, 28, 28);
+ g.beginPath();
+ g.moveTo(5, 27);
+ g.lineTo(27, 5);
+ g.stroke();
+ return c;
+ }
+ if (type === "water") {
+ g.fillStyle = t.c;
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = "rgba(255,255,255,0.08)";
+ g.fillRect(0, 6, 32, 2);
+ return c;
+ }
+ if (type === "bed") {
+ g.fillStyle = "#e91e63";
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = "#f8bbd0";
g.fillRect(2, 2, 14, 14);
- // Одеяло
- g.fillStyle = '#c2185b';
+ g.fillStyle = "#c2185b";
g.fillRect(16, 4, 14, 24);
- // Детали одеяла
- g.fillStyle = '#e91e63';
+ g.fillStyle = "#e91e63";
g.fillRect(18, 6, 10, 20);
return c;
}
- if (type === 'flower') {
- g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18);
- g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill();
+ if (type === "flower") {
+ g.fillStyle = "#2ecc71";
+ g.fillRect(14, 14, 4, 18);
+ g.fillStyle = t.c;
+ g.beginPath();
+ g.arc(16, 12, 6, 0, 6.28);
+ g.fill();
return c;
}
- if (type === 'boat') {
- // Корпус лодки
- g.fillStyle = '#8B4513';
+ if (type === "boat") {
+ g.fillStyle = "#8B4513";
g.fillRect(2, 12, 28, 8);
- // Борта
- g.fillStyle = '#A0522D';
+ g.fillStyle = "#A0522D";
g.fillRect(0, 10, 32, 12);
- // Внутренность
- g.fillStyle = '#DEB887';
+ g.fillStyle = "#DEB887";
g.fillRect(4, 14, 24, 4);
- // Дно
- g.fillStyle = '#654321';
+ g.fillStyle = "#654321";
g.fillRect(2, 20, 28, 4);
return c;
}
- if (type === 'ladder') {
- // Боковые стойки лестницы
- g.fillStyle = '#8B4513';
+ if (type === "ladder") {
+ g.fillStyle = "#8B4513";
g.fillRect(4, 0, 4, 32);
g.fillRect(24, 0, 4, 32);
- // Ступени
- g.fillStyle = '#A0522D';
+ g.fillStyle = "#A0522D";
g.fillRect(4, 4, 24, 3);
g.fillRect(4, 12, 24, 3);
g.fillRect(4, 20, 24, 3);
g.fillRect(4, 28, 24, 3);
return c;
}
- // === BIOME BLOCK TEXTURES ===
- if (type === 'snow') {
- g.fillStyle = '#ecf0f1';
- g.fillRect(0, 0, 32, 32);
- g.fillStyle = '#dfe6e9';
- for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3);
- return c;
- }
- if (type === 'ice') {
- g.fillStyle = '#74b9ff';
- g.fillRect(0, 0, 32, 32);
- g.strokeStyle = 'rgba(255,255,255,0.4)';
- g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke();
- g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke();
- return c;
- }
- if (type === 'cactus') {
- g.fillStyle = '#27ae60';
- g.fillRect(6, 2, 20, 28);
- g.fillStyle = '#2ecc71';
- g.fillRect(2, 8, 6, 4);
- g.fillRect(24, 14, 6, 4);
- g.fillStyle = '#1e8449';
- g.fillRect(12, 0, 2, 30);
- g.fillRect(18, 0, 2, 30);
- return c;
- }
- if (type === 'mushroom') {
- g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12);
- g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill();
- g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3);
- return c;
- }
- if (type === 'moss') {
- g.fillStyle = '#0a6640';
- g.fillRect(0, 0, 32, 32);
- g.fillStyle = '#1e8449';
- for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3);
- return c;
- }
- if (type === 'swamp_water') {
- g.fillStyle = 'rgba(100,120,40,0.6)';
- g.fillRect(0, 0, 32, 32);
- g.fillStyle = 'rgba(80,100,20,0.3)';
- g.fillRect(0, 10, 32, 2);
- g.fillRect(0, 22, 32, 2);
- return c;
- }
- if (type === 'farmland') {
- g.fillStyle = '#8B6914';
- g.fillRect(0, 0, 32, 32);
- g.fillStyle = '#7a5c10';
- for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2);
- g.fillStyle = '#6B4E0A';
- g.fillRect(8, 2, 2, 28);
- g.fillRect(18, 2, 2, 28);
- return c;
- }
- if (type === 'dead_bush') {
- g.fillStyle = '#b2bec3';
- g.fillRect(12, 16, 2, 16);
- g.fillRect(8, 14, 8, 2);
- g.fillRect(16, 12, 8, 2);
- g.fillRect(6, 18, 4, 2);
- return c;
- }
- if (type === 'spruce_leaves') {
- g.fillStyle = '#0a6640';
- g.fillRect(0, 0, 32, 32);
- g.fillStyle = '#0d7a4d';
- for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4);
- return c;
- }
- // CROP STAGES
- if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) {
- const st = parseInt(type.charAt(type.length-1));
- const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] :
- type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] :
- ['#a8e6a0','#c8b888','#bfaa78','#dfe6e9'];
- g.fillStyle = '#5d4037';
- g.fillRect(15, 18, 2, 14);
- if (st >= 1) {
- g.fillStyle = colors[st];
- g.fillRect(10, 8 + (3-st)*3, 12, 10);
- } else {
- g.fillStyle = '#a8e6a0';
- g.fillRect(14, 22, 4, 4);
- }
- return c;
- }
-
- g.fillStyle = t.c || '#000';
- g.fillRect(0,0,32,32);
-
- g.fillStyle = 'rgba(0,0,0,0.10)';
- for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4);
-
- if (type.endsWith('_ore') || type==='coal') {
- g.fillStyle = 'rgba(0,0,0,0.35)';
- for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6);
+ g.fillStyle = t.c || "#000";
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = "rgba(0,0,0,0.10)";
+ for (let i = 0; i < 6; i++) g.fillRect(Math.random() * 28 | 0, Math.random() * 28 | 0, 4, 4);
+ if (type.endsWith("_ore") || type === "coal") {
+ g.fillStyle = "rgba(0,0,0,0.35)";
+ for (let i = 0; i < 4; i++) g.fillRect(Math.random() * 24 | 0, Math.random() * 24 | 0, 6, 6);
}
return c;
}
- Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
-
- // Мир-хранилище
- const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
- const blocks = []; // для рендера/перебора видимых
- function k(gx,gy){ return gx+','+gy; }
- function getBlock(gx,gy){ return grid.get(k(gx,gy)); }
- function hasBlock(gx,gy){ return grid.has(k(gx,gy)); }
- function isSolid(gx,gy){
- const b = getBlock(gx,gy);
- if(!b || b.dead) return false;
- const def = BLOCKS[b.t];
- return !!def.solid && !def.fluid && !def.decor;
+ function initTextures() {
+ Object.keys(BLOCKS).forEach((k2) => tex[k2] = makeTex(k2));
+ const itemIds = [
+ "arrow",
+ "bow",
+ "wood_pickaxe",
+ "stone_pickaxe",
+ "iron_pickaxe",
+ "wood_axe",
+ "stone_axe",
+ "iron_axe",
+ "wood_shovel",
+ "stone_shovel",
+ "iron_shovel",
+ "wood_sword",
+ "stone_sword",
+ "iron_sword",
+ "iron_ingot",
+ "gold_ingot",
+ "copper_ingot",
+ "meat",
+ "cooked"
+ ];
+ itemIds.forEach((id) => {
+ itemTex[id] = makeItemTex(id);
+ });
}
- function setBlock(gx,gy,t, isPlayerPlaced = false){
- const key = k(gx,gy);
- if(grid.has(key)) return false;
- const b = { gx, gy, t, dead:false, active:false, fuse:0 };
- grid.set(key, b);
- blocks.push(b);
-
- // Отслеживаем блоки, установленные игроком
- if(isPlayerPlaced){
- placedBlocks.push({gx, gy, t});
+
+ // src/ui/hotbar.js
+ function rebuildHotbar() {
+ const hotbarEl = state.hotbarEl;
+ const inv = state.inv;
+ const selected = state.selected;
+ const recentItems = state.recentItems;
+ const toolDurability = state.toolDurability;
+ hotbarEl.innerHTML = "";
+ const items = recentItems.filter((id) => inv[id] > 0).slice(0, 5);
+ for (const id of items) {
+ const s = document.createElement("div");
+ s.className = "slot" + (id === selected ? " sel" : "");
+ if (BLOCKS[id] && tex[id]) {
+ s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ s.style.backgroundSize = "cover";
+ } else if (itemTex[id]) {
+ s.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
+ s.style.backgroundSize = "cover";
+ } else if (ITEMS[id]) {
+ s.textContent = ITEMS[id].icon || "\u{1F4E6}";
+ } else if (TOOLS[id]) {
+ s.textContent = TOOLS[id].icon || "\u{1F527}";
+ } else if (id === "iron_armor") {
+ s.textContent = "\u{1F6E1}\uFE0F";
+ s.style.background = "linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)";
+ }
+ const c = document.createElement("div");
+ c.className = "count";
+ c.textContent = inv[id];
+ s.appendChild(c);
+ s.onclick = () => {
+ playSound("click");
+ state.selected = id;
+ state.recentItems = state.recentItems.filter((item) => item !== id);
+ state.recentItems.unshift(id);
+ state.recentItems = state.recentItems.slice(0, 5);
+ rebuildHotbar();
+ };
+ if (id === "iron_armor" && state.player.equippedArmor === "iron_armor") {
+ const equipped = document.createElement("div");
+ equipped.className = "equipped-indicator";
+ equipped.textContent = "\u2713";
+ s.appendChild(equipped);
+ }
+ if (TOOLS[id] && inv[id] > 0) {
+ let curDur = 0, maxDur = TOOLS[id].durability;
+ for (const [tid, dur] of toolDurability) {
+ if (dur.type === id) {
+ curDur = dur.current;
+ maxDur = dur.max;
+ break;
+ }
+ }
+ if (maxDur > 0) {
+ const bar = document.createElement("div");
+ bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
+ const fill = document.createElement("div");
+ const pct = curDur / maxDur;
+ const color = pct > 0.5 ? "#2ecc71" : pct > 0.25 ? "#f39c12" : "#e74c3c";
+ fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`;
+ bar.appendChild(fill);
+ s.appendChild(bar);
+ }
+ }
+ hotbarEl.appendChild(s);
}
-
- return true;
}
- function removeBlock(gx,gy){
- const key = k(gx,gy);
- const b = grid.get(key);
- if(!b) return null;
- if(BLOCKS[b.t].unbreakable) return null;
- grid.delete(key);
- b.dead = true;
-
- // Отслеживаем удалённые блоки
- const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
- if(wasPlayerPlaced){
- // Удаляем из placedBlocks
- placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
+
+ // src/render/particles.js
+ var parts = [];
+ function spawnExplosion(x, y, power) {
+ const n = Math.floor(16 + power * 10);
+ for (let i = 0; i < n; i++) {
+ parts.push({
+ x,
+ y,
+ vx: (Math.random() - 0.5) * (300 + power * 200),
+ vy: (Math.random() - 0.5) * (300 + power * 200),
+ t: 0.7,
+ c: "#ffa500"
+ });
+ }
+ }
+
+ // src/world/tnt.js
+ function activateTNT(b, fuse = 3.2) {
+ if (b.dead) return;
+ if (b.active) return;
+ b.active = true;
+ b.fuse = fuse;
+ state.activeTNT.add(k(b.gx, b.gy));
+ }
+ function explodeAt(gx, gy) {
+ const center = getBlock(gx, gy);
+ if (!center) return;
+ let bonus = 0;
+ for (let x = gx - 2; x <= gx + 2; x++) {
+ for (let y = gy - 2; y <= gy + 2; y++) {
+ const b = getBlock(x, y);
+ if (b && !b.dead && b.t === "tnt" && !(x === gx && y === gy)) {
+ bonus += 0.8;
+ activateTNT(b, 0.12);
+ }
+ }
+ }
+ const power = 1 + bonus;
+ const radius = 3.2 + bonus * 0.7;
+ const dmgR = 150 + bonus * 60;
+ removeBlock(gx, gy);
+ state.activeTNT.delete(k(gx, gy));
+ playSound("explode1");
+ spawnExplosion(gx * TILE + TILE / 2, gy * TILE + TILE / 2, power);
+ for (let x = Math.floor(gx - radius); x <= Math.ceil(gx + radius); x++) {
+ for (let y = Math.floor(gy - radius); y <= Math.ceil(gy + radius); y++) {
+ const d = Math.hypot(x - gx, y - gy);
+ if (d > radius) continue;
+ const b = getBlock(x, y);
+ if (!b || b.dead) continue;
+ if (BLOCKS[b.t].fluid) continue;
+ if (BLOCKS[b.t].unbreakable) continue;
+ if (b.t === "tnt") {
+ activateTNT(b, 0.12);
+ continue;
+ }
+ removeBlock(x, y);
+ if (state.inv[b.t] !== void 0 && Math.random() < 0.2) state.inv[b.t]++;
+ }
+ }
+ rebuildHotbar();
+ const hurt = (e) => {
+ const dx = e.x + e.w / 2 - (gx * TILE + TILE / 2);
+ const dy = e.y + e.h / 2 - (gy * TILE + TILE / 2);
+ const dist = Math.hypot(dx, dy);
+ if (dist < dmgR) {
+ const dmg = (dmgR - dist) * 0.06 * power;
+ if (e === state.player) {
+ const actualDamage = calculateDamage(dmg);
+ state.player.hp -= actualDamage;
+ } else {
+ e.hp -= dmg;
+ }
+ e.vx += (dx / dist || 0) * 600;
+ e.vy -= 320;
+ }
+ };
+ hurt(state.player);
+ state.mobs.forEach(hurt);
+ }
+
+ // src/game/save.js
+ var SAVE_KEY2 = state.SAVE_KEY;
+ function initDB() {
+ return new Promise((resolve) => {
+ console.log("\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u043C localStorage \u0434\u043B\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0439 (sandbox \u0440\u0435\u0436\u0438\u043C)");
+ resolve(null);
+ });
+ }
+ function saveGame() {
+ const saveData = {
+ version: 2,
+ worldSeed: state.worldSeed,
+ player: {
+ x: state.player.x,
+ y: state.player.y,
+ hp: state.player.hp,
+ hunger: state.player.hunger,
+ o2: state.player.o2
+ },
+ inventory: state.inv,
+ time: state.worldTime,
+ isNight: state.isNightTime,
+ // Сохраняем только изменения
+ placedBlocks: state.placedBlocks.slice(),
+ removedBlocks: state.removedBlocks.slice()
+ };
+ const saveSize = JSON.stringify(saveData).length;
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435: player HP:", state.player.hp, "hunger:", state.player.hunger, "o2:", state.player.o2);
+ try {
+ localStorage.setItem(SAVE_KEY2, JSON.stringify(saveData));
+ console.log(`\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430 \u0432 localStorage (\u0440\u0430\u0437\u043C\u0435\u0440: ${saveSize} \u0431\u0430\u0439\u0442)`);
+ } catch (e) {
+ console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F \u0432 localStorage, \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u043C \u0442\u043E\u043B\u044C\u043A\u043E in-memory:", e);
+ state.inMemorySave = saveData;
+ console.log(`\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430 \u0432 \u043F\u0430\u043C\u044F\u0442\u0438 (\u0440\u0430\u0437\u043C\u0435\u0440: ${saveSize} \u0431\u0430\u0439\u0442)`);
+ }
+ }
+ function loadGame() {
+ return new Promise((resolve, reject) => {
+ try {
+ const localSave = localStorage.getItem(SAVE_KEY2);
+ if (localSave) {
+ const parsed = JSON.parse(localSave);
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0438\u0437 localStorage, player HP:", parsed.player?.hp);
+ resolve(parsed);
+ return;
+ }
+ } catch (e) {
+ console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A localStorage:", e);
+ }
+ if (state.inMemorySave) {
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0438\u0437 in-memory \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F, player HP:", state.inMemorySave.player?.hp);
+ resolve(state.inMemorySave);
+ return;
+ }
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E");
+ resolve(null);
+ });
+ }
+ function migrateV1toV2(saveData) {
+ console.log("\u041C\u0438\u0433\u0440\u0430\u0446\u0438\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F \u0441 \u0432\u0435\u0440\u0441\u0438\u0438 1 \u043D\u0430 \u0432\u0435\u0440\u0441\u0438\u044E 2...");
+ saveData.worldSeed = state.worldSeed;
+ saveData.placedBlocks = [];
+ saveData.removedBlocks = [];
+ delete saveData.generatedBlocks;
+ saveData.version = 2;
+ console.log("\u041C\u0438\u0433\u0440\u0430\u0446\u0438\u044F \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u0430");
+ }
+ async function applySave(saveData) {
+ if (!saveData) return;
+ console.log("=== applySave START ===");
+ console.log("player HP before applySave:", state.player.hp);
+ console.log("saveData.player.hp:", saveData.player?.hp);
+ if (saveData.version === 1) {
+ migrateV1toV2(saveData);
+ }
+ if (saveData.worldSeed !== void 0) {
+ state.worldSeed = saveData.worldSeed;
+ }
+ if (saveData.player) {
+ state.player.x = saveData.player.x;
+ state.player.y = saveData.player.y;
+ state.player.hunger = saveData.player.hunger;
+ state.player.o2 = saveData.player.o2;
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+ const savedHP = saveData.player.hp;
+ console.log("Saved HP from file:", savedHP);
+ if (savedHP <= 0) {
+ console.log("WARNING: Saved HP is <= 0, setting to 100!");
+ state.player.hp = 100;
+ } else {
+ state.player.hp = savedHP;
+ }
+ console.log("player HP after restore:", state.player.hp);
+ console.log("spawnPoint \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0438\u0437 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F: x=", state.spawnPoint.x, "y=", state.spawnPoint.y);
} else {
- // Это природный блок - добавляем в removedBlocks
- removedBlocks.push({gx, gy});
+ console.log("No player data in save, setting default HP: 100");
+ state.player.hp = 100;
}
-
- return b;
+ console.log("=== applySave END ===");
+ if (saveData.inventory) {
+ for (const key in saveData.inventory) {
+ state.inv[key] = saveData.inventory[key];
+ }
+ }
+ if (saveData.time !== void 0) {
+ state.worldTime = saveData.time;
+ }
+ if (saveData.isNight !== void 0) {
+ state.isNightTime = saveData.isNight;
+ }
+ regenerateVisibleChunks();
+ if (saveData.version === 2) {
+ for (const block of saveData.placedBlocks) {
+ setBlock(block.gx, block.gy, block.t, true);
+ }
+ for (const block of saveData.removedBlocks) {
+ removeBlock(block.gx, block.gy);
+ }
+ state.placedBlocks = saveData.placedBlocks || [];
+ state.removedBlocks = saveData.removedBlocks || [];
+ }
+ rebuildHotbar();
+ console.log("\u0418\u0433\u0440\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430");
}
-
- // Физика жидкости
- const waterUpdateQueue = new Set();
- let waterUpdateTimer = 0;
- const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
-
- function updateWaterPhysics(dt){
+
+ // src/ui/save-controls.js
+ var dropdownOpen = false;
+ function initSaveControls() {
+ const menuBtn = document.getElementById("menuBtn");
+ const menuDropdown = document.getElementById("menuDropdown");
+ const saveItem = document.getElementById("saveItem");
+ const resetItem = document.getElementById("resetItem");
+ const marketItem = document.getElementById("marketItem");
+ menuBtn.onclick = null;
+ menuBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ dropdownOpen = !dropdownOpen;
+ menuDropdown.classList.toggle("open", dropdownOpen);
+ });
+ document.addEventListener("click", () => {
+ if (dropdownOpen) {
+ dropdownOpen = false;
+ menuDropdown.classList.remove("open");
+ }
+ });
+ menuDropdown.addEventListener("click", (e) => {
+ e.stopPropagation();
+ });
+ saveItem.addEventListener("click", () => {
+ playSound("click");
+ saveGame();
+ dropdownOpen = false;
+ menuDropdown.classList.remove("open");
+ alert("\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430!");
+ });
+ resetItem.addEventListener("click", () => {
+ if (confirm("\u0412\u044B \u0443\u0432\u0435\u0440\u0435\u043D\u044B, \u0447\u0442\u043E \u0445\u043E\u0442\u0438\u0442\u0435 \u0443\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0438 \u043D\u0430\u0447\u0430\u0442\u044C \u043D\u043E\u0432\u0443\u044E \u0438\u0433\u0440\u0443?")) {
+ playSound("click");
+ try {
+ localStorage.removeItem(state.SAVE_KEY);
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0443\u0434\u0430\u043B\u0435\u043D\u043E \u0438\u0437 localStorage");
+ } catch (e) {
+ console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F:", e);
+ }
+ state.inMemorySave = null;
+ state.worldId = Math.random().toString(36).substring(2, 10);
+ console.log("\u041D\u043E\u0432\u044B\u0439 worldId \u043F\u043E\u0441\u043B\u0435 \u0441\u0431\u0440\u043E\u0441\u0430:", state.worldId);
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set("world", state.worldId);
+ if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") {
+ window.history.replaceState(null, "", newUrl.toString());
+ console.log("URL \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D:", newUrl.toString());
+ }
+ } catch (e) {
+ console.error("\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F URL:", e);
+ }
+ location.reload();
+ } else {
+ dropdownOpen = false;
+ menuDropdown.classList.remove("open");
+ }
+ });
+ marketItem.addEventListener("click", () => {
+ dropdownOpen = false;
+ menuDropdown.classList.remove("open");
+ document.dispatchEvent(new CustomEvent("toggleMarket"));
+ });
+ }
+ function updateSaveButtonVisibility() {
+ const saveItem = document.getElementById("saveItem");
+ if (!saveItem) return;
+ if (state.isMultiplayer && state.otherPlayers.size > 0) {
+ saveItem.style.display = "none";
+ } else {
+ saveItem.style.display = "block";
+ }
+ }
+
+ // src/multiplayer/socket-helpers.js
+ function getRandomPlayerColor(socketId) {
+ const colors = ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e91e63", "#00bcd4"];
+ let hash = 0;
+ for (let i = 0; i < socketId.length; i++) {
+ hash = (hash << 5) - hash + socketId.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return colors[Math.abs(hash) % colors.length];
+ }
+ var lastMoveSendTime = 0;
+ var MOVE_SEND_INTERVAL2 = 0.05;
+ var lastSentX = 0;
+ var lastSentY = 0;
+ function sendPlayerPosition() {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+ const now = performance.now() / 1e3;
+ if (now - lastMoveSendTime < MOVE_SEND_INTERVAL2) return;
+ const dx = Math.abs(state.player.x - lastSentX);
+ const dy = Math.abs(state.player.y - lastSentY);
+ if (dx < 1 && dy < 1) return;
+ lastMoveSendTime = now;
+ lastSentX = state.player.x;
+ lastSentY = state.player.y;
+ state.socket.emit("player_move", { x: state.player.x, y: state.player.y, player_name: state.playerName });
+ }
+ function sendBlockChange(gx, gy, t, op) {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+ state.socket.emit("block_change", { gx, gy, t, op });
+ }
+
+ // src/ui/trade.js
+ function itemName(id) {
+ return BLOCKS[id]?.n || TOOLS[id]?.n || ITEMS[id]?.n || id;
+ }
+ function itemIcon(id) {
+ if (BLOCKS[id]) return "\u{1F7EB}";
+ if (TOOLS[id]) return TOOLS[id].icon || "\u{1F527}";
+ if (ITEMS[id]) return ITEMS[id].icon || "\u{1F4E6}";
+ return "\u{1F4E6}";
+ }
+ var tradeOpen = false;
+ var currentTradeId = null;
+ var myOffer = {};
+ var partnerOffer = {};
+ var iConfirmed = false;
+ function initTradeSocket(socket2) {
+ socket2.on("trade_request", (data) => {
+ playSound("click");
+ const overlay = document.createElement("div");
+ overlay.id = "tradeRequestOverlay";
+ overlay.className = "custom-modal-overlay";
+ overlay.innerHTML = `
+
+
\u{1F91D} ${data.from_name} \u0445\u043E\u0447\u0435\u0442 \u0442\u043E\u0440\u0433\u043E\u0432\u0430\u0442\u044C
+
+
+
+
+
`;
+ document.getElementById("game").appendChild(overlay);
+ document.getElementById("tradeAcceptBtn").addEventListener("click", () => {
+ overlay.remove();
+ socket2.emit("trade_accept", { from_socket_id: data.from });
+ });
+ document.getElementById("tradeDeclineBtn").addEventListener("click", () => {
+ overlay.remove();
+ socket2.emit("trade_decline", { from_socket_id: data.from });
+ });
+ });
+ socket2.on("trade_start", (data) => {
+ currentTradeId = data.trade_id;
+ myOffer = {};
+ partnerOffer = {};
+ iConfirmed = false;
+ tradeOpen = true;
+ renderTradeUI(data.partner_name);
+ state.tradePanel.style.display = "block";
+ });
+ socket2.on("trade_update", (data) => {
+ partnerOffer = data.player1Offer || data.player2Offer || {};
+ const isP1 = state.socket && state.socket.id === data.player1Offer;
+ if (state.mySocketId) {
+ }
+ renderTradeOffers();
+ });
+ socket2.on("trade_status", (data) => {
+ const confirmBtn = document.getElementById("tradeConfirmBtn");
+ if (confirmBtn) {
+ const amIPlayer1 = true;
+ if (iConfirmed) {
+ confirmBtn.textContent = "\u2705 \u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435...";
+ confirmBtn.disabled = true;
+ confirmBtn.style.background = "#555";
+ }
+ }
+ const partnerStatus = document.getElementById("partnerConfirmStatus");
+ if (partnerStatus) {
+ const partnerConfirmed = data.player1Confirm || data.player2Confirm;
+ partnerStatus.textContent = partnerConfirmed ? "\u2705 \u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B" : "\u23F3 \u0415\u0449\u0451 \u043D\u0435 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B";
+ }
+ });
+ socket2.on("trade_complete", (data) => {
+ const received = data.received || {};
+ for (const [item, qty] of Object.entries(received)) {
+ if (qty > 0) {
+ state.inv[item] = (state.inv[item] || 0) + qty;
+ }
+ }
+ const sent = data.sent || {};
+ for (const [item, qty] of Object.entries(sent)) {
+ if (qty > 0) {
+ state.inv[item] = (state.inv[item] || 0) - qty;
+ if (state.inv[item] < 0) state.inv[item] = 0;
+ }
+ }
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u{1F91D} \u0421\u0434\u0435\u043B\u043A\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u0430!");
+ closeTrade();
+ });
+ socket2.on("trade_declined", () => {
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u0422\u043E\u0440\u0433\u043E\u0432\u043B\u044F \u043E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u0430");
+ closeTrade();
+ });
+ socket2.on("trade_cancelled", () => {
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u0422\u043E\u0440\u0433\u043E\u0432\u043B\u044F \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430");
+ closeTrade();
+ });
+ socket2.on("trade_error", (data) => {
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u274C " + data.msg);
+ });
+ }
+ function renderTradeUI(partnerName) {
+ const panel = state.tradePanel;
+ const content = state.tradeContent;
+ content.innerHTML = `
+ \u{1F91D} \u0422\u043E\u0440\u0433\u043E\u0432\u043B\u044F \u0441 ${partnerName}
+
+
+
\u0412\u044B \u043F\u0440\u0435\u0434\u043B\u0430\u0433\u0430\u0435\u0442\u0435:
+
+
+
+
\u21C4
+
+
\u041F\u0430\u0440\u0442\u043D\u0451\u0440 \u043F\u0440\u0435\u0434\u043B\u0430\u0433\u0430\u0435\u0442:
+
+ \u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435...
+
+
+
+ \u23F3 \u041F\u0430\u0440\u0442\u043D\u0451\u0440 \u0435\u0449\u0451 \u043D\u0435 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B
+
+
+
+
`;
+ document.getElementById("tradeAddItemBtn")?.addEventListener("click", showAddItemDialog);
+ document.getElementById("tradeConfirmBtn")?.addEventListener("click", confirmTrade);
+ document.getElementById("tradeCancelBtn")?.addEventListener("click", cancelTrade);
+ }
+ function renderTradeOffers() {
+ const myItems = document.getElementById("myOfferItems");
+ if (myItems) {
+ const keys = Object.keys(myOffer).filter((k2) => myOffer[k2] > 0);
+ if (keys.length === 0) {
+ myItems.innerHTML = '\u041D\u0438\u0447\u0435\u0433\u043E';
+ } else {
+ myItems.innerHTML = keys.map((k2) => `${itemIcon(k2)} ${itemName(k2)} x${myOffer[k2]}
`).join("");
+ }
+ }
+ }
+ function showAddItemDialog() {
+ const inv = state.inv;
+ const items = Object.keys(inv).filter((id) => inv[id] > 0);
+ if (items.length === 0) {
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", "\u0418\u043D\u0432\u0435\u043D\u0442\u0430\u0440\u044C \u043F\u0443\u0441\u0442!");
+ return;
+ }
+ const overlay = document.createElement("div");
+ overlay.id = "tradeItemPicker";
+ overlay.className = "custom-modal-overlay";
+ let html = '\u0427\u0442\u043E \u043F\u0440\u0435\u0434\u043B\u043E\u0436\u0438\u0442\u044C?
';
+ for (const id of items) {
+ const offered = myOffer[id] || 0;
+ const available = inv[id] - offered;
+ if (available <= 0) continue;
+ html += `
+ ${itemIcon(id)}
+ ${itemName(id)}
+ x${available}
+
`;
+ }
+ html += "
";
+ overlay.innerHTML = html;
+ document.getElementById("game").appendChild(overlay);
+ overlay.querySelectorAll(".trade-pick-item").forEach((el) => {
+ el.addEventListener("click", () => {
+ const itemId = el.dataset.item;
+ const available = inv[itemId] - (myOffer[itemId] || 0);
+ const qty = Math.min(available, available > 1 ? promptForQty(itemId, available) : 1);
+ if (qty > 0) {
+ myOffer[itemId] = (myOffer[itemId] || 0) + qty;
+ renderTradeOffers();
+ iConfirmed = false;
+ const btn = document.getElementById("tradeConfirmBtn");
+ if (btn) {
+ btn.textContent = "\u2705 \u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044C";
+ btn.disabled = false;
+ btn.style.background = "#2ecc71";
+ }
+ }
+ overlay.remove();
+ });
+ });
+ }
+ function promptForQty(itemId, max) {
+ const val = parseInt(prompt(`\u0421\u043A\u043E\u043B\u044C\u043A\u043E ${itemName(itemId)}? (\u043C\u0430\u043A\u0441: ${max})`, "1"));
+ return isNaN(val) || val < 1 ? 1 : Math.min(val, max);
+ }
+ function confirmTrade() {
+ if (!currentTradeId || !state.socket) return;
+ for (const [item, qty] of Object.entries(myOffer)) {
+ if (qty > 0) {
+ if (state.inv[item] < qty) {
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E ${itemName(item)}!`);
+ return;
+ }
+ }
+ }
+ state.socket.emit("trade_offer", { trade_id: currentTradeId, offer: myOffer });
+ setTimeout(() => {
+ state.socket.emit("trade_confirm", { trade_id: currentTradeId });
+ iConfirmed = true;
+ const btn = document.getElementById("tradeConfirmBtn");
+ if (btn) {
+ btn.textContent = "\u2705 \u041E\u0436\u0438\u0434\u0430\u043D\u0438\u0435...";
+ btn.disabled = true;
+ btn.style.background = "#555";
+ }
+ }, 200);
+ }
+ function cancelTrade() {
+ if (!currentTradeId || !state.socket) return;
+ state.socket.emit("trade_cancel", { trade_id: currentTradeId });
+ closeTrade();
+ }
+ function closeTrade() {
+ tradeOpen = false;
+ currentTradeId = null;
+ myOffer = {};
+ partnerOffer = {};
+ iConfirmed = false;
+ if (state.tradePanel) state.tradePanel.style.display = "none";
+ document.getElementById("tradeItemPicker")?.remove();
+ }
+
+ // src/multiplayer/socket.js
+ var socket = null;
+ function initSocket() {
+ try {
+ socket = io(SERVER_URL, {
+ path: "/socket.io/",
+ transports: ["websocket", "polling"]
+ });
+ state.socket = socket;
+ socket.on("connect", () => {
+ console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`);
+ state.mySocketId = socket.id;
+ state.isMultiplayer = true;
+ socket.emit("join_world", { world_id: state.worldId, player_name: state.playerName });
+ if (state.worldIdEl) state.worldIdEl.textContent = state.worldId;
+ if (state.multiplayerStatus) state.multiplayerStatus.style.display = "block";
+ initTradeSocket(socket);
+ });
+ socket.on("connect_error", (error) => {
+ console.error("Socket connection error:", error);
+ state.isMultiplayer = false;
+ });
+ socket.on("disconnect", () => {
+ console.log("Disconnected from server");
+ state.isMultiplayer = false;
+ state.otherPlayers.clear();
+ if (state.multiplayerStatus) state.multiplayerStatus.style.display = "none";
+ });
+ socket.on("world_state", (data) => {
+ console.log("Received world_state:", data);
+ if (data.seed !== void 0 && data.seed !== state.worldSeed) {
+ const oldSeed = state.worldSeed;
+ state.worldSeed = data.seed;
+ console.log("World seed changed from", oldSeed, "to", state.worldSeed);
+ state.generated.clear();
+ grid.clear();
+ blocks.length = 0;
+ state.placedBlocks = [];
+ state.removedBlocks = [];
+ console.log("World regenerated with new seed:", state.worldSeed);
+ }
+ if (data.blocks && Array.isArray(data.blocks)) {
+ for (const block of data.blocks) {
+ const key = k(block.gx, block.gy);
+ state.serverOverrides.set(key, { op: block.op, t: block.t });
+ if (block.op === "set") {
+ setBlock(block.gx, block.gy, block.t, false);
+ } else if (block.op === "remove") {
+ removeBlock(block.gx, block.gy);
+ }
+ }
+ }
+ if (data.time !== void 0) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+ {
+ const startGX = 6;
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === "water") {
+ for (let gy = SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy);
+ if (!b || b.dead || b.t === "air" || b.t === "water") continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.spawnPoint.x = startGX * TILE;
+ state.spawnPoint.y = safeGY * TILE;
+ console.log("Client-side spawn point:", state.spawnPoint, "surfaceY:", surfaceY, "safeGY:", safeGY);
+ }
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = 0;
+ state.player.vy = 0;
+ state.player.fallStartY = state.player.y;
+ console.log("Player moved to spawn point:", state.player.x, state.player.y);
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.invuln = 0;
+ console.log("[MULTIPLAYER CONNECT] Player HP set to 100% on connect");
+ if (data.players && Array.isArray(data.players)) {
+ state.otherPlayers.clear();
+ for (const p of data.players) {
+ if (p.socket_id !== state.mySocketId) {
+ state.otherPlayers.set(p.socket_id, {
+ x: p.x,
+ y: p.y,
+ color: getRandomPlayerColor(p.socket_id),
+ name: p.player_name || "\u0418\u0433\u0440\u043E\u043A"
+ });
+ }
+ }
+ state.playerCountEl.textContent = data.players.length;
+ }
+ if (data.mobs && Array.isArray(data.mobs)) {
+ state.serverMobs.clear();
+ for (const m of data.mobs) {
+ const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 };
+ state.serverMobs.set(m.id, sm);
+ }
+ }
+ if (data.tg_id) state.myTgId = data.tg_id;
+ });
+ socket.on("player_joined", (data) => {
+ console.log("Player joined:", data.socket_id);
+ if (data.socket_id !== state.mySocketId) {
+ const spawnGX = 6;
+ genColumn(spawnGX);
+ const surfaceY = surfaceGyAt(spawnGX);
+ const safeSpawnX = spawnGX * TILE;
+ const safeSpawnY = (surfaceY - 1) * TILE;
+ state.otherPlayers.set(data.socket_id, {
+ x: safeSpawnX,
+ y: safeSpawnY,
+ color: getRandomPlayerColor(data.socket_id),
+ name: data.player_name || "\u0418\u0433\u0440\u043E\u043A"
+ });
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u0418\u0433\u0440\u043E\u043A \u043F\u0440\u0438\u0441\u043E\u0435\u0434\u0438\u043D\u0438\u043B\u0441\u044F`);
+ updateSaveButtonVisibility();
+ }
+ });
+ socket.on("player_moved", (data) => {
+ if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
+ const p = state.otherPlayers.get(data.socket_id);
+ p.x = data.x;
+ p.y = data.y;
+ if (data.player_name) {
+ p.name = data.player_name;
+ }
+ }
+ });
+ socket.on("player_left", (data) => {
+ console.log("Player left:", data.socket_id);
+ state.otherPlayers.delete(data.socket_id);
+ addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u0418\u0433\u0440\u043E\u043A \u043F\u043E\u043A\u0438\u043D\u0443\u043B \u0438\u0433\u0440\u0443`);
+ updateSaveButtonVisibility();
+ });
+ socket.on("mob_spawned", (data) => {
+ const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 };
+ state.serverMobs.set(data.id, sm);
+ });
+ socket.on("mob_positions", (arr) => {
+ for (const u of arr) {
+ const sm = state.serverMobs.get(u.id);
+ if (sm) {
+ sm.x = u.x;
+ sm.y = u.y;
+ sm.vx = u.vx;
+ sm.vy = u.vy;
+ sm.dir = u.dir;
+ sm.hp = u.hp;
+ sm.fuse = u.fuse || 0;
+ }
+ }
+ });
+ socket.on("mob_despawned", (data) => {
+ state.serverMobs.delete(data.id);
+ });
+ socket.on("mob_died", (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm && data.killer === state.mySocketId) {
+ if (sm.kind === "chicken") playSound("hurt_chicken");
+ state.inv.meat += sm.kind === "chicken" ? 1 : 2;
+ if (sm.kind === "skeleton") {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ rebuildHotbar();
+ }
+ state.serverMobs.delete(data.id);
+ });
+ socket.on("mob_hurt_ack", (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm) sm.hp = data.hp;
+ });
+ socket.on("mob_explode", (data) => {
+ explodeAt(data.gx, data.gy);
+ state.serverMobs.delete(data.id);
+ });
+ socket.on("mob_shoot", (data) => {
+ state.projectiles.push({
+ x: data.x,
+ y: data.y,
+ vx: data.vx,
+ vy: data.vy,
+ dmg: data.dmg,
+ owner: "mob",
+ life: data.life
+ });
+ });
+ socket.on("block_changed", (data) => {
+ const key = k(data.gx, data.gy);
+ state.serverOverrides.set(key, { op: data.op, t: data.t });
+ if (data.op === "set") {
+ setBlock(data.gx, data.gy, data.t, false);
+ } else if (data.op === "remove") {
+ removeBlock(data.gx, data.gy);
+ }
+ });
+ socket.on("chat_message", (data) => {
+ const senderName = data.socket_id === state.mySocketId ? "\u0412\u044B" : `\u0418\u0433\u0440\u043E\u043A ${data.socket_id.substring(0, 6)}`;
+ addChatMessage(senderName, data.message);
+ });
+ socket.on("time_update", (data) => {
+ if (data.time !== void 0) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+ });
+ } catch (e) {
+ console.error("Error initializing socket:", e);
+ state.isMultiplayer = false;
+ }
+ }
+
+ // src/data/recipes.js
+ var RECIPES = [
+ { out: "planks", qty: 4, cost: { wood: 1 } },
+ { out: "ladder", qty: 3, cost: { planks: 7 } },
+ { out: "torch", qty: 2, cost: { coal: 1, planks: 1 } },
+ { out: "glass", qty: 1, cost: { sand: 3 } },
+ { out: "brick", qty: 1, cost: { stone: 2, clay: 1 } },
+ { out: "campfire", qty: 1, cost: { wood: 1, coal: 1 } },
+ { out: "tnt", qty: 1, cost: { sand: 2, coal: 1 } },
+ { out: "bed", qty: 1, cost: { wood: 3, planks: 3 } },
+ { out: "boat", qty: 1, cost: { wood: 5 } },
+ { out: "wood_pickaxe", qty: 1, cost: { wood: 3, planks: 2 } },
+ { out: "stone_pickaxe", qty: 1, cost: { wood: 1, stone: 3 } },
+ { out: "iron_pickaxe", qty: 1, cost: { wood: 1, iron_ore: 2 } },
+ { out: "wood_sword", qty: 1, cost: { wood: 2, planks: 1 } },
+ { out: "stone_sword", qty: 1, cost: { wood: 1, stone: 2 } },
+ { out: "iron_sword", qty: 1, cost: { wood: 1, iron_ore: 1 } },
+ { out: "wood_axe", qty: 1, cost: { wood: 3, planks: 2 } },
+ { out: "stone_axe", qty: 1, cost: { wood: 1, stone: 3 } },
+ { out: "iron_axe", qty: 1, cost: { wood: 1, iron_ore: 2 } },
+ { out: "wood_shovel", qty: 1, cost: { wood: 1, planks: 1 } },
+ { out: "stone_shovel", qty: 1, cost: { wood: 1, stone: 2 } },
+ { out: "iron_shovel", qty: 1, cost: { wood: 1, iron_ore: 1 } },
+ { out: "iron_armor", qty: 1, cost: { iron_ore: 5 } },
+ { out: "furnace", qty: 1, cost: { stone: 8 } },
+ { out: "bow", qty: 1, cost: { wood: 3, planks: 2 } },
+ { out: "arrow", qty: 4, cost: { stone: 1, wood: 1 } }
+ ];
+ var SMELTING_RECIPES = [
+ { in: "sand", qty: 1, out: "glass", outQty: 1, time: 3 },
+ { in: "clay", qty: 1, out: "brick", outQty: 1, time: 3 },
+ { in: "iron_ore", qty: 1, out: "iron_ingot", outQty: 1, time: 5 },
+ { in: "gold_ore", qty: 1, out: "gold_ingot", outQty: 1, time: 5 },
+ { in: "copper_ore", qty: 1, out: "copper_ingot", outQty: 1, time: 4 },
+ { in: "meat", qty: 1, out: "cooked", outQty: 1, time: 2 },
+ { in: "cobblestone", qty: 1, out: "stone", outQty: 1, time: 2 }
+ ];
+
+ // src/world/water.js
+ var waterUpdateQueue = /* @__PURE__ */ new Set();
+ var waterUpdateTimer = 0;
+ var WATER_UPDATE_INTERVAL = 0.05;
+ function updateWaterPhysics(dt) {
waterUpdateTimer += dt;
- if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
+ if (waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
waterUpdateTimer = 0;
-
- // Ограничиваем количество водных блоков для обработки (оптимизация)
const MAX_WATER_BLOCKS_PER_UPDATE = 50;
let processedCount = 0;
-
- // Собираем только видимые водные блоки в очередь (оптимизация)
waterUpdateQueue.clear();
- const minGX = Math.floor(camX/TILE) - 10;
- const maxGX = Math.floor((camX+W)/TILE) + 10;
- const minGY = Math.floor(camY/TILE) - 10;
- const maxGY = Math.floor((camY+H)/TILE) + 10;
-
- for(const b of blocks){
- if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
- if(!b.dead && b.t === 'water' &&
- b.gx >= minGX && b.gx <= maxGX &&
- b.gy >= minGY && b.gy <= maxGY){
+ const W = state.W;
+ const H = state.H;
+ const minGX = Math.floor(state.camX / TILE) - 10;
+ const maxGX = Math.floor((state.camX + W) / TILE) + 10;
+ const minGY = Math.floor(state.camY / TILE) - 10;
+ const maxGY = Math.floor((state.camY + H) / TILE) + 10;
+ for (const b of blocks) {
+ if (processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
+ if (!b.dead && b.t === "water" && b.gx >= minGX && b.gx <= maxGX && b.gy >= minGY && b.gy <= maxGY) {
waterUpdateQueue.add(k(b.gx, b.gy));
processedCount++;
}
}
-
- // Обновляем воду с ограничением глубины распространения
- const processed = new Set();
+ const processed = /* @__PURE__ */ new Set();
const toAdd = [];
- const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
-
- for(const key of waterUpdateQueue){
- if(processed.has(key)) continue;
+ const MAX_WATER_DEPTH = 20;
+ for (const key of waterUpdateQueue) {
+ if (processed.has(key)) continue;
const b = grid.get(key);
- if(!b || b.dead) continue;
+ if (!b || b.dead) continue;
processed.add(key);
-
const gx = b.gx;
const gy = b.gy;
-
- // Проверяем глубину - не распространяем воду слишком глубоко
- if(gy > SEA_GY + MAX_WATER_DEPTH) continue;
-
- // Проверяем, можно ли воде упасть вниз
+ if (gy > SEA_GY + MAX_WATER_DEPTH) continue;
const belowKey = k(gx, gy + 1);
const below = grid.get(belowKey);
-
- // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
- if(!below || below.dead){
- // Ограничиваем создание новых водных блоков
- if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление
- toAdd.push({gx, gy: gy + 1, t: 'water'});
+ if (!below || below.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx, gy: gy + 1, t: "water" });
processed.add(belowKey);
}
continue;
}
-
- // Если внизу не вода и не твёрдый блок - вода может течь вниз
- if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){
- if(toAdd.length < 20){
- toAdd.push({gx, gy: gy + 1, t: 'water'});
+ if (!isSolid(gx, gy + 1) && below && below.t !== "water") {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx, gy: gy + 1, t: "water" });
processed.add(belowKey);
}
continue;
}
-
- // Если внизу твёрдый блок или вода - вода растекается горизонтально
- // Проверяем левую сторону
const leftKey = k(gx - 1, gy);
const left = grid.get(leftKey);
- if(!left || left.dead){
- if(toAdd.length < 20){
- toAdd.push({gx: gx - 1, gy, t: 'water'});
+ if (!left || left.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx: gx - 1, gy, t: "water" });
processed.add(leftKey);
}
continue;
}
-
- // Проверяем правую сторону
const rightKey = k(gx + 1, gy);
const right = grid.get(rightKey);
- if(!right || right.dead){
- if(toAdd.length < 20){
- toAdd.push({gx: gx + 1, gy, t: 'water'});
+ if (!right || right.dead) {
+ if (toAdd.length < 20) {
+ toAdd.push({ gx: gx + 1, gy, t: "water" });
processed.add(rightKey);
}
continue;
}
}
-
- // Применяем изменения (только добавляем новые блоки)
- for(const newData of toAdd){
+ for (const newData of toAdd) {
const key = k(newData.gx, newData.gy);
- if(!grid.has(key)){
+ if (!grid.has(key)) {
const b = {
gx: newData.gx,
gy: newData.gy,
@@ -1567,239 +1872,500 @@ function customConfirm(msg, onYes) {
blocks.push(b);
}
}
-
- // Очищаем мёртвые блоки из массива
- for(let i = blocks.length - 1; i >= 0; i--){
- if(blocks[i].dead){
+ for (let i = blocks.length - 1; i >= 0; i--) {
+ if (blocks[i].dead) {
blocks.splice(i, 1);
}
}
}
-
- // Инвентарь
- const inv = {
- dirt:6, stone:0, sand:0, gravel:0, clay:0,
- wood:0, planks:0, ladder:0, leaves:0, coal:0,
- copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0,
- brick:0, glass:0,
- tnt:1, campfire:0, torch:0,
- meat:0, cooked:0, arrow:0,
- wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0,
- wood_sword:0, stone_sword:0, iron_sword:0,
- iron_armor:0, gold_armor:0,
- bow:0, furnace:0,
- bed:0, boat:0,
- iron_ingot:0, gold_ingot:0, copper_ingot:0,
- diamond_pickaxe:0, diamond_sword:0, hoe:0,
- wheat:0, bread:0, carrot:0, potato:0, baked_potato:0,
- scorpion_stinger:0, polar_fur:0, slime_ball:0, eagle_feather:0,
- snow:0, ice:0, cactus:0, mushroom:0, moss:0, farmland:0,
- spruce_leaves:0, dead_bush:0,
- wheat_stage0:0, carrot_stage0:0, potato_stage0:0
- };
- let selected = 'dirt';
-
- // Прочность инструментов: Map<"tooltype_id", {current, max}>
- // При крафте инструмента создаём запись с max durability
- const toolDurability = new Map();
-
- function addTool(type) {
- const def = TOOLS[type];
- if (!def) return;
- const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
- toolDurability.set(id, { type, current: def.durability, max: def.durability });
- return id;
+
+ // src/physics/water-detect.js
+ function isWaterAt(px, py) {
+ const gx = Math.floor(px / TILE);
+ const gy = Math.floor(py / TILE);
+ const b = getBlock(gx, gy);
+ return !!(b && b.t === "water");
}
-
- function getToolDurability(id) {
- return toolDurability.get(id);
+ function updateWaterFlag(e) {
+ const cx = e.x + e.w / 2;
+ const wasInWater = e.inWater;
+ const mid = isWaterAt(cx, e.y + e.h / 2);
+ const feet = isWaterAt(cx, e.y + e.h - 2);
+ e.inWater = mid || feet;
+ e.headInWater = isWaterAt(cx, e.y + 4);
+ if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
+ playSound("splash");
+ }
}
-
- // Найти лучший инструмент данного типа в инвентаре
- function findBestTool(toolType) {
- if (inv[toolType] <= 0) return null;
- // Возвращаем первый попавшийся — упрощённо
- return toolType;
- }
-
- // Использовать инструмент (уменьшить прочность). Возвращает true если сломался
- function useTool(toolType) {
- // Ищем любой инструмент этого типа с прочностью
- for (const [id, dur] of toolDurability) {
- if (dur.type === toolType) {
- dur.current--;
- if (dur.current <= 0) {
- toolDurability.delete(id);
- inv[toolType]--;
- rebuildHotbar();
- return true; // сломался
+
+ // src/physics/collision.js
+ function resolveY(e) {
+ e.grounded = false;
+ const x1 = e.x + 2;
+ const x2 = e.x + e.w - 2;
+ const cx = e.x + e.w / 2;
+ const cy = e.y + e.h / 2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+ if (onLadder) {
+ e.grounded = true;
+ if (state.inp.jump) {
+ e.vy = -200;
+ } else if (state.inp.down) {
+ e.vy = 100;
+ } else {
+ e.vy = 0;
+ }
+ return;
+ }
+ const leftGX = Math.floor((e.x - 4) / TILE);
+ const rightGX = Math.floor((e.x + e.w + 4) / TILE);
+ const playerGY = Math.floor((e.y + e.h / 2) / TILE);
+ const leftBlock = getBlock(leftGX, playerGY);
+ const rightBlock = getBlock(rightGX, playerGY);
+ const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
+ const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
+ if ((leftLadder || rightLadder) && state.inp.jump && e.vy < 0) {
+ if (leftLadder && e.x > leftGX * TILE + TILE / 2) {
+ e.x = leftGX * TILE + TILE / 2 - e.w / 2;
+ } else if (rightLadder && e.x < rightGX * TILE + TILE / 2) {
+ e.x = rightGX * TILE + TILE / 2 - e.w / 2;
+ }
+ e.grounded = true;
+ e.vy = -150;
+ return;
+ }
+ if (e.vy >= 0) {
+ const probeY = e.y + e.h + 1;
+ const gy2 = Math.floor(probeY / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if (isSolid(gxA, gy2) || isSolid(gxB, gy2)) {
+ e.y = gy2 * TILE - e.h;
+ e.vy = 0;
+ e.grounded = true;
+ if (e === state.player && !state.player.inWater) {
+ const fallTiles = (e.y - e.fallStartY) / TILE;
+ if (fallTiles > 6) {
+ const damage = calculateDamage((fallTiles - 6) * 10);
+ state.player.hp -= damage;
+ }
}
- return false;
+ if (e === state.player) e.fallStartY = e.y;
}
}
- return false;
- }
-
- const RECIPES = [
- { out:'planks', qty:4, cost:{ wood:1 }, requiredLevel:1 },
- { out:'ladder', qty:3, cost:{ planks:7 }, requiredLevel:1 },
- { out:'torch', qty:2, cost:{ coal:1, planks:1 }, requiredLevel:1 },
- { out:'glass', qty:1, cost:{ sand:3 }, requiredLevel:1 },
- { out:'brick', qty:1, cost:{ stone:2, clay:1 }, requiredLevel:1 },
- { out:'campfire', qty:1, cost:{ wood:1, coal:1 }, requiredLevel:1 },
- { out:'tnt', qty:1, cost:{ sand:2, coal:1 }, requiredLevel:8 },
- { out:'bed', qty:1, cost:{ wood: 3, planks: 3 }, requiredLevel:1 },
- { out:'boat', qty:1, cost:{ wood: 5 }, requiredLevel:2 },
- { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:1 },
- { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 }, requiredLevel:2 },
- { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 }, requiredLevel:3 },
- { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 },
- { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 }, requiredLevel:2 },
- { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 }, requiredLevel:3 },
- { out:'iron_armor', qty:1, cost:{ iron_ore: 5 }, requiredLevel:5 },
- { out:'gold_armor', qty:1, cost:{ gold_ore: 8 }, requiredLevel:6 },
- { out:'furnace', qty:1, cost:{ stone: 8 }, requiredLevel:3 },
- { out:'bow', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:4 },
- { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 }, requiredLevel:1 },
- // === NEW RECIPES ===
- { out:'bread', qty:1, cost:{ wheat: 3 }, requiredLevel:1 },
- { out:'diamond_pickaxe', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 },
- { out:'diamond_sword', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 },
- { out:'hoe', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 }
- ];
-
- // Рецепты печи (обжиг)
- const SMELTING_RECIPES = [
- { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло
- { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич
- { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток
- { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток
- { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток
- { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное
- { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 }, // булыжник → камень
- { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } // картофель → печёная картошка
- ];
-
- // Новые предметы от обжига
- ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
- ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
- ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
- ITEMS.diamond_pickaxe = { n:'Алмазная кирка', icon:'⛏️', durability:500, miningPower:5 };
- ITEMS.diamond_sword = { n:'Алмазный меч', icon:'⚔️', durability:400, damage:18 };
- ITEMS.hoe = { n:'Мотыга', icon:'🔨', durability:80, tillTo:'farmland' };
-
- // Активные печи: Map ключа блока → { recipe, progress, totalTime }
- const activeFurnaces = new Map();
-
- // UI
- const hpEl = document.getElementById('hp');
- const foodEl = document.getElementById('food');
- const tempEl = document.getElementById("temp");
- const sxEl = document.getElementById('sx');
- const syEl = document.getElementById('sy');
- const todEl = document.getElementById('tod');
- const worldIdEl = document.getElementById('worldId');
- // Toast уведомление
- function showToast(msg, duration) {
- duration = duration || 1800;
- const t = document.createElement('div');
- t.textContent = msg;
- t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;';
- document.body.appendChild(t);
- setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration);
- }
- // Клик на worldId → копировать код мира + toast
- worldIdEl.addEventListener('click', () => {
- const code = worldId || '';
- if (!code) return;
- const text = code; // копируем только код, не URL
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(() => showToast('📋 Скопировано: ' + code)).catch(() => showToast(code));
- } else {
- // fallback
- const ta = document.createElement('textarea');
- ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
- document.body.appendChild(ta); ta.select();
- try { document.execCommand('copy'); showToast('📋 Скопировано: ' + code); } catch(e) { showToast(code); }
- ta.remove();
+ if (e.vy < 0 && e === state.player) {
+ const gy2 = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if ((isSolid(gxA, gy2) || isSolid(gxB, gy2)) && !isSolid(gxA, gy2 - 1) && !isSolid(gxB, gy2 - 1)) {
+ e.y = (gy2 + 1) * TILE;
+ e.vy = 0;
+ e.grounded = true;
+ if (e === state.player) e.fallStartY = e.y;
+ console.log("Jumped onto block!");
+ }
}
- });
- const playerCountEl = document.getElementById('playerCount');
- const hotbarEl = document.getElementById('hotbar');
- const craftPanel = document.getElementById('craftPanel');
- const recipesEl = document.getElementById('recipes');
- const deathEl = document.getElementById('death');
- const inventoryPanel = document.getElementById('inventoryPanel');
- const inventoryGrid = document.getElementById('inventoryGrid');
-
- // ==================== МИНИКАРТА ====================
- const minimapWrap = document.getElementById('minimapWrap');
- const minimapCanvas = document.getElementById('minimap');
- const minimapCtx = minimapCanvas.getContext('2d');
- let minimapOpen = false;
-
- document.getElementById('mapToggle').onclick = () => {
- playSound('click');
- minimapOpen = !minimapOpen;
- minimapWrap.style.display = minimapOpen ? 'block' : 'none';
+ if (e.vy < 0) {
+ const gy2 = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if (isSolid(gxA, gy2) || isSolid(gxB, gy2)) {
+ e.y = (gy2 + 1) * TILE;
+ e.vy = 0;
+ }
+ }
+ }
+ function resolveX(e) {
+ const y1 = e.y + 2;
+ const y2 = e.y + e.h - 2;
+ const cx = e.x + e.w / 2;
+ const cy = e.y + e.h / 2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+ if (e.vx > 0) {
+ const gx2 = Math.floor((e.x + e.w) / TILE);
+ const gyA = Math.floor(y1 / TILE);
+ const gyB = Math.floor(y2 / TILE);
+ const solidA = isSolid(gx2, gyA);
+ const solidB = isSolid(gx2, gyB);
+ if (solidA || solidB) {
+ e.x = gx2 * TILE - e.w;
+ e.vx = 0;
+ }
+ } else if (e.vx < 0) {
+ const gx2 = Math.floor(e.x / TILE);
+ const gyA = Math.floor(y1 / TILE);
+ const gyB = Math.floor(y2 / TILE);
+ const solidA = isSolid(gx2, gyA);
+ const solidB = isSolid(gx2, gyB);
+ if (solidA || solidB) {
+ e.x = (gx2 + 1) * TILE;
+ e.vx = 0;
+ }
+ }
+ }
+
+ // src/entities/mob-ai.js
+ function mobAI(m, dt) {
+ updateWaterFlag(m);
+ if (m.kind === "zombie") {
+ const night = isNight();
+ if (!night) {
+ m.hp -= 30 * dt;
+ m.vx *= 0.5;
+ return;
+ }
+ const dir = Math.sign(state.player.x - m.x);
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ if (Math.abs(m.x + m.w / 2 - (state.player.x + state.player.w / 2)) < 28 && Math.abs(m.y + m.h / 2 - (state.player.y + state.player.h / 2)) < 40 && state.player.invuln <= 0) {
+ const damage = calculateDamage(15);
+ state.player.hp -= damage;
+ state.player.invuln = 0.8;
+ state.player.vx += dir * 420;
+ state.player.vy -= 260;
+ playSound("hit1");
+ }
+ } else if (m.kind === "creeper") {
+ const night = isNight();
+ if (!night) {
+ m.hp -= 30 * dt;
+ m.vx *= 0.5;
+ return;
+ }
+ const dir = Math.sign(state.player.x - m.x);
+ const dist = Math.hypot(state.player.x + state.player.w / 2 - (m.x + m.w / 2), state.player.y + state.player.h / 2 - (m.y + m.h / 2));
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ if (dist < 60) {
+ m.fuse -= dt;
+ if (m.fuse <= 0) {
+ explodeAt(Math.floor((m.x + m.w / 2) / TILE), Math.floor((m.y + m.h / 2) / TILE));
+ m.hp = 0;
+ }
+ } else {
+ if (dist < 40) {
+ m.fuse = 0.5;
+ }
+ }
+ } else if (m.kind === "skeleton") {
+ const night = isNight();
+ if (!night) {
+ m.hp -= 30 * dt;
+ m.vx *= 0.5;
+ return;
+ }
+ const dir = Math.sign(state.player.x - m.x);
+ const dist = Math.hypot(state.player.x + state.player.w / 2 - (m.x + m.w / 2), state.player.y + state.player.h / 2 - (m.y + m.h / 2));
+ m.vx = dir * m.speed;
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ m.shootCooldown -= dt;
+ if (dist < 300 && m.shootCooldown <= 0) {
+ m.shootCooldown = 2;
+ const dx = state.player.x + state.player.w / 2 - (m.x + m.w / 2);
+ const dy = state.player.y + state.player.h / 2 - (m.y + m.h / 2);
+ const angle = Math.atan2(dy, dx);
+ const speed = 450;
+ state.projectiles.push({
+ x: m.x + m.w / 2,
+ y: m.y + m.h / 3,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ dmg: 6,
+ owner: "mob",
+ life: 3
+ });
+ }
+ } else {
+ m.aiT -= dt;
+ if (m.aiT <= 0) {
+ m.aiT = 1.8 + Math.random() * 2.5;
+ m.dir = Math.random() < 0.5 ? -1 : 1;
+ if (Math.random() < 0.25) m.dir = 0;
+ }
+ m.vx = m.dir * (m.kind === "chicken" ? 55 : 40);
+ if (m.inWater) m.vy = -120;
+ }
+ const g = m.inWater ? GRAV_WATER : GRAV;
+ m.vy += g * dt;
+ m.y += m.vy * dt;
+ m.grounded = false;
+ resolveY(m);
+ m.x += m.vx * dt;
+ resolveX(m);
+ }
+ function isNight() {
+ return state.worldTime > 0.5;
+ }
+
+ // src/ui/furnace.js
+ var furnacePanel = document.getElementById("furnacePanel");
+ var furnaceContent = document.getElementById("furnaceContent");
+ function initFurnace() {
+ document.getElementById("furnaceClose").onclick = () => {
+ furnacePanel.style.display = "none";
+ state.currentFurnaceKey = null;
+ };
+ }
+ function openFurnaceUI(gx, gy) {
+ state.currentFurnaceKey = `${gx},${gy}`;
+ furnacePanel.style.display = "block";
+ renderFurnaceUI();
+ }
+ function renderFurnaceUI() {
+ if (!state.currentFurnaceKey) return;
+ const [fgx, fgy] = state.currentFurnaceKey.split(",").map(Number);
+ const fb = getBlock(fgx, fgy);
+ if (!fb || fb.t !== "furnace") {
+ furnacePanel.style.display = "none";
+ state.currentFurnaceKey = null;
+ return;
+ }
+ const active = state.activeFurnaces.get(state.currentFurnaceKey);
+ let html = '';
+ for (let i = 0; i < SMELTING_RECIPES.length; i++) {
+ const recipe = SMELTING_RECIPES[i];
+ const haveCount = state.inv[recipe.in] || 0;
+ const canSmelt = haveCount >= recipe.qty;
+ const outDef = BLOCKS[recipe.out];
+ const outItem = ITEMS[recipe.out];
+ const iconStr = outItem ? outItem.icon : outDef ? "\u{1F9F1}" : "\u2753";
+ const nameStr = outItem ? outItem.n : outDef ? outDef.n : recipe.out;
+ const inItem = ITEMS[recipe.in];
+ const inDef = BLOCKS[recipe.in];
+ const inName = inItem ? inItem.n : inDef ? inDef.n : recipe.in;
+ html += `
`;
+ html += `
${iconStr}
`;
+ html += `
`;
+ html += `
${nameStr}
`;
+ html += `
${inName} x${recipe.qty} (\u0435\u0441\u0442\u044C: ${haveCount}) \u2022 ${recipe.time}\u0441
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+ if (active) {
+ const pct = Math.min(100, Math.floor(active.progress / active.recipe.time * 100));
+ html += `
`;
+ html += `
\u{1F525} \u041E\u0431\u0436\u0438\u0433: ${pct}%
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+ html += "
";
+ furnaceContent.innerHTML = html;
+ }
+ window._smelt = (recipeIdx) => {
+ if (!state.currentFurnaceKey) return;
+ const recipe = SMELTING_RECIPES[recipeIdx];
+ if ((state.inv[recipe.in] || 0) < recipe.qty) return;
+ if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
+ state.inv[recipe.in] -= recipe.qty;
+ state.activeFurnaces.set(state.currentFurnaceKey, {
+ recipe,
+ progress: 0
+ });
+ playSound("fire");
+ rebuildHotbar();
+ renderFurnaceUI();
};
-
- // Цвета блоков для миникарты (по 1 пикселю на блок)
- const MINIMAP_COLORS = {
- grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
- gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
- leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
- copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
- diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
- campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
- flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410',
- snow: '#ecf0f1', ice: '#74b9ff', cactus: '#27ae60', mushroom: '#e74c3c',
- moss: '#0a6640', swamp_water: '#687828', farmland: '#8B6914',
- dead_bush: '#b2bec3', spruce_leaves: '#0a6640',
- wheat_stage0: '#a8e6a0', wheat_stage1: '#7dcea0', wheat_stage2: '#b8d730', wheat_stage3: '#f1c40f',
- carrot_stage0: '#a8e6a0', carrot_stage1: '#f0c27a', carrot_stage2: '#e8a040', carrot_stage3: '#e67e22',
- potato_stage0: '#a8e6a0', potato_stage1: '#c8b888', potato_stage2: '#bfaa78', potato_stage3: '#dfe6e9'
+ function tickFurnaces(dt) {
+ for (const [key, furnace] of state.activeFurnaces) {
+ furnace.progress += dt;
+ if (furnace.progress >= furnace.recipe.time) {
+ const outItem = furnace.recipe.out;
+ if (ITEMS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ } else if (BLOCKS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ }
+ playSound("stone_build");
+ state.activeFurnaces.delete(key);
+ if (key === state.currentFurnaceKey) {
+ renderFurnaceUI();
+ }
+ }
+ }
+ }
+
+ // src/world/weather.js
+ function updateWeather(dt) {
+ state.weatherTimer += dt;
+ if (state.weatherTimer >= state.weatherChangeInterval) {
+ state.weatherTimer = 0;
+ state.weatherChangeInterval = 60 + Math.random() * 120;
+ const nightChance = isNight() ? 0.25 : 0.4;
+ state.isRaining = Math.random() < nightChance;
+ }
+ const target = state.isRaining ? 0.4 + Math.random() * 0.01 : 0;
+ state.rainIntensity += (target - state.rainIntensity) * dt * 0.5;
+ if (state.rainIntensity < 0.01) state.rainIntensity = 0;
+ }
+ function updateRain(dt) {
+ if (!state.isRaining || state.rainIntensity < 0.01) {
+ state.raindrops.length = 0;
+ return;
+ }
+ const spawnRate = Math.floor(state.rainIntensity * 60 * dt);
+ for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) {
+ state.raindrops.push({
+ x: state.camX + Math.random() * state.W,
+ y: state.camY - 20,
+ vy: 400 + Math.random() * 200,
+ len: 8 + Math.random() * 12
+ });
+ }
+ for (let i = state.raindrops.length - 1; i >= 0; i--) {
+ const d = state.raindrops[i];
+ d.y += d.vy * dt;
+ d.x -= 30 * dt;
+ if (d.y > state.camY + state.H + 20) {
+ state.raindrops.splice(i, 1);
+ }
+ }
+ }
+ function drawRain() {
+ if (state.raindrops.length === 0) return;
+ const ctx = state.ctx;
+ ctx.save();
+ ctx.strokeStyle = "rgba(174,194,224,0.5)";
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ for (const d of state.raindrops) {
+ ctx.moveTo(d.x, d.y);
+ ctx.lineTo(d.x - 3, d.y + d.len);
+ }
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ // src/entities/mobs.js
+ var Entity = class {
+ constructor(x, y, w, h) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ this.vx = 0;
+ this.vy = 0;
+ this.hp = 3;
+ this.grounded = false;
+ this.inWater = false;
+ this.aiT = 0;
+ this.dir = 1;
+ }
};
-
+ var Pig = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 24);
+ this.kind = "pig";
+ this.hp = 2;
+ }
+ };
+ var Chicken = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 26, 22);
+ this.kind = "chicken";
+ this.hp = 1;
+ }
+ };
+ var Zombie = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 50);
+ this.kind = "zombie";
+ this.hp = 4;
+ this.speed = 80 + Math.random() * 40;
+ }
+ };
+ var Creeper = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 50);
+ this.kind = "creeper";
+ this.hp = 4;
+ this.speed = 60 + Math.random() * 30;
+ this.fuse = 3.2;
+ }
+ };
+ var Skeleton = class extends Entity {
+ constructor(x, y) {
+ super(x, y, 34, 50);
+ this.kind = "skeleton";
+ this.hp = 4;
+ this.speed = 70 + Math.random() * 30;
+ this.shootCooldown = 0;
+ }
+ };
+
+ // src/ui/minimap.js
+ var minimapWrap = document.getElementById("minimapWrap");
+ var minimapCanvas = document.getElementById("minimap");
+ var minimapCtx = minimapCanvas.getContext("2d");
+ var MINIMAP_COLORS = {
+ grass: "#4a8c2a",
+ dirt: "#6b3410",
+ stone: "#6a6a6a",
+ sand: "#d4b84a",
+ gravel: "#888",
+ clay: "#5a9ad4",
+ wood: "#a63d00",
+ planks: "#c46a10",
+ leaves: "#2a8a3a",
+ glass: "#aaddee",
+ water: "#2980b9",
+ coal: "#1a1a2a",
+ copper_ore: "#a05535",
+ iron_ore: "#b0b0b0",
+ gold_ore: "#d4a017",
+ diamond_ore: "#0090d0",
+ brick: "#8a2010",
+ tnt: "#c02020",
+ campfire: "#d08020",
+ torch: "#d0a020",
+ bedrock: "#1a1a1a",
+ flower: "#d03050",
+ bed: "#c03060",
+ ladder: "#a04000",
+ boat: "#6b3410"
+ };
+ function initMinimap() {
+ document.getElementById("mapToggle").onclick = () => {
+ playSound("click");
+ state.minimapOpen = !state.minimapOpen;
+ minimapWrap.style.display = state.minimapOpen ? "block" : "none";
+ };
+ }
function renderMinimap() {
- if (!minimapOpen) return;
+ if (!state.minimapOpen) return;
const mW = minimapCanvas.width;
const mH = minimapCanvas.height;
- const scale = 2; // пикселей на блок
-
- // Область карты — центрирована на игроке
- const pGX = Math.floor(player.x / TILE);
- const biome = getCachedBiome(pGX);
- const pGY = Math.floor(player.y / TILE);
+ const scale = 2;
+ const TILE2 = state.TILE;
+ const player = state.player;
+ const pGX = Math.floor(player.x / TILE2);
+ const pGY = Math.floor(player.y / TILE2);
const viewW = Math.floor(mW / scale);
const viewH = Math.floor(mH / scale);
const startGX = pGX - Math.floor(viewW / 2);
const startGY = pGY - Math.floor(viewH / 2);
-
- // Очищаем
- minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
+ minimapCtx.fillStyle = isNight() ? "#070816" : "#87CEEB";
minimapCtx.fillRect(0, 0, mW, mH);
-
- // Рисуем блоки
const imgData = minimapCtx.createImageData(mW, mH);
const data = imgData.data;
-
for (let dx = 0; dx < viewW; dx++) {
for (let dy = 0; dy < viewH; dy++) {
const gx = startGX + dx;
const gy = startGY + dy;
const b = getBlock(gx, gy);
- if (!b || b.dead || b.t === 'air') continue;
-
+ if (!b || b.dead || b.t === "air") continue;
const color = MINIMAP_COLORS[b.t];
if (!color) continue;
-
- // Парсим hex цвет
- const r = parseInt(color.slice(1,3), 16);
- const g = parseInt(color.slice(3,5), 16);
- const bl = parseInt(color.slice(5,7), 16);
-
- // Заполняем scale x scale пикселей
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const bl = parseInt(color.slice(5, 7), 16);
for (let sx = 0; sx < scale; sx++) {
for (let sy = 0; sy < scale; sy++) {
const px = dx * scale + sx;
@@ -1807,3876 +2373,1564 @@ function customConfirm(msg, onYes) {
if (px >= mW || py >= mH) continue;
const idx = (py * mW + px) * 4;
data[idx] = r;
- data[idx+1] = g;
- data[idx+2] = bl;
- data[idx+3] = 255;
+ data[idx + 1] = g;
+ data[idx + 2] = bl;
+ data[idx + 3] = 255;
}
}
}
}
-
minimapCtx.putImageData(imgData, 0, 0);
-
- // Игрок — белый пиксель по центру
- minimapCtx.fillStyle = '#fff';
- minimapCtx.fillRect(Math.floor(mW/2) - 2, Math.floor(mH/2) - 2, 4, 4);
-
- // Другие игроки — жёлтые точки
- for (const [sid, p] of otherPlayers) {
- const dx = Math.floor(p.x / TILE) - startGX;
- const dy = Math.floor(p.y / TILE) - startGY;
+ minimapCtx.fillStyle = "#fff";
+ minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4);
+ for (const [sid, p] of state.otherPlayers) {
+ const dx = Math.floor(p.x / TILE2) - startGX;
+ const dy = Math.floor(p.y / TILE2) - startGY;
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
- minimapCtx.fillStyle = '#f1c40f';
+ minimapCtx.fillStyle = "#f1c40f";
minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
}
}
-
- // Мобы — красные (враждебные) / зелёные (животные)
- const allMobsForMap = getAllMobs();
+ const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
for (const m of allMobsForMap) {
- const dx = Math.floor(m.x / TILE) - startGX;
- const dy = Math.floor(m.y / TILE) - startGY;
+ const dx = Math.floor(m.x / TILE2) - startGX;
+ const dy = Math.floor(m.y / TILE2) - startGY;
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
- const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
- minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
+ const hostile = m.kind === "zombie" || m.kind === "creeper" || m.kind === "skeleton";
+ minimapCtx.fillStyle = hostile ? "#e74c3c" : "#2ecc71";
minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
}
}
}
-
- // ==================== ПЕЧЬ (ОБЖИГ) ====================
- const furnacePanel = document.getElementById('furnacePanel');
- const furnaceContent = document.getElementById('furnaceContent');
- let currentFurnaceKey = null; // "gx,gy" текущей открытой печи
-
- document.getElementById('furnaceClose').onclick = () => {
- furnacePanel.style.display = 'none';
- currentFurnaceKey = null;
- };
-
- function openFurnaceUI(gx, gy) {
- currentFurnaceKey = `${gx},${gy}`;
- furnacePanel.style.display = 'block';
- renderFurnaceUI();
- }
-
- function renderFurnaceUI() {
- if (!currentFurnaceKey) return;
-
- // Проверяем что печь всё ещё существует
- const [fgx, fgy] = currentFurnaceKey.split(',').map(Number);
- const fb = getBlock(fgx, fgy);
- if (!fb || fb.t !== 'furnace') {
- furnacePanel.style.display = 'none';
- currentFurnaceKey = null;
- return;
- }
-
- // Текущий процесс обжига
- const active = activeFurnaces.get(currentFurnaceKey);
-
- let html = '';
-
- // Доступные рецепты — показываем только те, для которых есть ресурсы
- for (let i = 0; i < SMELTING_RECIPES.length; i++) {
- const recipe = SMELTING_RECIPES[i];
- const haveCount = inv[recipe.in] || 0;
- const canSmelt = haveCount >= recipe.qty;
-
- // Иконка результата
- const outDef = BLOCKS[recipe.out];
- const outItem = ITEMS[recipe.out];
- const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
- const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
- const inItem = ITEMS[recipe.in];
- const inDef = BLOCKS[recipe.in];
- const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
-
- html += `
`;
- html += `
${iconStr}
`;
- html += `
`;
- html += `
${nameStr}
`;
- html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`;
- html += `
`;
- html += `
`;
- html += `
`;
- }
-
- // Текущий прогресс
- if (active) {
- const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
- html += `
`;
- html += `
🔥 Обжиг: ${pct}%
`;
- html += `
`;
- html += `
`;
- html += `
`;
- }
-
- html += '
';
- furnaceContent.innerHTML = html;
- }
-
- // Глобальная функция для кнопки обжига
- window._smelt = (recipeIdx) => {
- if (!currentFurnaceKey) return;
- const recipe = SMELTING_RECIPES[recipeIdx];
- if ((inv[recipe.in] || 0) < recipe.qty) return;
-
- // Уже обжигаем в этой печи?
- if (activeFurnaces.has(currentFurnaceKey)) return;
-
- // Забираем ресурсы
- inv[recipe.in] -= recipe.qty;
-
- // Запускаем обжиг
- activeFurnaces.set(currentFurnaceKey, {
- recipe: recipe,
- progress: 0
- });
-
- playSound('fire');
- rebuildHotbar();
- renderFurnaceUI();
- };
-
- // Тик печей — вызывается в главном цикле
- function tickFurnaces(dt) {
- for (const [key, furnace] of activeFurnaces) {
- furnace.progress += dt;
- if (furnace.progress >= furnace.recipe.time) {
- // Обжиг завершён — выдаём результат
- const outItem = furnace.recipe.out;
- if (ITEMS[outItem]) {
- inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
- } else if (BLOCKS[outItem]) {
- inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
- }
- playSound('stone_build');
- activeFurnaces.delete(key);
-
- // Если эта печь открыта — обновляем UI
- if (key === currentFurnaceKey) {
- renderFurnaceUI();
- }
- }
- }
- }
-
-
- // ==================== ГОЛОСОВОЙ ЧАТ v3 ====================
- // ==================== ГОЛОСОВОЙ ЧАТ v3 ====================
-// Per-speaker architecture, AudioWorklet, VAD, Opus/PCM, spatial audio
-// Replaces lines 1449-1665 in game.js
-
- let voiceSocket = null;
- let voiceStream = null;
- let audioCtx = null;
- let voiceActive = false;
- let voiceMode = 'near';
- const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
-
- // Voice config
- const VC = {
- sampleRate: 16000, // 16kHz — sufficient for voice, saves 33% bandwidth
- frameMs: 20, // 20ms frames = 320 samples @ 16kHz
- samplesPerFrame: 320, // 16000 * 0.02
- vadThreshold: 0.0005, // RMS threshold for voice detection
- vadHangover: 5, // 100ms hangover after speech ends
- jbufTargetMs: 80, // Target jitter: 80ms (was 200ms)
- jbufMinMs: 40,
- jbufMaxMs: 200,
- maxSpeakers: 6,
- voiceRadius: 600,
- opusBitrate: 16000,
- posUpdateMs: 200, // Update position every 200ms (was 500ms)
- };
-
- // Codec state
- let voiceCodec = 'pcm'; // 'opus' or 'pcm'
- let voiceEncoder = null; // WebCodecs AudioEncoder
- let voiceSeq = 0;
- let voiceTimestamp = 0;
- let wasSpeaking = false;
- let silenceFrames = 0;
- let captureNode = null;
- let playbackNode = null;
-
- // Per-speaker map
- const remoteSpeakers = new Map(); // socketId → { jitterBuf, lastFrame, gain, panner, lowpass, decoder, codec, x, y, name, mode, speaking, lastActive }
-
- // ==================== WORKLET CODE (inline strings) ====================
-
- const voiceCaptureWorkletCode = `
-class VoiceCaptureProcessor extends AudioWorkletProcessor {
- constructor() {
- super();
- this._buf = new Float32Array(320); // 20ms @ 16kHz
- this._pos = 0;
- this._speaking = false;
- this._silenceFrames = 0;
- this._hangover = 5; // 100ms
- this._vadThreshold = 0.0005;
- this._lastRms = 0;
+ // src/game/modes.js
+ var MODES = [{ id: "mine", icon: "\u26CF\uFE0F" }, { id: "build", icon: "\u{1F9F1}" }];
+ function mode() {
+ return MODES[state.modeIdx].id;
}
- process(inputs) {
- const input = inputs[0];
- if (!input || !input[0]) return true;
- const ch = input[0];
- let i = 0;
- while (i < ch.length) {
- const rem = this._buf.length - this._pos;
- const n = Math.min(rem, ch.length - i);
- this._buf.set(ch.subarray(i, i + n), this._pos);
- this._pos += n;
- i += n;
- if (this._pos >= this._buf.length) {
- this._processFrame();
- this._pos = 0;
- }
- }
- return true;
- }
- _processFrame() {
- let sum = 0;
- for (let i = 0; i < this._buf.length; i++) sum += this._buf[i] * this._buf[i];
- const rms = Math.sqrt(sum / this._buf.length);
- this._lastRms = rms;
-
- if (rms > this._vadThreshold) {
- this._speaking = true;
- this._silenceFrames = 0;
- } else {
- this._silenceFrames++;
- if (this._silenceFrames > this._hangover) this._speaking = false;
- }
-
- this.port.postMessage({
- type: 'frame',
- samples: this._buf.slice(),
- speaking: this._speaking,
- rms: rms
- });
- }
-}
-registerProcessor('voice-capture', VoiceCaptureProcessor);
-`;
-
- const voicePlaybackWorkletCode = `
-class VoicePlaybackProcessor extends AudioWorkletProcessor {
- constructor() {
- super();
- this.speakers = new Map(); // id → { ringBuf, readPos, writePos, ready, gain, pan, lastSample, active }
- this.RING_SIZE = 48000; // 3 seconds @ 16kHz
- this.port.onmessage = (e) => {
- const d = e.data;
- if (d.type === 'addSpeaker') {
- this.speakers.set(d.id, {
- ringBuf: new Float32Array(this.RING_SIZE),
- readPos: 0, writePos: 0, ready: 0,
- gain: d.gain || 1, pan: d.pan || 0,
- lastSample: 0, active: false,
- jbufTarget: 320 * 4, // ~80ms in samples
- fadeOut: 0
- });
- } else if (d.type === 'removeSpeaker') {
- this.speakers.delete(d.id);
- } else if (d.type === 'pushFrames') {
- const sp = this.speakers.get(d.id);
- if (!sp) return;
- const frames = d.samples; // Float32Array
- for (let i = 0; i < frames.length; i++) {
- sp.ringBuf[sp.writePos] = frames[i];
- sp.writePos = (sp.writePos + 1) % this.RING_SIZE;
- if (sp.ready < this.RING_SIZE) sp.ready++;
- }
- sp.active = true;
- sp.fadeOut = 0;
- } else if (d.type === 'updateSpatial') {
- const sp = this.speakers.get(d.id);
- if (sp) {
- sp.gain = d.gain;
- sp.pan = d.pan;
- }
- }
+ function initModes() {
+ const modeBtn = document.getElementById("modeBtn");
+ modeBtn.onclick = () => {
+ playSound("click");
+ state.modeIdx = (state.modeIdx + 1) % MODES.length;
+ modeBtn.textContent = MODES[state.modeIdx].icon;
};
}
- process(inputs, outputs) {
- const output = outputs[0];
- if (!output || !output[0]) return true;
- const left = output[0];
- const right = output[1] || output[0]; // mono fallback
-
- // Clear output
- for (let i = 0; i < left.length; i++) { left[i] = 0; right[i] = 0; }
-
- for (const [id, sp] of this.speakers) {
- if (sp.ready < 1 && sp.active) {
- // Fade out over ~20ms (128 samples)
- sp.fadeOut++;
- if (sp.fadeOut > 128) { sp.active = false; continue; }
- const fade = 1 - (sp.fadeOut / 128);
- const g = sp.gain * fade;
- const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4);
- const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4);
- // Repeat last sample with fade
- for (let i = 0; i < left.length; i++) {
- left[i] += sp.lastSample * lg;
- right[i] += sp.lastSample * rg;
- }
- continue;
- }
- if (!sp.active && sp.ready < sp.jbufTarget) continue; // Wait for jitter buffer to fill
- if (!sp.active && sp.ready >= sp.jbufTarget) sp.active = true;
- if (!sp.active) continue;
-
- sp.fadeOut = 0;
- const g = sp.gain;
- // Constant-power pan law
- const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4);
- const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4);
-
- for (let i = 0; i < left.length; i++) {
- if (sp.ready > 0) {
- const s = sp.ringBuf[sp.readPos];
- sp.lastSample = s;
- left[i] += s * lg;
- right[i] += s * rg;
- sp.readPos = (sp.readPos + 1) % this.RING_SIZE;
- sp.ready--;
- } else {
- // Underrun — fade from last sample
- sp.lastSample *= 0.85;
- left[i] += sp.lastSample * lg;
- right[i] += sp.lastSample * rg;
- }
- }
- }
- return true;
- }
-}
-registerProcessor('voice-playback', VoicePlaybackProcessor);
-`;
- // ==================== Opus ENCODE/DECODE ====================
-
- async function initVoiceEncoder() {
- if (false && 'AudioEncoder' in window) {
- try {
- // Check if Opus is supported
- const support = await AudioEncoder.isConfigSupported({
- codec: 'opus',
- sampleRate: VC.sampleRate,
- numberOfChannels: 1,
- bitrate: VC.opusBitrate
- });
- if (!support.supported) throw new Error('Opus not supported');
-
- voiceEncoder = new AudioEncoder({
- output: (chunk) => {
- // chunk: EncodedAudioChunk with Opus data
- const data = new Uint8Array(chunk.byteLength);
- chunk.copyTo(data);
- // Send as binary frame via Socket.IO
- console.log('[voice] TX Opus chunk, seq:', voiceSeq, 'size:', data.buffer.byteLength);
- voiceSocket.emit('voice_data', {
- codec: 'opus',
- data: data.buffer,
- seq: voiceSeq++,
- ts: chunk.timestamp,
- speaking: true
- });
- },
- error: (e) => {
- console.error('[voice] encoder error:', e);
- voiceCodec = 'pcm';
- voiceEncoder = null;
- }
- });
-
- await voiceEncoder.configure({
- codec: 'opus',
- sampleRate: VC.sampleRate,
- numberOfChannels: 1,
- bitrate: VC.opusBitrate
- });
-
- voiceCodec = 'opus';
- console.log('[voice] WebCodecs Opus encoder initialized @', VC.opusBitrate, 'bps');
+ // src/input/mouse-handler.js
+ var mouse = { x: null, y: null };
+ var miningHeld = false;
+ var miningInterval = null;
+ function initMouseHandlers() {
+ const canvas = state.canvas;
+ canvas.addEventListener("pointermove", (e) => {
+ const r = canvas.getBoundingClientRect();
+ mouse.x = e.clientX - r.left;
+ mouse.y = e.clientY - r.top;
+ });
+ canvas.addEventListener("pointerdown", (e) => {
+ if (state.craftOpen) return;
+ if (state.player.hp <= 0) return;
+ const r = canvas.getBoundingClientRect();
+ const sx = e.clientX - r.left;
+ const sy = e.clientY - r.top;
+ const wx = sx + state.camX;
+ const wy = sy + state.camY;
+ const gx = Math.floor(wx / state.TILE);
+ const gy = Math.floor(wy / state.TILE);
+ const b = getBlock(gx, gy);
+ if (state.player.sleeping && b && b.t === "bed") {
+ state.player.sleeping = false;
return;
- } catch (e) {
- console.warn('[voice] WebCodecs Opus not available, PCM fallback:', e.message);
}
- }
- voiceCodec = 'pcm';
- console.log('[voice] Using PCM @', VC.sampleRate, 'Hz');
- }
-
- async function initDecoderForSpeaker(speakerId, codec) {
- const sp = remoteSpeakers.get(speakerId);
- if (!sp) return;
-
- if (codec === 'opus' && 'AudioDecoder' in window) {
- try {
- const decoder = new AudioDecoder({
- output: (audioData) => {
- const samples = new Float32Array(audioData.numberOfFrames);
- audioData.copyTo(samples, { planeIndex: 0 });
- audioData.close();
- // Push decoded PCM to worklet
- if (playbackNode) {
- playbackNode.port.postMessage({
- type: 'pushFrames',
- id: speakerId,
- samples: samples
- });
+ if (state.player.sleeping) return;
+ if (b && b.t === "furnace" && mode() === "mine") {
+ openFurnaceUI(gx, gy);
+ return;
+ }
+ if (mode() === "mine") {
+ if (state.isMultiplayer) {
+ for (const [id, sm] of state.serverMobs) {
+ if (sm.dead) continue;
+ if (wx >= sm.x && wx <= sm.x + sm.w && wy >= sm.y && wy <= sm.y + sm.h) {
+ let dmg = 1;
+ const swordTypes = ["iron_sword", "stone_sword", "wood_sword"];
+ for (const st of swordTypes) {
+ if (state.inv[st] > 0) {
+ dmg = TOOLS[st].damage || 3;
+ useTool(st);
+ break;
+ }
+ }
+ state.socket.emit("mob_hurt", { id: sm.id, dmg });
+ playSound("attack");
+ return;
}
- },
- error: (e) => {
- console.error('[voice] decoder error for', speakerId, e);
- sp.codec = 'pcm'; // Fallback to PCM
- }
- });
-
- await decoder.configure({
- codec: 'opus',
- sampleRate: VC.sampleRate,
- numberOfChannels: 1
- });
-
- sp.decoder = decoder;
- sp.codec = 'opus';
- console.log('[voice] Opus decoder initialized for', speakerId);
- } catch (e) {
- console.warn('[voice] Opus decoder failed, PCM fallback:', e.message);
- sp.codec = 'pcm';
- }
- } else {
- sp.codec = 'pcm';
- }
- }
-
- // ==================== RemoteSpeaker helper ====================
-
- function createRemoteSpeaker(socketId, name, codec) {
- const sp = {
- id: socketId,
- name: name || '???',
- codec: codec || 'pcm',
- decoder: null,
- x: 0, y: 0,
- mode: 'near',
- speaking: false,
- lastActive: Date.now(),
- // Audio nodes per speaker (managed on main thread for smooth ramps)
- gainNode: audioCtx.createGain(),
- pannerNode: audioCtx.createStereoPanner(),
- lowpassNode: audioCtx.createBiquadFilter(),
- };
- // lowpass for distance muffling
- sp.lowpassNode.type = 'lowpass';
- sp.lowpassNode.frequency.value = 4000;
- sp.lowpassNode.Q.value = 0.7;
- // Connect: lowpass → panner → gain → (connected to mixer later)
- sp.lowpassNode.connect(sp.pannerNode);
- sp.pannerNode.connect(sp.gainNode);
- sp.gainNode.gain.value = 1.0; // Start with volume (worklet needs non-zero gain)
-
- remoteSpeakers.set(socketId, sp);
- // Register speaker in playback worklet
- if (playbackNode) {
- playbackNode.port.postMessage({ type: 'addSpeaker', id: socketId, gain: 1.0, pan: 0 });
- console.log('[voice] addSpeaker posted to worklet, id:', socketId?.substring(0,8), 'gain:', sp.gainNode.gain.value);
- }
- initDecoderForSpeaker(socketId, codec);
- return sp;
- }
-
- function removeRemoteSpeaker(socketId) {
- const sp = remoteSpeakers.get(socketId);
- if (!sp) return;
- try { sp.gainNode.disconnect(); } catch(e) {}
- try { sp.pannerNode.disconnect(); } catch(e) {}
- try { sp.lowpassNode.disconnect(); } catch(e) {}
- if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} }
- remoteSpeakers.delete(socketId);
- if (playbackNode) {
- playbackNode.port.postMessage({ type: 'removeSpeaker', id: socketId });
- }
- }
-
- function updateSpeakerSpatial(sp) {
- if (!audioCtx) return;
- // Calculate distance-based audio
- const dx = sp.x - player.x;
- const dy = sp.y - player.y;
- const dist = Math.sqrt(dx * dx + dy * dy);
-
- const now = audioCtx.currentTime;
- const rampTime = 0.05; // 50ms smooth ramp
-
- if (sp.mode === 'world' && voiceMode === 'world') {
- // World mode: full volume, no panning, no distance filter
- sp.gainNode.gain.cancelScheduledValues(now);
- sp.gainNode.gain.linearRampToValueAtTime(1.0, now + rampTime);
- sp.pannerNode.pan.cancelScheduledValues(now);
- sp.pannerNode.pan.linearRampToValueAtTime(0, now + rampTime);
- sp.lowpassNode.frequency.cancelScheduledValues(now);
- sp.lowpassNode.frequency.linearRampToValueAtTime(4000, now + rampTime);
- } else if (dist > VC.voiceRadius) {
- // Out of range
- sp.gainNode.gain.cancelScheduledValues(now);
- sp.gainNode.gain.linearRampToValueAtTime(0, now + rampTime);
- } else {
- // Near mode with spatial audio
- const volume = 1 / (1 + dist / 150); // Perceptual falloff
- const pan = Math.max(-1, Math.min(1, dx / 300)); // Stereo pan based on X
- const cutoff = 4000 - (3800 * (dist / VC.voiceRadius)); // Muffle at distance
-
- sp.gainNode.gain.cancelScheduledValues(now);
- sp.gainNode.gain.linearRampToValueAtTime(volume, now + rampTime);
- sp.pannerNode.pan.cancelScheduledValues(now);
- sp.pannerNode.pan.linearRampToValueAtTime(pan, now + rampTime);
- sp.lowpassNode.frequency.cancelScheduledValues(now);
- sp.lowpassNode.frequency.linearRampToValueAtTime(Math.max(200, cutoff), now + rampTime);
- }
-
- // Update worklet spatial params
- if (playbackNode) {
- const currentGain = sp.gainNode.gain.value;
- const currentPan = sp.pannerNode.pan.value;
- if (currentGain > 0.01) console.log('[voice] updateSpatial, id:', sp.id?.substring(0,8), 'gain:', currentGain.toFixed(3), 'pan:', currentPan.toFixed(3));
- playbackNode.port.postMessage({
- type: 'updateSpatial',
- id: sp.id,
- gain: currentGain,
- pan: currentPan
- });
- }
- }
-
- // ==================== UI ====================
-
- const voiceBtn = document.createElement('div');
- voiceBtn.innerHTML = '🎤/';
- voiceBtn.title = 'Голосовой чат (выкл)';
- voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
- document.querySelector('.ui').appendChild(voiceBtn);
-
- const voiceModeBtn = document.createElement('div');
- voiceModeBtn.innerHTML = '📢';
- voiceModeBtn.title = 'Режим: рядом (600px)';
- voiceModeBtn.style.cssText = 'position:absolute;top:74px;right:190px;width:48px;height:52px;border-radius:12px;background:#3498db;z-index:200;display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;border:2px solid rgba(255,255,255,0.7);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;color:#fff;font-weight:bold;';
- document.querySelector('.ui').appendChild(voiceModeBtn);
- voiceModeBtn.onclick = () => {
- if (voiceMode === 'near') {
- voiceMode = 'world';
- voiceModeBtn.innerHTML = '🌍';
- voiceModeBtn.title = 'Режим: весь мир';
- voiceModeBtn.style.background = '#e67e22';
- } else {
- voiceMode = 'near';
- voiceModeBtn.innerHTML = '📢';
- voiceModeBtn.title = 'Режим: рядом (600px)';
- voiceModeBtn.style.background = '#3498db';
- }
- if (voiceSocket && voiceSocket.connected) {
- voiceSocket.emit('voice_mode', { mode: voiceMode });
- }
- // Update spatial for all speakers
- for (const [id, sp] of remoteSpeakers) updateSpeakerSpatial(sp);
- };
-
- // Speaking indicators per players
- const speakingIndicator = document.createElement('div');
- speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;max-width:200px;line-height:1.4;';
- document.querySelector('.ui').appendChild(speakingIndicator);
- let speakingTimeouts = new Map(); // socketId → timeout
-
- // Codec indicator
- const codecIndicator = document.createElement('div');
- codecIndicator.style.cssText = 'position:absolute;top:180px;right:10px;z-index:200;font-size:10px;color:rgba(255,255,255,0.5);pointer-events:none;';
- codecIndicator.textContent = '';
- document.querySelector('.ui').appendChild(codecIndicator);
-
- // ==================== VOICE ON/OFF ====================
-
- voiceBtn.onclick = async () => {
- if (voiceActive) {
- // === DISABLE ===
- voiceActive = false;
- voiceBtn.innerHTML = '🎤/';
- voiceBtn.style.background = '#555';
- codecIndicator.textContent = '';
-
- // Clean up capture
- if (captureNode) { captureNode.disconnect(); captureNode = null; }
- if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; }
-
- // Clean up playback
- if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
-
- // Clean up per-speaker nodes
- for (const [id, sp] of remoteSpeakers) {
- try { sp.gainNode.disconnect(); } catch(e) {}
- try { sp.pannerNode.disconnect(); } catch(e) {}
- try { sp.lowpassNode.disconnect(); } catch(e) {}
- if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} }
- }
- remoteSpeakers.clear();
-
- // Clean up encoder
- if (voiceEncoder) { try { voiceEncoder.close(); } catch(e) {} voiceEncoder = null; }
-
- if (audioCtx) { audioCtx.close(); audioCtx = null; }
- if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
- speakingIndicator.style.display = 'none';
- return;
- }
-
- // === ENABLE ===
- try {
- voiceStream = await navigator.mediaDevices.getUserMedia({
- audio: {
- echoCancellation: true,
- noiseSuppression: true,
- autoGainControl: true,
- echoCancellationType: 'browser',
- noiseSuppressionType: 'browser',
- sampleRate: { ideal: VC.sampleRate },
- channelCount: { ideal: 1 }
- }
- });
-
- audioCtx = new AudioContext({ sampleRate: VC.sampleRate });
- if (audioCtx.state === 'suspended') await audioCtx.resume();
- console.log('[voice] AudioContext:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
-
- // Register worklets via Blob URLs
- const captureURL = URL.createObjectURL(new Blob([voiceCaptureWorkletCode], { type: 'application/javascript' }));
- const playbackURL = URL.createObjectURL(new Blob([voicePlaybackWorkletCode], { type: 'application/javascript' }));
-
- await audioCtx.audioWorklet.addModule(captureURL);
- await audioCtx.audioWorklet.addModule(playbackURL);
- URL.revokeObjectURL(captureURL);
- URL.revokeObjectURL(playbackURL);
- console.log('[voice] AudioWorklets registered');
-
- // Connect mic → capture worklet
- const micSource = audioCtx.createMediaStreamSource(voiceStream);
- captureNode = new AudioWorkletNode(audioCtx, 'voice-capture');
- micSource.connect(captureNode);
- // Do NOT connect captureNode to destination — we don't hear ourselves
-
- // Connect playback worklet → per-speaker gain nodes → destination
- playbackNode = new AudioWorkletNode(audioCtx, 'voice-playback', { numberOfOutputs: 1, outputChannelCount: [2] });
- playbackNode.connect(audioCtx.destination);
- // Per-speaker nodes will connect between playback worklet output and destination
- // Actually: playback worklet mixes internally → stereo output → destination
- // Per-speaker Web Audio nodes (lowpass, pan, gain) used for spatial UPDATES only (values sent to worklet via messages)
- // The actual mixing happens inside the worklet
-
- // Connect to voice server FIRST so capture frames have a socket
- voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
-
- // Handle capture frames
- voiceSeq = 0;
- voiceTimestamp = 0;
- wasSpeaking = false;
- voiceActive = true; // Enable capture BEFORE onmessage handler
- let _frameCount = 0;
- captureNode.port.onmessage = (e) => {
- const { type, samples, speaking, rms } = e.data;
- if (type !== 'frame') return;
- _frameCount++;
- if (_frameCount <= 5 || _frameCount % 100 === 0) {
- console.log('[voice] capture frame #', _frameCount, 'rms:', rms?.toFixed(4), 'speaking:', speaking, 'voiceActive:', voiceActive, 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected, 'codec:', voiceCodec);
- }
- if (!voiceActive || !voiceSocket || !voiceSocket.connected) {
-
- return;
- }
-
- voiceTimestamp += VC.samplesPerFrame; // 320 samples * (1/16000) = 20ms per frame
-
- if (!speaking && !wasSpeaking) {
- // Complete silence — don't transmit
- silenceFrames++;
- return;
- }
-
- if (voiceCodec === 'opus' && voiceEncoder) {
- // Encode with Opus via WebCodecs
- try {
- const audioData = new AudioData({
- format: 'float32-planar',
- sampleRate: VC.sampleRate,
- numberOfFrames: samples.length,
- numberOfChannels: 1,
- timestamp: voiceTimestamp * (1000000 / VC.sampleRate), // microseconds
- data: samples
- });
- voiceEncoder.encode(audioData);
- audioData.close();
- } catch (err) {
- // Fallback: send as PCM
- sendPCMFrame(samples, speaking, wasSpeaking);
- }
- } else {
- sendPCMFrame(samples, speaking, wasSpeaking);
- }
-
- wasSpeaking = speaking;
- silenceFrames = 0;
- };
-
- function sendPCMFrame(samples, isSpeaking, wasSp) {
- const int16 = new Int16Array(samples.length);
- for (let i = 0; i < samples.length; i++) {
- const s = Math.max(-1, Math.min(1, samples[i]));
- int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
- }
- voiceSocket.emit('voice_data', {
- codec: 'pcm',
- data: int16.buffer,
- seq: voiceSeq++,
- ts: performance.now(),
- speaking: isSpeaking || wasSp
- });
- }
-
- // Initialize encoder
- await initVoiceEncoder();
- codecIndicator.textContent = voiceCodec === 'opus' ? '🔊 Opus' : '🔊 PCM';
-
- // Voice socket already created above
-
- voiceSocket.on('connect', () => {
- console.log('[voice] Connected, id:', voiceSocket.id, 'codec:', voiceCodec);
- voiceSocket.emit('voice_join', {
- world_id: worldId,
- x: player.x, y: player.y,
- name: playerName || 'Игрок',
- mode: voiceMode,
- codec: voiceCodec
- });
- });
-
- voiceSocket.on('connect_error', (err) => {
- console.error('[voice] Connect error:', err.message);
- });
-
- voiceSocket.on('voice_in', (payload) => {
- console.log('[voice] RX voice_in, codec:', payload.codec, 'dataSize:', payload.data?.byteLength || payload.data?.length || 'N/A', 'from:', payload.meta?.from?.substring(0,8));
- const { data, meta } = payload;
- if (!audioCtx || audioCtx.state === 'closed') { console.warn('[voice] audioCtx missing/closed'); return; }
-
- let sp = remoteSpeakers.get(meta.from);
- if (!sp) {
- // New speaker — create per-speaker audio nodes
- sp = createRemoteSpeaker(meta.from, meta.name, meta.codec || 'pcm');
- }
-
- // Update position
- sp.x = meta.x || 0;
- sp.y = meta.y || 0;
- sp.mode = meta.mode || 'near';
- sp.lastActive = Date.now();
- updateSpeakerSpatial(sp);
-
- // Decode and push to worklet
- if ((meta.codec || 'pcm') === 'opus' && sp.decoder) {
- // Opus decode
- try {
- const uint8 = new Uint8Array(data);
- const chunk = new EncodedAudioChunk({
- type: 'key', // Opus frames are self-decodable
- timestamp: performance.now() * 1000, // microseconds
- data: uint8
- });
- sp.decoder.decode(chunk);
- } catch (err) {
- // Opus decode failed, try PCM fallback
- decodeAndPushPCM(sp.id, data);
- }
- } else {
- decodeAndPushPCM(sp.id, data);
- }
-
- // Update speaking indicator
- sp.speaking = true;
- speakingIndicator.style.display = 'block';
- speakingIndicator.textContent = '🔊 ' + sp.name;
- clearTimeout(speakingTimeouts.get(meta.from));
- speakingTimeouts.set(meta.from, setTimeout(() => {
- sp.speaking = false;
- // Check if anyone is still speaking
- let anyoneSpeaking = false;
- for (const [, s] of remoteSpeakers) { if (s.speaking) { anyoneSpeaking = true; break; } }
- if (!anyoneSpeaking) speakingIndicator.style.display = 'none';
- speakingTimeouts.delete(meta.from);
- }, 1500));
- });
-
- voiceSocket.on('voice_leave', (data) => {
- removeRemoteSpeaker(data.id);
- });
-
- voiceSocket.on('disconnect', () => {
- console.log('[voice] Disconnected');
- // Clear all speakers on disconnect
- for (const [id] of remoteSpeakers) removeRemoteSpeaker(id);
- });
-
- // voiceActive already set to true above
- voiceBtn.textContent = '🎤';
- voiceBtn.style.background = '#2ecc71';
- console.log('[voice] Voice chat ACTIVE, codec:', voiceCodec);
-
- } catch(e) {
- console.error('[voice] Error:', e);
- voiceBtn.style.background = '#e74c3c';
- }
- };
-
- // ==================== PCM decode helper ====================
-
- function decodeAndPushPCM(speakerId, buffer) {
- console.log('[voice] decodeAndPushPCM, id:', speakerId?.substring(0,8), 'bufSize:', buffer?.byteLength || buffer?.length || 'N/A');
- const int16 = new Int16Array(buffer);
- const float32 = new Float32Array(int16.length);
- for (let i = 0; i < int16.length; i++) {
- float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
- }
- if (playbackNode) {
- playbackNode.port.postMessage({
- type: 'pushFrames',
- id: speakerId,
- samples: float32
- });
- }
- }
-
- // ==================== Voice position update ====================
-
- let voicePosT = 0;
-
- // Клик на часы для включения ночи
- todEl.style.cursor = 'pointer';
- todEl.onclick = () => {
- playSound('click');
- worldTime = 0.6; // Устанавливаем ночь
- isNightTime = true;
- };
-
- function rebuildHotbar(){
- hotbarEl.innerHTML='';
-
- // Показываем последние 5 выбранных предметов (если они есть в инвентаре)
- const items = recentItems.filter(id => inv[id] > 0).slice(0, 5);
-
- for(const id of items){
- const s = document.createElement('div');
- s.className = 'slot'+(id===selected?' sel':'');
- if(BLOCKS[id]) {
- s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
- s.style.backgroundSize = 'cover';
- } else if(ITEMS[id]) {
- s.textContent = ITEMS[id].icon;
- } else if(TOOLS[id]) {
- s.textContent = TOOLS[id].icon;
- } else if(id === 'iron_armor') {
- s.textContent = '🛡️';
- s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
- }
- const c = document.createElement('div');
- c.className='count';
- c.textContent = inv[id];
- s.appendChild(c);
- s.onclick = () => {
- playSound('click'); // Звук клика по инвентарю
- if(selected === id) {
- // Повторный клик — снимаем выбор, возвращаем к первому блоку
- selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
- } else {
- selected = id;
- // Обновляем список последних предметов
- recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
- recentItems.unshift(id); // Добавляем в начало
- recentItems = recentItems.slice(0, 5); // Оставляем только 5
- }
- rebuildHotbar();
- };
-
- // Показываем индикатор надетой брони
- if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') {
- const equipped = document.createElement('div');
- equipped.className = 'equipped-indicator';
- equipped.textContent = '✓';
- s.appendChild(equipped);
- }
-
- // Durability bar для инструментов
- if(TOOLS[id] && inv[id] > 0) {
- // Находим текущую прочность
- let curDur = 0, maxDur = TOOLS[id].durability;
- for (const [tid, dur] of toolDurability) {
- if (dur.type === id) {
- curDur = dur.current;
- maxDur = dur.max;
- break;
}
}
- if (maxDur > 0) {
- const bar = document.createElement('div');
- bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
- const fill = document.createElement('div');
- const pct = curDur / maxDur;
- const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
- fill.style.cssText = `width:${pct*100}%;height:100%;background:${color};`;
- bar.appendChild(fill);
- s.appendChild(bar);
- }
- }
- hotbarEl.appendChild(s);
- }
- }
-
- function renderInventory() {
- inventoryGrid.innerHTML = '';
-
- // Создаём сетку инвентаря 7x3
- const items = Object.keys(inv).filter(id => inv[id] > 0);
-
- // Добавляем пустые слоты для полной сетки
- for(let i = 0; i < 21; i++) {
- const slot = document.createElement('div');
- slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
-
- if(i < items.length) {
- const id = items[i];
- if(BLOCKS[id]) {
- slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
- slot.style.backgroundSize = 'cover';
- } else if(ITEMS[id]) {
- slot.textContent = ITEMS[id].icon;
- } else if(TOOLS[id]) {
- slot.textContent = TOOLS[id].icon;
- } else if(id === 'iron_armor') {
- slot.textContent = '🛡️';
- slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
- }
-
- const count = document.createElement('div');
- count.className = 'inv-count';
- count.textContent = inv[id];
- slot.appendChild(count);
-
- slot.onclick = () => {
- playSound('click'); // Звук клика по инвентарю
- if(selected === id) {
- selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
- } else {
- selected = id;
- recentItems = recentItems.filter(item => item !== id);
- recentItems.unshift(id);
- recentItems = recentItems.slice(0, 5);
- }
- rebuildHotbar();
- renderInventory();
- };
-
- // Двойной клик для надевания брони
- slot.ondblclick = () => {
- if(id === 'iron_armor' && inv.iron_armor > 0) {
- // Если уже надета броня - снимаем её
- if(player.equippedArmor === 'iron_armor') {
- player.equippedArmor = null;
- player.armor = 0;
- console.log('[ARMOR] Iron armor unequipped');
- } else {
- // Надеваем броню
- player.equippedArmor = 'iron_armor';
- player.armor = BLOCKS['iron_armor'].armor;
- console.log('[ARMOR] Iron armor equipped - armor:', player.armor);
+ for (let i = state.mobs.length - 1; i >= 0; i--) {
+ const m = state.mobs[i];
+ if (wx >= m.x && wx <= m.x + m.w && wy >= m.y && wy <= m.y + m.h) {
+ let dmg = 1;
+ const swordTypes = ["iron_sword", "stone_sword", "wood_sword"];
+ for (const st of swordTypes) {
+ if (state.inv[st] > 0) {
+ dmg = TOOLS[st].damage || 3;
+ useTool(st);
+ break;
+ }
}
- playSound('click');
- renderInventory();
- }
- if(id === 'gold_armor' && inv.gold_armor > 0) {
- if(player.equippedArmor === 'gold_armor') {
- player.equippedArmor = null;
- player.armor = 0;
- } else {
- player.equippedArmor = 'gold_armor';
- player.armor = ITEMS['gold_armor'].armor;
+ m.hp -= dmg;
+ m.vx += (m.x - state.player.x) * 2;
+ m.vy -= 200;
+ playSound("attack");
+ if (m.hp <= 0) {
+ if (m.kind === "chicken") playSound("hurt_chicken");
+ state.inv.meat += m.kind === "chicken" ? 1 : 2;
+ if (m.kind === "skeleton") {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ state.mobs.splice(i, 1);
+ rebuildHotbar();
}
- playSound('click');
- renderInventory();
- }
- };
- }
-
- inventoryGrid.appendChild(slot);
- }
- }
-
- function canCraft(r){
- console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost);
- for(const res in r.cost){
- const have = inv[res] || 0;
- const need = r.cost[res];
- console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need);
- if(have < need) return false;
- }
- return true;
- }
- function renderCraft(){
- recipesEl.innerHTML='';
- for(const r of RECIPES){
- const row = document.createElement('div');
- row.className='recipe';
- const reqLv = r.requiredLevel || 1;
- const locked = player.level < reqLv;
- const icon = document.createElement('div');
- icon.className='ricon';
- // Иконка — блок, инструмент или предмет
- if(tex[r.out]){
- icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
- } else if(TOOLS[r.out]){
- icon.textContent = TOOLS[r.out].icon;
- icon.style.fontSize = '24px';
- icon.style.display = 'flex';
- icon.style.alignItems = 'center';
- icon.style.justifyContent = 'center';
- } else if(ITEMS[r.out]){
- icon.textContent = ITEMS[r.out].icon;
- icon.style.fontSize = '24px';
- icon.style.display = 'flex';
- icon.style.alignItems = 'center';
- icon.style.justifyContent = 'center';
- }
- const info = document.createElement('div');
- info.className='rinfo';
- const nm = document.createElement('div');
- nm.className='rname';
- const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
- nm.textContent = `${itemName} x${r.qty}` + (locked ? ` (Lv.${reqLv})` : '');
- if(locked) { nm.style.color = '#888'; nm.style.textDecoration = 'line-through'; }
- const cs = document.createElement('div');
- cs.className='rcost';
- cs.textContent = Object.keys(r.cost).map(x => {
- const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
- return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`;
- }).join(' ');
- if(locked) cs.style.color = '#666';
- info.appendChild(nm); info.appendChild(cs);
- const btn = document.createElement('button');
- btn.className='rcraft';
- btn.textContent='Создать';
- btn.disabled = !canCraft(r) || locked;
- if(locked) btn.title = `Требуется уровень ${reqLv}`;
- btn.onclick = () => {
- if(!canCraft(r) || locked) return;
- playSound('click');
- for(const res in r.cost) inv[res]-=r.cost[res];
- inv[r.out] = (inv[r.out]||0) + r.qty;
- if(TOOLS[r.out]) addTool(r.out);
- rebuildHotbar();
- renderCraft();
- };
- row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
- recipesEl.appendChild(row);
- }
- }
-
- let craftOpen=false;
- let inventoryOpen = false;
-
- document.getElementById('craftBtn').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- craftOpen = !craftOpen;
- craftPanel.style.display = craftOpen ? 'block' : 'none';
- if(craftOpen) {
- renderCraft();
- // Закрываем инвентарь если открыт крафт
- inventoryOpen = false;
- inventoryPanel.style.display = 'none';
- }
- };
- document.getElementById('craftClose').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- craftOpen = false;
- craftPanel.style.display = 'none';
- };
-
- // Кнопка открытия инвентаря
- document.getElementById('invToggle').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- inventoryOpen = !inventoryOpen;
- inventoryPanel.style.display = inventoryOpen ? 'block' : 'none';
- if(inventoryOpen) {
- renderInventory();
- // Закрываем крафт если открыт инвентарь
- craftOpen = false;
- craftPanel.style.display = 'none';
- }
- };
-
- document.getElementById('inventoryClose').onclick = () => {
- playSound('click'); // Звук клика по кнопке
- inventoryOpen = false;
- inventoryPanel.style.display = 'none';
- };
-
- // Кнопка сохранения игры (только для одиночного режима)
- const saveBtn = document.getElementById('saveBtn');
- saveBtn.onclick = () => {
- playSound('click');
- saveGame();
- customAlert('Игра сохранена!');
- };
-
- // Кнопка сброса игры (удаление сохранения и создание нового мира)
- const resetBtn = document.getElementById('resetBtn');
- resetBtn.onclick = () => {
- customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
- playSound('click');
-
- // Удаляем сохранение из localStorage
- try {
- localStorage.removeItem(SAVE_KEY);
- console.log('Сохранение удалено из localStorage');
- } catch (e) {
- console.warn('Ошибка удаления сохранения:', e);
- }
-
- // Сбрасываем in-memory сохранение
- inMemorySave = null;
-
- // Генерируем новый worldId
- worldId = Math.random().toString(36).substring(2, 10);
- console.log('Новый worldId после сброса:', worldId);
-
- // Обновляем URL
- try {
- const newUrl = new URL(window.location.href);
- newUrl.searchParams.set('world', worldId);
- const newUrlString = newUrl.toString();
-
- if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
- window.history.replaceState(null, '', newUrlString);
- console.log('URL обновлён:', newUrlString);
- }
- } catch (e) {
- console.error('Ошибка обновления URL:', e);
- }
-
- // Перезагружаем страницу
- location.reload();
- });
- };
-
- // Показываем кнопку сохранения только если играем одни
- function updateSaveButtonVisibility() {
- if (isMultiplayer && otherPlayers.size > 0) {
- saveBtn.style.display = 'none';
- } else {
- saveBtn.style.display = 'flex';
- }
- }
-
- // Режимы
- const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}];
- let modeIdx=0;
- const modeBtn = document.getElementById('modeBtn');
- function mode(){ return MODES[modeIdx].id; }
- modeBtn.onclick = () => {
- playSound('click'); // Звук клика по кнопке режима
- modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon;
- };
-
- // День/ночь (автоматический цикл)
- let isNightTime = false;
-
- // Управление
- const inp = { l:false, r:false, j:false, s:false };
- function bindHold(el, key){
- const down=(e)=>{ e.preventDefault(); inp[key]=true; };
- const up=(e)=>{ e.preventDefault(); inp[key]=false; };
- el.addEventListener('pointerdown', down);
- el.addEventListener('pointerup', up);
- el.addEventListener('pointerleave', up);
- }
- const leftBtn = document.getElementById('left');
- const rightBtn = document.getElementById('right');
- const jumpBtn = document.getElementById('jump');
- const downBtn = document.getElementById('down');
-
- if(leftBtn) bindHold(leftBtn,'l');
- if(rightBtn) bindHold(rightBtn,'r');
- if(jumpBtn) bindHold(jumpBtn,'j');
- if(downBtn) bindHold(downBtn,'s');
-
- window.addEventListener('keydown', (e)=>{
- if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true;
- if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true;
- if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true;
- if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true;
- });
- window.addEventListener('keyup', (e)=>{
- if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false;
- if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false;
- if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false;
- if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false;
- });
-
- // Лодка
- const boat = {
- x: 0, y: 0,
- w: 34, h: 34,
- vx: 0, vy: 0,
- active: false,
- inWater: false
- };
-
- // Функция для расчёта урона с учётом брони
- function calculateDamage(baseDamage) {
- // Броня снижает урон пропорционально
- // armor: 0 = без брони (100% урона)
- // armor: 0.5 = железная броня (50% урона)
- const reduction = player.armor;
- const actualDamage = baseDamage * (1 - reduction);
- console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
- return actualDamage;
- }
-
- // Игрок
- const player = {
- x: 6*TILE, y: 0*TILE,
- w: 34, h: 34,
- vx: 0, vy: 0,
- grounded: false,
- inWater: false,
- headInWater: false,
- hp: 100,
- hunger: 100,
- o2: 100,
- invuln: 0,
- slowTimer: 0, // яд скорпиона — замедление
- fallStartY: 0,
- lastStepTime: 0,
- sleeping: false,
- inBoat: false,
- armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
- equippedArmor: null, // Тип надетой брони
- xp: 0,
- level: 1
- };
-
- // Сохраняем начальную позицию для возрождения
- const spawnPoint = { x: 6*TILE, y: 0*TILE };
-
- // Система дропов с мобов
- const drops = []; // {x, y, vy, item, qty, age}
- let levelUpPopup = null; // {text, timer}
-
- function xpForLevel(lv) {
- if (lv <= 1) return 0;
- const thresholds = [0, 50, 150, 300, 500, 800, 1200, 1700, 2300, 3000];
- if (lv - 1 < thresholds.length) return thresholds[lv - 1];
- return Math.floor(3000 + (lv - 10) * (lv - 10) * 50 + (lv - 10) * 200);
- }
-
- function getMobLoot(kind) {
- const table = {
- chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
- pig: [{item:'meat',min:1,max:2,chance:1}],
- zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
- skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
- creeper: [{item:'gunpowder',min:1,max:2,chance:1}],
- scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
- polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
- slime: [{item:'slime_ball',min:1,max:2,chance:1}],
- eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}]
- };
- return table[kind] || [];
- }
-
- function getMobXP(kind) {
- const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15, scorpion:8, polar_bear:20, slime:5, eagle:15 };
- return xpTable[kind] || 0;
- }
-
- function spawnDrops(mx, my, kind) {
- const loot = getMobLoot(kind);
- for (const entry of loot) {
- if (Math.random() > entry.chance) continue;
- const qty = entry.min + Math.floor(Math.random() * (entry.max - entry.min + 1));
- if (qty <= 0) continue;
- drops.push({
- x: mx + (Math.random() - 0.5) * 20,
- y: my + (Math.random() - 0.5) * 10,
- vy: -1 - Math.random() * 2,
- item: entry.item,
- qty: qty,
- age: 0, xpValue: getMobXP(kind)
- });
- }
- }
-
- const LEVEL_UNLOCKS = {
- 2: 'Каменные инструменты, Лодка',
- 3: 'Железные инструменты, Печь',
- 4: 'Лук и стрелы',
- 5: 'Железная броня',
- 6: 'Золотая броня',
- 7: 'Алмазные инструменты',
- 8: 'TNT'
- };
-
- function grantXP(amount) {
- player.xp += amount;
- while (player.xp >= xpForLevel(player.level + 1)) {
- player.level++;
- const unlock = LEVEL_UNLOCKS[player.level] || '';
- levelUpPopup = { text: '⭐ Уровень ' + player.level + '!' + (unlock ? ' ' + unlock : ''), timer: 240 };
- }
- }
-
- function pickupDrops() {
- for (let i = drops.length - 1; i >= 0; i--) {
- const d = drops[i];
- const dx = player.x + player.w/2 - d.x;
- const dy = player.y + player.h/2 - d.y;
- if (dx*dx + dy*dy < 30*30) {
- if (!inv[d.item]) inv[d.item] = 0;
- inv[d.item] += d.qty;
- if (d.xpValue) grantXP(Math.ceil(d.xpValue * 0.3));
- drops.splice(i, 1);
- rebuildHotbar();
- }
- }
- }
-
- function drawDrops(ctx) {
- for (let i = drops.length - 1; i >= 0; i--) {
- const d = drops[i];
- d.age++;
- if (d.age > 3600) { drops.splice(i, 1); continue; } // 60 sec at 60fps
- // Bounce animation
- const bounce = Math.abs(Math.sin(d.age * 0.05)) * 6;
- const dy = d.y - bounce;
- const sx = d.x - camX;
- const sy = dy - camY;
- // Skip if off screen
- if (sx < -40 || sx > W + 40 || sy < -40 || sy > H + 40) continue;
- // Glow effect
- ctx.save();
- ctx.globalAlpha = 0.3;
- ctx.fillStyle = '#ffff00';
- ctx.beginPath();
- ctx.arc(sx, sy, 14, 0, Math.PI*2);
- ctx.fill();
- ctx.restore();
- // Item icon
- ctx.save();
- ctx.font = '16px system-ui';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- const itemDef = ITEMS[d.item];
- const icon = itemDef ? itemDef.icon : '🎁';
- const label = d.qty > 1 ? icon + '×' + d.qty : icon;
- ctx.fillText(label, sx, sy);
- ctx.restore();
- }
- }
-
- function drawLevelUpPopup(ctx) {
- if (!levelUpPopup) return;
- levelUpPopup.timer--;
- if (levelUpPopup.timer <= 0) { levelUpPopup = null; return; }
- const alpha = Math.min(1, levelUpPopup.timer / 60);
- ctx.save();
- ctx.globalAlpha = alpha;
- ctx.fillStyle = '#FFD700';
- ctx.font = 'bold 36px system-ui';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.strokeStyle = '#000';
- ctx.lineWidth = 3;
- ctx.strokeText(levelUpPopup.text, W/2, H/2 - 60);
- ctx.fillText(levelUpPopup.text, W/2, H/2 - 60);
- ctx.restore();
- }
-
- // Система сохранения игры (localStorage + in-memory fallback)
- const SAVE_KEY = 'minegrechka_save';
- let db = null; // Оставляем для совместимости, но не используем
- let inMemorySave = null; // Запасное сохранение в памяти
-
- // Инициализация (localStorage + in-memory fallback)
- function initDB(){
- return new Promise((resolve) => {
- console.log('Используем localStorage для сохранений (sandbox режим)');
- resolve(null);
- });
- }
-
- // Детерминированный генератор псевдослучайных чисел на основе seed
- function seededRandom(gx, gy){
- const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453;
- return n - Math.floor(n);
- }
-
- function saveGame(){
- const saveData = {
- version: 2,
- worldSeed: worldSeed,
- player: {
- x: player.x,
- y: player.y,
- hp: player.hp,
- hunger: player.hunger,
- o2: player.o2,
- xp: player.xp,
- level: player.level
- },
- temperature: player.temperature,
- inventory: inv,
- time: worldTime,
- isNight: isNightTime,
- // Сохраняем только изменения
- placedBlocks: placedBlocks.slice(),
- removedBlocks: removedBlocks.slice()
- };
-
- const saveSize = JSON.stringify(saveData).length;
- console.log('Сохранение: player HP:', player.hp, 'hunger:', player.hunger, 'o2:', player.o2);
-
- // Пробуем сохранить в localStorage (основной метод для персистентности)
- try {
- localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
- console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
- // TG: also save to server
- if (tgUser && tgUser.id) {
- try {
- fetch(SERVER_URL + '/api/tg/save', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ tg_id: tgUser.id, save_data: JSON.stringify(saveData), world_id: worldId, player_name: playerName })
- }).catch(e2 => console.warn('[TG] Save to server failed:', e2));
- } catch(tgE) {}
- }
- } catch(e){
- console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
-
- // Если localStorage недоступен, используем in-memory fallback
- inMemorySave = saveData;
- console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
- }
- }
-
- function loadGame(){
- return new Promise(async (resolve, reject) => {
- // TG: try server save first
- if (tgUser && tgUser.id && tgSaveLoaded) {
- try {
- const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
- const data = await resp.json();
- if (data.ok && data.save_data) {
- const parsed = JSON.parse(data.save_data);
- console.log('[TG] Loaded save from server, HP:', parsed.player?.hp);
- tgSaveLoaded = parsed; // store for later use
- resolve(parsed);
return;
}
- } catch(e) { console.warn('[TG] Load from server failed:', e); }
- }
- // Пробуем localStorage
- try {
- const localSave = localStorage.getItem(SAVE_KEY);
- if(localSave){
- const parsed = JSON.parse(localSave);
- console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
- resolve(parsed);
- return;
}
- } catch(e){
- console.warn('Ошибка доступа к localStorage:', e);
}
-
- // Если localStorage недоступен, используем in-memory сохранение
- if(inMemorySave){
- console.log('Загружено из in-memory сохранения, player HP:', inMemorySave.player?.hp);
- resolve(inMemorySave);
+ if (state.selected === "bow" && state.inv.bow > 0 && state.inv.arrow > 0) {
+ const aimX = wx - state.player.x - state.player.w / 2;
+ const aimY = wy - state.player.y - state.player.h / 2;
+ const angle = Math.atan2(aimY, aimX);
+ state.projectiles.push({
+ x: state.player.x + state.player.w / 2,
+ y: state.player.y + state.player.h / 3,
+ vx: Math.cos(angle) * 550,
+ vy: Math.sin(angle) * 550,
+ dmg: 10,
+ owner: "player",
+ life: 4
+ });
+ state.inv.arrow--;
+ useTool("bow");
+ playSound("hit1");
+ rebuildHotbar();
return;
}
-
- console.log('Сохранение не найдено');
- resolve(null);
- });
- }
-
- // Миграция с версии 1 на версию 2
- function migrateV1toV2(saveData){
- console.log('Миграция сохранения с версии 1 на версию 2...');
-
- // Сохраняем seed из текущей игры (так как v1 его не хранил)
- saveData.worldSeed = worldSeed;
-
- // Инициализируем массивы изменений
- saveData.placedBlocks = [];
- saveData.removedBlocks = [];
-
- // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
- // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
- // и при загрузке просто перегенерируем мир
-
- // Удаляем старые данные
- delete saveData.generatedBlocks;
-
- saveData.version = 2;
- console.log('Миграция завершена');
- }
-
- async function applySave(saveData){
- if(!saveData) return;
-
- console.log('=== applySave START ===');
- console.log('player HP before applySave:', player.hp);
- console.log('saveData.player.hp:', saveData.player?.hp);
-
- // Миграция версий
- if(saveData.version === 1){
- player.temperature = saveData.player.temperature !== undefined ? saveData.player.temperature : 15;
- migrateV1toV2(saveData);
- }
-
- // Восстанавливаем seed
- if(saveData.worldSeed !== undefined){
- worldSeed = saveData.worldSeed;
- }
-
- // Восстанавливаем игрока
- if(saveData.player){
- player.x = saveData.player.x;
- player.y = saveData.player.y;
- player.hunger = saveData.player.hunger;
- player.o2 = saveData.player.o2;
- player.xp = saveData.player.xp || 0;
- player.level = saveData.player.level || 1;
-
- // Обновляем spawnPoint на позицию из сохранения
- spawnPoint.x = player.x;
- spawnPoint.y = player.y;
-
- // Проверяем HP из сохранения - если <= 0, устанавливаем 100
- const savedHP = saveData.player.hp;
- console.log('Saved HP from file:', savedHP);
- if(savedHP <= 0){
- console.log('WARNING: Saved HP is <= 0, setting to 100!');
- player.hp = 100;
- } else {
- player.hp = savedHP;
- }
- console.log('player HP after restore:', player.hp);
- console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y);
- } else {
- console.log('No player data in save, setting default HP: 100');
- player.hp = 100;
- }
-
- console.log('=== applySave END ===');
-
- // Восстанавливаем инвентарь
- if(saveData.inventory){
- for(const key in saveData.inventory){
- inv[key] = saveData.inventory[key];
- }
- }
-
- // Восстанавливаем время
- if(saveData.time !== undefined){
- worldTime = saveData.time;
- }
-
- // Восстанавливаем день/ночь
- if(saveData.isNight !== undefined){
- isNightTime = saveData.isNight;
- }
-
- // Перегенерируем мир по seed
- regenerateVisibleChunks();
-
- // Применяем изменения (только для v2)
- if(saveData.version === 2){
- // Применяем блоки, установленные игроком
- for(const block of saveData.placedBlocks){
- setBlock(block.gx, block.gy, block.t, true);
- }
-
- // Применяем удалённые блоки
- for(const block of saveData.removedBlocks){
- removeBlock(block.gx, block.gy);
- }
-
- // Восстанавливаем массивы изменений
- placedBlocks = saveData.placedBlocks || [];
- removedBlocks = saveData.removedBlocks || [];
- }
-
- rebuildHotbar();
- console.log('Игра загружена');
- }
-
- // Камера (двухосевая)
- let camX=0, camY=0;
-
- // День/ночь
- let worldTime=0;
- const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза
-
- // Облака
- const clouds = Array.from({length:10}, ()=>({
- x: Math.random()*2000,
- y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается
- w: 80+Math.random()*120,
- s: 12+Math.random()*20
- }));
-
- // Дождь
- let isRaining = false;
- let rainIntensity = 0; // 0..1
- const snowflakes = []; // снежинки для тундры
- const MAX_SNOWFLAKES = 150;
- const raindrops = [];
- const MAX_RAINDROPS = 200;
-
- // ==================== РОСТ КУЛЬТУР ====================
- const growthTimers = {}; // ключ: "gx,gy" → { stage:0-3, growTimer:X }
-
- // Старая функция заменена — теперь погода через биомы (см. weatherState выше)
- // updateWeather(dt) вызывается из основного цикла — биом-зависимая
-
- // Интеграция: определяем isRaining из weatherState для визуализации
- function syncWeatherVisual() {
- isRaining = (weatherState.type === 'rain' || weatherState.type === 'storm');
- if (weatherState.type === 'clear') {
- rainIntensity *= 0.95; // плавное затухание
- if (rainIntensity < 0.01) rainIntensity = 0;
- }
- }
-
- function updateRain(dt) {
- syncWeatherVisual();
- // Дождь
- if ((weatherState.type === 'rain' || weatherState.type === 'storm') && rainIntensity < weatherState.intensity * 0.6) {
- rainIntensity += dt * 0.3;
- } else if (weatherState.type === 'clear' || weatherState.type === 'snow' || weatherState.type === 'fog') {
- rainIntensity = Math.max(0, rainIntensity - dt * 0.5);
- }
- if (rainIntensity < 0.01) {
- raindrops.length = 0;
- } else {
- const spawnRate = Math.floor(rainIntensity * 80 * dt);
- for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) {
- raindrops.push({
- x: camX + Math.random() * W,
- y: camY - 20,
- vy: 400 + Math.random() * 200,
- len: 8 + Math.random() * 12
- });
- }
- for (let i = raindrops.length - 1; i >= 0; i--) {
- const d = raindrops[i];
- d.y += d.vy * dt;
- d.x -= 30 * dt;
- if (d.y > camY + H + 20) raindrops.splice(i, 1);
- }
- }
- // Снег
- if (weatherState.type === 'snow') {
- const spawnRate = Math.floor(weatherState.intensity * 30 * dt);
- for (let i = 0; i < spawnRate && snowflakes.length < MAX_SNOWFLAKES; i++) {
- snowflakes.push({
- x: camX + Math.random() * W,
- y: camY - 10,
- vy: 40 + Math.random() * 60,
- vx: (Math.random() - 0.5) * 30,
- size: 2 + Math.random() * 3
- });
- }
- }
- for (let i = snowflakes.length - 1; i >= 0; i--) {
- const s = snowflakes[i];
- s.y += s.vy * dt;
- s.x += s.vx * dt + Math.sin(s.y * 0.02) * 10 * dt;
- if (s.y > camY + H + 20 || weatherState.type !== 'snow') snowflakes.splice(i, 1);
- }
- }
-
- function drawRain() {
- // Дождь
- if (raindrops.length > 0) {
- ctx.save();
- ctx.strokeStyle = (weatherState.type === 'storm') ? 'rgba(174,194,224,0.7)' : 'rgba(174,194,224,0.5)';
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- for (const d of raindrops) {
- ctx.moveTo(d.x, d.y);
- ctx.lineTo(d.x - 3, d.y + d.len);
- }
- ctx.stroke();
- ctx.restore();
- }
- // Снег
- if (snowflakes.length > 0) {
- ctx.save();
- ctx.fillStyle = 'rgba(255,255,255,0.8)';
- for (const s of snowflakes) {
- ctx.beginPath();
- ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
- ctx.fill();
- }
- ctx.restore();
- }
- // Гроза — вспышка
- if (weatherState.type === 'storm' && Math.random() < 0.003) {
- ctx.save();
- ctx.fillStyle = 'rgba(255,255,255,0.3)';
- ctx.fillRect(camX, camY, W, H);
- ctx.restore();
- }
- // Туман — серый оверлей
- if (weatherState.type === 'fog') {
- ctx.save();
- ctx.fillStyle = 'rgba(200,200,200,0.4)';
- ctx.fillRect(camX, camY, W, H);
- ctx.restore();
- }
- }
-
- // Частицы (взрыв)
- const parts = [];
- function spawnExplosion(x,y, power){
- const n = Math.floor(16 + power*10);
- for(let i=0;i 100){
- playSound('splash');
- }
- }
-
- function resolveY(e){
- // Всегда пересчитываем grounded (не держим "липким")
- e.grounded = false;
-
- const x1 = e.x + 2;
- const x2 = e.x + e.w - 2;
-
- // Проверяем, находится ли игрок на лестнице (по центру)
- const cx = e.x + e.w/2;
- const cy = e.y + e.h/2;
- const gx = Math.floor(cx / TILE);
- const gy = Math.floor(cy / TILE);
- const b = getBlock(gx, gy);
- const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
-
- // Если на лестнице - можно двигаться вверх/вниз
- if(onLadder){
- e.grounded = true;
-
- // Если нажимаем прыжок на лестнице - поднимаемся
- if(inp.j){
- e.vy = -200;
- }
- // Если нажимаем вниз - спускаемся
- else if(inp.s){
- e.vy = 100;
- }
- // Иначе - остаёмся на месте (нет гравитации)
- else {
- e.vy = 0;
- }
- return;
- }
-
- // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
- const leftGX = Math.floor((e.x - 4) / TILE);
- const rightGX = Math.floor((e.x + e.w + 4) / TILE);
- const playerGY = Math.floor((e.y + e.h/2) / TILE);
-
- const leftBlock = getBlock(leftGX, playerGY);
- const rightBlock = getBlock(rightGX, playerGY);
- const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
- const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
-
- // Если рядом есть лестница и игрок прыгает - притягиваем к ней
- if((leftLadder || rightLadder) && inp.j && e.vy < 0){
- // Перемещаем игрока к лестнице
- if(leftLadder && e.x > leftGX * TILE + TILE/2){
- e.x = leftGX * TILE + TILE/2 - e.w/2;
- } else if(rightLadder && e.x < rightGX * TILE + TILE/2){
- e.x = rightGX * TILE + TILE/2 - e.w/2;
- }
- e.grounded = true;
- e.vy = -150; // меньший прыжок при запрыгивании на лестницу
- return;
- }
-
- // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
- // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
- if(e.vy >= 0){
- const probeY = e.y + e.h + 1;
- const gy = Math.floor(probeY / TILE);
- const gxA = Math.floor(x1 / TILE);
- const gxB = Math.floor(x2 / TILE);
-
- if(isSolid(gxA, gy) || isSolid(gxB, gy)){
- e.y = gy * TILE - e.h; // прижимаем к полу
- e.vy = 0;
- e.grounded = true;
-
- // урон от падения — только игроку и только не в воде
- if(e === player && !player.inWater){
- const fallTiles = (e.y - e.fallStartY) / TILE;
- if(fallTiles > 6) {
- const damage = calculateDamage((fallTiles - 6) * 10);
- player.hp -= damage;
- }
+ if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
+ const it = ITEMS[state.selected];
+ if (state.player.hp < 100 || state.player.hunger < 100) {
+ playSound("eat1");
+ state.player.hunger = Math.min(100, state.player.hunger + it.food);
+ state.player.hp = Math.min(100, state.player.hp + 15);
+ state.inv[state.selected]--;
+ rebuildHotbar();
}
- if(e === player) e.fallStartY = e.y;
+ return;
}
- }
-
- // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
- if(e.vy < 0 && e === player){
- const gy = Math.floor(e.y / TILE);
- const gxA = Math.floor(x1 / TILE);
- const gxB = Math.floor(x2 / TILE);
-
- // Проверяем, есть ли блок рядом с игроком
- if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){
- e.y = (gy + 1) * TILE;
- e.vy = 0;
- e.grounded = true;
- if(e === player) e.fallStartY = e.y;
- console.log("Jumped onto block!");
+ if (b && b.t === "campfire" && state.selected === "meat" && state.inv.meat > 0) {
+ playSound("fire");
+ state.inv.meat--;
+ state.inv.cooked++;
+ rebuildHotbar();
+ return;
}
- }
-
- // 2) Если движемся вверх — проверяем потолок
- if(e.vy < 0){
- const gy = Math.floor(e.y / TILE);
- const gxA = Math.floor(x1 / TILE);
- const gxB = Math.floor(x2 / TILE);
- if(isSolid(gxA, gy) || isSolid(gxB, gy)){
- e.y = (gy + 1) * TILE;
- e.vy = 0;
+ if (b && b.t === "bed" && isNight()) {
+ state.player.sleeping = true;
+ saveGame();
+ return;
}
- }
- }
-
- function resolveX(e){
- const y1 = e.y + 2;
- const y2 = e.y + e.h - 2;
-
- // Проверяем, находимся ли мы на лестнице
- const cx = e.x + e.w/2;
- const cy = e.y + e.h/2;
- const gx = Math.floor(cx / TILE);
- const gy = Math.floor(cy / TILE);
- const b = getBlock(gx, gy);
- const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
-
- if(e.vx > 0){
- const gx = Math.floor((e.x + e.w)/TILE);
- const gyA = Math.floor(y1/TILE);
- const gyB = Math.floor(y2/TILE);
- const solidA = isSolid(gx, gyA);
- const solidB = isSolid(gx, gyB);
-
- if(solidA || solidB){
- e.x = gx*TILE - e.w;
- e.vx = 0;
- }
- } else if(e.vx < 0){
- const gx = Math.floor(e.x/TILE);
- const gyA = Math.floor(y1/TILE);
- const gyB = Math.floor(y2/TILE);
- const solidA = isSolid(gx, gyA);
- const solidB = isSolid(gx, gyB);
-
- if(solidA || solidB){
- e.x = (gx+1)*TILE;
- e.vx = 0;
- }
- }
- }
-
- // TNT логика: цепь + усиление
- const activeTNT = new Set(); // хранит key
- function activateTNT(b, fuse=3.2){
- if(b.dead) return;
- if(b.active) return;
- b.active=true;
- b.fuse=fuse;
- activeTNT.add(k(b.gx,b.gy));
- }
-
- function explodeAt(gx,gy){
- const center = getBlock(gx,gy);
- if(!center) return;
-
- // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
- let bonus = 0;
- for(let x=gx-2; x<=gx+2; x++){
- for(let y=gy-2; y<=gy+2; y++){
- const b = getBlock(x,y);
- if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){
- bonus += 0.8;
- activateTNT(b, 0.12); // цепь
+ if (mode() === "mine") {
+ if (!b) return;
+ if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return;
+ if (b.t === "tnt") {
+ activateTNT(b, 3.2);
+ return;
}
- }
- }
-
- const power = 1 + bonus; // условная мощность
- const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах
- const dmgR = 150 + bonus*60; // радиус урона в пикселях
-
- removeBlock(gx,gy);
- activeTNT.delete(k(gx,gy));
- playSound('explode1'); // Звук взрыва
- spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power);
-
- for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){
- for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){
- const d = Math.hypot(x-gx, y-gy);
- if(d > radius) continue;
- const b = getBlock(x,y);
- if(!b || b.dead) continue;
- if(BLOCKS[b.t].fluid) continue;
- if(BLOCKS[b.t].unbreakable) continue;
- if(b.t==='tnt') { activateTNT(b, 0.12); continue; }
- removeBlock(x,y);
- if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа
- }
- }
- rebuildHotbar();
-
- // урон
- const hurt = (e)=>{
- const dx = (e.x+e.w/2) - (gx*TILE+TILE/2);
- const dy = (e.y+e.h/2) - (gy*TILE+TILE/2);
- const dist = Math.hypot(dx,dy);
- if(dist < dmgR){
- const dmg = (dmgR - dist) * 0.06 * power;
- if(e === player) {
- const actualDamage = calculateDamage(dmg);
- player.hp -= actualDamage;
- } else {
- e.hp -= dmg;
- }
- e.vx += (dx/dist || 0) * 600;
- e.vy -= 320;
- }
- };
- hurt(player);
- mobs.forEach(hurt);
- }
-
- // Взаимодействие мышь/тап
- const mouse = { x:null, y:null };
- canvas.addEventListener('pointermove', (e)=>{
- const r = canvas.getBoundingClientRect();
- mouse.x = e.clientX - r.left;
- mouse.y = e.clientY - r.top;
- });
-
- canvas.addEventListener('pointerdown', (e)=>{
- if(craftOpen) return;
- if(player.hp<=0) return;
-
- const r = canvas.getBoundingClientRect();
- const sx = e.clientX - r.left;
- const sy = e.clientY - r.top;
-
- const wx = sx + camX;
- const wy = sy + camY;
-
- const gx = Math.floor(wx / TILE);
- const gy = Math.floor(wy / TILE);
-
- // Пробуждение: клик по любой кровати когда спишь
- const b = getBlock(gx,gy);
- if(player.sleeping && b && b.t==='bed'){
- player.sleeping = false;
- return;
- }
-
- if(player.sleeping) return; // Нельзя взаимодействовать во время сна
-
- // Клик по печи — открываем панель обжига
- if(b && b.t === 'furnace' && mode() === 'mine'){
- openFurnaceUI(gx, gy);
- return;
- }
-
- // клик по мобу (в режиме mine)
- if(mode()==='mine'){
- // Check all mobs (local + server-spawned) using getAllMobs
- const allClickMobs = getAllMobs();
- for(let i = allClickMobs.length - 1; i >= 0; i--){
- const m = allClickMobs[i];
- if(m.dead) continue;
- if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
- let dmg = 1;
- const swordTypes = ['iron_sword','stone_sword','wood_sword'];
- for (const st of swordTypes) {
- if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
- }
- m.hp -= dmg;
- m.vx += (m.x - player.x) * 2;
- m.vy -= 200;
- playSound('attack');
- // Server-spawned mob: emit hurt to server for relay, handle death locally
- if(m.id !== undefined && isMultiplayer){
- socket.emit('mob_hurt', { id: m.id, dmg });
- if(m.hp <= 0){
- socket.emit('mob_died', { id: m.id });
- }
- }
- if(m.hp<=0){
- if(m.kind === 'chicken') playSound('hurt_chicken');
- spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
- // Remove from the correct array
- if(m.id !== undefined){
- serverMobs.delete(m.id);
- } else {
- const localIdx = mobs.indexOf(m);
- if(localIdx >= 0) mobs.splice(localIdx, 1);
- }
+ const hardness = BLOCKS[b.t].hardness || 1;
+ if (hardness === Infinity) return;
+ if (hardness === 0) {
+ const removed = removeBlock(gx, gy);
+ if (removed) {
+ state.inv[removed.t] = (state.inv[removed.t] || 0) + 1;
+ sendBlockChange(gx, gy, removed.t, "remove");
rebuildHotbar();
}
return;
}
- }
- }
-
- // Лук — стреляем стрелой
- if(selected === 'bow' && inv.bow > 0 && inv.arrow > 0){
- const aimX = wx - player.x - player.w/2;
- const aimY = wy - player.y - player.h/2;
- const angle = Math.atan2(aimY, aimX);
- projectiles.push({
- x: player.x + player.w/2,
- y: player.y + player.h/3,
- vx: Math.cos(angle) * 550,
- vy: Math.sin(angle) * 550,
- dmg: 10,
- owner: 'player',
- life: 4
- });
- inv.arrow--;
- useTool('bow');
- playSound('hit1');
- rebuildHotbar();
- return;
- }
-
- // еда (предмет)
- if(ITEMS[selected] && inv[selected]>0){
- const it = ITEMS[selected];
- if(player.hp < 100 || player.hunger < 100){
- playSound('eat1');
- player.hunger = Math.min(100, player.hunger + it.food);
- player.hp = Math.min(100, player.hp + 15);
- inv[selected]--;
- rebuildHotbar();
- }
- return;
- }
-
- //Посадка семян на грядку
- if(b && b.t === 'farmland' && mode()==='build'){
- const seedMap = { wheat: 'wheat_stage0', carrot: 'carrot_stage0', potato: 'potato_stage0' };
- if(seedMap[selected] && inv[selected] > 0){
- inv[selected]--;
- const cropType = seedMap[selected];
- setBlock(gx, gy-1, cropType);
- growthTimers[gx+','+(gy-1)] = { stage:0, growTimer: 10+Math.random()*5 };
- sendBlockChange(gx, gy-1, cropType, 'add');
- playSound('cloth1');
- rebuildHotbar();
- return;
- }
- }
-
- // Мотыга — превращает grass/dirt в farmland (в любом режиме)
- if(selected === 'hoe' && inv.hoe > 0 && b){
- if(b.t === 'grass' || b.t === 'dirt'){
- setBlock(gx, gy, 'farmland');
- sendBlockChange(gx, gy, b.t, 'remove');
- sendBlockChange(gx, gy, 'farmland', 'add');
- useTool('hoe');
- playSound('cloth1');
- rebuildHotbar();
- return;
- }
- }
-
- // жарка на костре: выбран meat + клик по campfire
- if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){
- playSound('fire'); // Звук при жарке на костре
- inv.meat--; inv.cooked++;
- rebuildHotbar();
- return;
- }
-
- // Сон на кровати: клик по bed
- if(b && b.t==='bed' && isNight()){
- player.sleeping = true;
- saveGame(); // Сохраняем при отходе ко сну
- return;
- }
-
- if(mode()==='mine'){
- if(!b) return;
- if(BLOCKS[b.t].fluid) return;
- // Клик на урожайную культуру — сбор
- if(BLOCKS[b.t].harvestable){
- const hInfo = BLOCKS[b.t];
- inv[hInfo.harvestItem] = (inv[hInfo.harvestItem]||0) + hInfo.harvestQty;
- removeBlock(gx, gy);
- sendBlockChange(gx, gy, b.t, 'remove');
- delete growthTimers[gx+','+gy];
- playSound('cloth1');
- rebuildHotbar();
- return;
- }
- if(BLOCKS[b.t].decor) return;
-
- if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу
-
- const removed = removeBlock(gx,gy);
- if(removed){
- inv[removed.t] = (inv[removed.t]||0) + 1;
-
- // Тратим прочность кирки (если есть в инвентаре)
- const pickTypes = ['iron_pickaxe','stone_pickaxe','wood_pickaxe'];
- for (const pt of pickTypes) {
- if (inv[pt] > 0) {
- const broke = useTool(pt);
- if (broke) playSound('cloth1'); // звук поломки
- break;
- }
+ if (state.mining.gx === gx && state.mining.gy === gy) {
+ state.mining.progress += getMiningSpeed(b.t);
+ } else {
+ state.mining = { gx, gy, progress: getMiningSpeed(b.t), hardness };
}
-
- // Отправляем изменение блока на сервер
- sendBlockChange(gx, gy, removed.t, 'remove');
-
- // Звуки при добыче блоков
- if(removed.t === 'glass') playSound('glass1');
- else if(removed.t === 'sand') playSound('sand1');
- else if(removed.t === 'snow') playSound('snow1');
- else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
- else if(removed.t === 'wood') playSound('wood1');
- else playSound('cloth1');
-
- rebuildHotbar();
+ if (b.t === "glass") playSound("glass1");
+ else if (b.t === "sand") playSound("sand1");
+ else if (b.t === "stone" || b.t.endsWith("_ore")) playSound("stone1");
+ else if (b.t === "wood" || b.t === "planks") playSound("wood1");
+ else playSound("cloth1");
+ if (state.mining.progress >= hardness) {
+ const minTool = BLOCKS[b.t].minTool;
+ let drops = true;
+ if (minTool) {
+ const toolId = getBestTool(b.t);
+ if (toolId) {
+ const tool = TOOLS[toolId];
+ if (toolTierLessThan(tool.toolTier, minTool)) drops = false;
+ else useTool(toolId);
+ } else {
+ drops = false;
+ }
+ } else {
+ const toolId = getBestTool(b.t);
+ if (toolId) useTool(toolId);
+ }
+ const removed = removeBlock(gx, gy);
+ if (removed) {
+ if (drops) {
+ state.inv[removed.t] = (state.inv[removed.t] || 0) + 1;
+ } else {
+ if (BLOCKS[removed.t]?.toolType === "pickaxe") {
+ state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
+ }
+ }
+ sendBlockChange(gx, gy, removed.t, "remove");
+ if (removed.t === "glass") playSound("glass1");
+ else if (removed.t === "sand") playSound("sand1");
+ else if (removed.t === "stone" || removed.t.endsWith("_ore")) playSound("stone1");
+ else if (removed.t === "wood") playSound("wood1");
+ else playSound("cloth1");
+ rebuildHotbar();
+ }
+ state.mining = { gx: null, gy: null, progress: 0, hardness: 0 };
+ }
+ miningHeld = true;
+ if (miningInterval) clearInterval(miningInterval);
+ miningInterval = setInterval(() => {
+ if (!miningHeld) {
+ clearInterval(miningInterval);
+ miningInterval = null;
+ return;
+ }
+ const mb = getBlock(state.mining.gx, state.mining.gy);
+ if (!mb || mb.dead || mb.t !== b.t) {
+ miningHeld = false;
+ clearInterval(miningInterval);
+ miningInterval = null;
+ return;
+ }
+ if (state.mining.gx !== null && state.mining.hardness > 0) {
+ state.mining.progress += getMiningSpeed(mb.t) * 0.3;
+ if (state.mining.progress >= state.mining.hardness) {
+ const mgx = state.mining.gx;
+ const mgy = state.mining.gy;
+ const mMinTool = BLOCKS[mb.t].minTool;
+ let mDrops = true;
+ if (mMinTool) {
+ const mToolId = getBestTool(mb.t);
+ if (mToolId) {
+ const mTool = TOOLS[mToolId];
+ if (toolTierLessThan(mTool.toolTier, mMinTool)) mDrops = false;
+ else useTool(mToolId);
+ } else {
+ mDrops = false;
+ }
+ } else {
+ const mToolId = getBestTool(mb.t);
+ if (mToolId) useTool(mToolId);
+ }
+ const removed = removeBlock(mgx, mgy);
+ if (removed) {
+ if (mDrops) {
+ state.inv[removed.t] = (state.inv[removed.t] || 0) + 1;
+ } else {
+ if (BLOCKS[removed.t]?.toolType === "pickaxe") {
+ state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
+ }
+ }
+ sendBlockChange(mgx, mgy, removed.t, "remove");
+ rebuildHotbar();
+ }
+ state.mining = { gx: null, gy: null, progress: 0, hardness: 0 };
+ miningHeld = false;
+ clearInterval(miningInterval);
+ miningInterval = null;
+ }
+ }
+ }, 100);
+ return;
}
- return;
- }
-
- if(mode()==='build'){
- if(inv[selected] <= 0) return;
- if(!BLOCKS[selected]) return;
- if(b) return; // занято
-
- // Проверяем, ставим ли лодку
- if(selected === 'boat'){
- // Лодку можно ставить только на воду
- const waterBelow = getBlock(gx, gy+1);
- if(!waterBelow || waterBelow.t !== 'water'){
+ if (mode() === "build") {
+ if (state.inv[state.selected] <= 0) return;
+ if (!BLOCKS[state.selected]) return;
+ if (b) return;
+ if (state.selected === "boat") {
+ const waterBelow = getBlock(gx, gy + 1);
+ if (!waterBelow || waterBelow.t !== "water") {
+ return;
+ }
+ state.boat.x = gx * state.TILE;
+ state.boat.y = gy * state.TILE;
+ state.boat.vx = 0;
+ state.boat.vy = 0;
+ state.boat.active = true;
+ state.boat.inWater = true;
+ state.player.inBoat = true;
+ state.player.x = state.boat.x;
+ state.player.y = state.boat.y;
+ state.player.vx = 0;
+ state.player.vy = 0;
+ playSound("splash");
+ state.inv[state.selected]--;
+ rebuildHotbar();
return;
}
-
- // Создаём лодку
- boat.x = gx * TILE;
- boat.y = gy * TILE;
- boat.vx = 0;
- boat.vy = 0;
- boat.active = true;
- boat.inWater = true;
-
- // Сажаем игрока в лодку
- player.inBoat = true;
- player.x = boat.x;
- player.y = boat.y;
- player.vx = 0;
- player.vy = 0;
-
- playSound('splash');
- inv[selected]--;
+ const TILE2 = state.TILE;
+ const bx = gx * TILE2, by = gy * TILE2;
+ const overlap = !(bx >= state.player.x + state.player.w || bx + TILE2 <= state.player.x || by >= state.player.y + state.player.h || by + TILE2 <= state.player.y);
+ if (overlap) return;
+ setBlock(gx, gy, state.selected, true);
+ state.inv[state.selected]--;
+ sendBlockChange(gx, gy, state.selected, "set");
+ if (state.selected === "stone" || state.selected === "brick") playSound("stone_build");
+ else if (state.selected === "wood" || state.selected === "planks") playSound("wood_build");
+ else if (state.selected === "glass") playSound("glass1");
+ else if (state.selected === "sand") playSound("sand1");
+ else if (state.selected === "dirt" || state.selected === "grass") playSound("cloth1");
rebuildHotbar();
return;
}
-
- // запрет ставить в игрока
- const bx = gx*TILE, by = gy*TILE;
- const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y);
- if(overlap) return;
-
- setBlock(gx,gy,selected, true); // true = блок установлен игроком
- inv[selected]--;
-
- // Отправляем изменение блока на сервер
- sendBlockChange(gx, gy, selected, 'set');
-
- // Звук при строительстве
- if(selected === 'stone' || selected === 'brick') playSound('stone_build');
- else if(selected === 'wood' || selected === 'planks') playSound('wood_build');
- else if(selected === 'glass') playSound('glass1');
- else if(selected === 'sand') playSound('sand1');
- else if(selected === 'snow') playSound('snow1');
- else if(selected === 'dirt' || selected === 'grass') playSound('cloth1');
-
- rebuildHotbar();
- return;
- }
- });
-
- // ==================== БИОМЫ ====================
- const BIOMES = {
- plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 },
- desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 },
- tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 },
- swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 },
- mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 }
- };
-
- function getBiome(gx) {
- const temp = Math.sin(gx*0.003 + worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + worldSeed*0.02)*0.3 + 0.5;
- const humid = Math.sin(gx*0.004 + worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + worldSeed*0.02 + 2000)*0.3 + 0.5;
- const mtVal = Math.sin(gx*0.001 + worldSeed*0.005)*0.5 + 0.5;
- if (temp > 0.7) return 'desert';
- if (temp < 0.3) return 'tundra';
- if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp';
- if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains';
- return 'plains';
+ });
+ canvas.addEventListener("pointerup", () => {
+ miningHeld = false;
+ });
+ canvas.addEventListener("pointerleave", () => {
+ miningHeld = false;
+ });
}
- const biomeCache = {};
- function getCachedBiome(gx) {
- const chunk = Math.floor(gx / 8); // cache per 8-tile chunk for smoother biomes
- if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8);
- return biomeCache[chunk];
- }
-
- // ==================== ПОГОДА ====================
- const weatherState = { type: 'clear', intensity: 0, timer: 0, duration: 180, nextChange: 120 + Math.random()*180 };
- const BIOME_WEATHER = {
- plains: { clear:0.50, rain:0.30, storm:0.10, snow:0, fog:0.10 },
- desert: { clear:0.80, rain:0.05, storm:0, snow:0, fog:0.15 },
- tundra: { clear:0.20, rain:0, storm:0.10, snow:0.60, fog:0.10 },
- swamp: { clear:0.20, rain:0.30, storm:0.10, snow:0, fog:0.40 },
- mountains:{ clear:0.40, rain:0.30, storm:0.10, snow:0.10, fog:0.10 }
- };
-
- function updateWeather(dt) {
- weatherState.timer += dt;
- if (weatherState.timer >= weatherState.nextChange) {
- weatherState.timer = 0;
- weatherState.nextChange = 60 + Math.random() * 240;
- const biome = getCachedBiome(Math.floor(player.x / TILE));
- const probs = BIOME_WEATHER[biome] || BIOME_WEATHER.plains;
- const r = Math.random();
- let cum = 0;
- if ((cum += probs.clear) > r) { weatherState.type = 'clear'; }
- else if ((cum += probs.rain) > r) { weatherState.type = 'rain'; }
- else if ((cum += probs.storm) > r) { weatherState.type = 'storm'; }
- else if ((cum += probs.snow) > r) { weatherState.type = 'snow'; }
- else { weatherState.type = 'fog'; }
- weatherState.duration = 60 + Math.random() * 300;
- }
- // Intensity interpolation
- const target = (weatherState.type === 'clear') ? 0 : 1;
- weatherState.intensity += (target - weatherState.intensity) * dt * 0.5;
- }
-
- function getWeatherSpeedMultiplier() {
- if (weatherState.type === 'rain') return 0.85;
- if (weatherState.type === 'snow') return 0.7;
- if (weatherState.type === 'storm') return 0.85;
- return 1;
- }
-
- function isOutdoorLight(lx, ly) {
- // Check if position is outdoors (no block above)
- const aboveGy = Math.floor(ly / TILE) - 1;
- const aboveGx = Math.floor(lx / TILE);
- const above = getBlock(aboveGx, aboveGy);
- return !above || !BLOCKS[above.t]?.solid;
- }
-
- // ==================== СТРУКТУРЫ МИРА ====================
- function placeStructure(startGx, startGy, pattern) {
- for (let dy = 0; dy < pattern.length; dy++) {
- for (let dx = 0; dx < pattern[dy].length; dx++) {
- const bt = pattern[dy][dx];
- if (bt && bt !== 'air') {
- setBlock(startGx + dx, startGy + dy, bt);
- }
- }
- }
- }
-
- // Пирамида в пустыне
- const PYRAMID_PATTERN = [
- ['sand','sand','sand','sand','sand','sand','sand'],
- ['sand','stone','stone','stone','stone','stone','sand'],
- ['sand','stone','air','air','air','stone','sand'],
- ['sand','stone','air','air','air','stone','sand'],
- ['sand','stone','air','air','air','stone','sand'],
- ['sand','sand','stone','stone','stone','sand','sand']
- ];
-
- // Дом в равнине
- const HOUSE_PATTERN = [
- ['air','planks','planks','planks','planks','air'],
- ['planks','air','air','air','air','planks'],
- ['planks','air','torch','air','air','planks'],
- ['planks','air','air','air','air','planks'],
- ['planks','planks','air','planks','planks','planks']
- ];
-
- // Хижина в болоте
- const HUT_PATTERN = [
- ['air','wood','wood','wood','air'],
- ['wood','air','air','air','wood'],
- ['wood','air','torch','air','wood'],
- ['moss','moss','air','moss','moss']
- ];
-
- // ==================== ГЕНЕРАЦИЯ ====================
- const generated = new Set(); // gx already generated
- function surfaceGyAt(gx) {
- const biome = getCachedBiome(gx);
- // Base noise (same for all biomes)
- const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8;
- const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12;
- const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6;
- const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4;
- const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5;
- let h;
- switch(biome) {
- case 'desert':
- h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // flatter, slightly higher
- break;
- case 'tundra':
- h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // gentle rolling
- break;
- case 'swamp':
- h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // very flat, near sea level
- h = Math.max(h, SEA_GY - 3); // never too deep
- break;
- case 'mountains':
- h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // tall peaks
- break;
- default: // plains
- h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5);
- }
- return h;
- }
-
- function genColumn(gx) {
- if(generated.has(gx)) return;
- if(worldSeed == null) return; // wait for server seed in multiplayer
- generated.add(gx);
-
- const sgy = surfaceGyAt(gx);
- const biome = getCachedBiome(gx);
-
- // === Вода и поверхность ===
- if(sgy > SEA_GY) {
- // ниже уровня моря — заливаем водой
- for(let gy = SEA_GY; gy < sgy; gy++) {
- const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water';
- setBlock(gx, gy, blockType);
- }
- setBlock(gx, sgy, 'sand');
- } else {
- // поверхность
- const b = BIOMES[biome];
- setBlock(gx, sgy, b.surface);
- // болото: случайные лужи болотной воды
- if(biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) {
- setBlock(gx, sgy-1, 'swamp_water');
- }
- // тундра: лёд на воде рядом
- if(biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) {
- setBlock(gx, SEA_GY-1, 'ice');
- }
- }
-
- // === Подповерхностные слои ===
- for(let gy = sgy+1; gy <= BEDROCK_GY; gy++) {
- if(gy === BEDROCK_GY) { setBlock(gx,gy,'bedrock'); continue; }
-
- let t = BIOMES[biome].subsurface;
-
- // глубже — камень
- if(gy > sgy + 3) t = 'stone';
-
- // пустыня: sand глубже
- if(biome === 'desert' && gy <= sgy + 6) t = 'sand';
-
- // болото: глина ближе к поверхности
- if(biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay';
-
- // горы: gravel
- if(biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) t = 'gravel';
-
- // общие руды
- const depth = gy - sgy;
- const r = seededRandom(gx, gy);
- if(t === 'stone') {
- if(r < 0.06) t = 'coal';
- else if(r < 0.10) t = 'copper_ore';
- else if(r < 0.13) t = 'iron_ore';
- else if(depth > 40 && r < 0.145) t = 'gold_ore';
- else if(depth > 70 && r < 0.152) t = 'diamond_ore';
- }
-
- setBlock(gx, gy, t);
- }
-
- // === Растительность и декор ===
- const b = BIOMES[biome];
- const top = getBlock(gx, sgy);
-
- // цветы (только plain)
- if(biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) {
- setBlock(gx, sgy-1, 'flower');
- }
-
- // деревья
- if(b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) {
- if(biome === 'tundra') {
- // ёлки (треугольные, 3-5 высоты)
- const th = 3 + Math.floor(seededRandom(gx, sgy) * 3);
- for(let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood');
- // крона (треугольник)
- for(let row = 0; row < th; row++) {
- const w = Math.min(row + 1, 2);
- for(let dx = -w; dx <= w; dx++) {
- const ly = sgy-1-th+row;
- if(ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves');
- }
- }
- } else if(biome === 'swamp') {
- // болотное дерево (короче, с мхом)
- setBlock(gx, sgy-1, 'wood');
- setBlock(gx, sgy-2, 'moss');
- setBlock(gx-1, sgy-2, 'leaves');
- setBlock(gx+1, sgy-2, 'leaves');
- } else {
- // обычное дерево (plains)
- setBlock(gx, sgy-1, 'wood');
- setBlock(gx, sgy-2, 'wood');
- setBlock(gx, sgy-3, 'leaves');
- setBlock(gx-1, sgy-3, 'leaves');
- setBlock(gx+1, sgy-3, 'leaves');
- }
- }
-
- // кактусы (пустыня)
- if(biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) {
- const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2);
- for(let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus');
- }
-
- // грибы (болото)
- if(biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) {
- setBlock(gx, sgy-1, 'mushroom');
- }
-
- // сухие кусты (пустыня)
- if(biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) {
- setBlock(gx, sgy-1, 'dead_bush');
- }
-
- // === Структуры мира ===
- // Пирамида в пустыне
- if(biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) {
- placeStructure(gx, sgy - 5, PYRAMID_PATTERN);
- }
- // Дом в равнине
- if(biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) {
- placeStructure(gx, sgy - 4, HOUSE_PATTERN);
- }
- // Хижина в болоте
- if(biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) {
- placeStructure(gx, sgy - 3, HUT_PATTERN);
- }
-
- // Применяем серверные оверрайды для этой колонны
- const colPrefix = gx + ',';
- for (const [key, ov] of serverOverrides) {
- if (!key.startsWith(colPrefix)) continue;
- if (ov.op === 'remove') {
- const b = grid.get(key);
- if (b) { grid.delete(key); b.dead = true; }
- } else if (ov.op === 'set') {
- if (!grid.has(key)) {
- const gy = parseInt(key.split(',')[1]);
- const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
- grid.set(key, nb);
- blocks.push(nb);
- }
- }
- }
- }
-
- // Перегенерация видимых чанков (используется при загрузке сохранения)
- function regenerateVisibleChunks(){
- const gx0 = Math.floor(camX/TILE);
- for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
- // Принудительно перегенерируем колонну
- generated.delete(gx);
- genColumn(gx);
- }
- }
-
- function ensureGenAroundCamera(){
- const gx0 = Math.floor(camX/TILE);
- for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
- genColumn(gx);
- }
- }
-
- // Лут с дерева/листвы: дерево -> wood; листья -> leaves
- // (уже в mine добавляется inv[type] автоматически)
-
- // Рисование костра: огонь поверх текстуры
- function drawFire(wx,wy,now){
+ // src/render/draw-fire.js
+ function drawFire(ctx, wx, wy, now) {
const baseX = wx;
const baseY = wy;
- const flick = 1.5 + (Math.sin(now/200)+1)*1;
- ctx.fillStyle = 'rgba(255,140,0,0.85)';
+ const flick = 6 + (Math.sin(now / 90) + 1) * 4;
+ ctx.fillStyle = "rgba(255,140,0,0.85)";
ctx.beginPath();
- ctx.moveTo(baseX+10, baseY+30);
- ctx.lineTo(baseX+20, baseY+30-flick);
- ctx.lineTo(baseX+30, baseY+30);
+ ctx.moveTo(baseX + 10, baseY + 30);
+ ctx.lineTo(baseX + 20, baseY + 30 - flick);
+ ctx.lineTo(baseX + 30, baseY + 30);
ctx.fill();
-
- ctx.fillStyle = 'rgba(255,230,150,0.75)';
+ ctx.fillStyle = "rgba(255,230,150,0.75)";
ctx.beginPath();
- ctx.moveTo(baseX+14, baseY+30);
- ctx.lineTo(baseX+20, baseY+30-(flick*0.7));
- ctx.lineTo(baseX+26, baseY+30);
+ ctx.moveTo(baseX + 14, baseY + 30);
+ ctx.lineTo(baseX + 20, baseY + 30 - flick * 0.7);
+ ctx.lineTo(baseX + 26, baseY + 30);
ctx.fill();
}
-
- // Моб AI
- function mobAI(m, dt){
- updateWaterFlag(m);
-
- if(m.kind==='zombie'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
- const dir = Math.sign((player.x) - m.x);
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
- // атака
- if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
- Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
- player.invuln <= 0){
- const damage = calculateDamage(15);
- player.hp -= damage;
- player.invuln = 0.8;
- player.vx += dir*420;
- player.vy -= 260;
- playSound('hit1'); // Звук при атаке зомби
- }
- } else if(m.kind==='creeper'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
- const dir = Math.sign((player.x) - m.x);
- const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
-
- // Движение к игроку
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
-
- // Взрыв если близко к игроку
- if(dist < 60){
- m.fuse -= dt;
- if(m.fuse <= 0){
- explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE));
- m.hp = 0;
- }
- } else {
- // Поджигаем если очень близко
- if(dist < 40){
- m.fuse = 0.5; // Быстрый взрыв
- }
- }
- } else if(m.kind==='skeleton'){
- // активность ночью
- const night = isNight();
- if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
- const dir = Math.sign((player.x) - m.x);
- const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
-
- // Движение к игроку
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
-
- // Стрельба стрелами
- m.shootCooldown -= dt;
- if(dist < 300 && m.shootCooldown <= 0){
- m.shootCooldown = 2.0;
- const dx = (player.x+player.w/2) - (m.x+m.w/2);
- const dy = (player.y+player.h/2) - (m.y+m.h/2);
- const angle = Math.atan2(dy, dx);
- const speed = 450;
- projectiles.push({
- x: m.x + m.w/2,
- y: m.y + m.h/3,
- vx: Math.cos(angle) * speed,
- vy: Math.sin(angle) * speed,
- dmg: 6,
- owner: 'mob',
- life: 3
- });
- }
- } else if(m.kind==='scorpion') {
- // Скорпион — бежит к игроку, яд (замедление)
- const dir = Math.sign((player.x) - m.x);
- m.vx = dir * m.speed;
- if(m.inWater && Math.random()<0.06) m.vy = -260;
- // Яд при касании — замедление на 3 сек
- if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
- Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
- player.invuln <= 0){
- const damage = calculateDamage(8);
- player.hp -= damage;
- player.invuln = 0.8;
- player.slowTimer = 3; // замедление
- player.vx += dir*300;
- player.vy -= 200;
- playSound('hit1');
- }
- } else if(m.kind==='polar_bear') {
- // Белый медведь — нейтрален, атакует если ударили (hostile пока нет, атакует через proximity)
- m.aiT -= dt;
- if(m.aiT <= 0){
- m.aiT = 2.0 + Math.random()*3;
- m.dir = Math.random()<0.5 ? -1 : 1;
- if(Math.random()<0.3) m.dir = 0;
- }
- m.vx = m.dir * m.speed;
- if(m.inWater) m.vy = -120;
- } else if(m.kind==='slime') {
- // Слизь — прыгает к игроку
- const dir = Math.sign((player.x+player.w/2) - (m.x+m.w/2));
- m.aiT -= dt;
- if(m.aiT <= 0){
- m.aiT = 1.5 + Math.random()*1.5;
- m.dir = dir;
- // Прыжок
- m.vy = -200;
- }
- m.vx = m.dir * m.speed;
- } else if(m.kind==='eagle') {
- // Орёл — летает, атакует пикированием
- const dx = (player.x+player.w/2) - (m.x+m.w/2);
- const dy = (player.y+player.h/2) - (m.y+m.h/2);
- const dist = Math.hypot(dx, dy);
- if(dist < 400) {
- // Пикирует на игрока
- m.vx = Math.sign(dx) * m.speed;
- m.vy = dy > 0 ? 60 : -60; // летит вниз к игроку
- } else {
- // Патрулирует
- m.aiT -= dt;
- if(m.aiT <= 0){
- m.aiT = 2+Math.random()*3;
- m.dir = Math.random()<0.5 ? -1 : 1;
- }
- m.vx = m.dir * m.speed * 0.5;
- m.vy = Math.sin(performance.now()/1000) * 30; // мягкое покачивание
- }
- // Атака при касании
- if(dist < 30 && player.invuln <= 0){
- const damage = calculateDamage(10);
- player.hp -= damage;
- player.invuln = 0.8;
- player.vy -= 200;
- playSound('hit1');
- }
- // Орёл не падает — летающий моб
- m.vy *= 0.5;
- m.grounded = false;
- } else {
- // животные (pig, chicken)
- m.aiT -= dt;
- if(m.aiT <= 0){
- m.aiT = 1.8 + Math.random()*2.5;
- m.dir = Math.random()<0.5 ? -1 : 1;
- if(Math.random()<0.25) m.dir = 0;
- }
- m.vx = m.dir * (m.kind==='chicken' ? 55 : 40);
- if(m.inWater) m.vy = -120;
- }
-
- // физика моба
- const g = m.inWater ? GRAV_WATER : GRAV;
- m.vy += g*dt;
-
- m.y += m.vy*dt; m.grounded=false; resolveY(m);
- m.x += m.vx*dt; resolveX(m);
- }
-
- function isNight(){
- // Автоматический цикл: ночь когда worldTime > 0.5
- return worldTime > 0.5;
- }
-
- // Respawn
- document.getElementById('respawnBtn').onclick = async () => {
- playSound('click'); // Звук клика по кнопке
-
- console.log('=== RESPAWN CLICKED ===');
- console.log('isMultiplayer:', isMultiplayer);
- console.log('otherPlayers.size:', otherPlayers.size);
- console.log('player.hp before respawn:', player.hp);
-
- // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
- if (isMultiplayer && otherPlayers.size > 0) {
- console.log('Мультиплеер режим - возрождение в начальной точке');
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.fallStartY = player.y;
- console.log('Возрождение в начальной точке, HP:', player.hp);
- } else {
- console.log('Одиночный режим - загружаем последнее сохранение');
- // Одиночный режим - загружаем последнее сохранение
- const loadedSave = await loadGame();
- if(loadedSave){
- await applySave(loadedSave);
- console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
- } else {
- // Если сохранения нет, возрождаемся в начальной точке
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.fallStartY = player.y;
- console.log('Возрождение в начальной точке, HP:', player.hp);
- }
- }
-
- console.log('player.hp after respawn logic:', player.hp);
- console.log('Hiding death screen...');
- deathEl.style.display='none';
- console.log('=== RESPAWN END ===');
- };
-
- // Resize
- function resize(){
- W = gameEl.clientWidth;
- H = gameEl.clientHeight;
- canvas.width = W*dpr;
- canvas.height = H*dpr;
- lightC.width = W*dpr;
- lightC.height = H*dpr;
- ctx.setTransform(dpr,0,0,dpr,0,0);
- }
- window.addEventListener('resize', resize);
-
- // init
- resize();
- rebuildHotbar();
-
- // Инициализируем и загружаем сохранение
- initDB().then(async () => {
- // Пытаемся загрузить сохранённую игру
- const loadedSave = await loadGame();
- if(loadedSave){
- await applySave(loadedSave);
- console.log('Загружено сохранение, HP:', player.hp);
-
- // Проверяем HP после загрузки - если <= 0, возрождаемся
- if (player.hp <= 0) {
- console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.x = spawnPoint.x;
- player.y = spawnPoint.y;
- player.vx = player.vy = 0;
- player.invuln = 0;
- player.fallStartY = player.y;
- }
- } else {
- console.log('Сохранение не найдено, начинаем новую игру');
-
- // Инициализируем игрока для новой игры
- player.hp = 100;
- player.hunger = 100;
- player.o2 = 100;
- player.vx = player.vy = 0;
- player.invuln = 0;
-
- // старт — на поверхности (используем ту же логику что и в world_state)
- const startGX = 6;
- for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
- const surfaceY = surfaceGyAt(startGX);
- let safeGY = surfaceY - 1;
- const aboveBlock = getBlock(startGX, surfaceY - 1);
- if (aboveBlock && aboveBlock.t === 'water') {
- for (let gy = SEA_GY - 1; gy >= 0; gy--) {
- const b = getBlock(startGX, gy);
- if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
- safeGY = gy - 1;
- break;
- }
- }
- player.y = safeGY * TILE;
- player.x = startGX * TILE;
- player.fallStartY = player.y;
-
- // Обновляем spawnPoint, чтобы возрождение было на поверхности
- spawnPoint.x = player.x;
- spawnPoint.y = player.y;
-
- console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp);
- console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y);
-
- // Генерируем карту вокруг стартовой позиции при инициализации
- for(let gx = startGX - 50; gx <= startGX + 50; gx++){
- genColumn(gx);
- }
- }
-
- // Автосейв при скрытии страницы (защита от потери прогресса)
- document.addEventListener('visibilitychange', () => {
- if(document.hidden){
- saveGame();
- }
- });
-
- // Автосейв перед закрытием страницы (защита от потери прогресса)
- window.addEventListener('beforeunload', () => {
- saveGame();
- });
- }).catch(err => {
- console.error('Ошибка инициализации:', err);
- // При ошибке начинаем новую игру
- const startGX = 6;
- genColumn(startGX);
- player.y = (surfaceGyAt(startGX)-1)*TILE;
- player.fallStartY = player.y;
-
- for(let gx = startGX - 50; gx <= startGX + 50; gx++){
- genColumn(gx);
- }
- });
-
- // main loop
- let last = performance.now();
- let prevJump = false;
- // При возврате на вкладку — сбрасываем last чтобы не было скачка dt
- document.addEventListener('visibilitychange', () => {
+
+ // src/game/loop.js
+ var last = performance.now();
+ var prevJump = false;
+ document.addEventListener("visibilitychange", () => {
if (!document.hidden) last = performance.now();
});
- function loop(now){
- const rawDt = Math.min(0.05, (now-last)/1000);
+ function loop(now) {
+ const rawDt = Math.min(0.05, (now - last) / 1e3);
last = now;
- // Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
const PHYSICS_STEP = 0.016;
const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
const dt = rawDt / steps;
-
- const jumpPressed = inp.j && !prevJump;
- prevJump = inp.j;
-
- // Ускорение времени во время сна
- if(player.sleeping && isNight()){
- worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
- // Восстанавливаем здоровье во время сна
+ const player = state.player;
+ const inv = state.inv;
+ const inp2 = state.inp;
+ const clouds = state.clouds;
+ const mobs = state.mobs;
+ const projectiles = state.projectiles;
+ const parts2 = state.parts;
+ const boat = state.boat;
+ const jumpPressed = inp2.j && !prevJump;
+ prevJump = inp2.j;
+ if (player.sleeping && isNight()) {
+ state.worldTime += dt * 8 / state.DAY_LEN;
player.hp = Math.min(100, player.hp + dt * 20);
- // Автоматическое пробуждение когда наступает день
- if(!isNight()){
+ if (!isNight()) {
player.sleeping = false;
}
} else {
- worldTime += dt / DAY_LEN;
+ state.worldTime += dt / state.DAY_LEN;
}
- if(worldTime >= 1) worldTime -= 1;
-
- // камера следует за игроком по X/Y
- camX = Math.floor((player.x + player.w/2) - W/2);
- camY = Math.floor((player.y + player.h/2) - H/2);
-
+ if (state.worldTime >= 1) state.worldTime -= 1;
+ state.camX = Math.floor(player.x + player.w / 2 - state.W / 2);
+ state.camY = Math.floor(player.y + player.h / 2 - state.H / 2);
ensureGenAroundCamera();
-
- // clouds parallax
- for(const c of clouds){
+ for (const c of clouds) {
c.x -= c.s * dt;
- if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700;
+ if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700;
}
-
- // Погода (биом-зависимая)
- updateWeather(dt);
-
- // player
updateWaterFlag(player);
-
- // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223]
- if(player.headInWater){
- player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза
- if(player.o2 === 0){
- const damage = calculateDamage(4*dt);
+ if (player.headInWater) {
+ player.o2 = Math.max(0, player.o2 - 6 * dt);
+ if (player.o2 === 0) {
+ const damage = calculateDamage(4 * dt);
player.hp -= damage;
}
} else {
- player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза
+ player.o2 = Math.min(100, player.o2 + 10 * dt);
}
-
- // голод убывает, но HP не отнимает (как просили)
- player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза
-
-
- // ========== ТЕМПЕРАТУРА ==========
- if (player.temperature === undefined) player.temperature = 15;
- const BIOME_TEMP = { tundra: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 };
- const UNDERGROUND_TEMP = 15;
- const COLD_THRESHOLD = -5;
- const HEAT_THRESHOLD = 35;
- const HEAT_RADIUS = 5;
- const pGX = Math.floor(player.x / TILE);
- const biome = getCachedBiome(pGX);
- const pGY = Math.floor(player.y / TILE);
- const sGY = surfaceGyAt(pGX);
- const isUndergroundTemp = (pGY - sGY) > 2; // player deeper than 2 blocks below surface
- let targetTemp = BIOME_TEMP[biome] || 15;
- if (isUndergroundTemp) targetTemp = UNDERGROUND_TEMP;
- if (isNight() && !isUndergroundTemp) targetTemp -= 10;
- if ((weatherState.type === 'rain' || weatherState.type === 'storm') && !isUndergroundTemp) targetTemp -= 5;
- player.temperature += (targetTemp - player.temperature) * dt * 0.3;
- let nearHeat = false, nearCool = false;
- for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) {
- for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) {
- const b = getBlock(pGX + dx, pGY + dy);
- if (!b || b.dead) continue;
- if (b.t === 'campfire' || b.t === 'torch') nearHeat = true;
- if (b.t === 'water' && biome === 'desert') nearCool = true;
- }
- }
- if (nearHeat) {
- player.temperature += (30 - player.temperature) * dt * 2;
- if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt);
- }
- if (nearCool) {
- player.temperature += (15 - player.temperature) * dt * 1.5;
- if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) {
- player.hp = Math.min(100, player.hp + 8 * dt);
- }
- }
- if (isUndergroundTemp && !nearHeat) {
- if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) {
- player.hp = Math.min(100, player.hp + 2 * dt);
- }
- }
- const aboveBlk = getBlock(pGX, pGY - 1);
- const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass';
- if (inShade && !isUndergroundTemp && !nearCool) {
- player.temperature -= 5 * dt;
- if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) {
- player.hp = Math.min(100, player.hp + 1 * dt);
- }
- }
- if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) {
- const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15;
- player.hp -= 1 * severity * dt;
- if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt);
- }
- if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) {
- const severity = (player.temperature - HEAT_THRESHOLD) / 15;
- player.hp -= 1 * severity * dt;
- if (severity > 0.5) player.hunger -= 0.5 * severity * dt;
- }
- // Игрок не может двигаться во время сна
- if(player.sleeping){
+ player.hunger = Math.max(0, player.hunger - dt * 0.2);
+ if (player.sleeping) {
player.vx = 0;
player.vy = 0;
} else {
- const dir = (inp.r?1:0) - (inp.l?1:0);
- const speedMult = getWeatherSpeedMultiplier() * (player.slowTimer > 0 ? 0.4 : 1.0);
- if(dir) player.vx = dir * MOVE * speedMult;
+ const dir = (inp2.r ? 1 : 0) - (inp2.l ? 1 : 0);
+ if (dir) player.vx = dir * state.MOVE;
else player.vx *= 0.82;
}
-
- // Звук шагов при движении по земле
- if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){
- const stepInterval = 0.35; // Интервал между шагами в секундах
- if(now/1000 - player.lastStepTime > stepInterval){
- playSound('step');
- player.lastStepTime = now/1000;
+ if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) {
+ const stepInterval = 0.35;
+ if (now / 1e3 - player.lastStepTime > stepInterval) {
+ playSound("step");
+ player.lastStepTime = now / 1e3;
}
}
-
- // прыжок/плавание (новая логика)
- if(player.inBoat){
- // Игрок в лодке - лодка следует за игроком
- const dir = (inp.r?1:0) - (inp.l?1:0);
- if(dir) boat.vx = dir * MOVE;
+ if (player.inBoat) {
+ const dir = (inp2.r ? 1 : 0) - (inp2.l ? 1 : 0);
+ if (dir) boat.vx = dir * state.MOVE;
else boat.vx *= 0.95;
-
- // Лодка плавает на воде
boat.vy = 0;
-
- // Игрок следует за лодкой (сидит внутри неё)
- player.x = boat.x + 2; // Игрок по центру лодки
- player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
+ player.x = boat.x + 2;
+ player.y = boat.y - 4;
player.vx = boat.vx;
player.vy = boat.vy;
player.grounded = true;
- player.inWater = false; // Игрок не в воде когда в лодке
-
- // Прыжок из лодки (высадка)
- if(jumpPressed){
- // Возвращаем лодку в инвентарь
+ player.inWater = false;
+ if (jumpPressed) {
inv.boat = (inv.boat || 0) + 1;
-
player.inBoat = false;
boat.active = false;
- player.y += TILE; // Прыгаем из лодки
- player.vy = -JUMP * 0.5;
- playSound('splash');
+ player.y += state.TILE;
+ player.vy = -state.JUMP * 0.5;
+ playSound("splash");
}
-
- } else if(player.inWater){
- // сопротивление в воде
- player.vx *= 0.90;
+ } else if (player.inWater) {
+ player.vx *= 0.9;
player.vy *= 0.92;
-
- // Если не нажимаем прыжок - тонем (гравитация в воде)
- if(!jumpPressed && !inp.j){
- // Применяем гравитацию в воде - игрок тонет
- player.vy += GRAV_WATER * dt;
+ if (!jumpPressed && !inp2.j) {
+ player.vy += state.GRAV_WATER * dt;
} else {
- // Если нажимаем прыжок - поднимаемся на поверхность
- if(jumpPressed){
- player.vy = Math.min(player.vy, -520); // рывок вверх
- } else if(inp.j){
- // если держим — мягкое всплытие
+ if (jumpPressed) {
+ player.vy = Math.min(player.vy, -520);
+ } else if (inp2.j) {
player.vy = Math.min(player.vy, -260);
}
}
-
} else {
- // обычный прыжок (только по нажатию)
- if(jumpPressed && player.grounded && !player.sleeping){
- player.vy = -JUMP;
+ if (jumpPressed && player.grounded && !player.sleeping) {
+ player.vy = -state.JUMP;
player.grounded = false;
player.fallStartY = player.y;
}
}
-
- // Гравитация применяется только вне воды и вне лодки
- if(!player.inWater && !player.inBoat){
- player.vy += GRAV*dt;
+ if (!player.inWater && !player.inBoat) {
+ player.vy += state.GRAV * dt;
}
-
- // Обновляем позицию лодки
- if(boat.active){
+ if (boat.active) {
boat.x += boat.vx * dt;
boat.y += boat.vy * dt;
-
- // Лодка не выходит за пределы воды
- const boatGX = Math.floor(boat.x / TILE);
- const boatGY = Math.floor(boat.y / TILE);
+ const boatGX = Math.floor(boat.x / state.TILE);
+ const boatGY = Math.floor(boat.y / state.TILE);
const below = getBlock(boatGX, boatGY + 1);
-
- if(!below || below.t !== 'water'){
- // Если лодка вышла из воды - выкидываем игрока
+ if (!below || below.t !== "water") {
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
boat.active = false;
- player.y += TILE;
+ player.y += state.TILE;
player.vy = -200;
- playSound('splash');
+ playSound("splash");
}
}
-
- // Проверяем, не доплыл ли игрок из лодки
- if(player.inBoat && !boat.active){
+ if (player.inBoat && !boat.active) {
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
- player.y += TILE;
+ player.y += state.TILE;
player.vy = -200;
- playSound('splash');
+ playSound("splash");
}
-
- // Sub-stepped physics: применяем движение мелкими шагами
for (let step = 0; step < steps; step++) {
- player.y += player.vy*dt;
+ player.y += player.vy * dt;
resolveY(player);
- player.x += player.vx*dt;
+ player.x += player.vx * dt;
resolveX(player);
}
-
- // Отправляем позицию на сервер (мультиплеер)
sendPlayerPosition();
-
- // Обновляем физику воды
updateWaterPhysics(dt);
-
- // Погода и дождь
updateWeather(dt);
updateRain(dt);
-
player.invuln = Math.max(0, player.invuln - dt);
- if(player.slowTimer > 0) player.slowTimer = Math.max(0, player.slowTimer - dt);
-
- // Рост культур
- for(const key of Object.keys(growthTimers)){
- const tile = growthTimers[key];
- if(tile.stage < 3){
- tile.growTimer -= dt;
- if(tile.growTimer <= 0){
- tile.stage++;
- tile.growTimer = 8 + Math.random()*6;
- // Обновляем визуальный блок
- const [gxStr, gyStr] = key.split(',');
- const gx = parseInt(gxStr), gy = parseInt(gyStr);
- const curBlock = getBlock(gx, gy);
- if(curBlock && curBlock.t !== curBlock.t.replace(/_stage\d/, '_stage'+tile.stage)){
- // Вычисляем следующую стадию
- const baseType = curBlock.t.replace(/_stage\d/, '');
- const nextType = baseType + '_stage' + tile.stage;
- setBlock(gx, gy, nextType);
- sendBlockChange(gx, gy, nextType, 'add');
- // Проверяем созрел ли
- if(tile.stage >= 3) delete growthTimers[key];
- }
- }
- }
+ state.voicePosT += dt;
+ if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) {
+ state.voicePosT = 0;
+ state.voiceSocket.emit("voice_pos", { x: player.x, y: player.y });
}
-
- // Voice position update
- voicePosT += dt;
- if(voicePosT > 0.5 && voiceSocket && voiceSocket.connected){
- voicePosT = 0;
- voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
- }
-
- // Furnace tick
tickFurnaces(dt);
-
- // Обновляем UI печи если открыта
- if(currentFurnaceKey && Math.random() < 0.1){
+ if (state.currentFurnaceKey && Math.random() < 0.1) {
renderFurnaceUI();
}
-
- // Projectile tick (стрелы)
- for(let i = projectiles.length-1; i>=0; i--){
+ for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
- p.vy += 400 * dt; // гравитация
+ p.vy += 400 * dt;
p.life -= dt;
-
- // Столкновение с блоком
- const gx = Math.floor(p.x / TILE);
- const gy = Math.floor(p.y / TILE);
+ const gx = Math.floor(p.x / state.TILE);
+ const gy = Math.floor(p.y / state.TILE);
const blk = getBlock(gx, gy);
- if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid){
- // Врезался в стену
- if(p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50%
+ if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) {
+ if (p.owner === "player" && Math.random() < 0.5) inv.arrow++;
projectiles.splice(i, 1);
continue;
}
-
- // Столкновение с сущностью
- if(p.owner === 'mob'){
- // Попал в игрока
- if(p.x > player.x && p.x < player.x+player.w && p.y > player.y && p.y < player.y+player.h){
- if(player.invuln <= 0){
+ if (p.owner === "mob") {
+ if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) {
+ if (player.invuln <= 0) {
player.hp -= calculateDamage(p.dmg);
player.invuln = 0.4;
player.vx += p.vx * 0.3;
player.vy -= 150;
- playSound('hit1');
+ playSound("hit1");
}
projectiles.splice(i, 1);
continue;
}
} else {
- // Попал в моба — check all mobs (client-authoritative)
- const allArrowMobs = getAllMobs();
- for(let j = allArrowMobs.length - 1; j >= 0; j--){
- const m = allArrowMobs[j];
- if(m.dead) continue;
- if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){
- m.hp -= p.dmg;
- m.vx += p.vx * 0.2;
- m.vy -= 200;
- // Server-spawned mob: emit arrow hit to server for relay
- if(m.id !== undefined && isMultiplayer){
- socket.emit('mob_arrow_hit', { id: m.id, dmg: p.dmg, vx: p.vx });
- if(m.hp <= 0){
- socket.emit('mob_died', { id: m.id });
- }
+ let hitMob = false;
+ if (state.isMultiplayer) {
+ for (const [id, sm] of state.serverMobs) {
+ if (sm.dead) continue;
+ if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) {
+ state.socket.emit("mob_arrow_hit", { id: sm.id, dmg: p.dmg, vx: p.vx });
+ projectiles.splice(i, 1);
+ hitMob = true;
+ break;
}
- if(m.hp <= 0){
- spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
- // Remove from the correct array
- if(m.id !== undefined){
- serverMobs.delete(m.id);
- } else {
- const localIdx = mobs.indexOf(m);
- if(localIdx >= 0) mobs.splice(localIdx, 1);
+ }
+ }
+ if (!hitMob) {
+ for (let j = mobs.length - 1; j >= 0; j--) {
+ const m = mobs[j];
+ if (p.x > m.x && p.x < m.x + m.w && p.y > m.y && p.y < m.y + m.h) {
+ m.hp -= p.dmg;
+ m.vx += p.vx * 0.2;
+ m.vy -= 200;
+ if (m.hp <= 0) {
+ inv.meat += m.kind === "chicken" ? 1 : 2;
+ if (m.kind === "skeleton") {
+ inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1;
+ }
+ mobs.splice(j, 1);
+ rebuildHotbar();
}
- rebuildHotbar();
+ projectiles.splice(i, 1);
+ break;
}
- projectiles.splice(i, 1);
- break;
}
}
}
-
- // Таймаут
- if(p.life <= 0) projectiles.splice(i, 1);
+ if (p.life <= 0) projectiles.splice(i, 1);
}
-
- // TNT tick
- for(const key of Array.from(activeTNT)){
- const b = grid.get(key);
- if(!b || b.dead){ activeTNT.delete(key); continue; }
+ for (const key of Array.from(state.activeTNT)) {
+ const b = state.grid.get(key);
+ if (!b || b.dead) {
+ state.activeTNT.delete(key);
+ continue;
+ }
b.fuse -= dt;
- if(b.fuse <= 0){
- explodeAt(b.gx,b.gy);
+ if (b.fuse <= 0) {
+ explodeAt(b.gx, b.gy);
}
}
-
- // mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events)
- spawnT += dt;
- if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){
- spawnT = 0;
-
- // Выбираем сторону спавна (левая или правая)
+ state.spawnT += dt;
+ if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) {
+ state.spawnT = 0;
const spawnLeft = Math.random() < 0.5;
- const gx = spawnLeft
- ? Math.floor((camX - 200)/TILE)
- : Math.floor((camX + W + 200)/TILE);
-
+ const gx = spawnLeft ? Math.floor((state.camX - 200) / state.TILE) : Math.floor((state.camX + state.W + 200) / state.TILE);
genColumn(gx);
const sgy = surfaceGyAt(gx);
- const wx = gx*TILE + 4;
- const wy = (sgy-1)*TILE; // 1 блок выше поверхности
-
- // не спавнить в воде
+ const wx = gx * state.TILE + 4;
+ const wy = (sgy - 2) * state.TILE;
const top = getBlock(gx, sgy);
- if(top && top.t==='water') {
- // skip
+ if (top && top.t === "water") {
} else {
- const night = isNight();
- if(night){
- // Ночью спавним враждебных мобов (максимум 12 хостайл)
- const hostileCount = mobs.filter(m => m.kind==='zombie'||m.kind==='creeper'||m.kind==='skeleton').length;
- if(hostileCount < 12){
+ const night2 = isNight();
+ if (night2) {
+ const hostileCount = mobs.filter((m) => m.kind === "zombie" || m.kind === "creeper" || m.kind === "skeleton").length;
+ if (hostileCount < 12) {
const rand = Math.random();
- if(rand < 0.35){
+ if (rand < 0.35) {
mobs.push(new Zombie(wx, wy));
- } else if(rand < 0.55){
+ } else if (rand < 0.55) {
mobs.push(new Creeper(wx, wy));
} else {
mobs.push(new Skeleton(wx, wy));
}
}
}
- // Животные спавнятся и днём и ночью (с лимитом)
- const animalCount = mobs.filter(m => m.kind==='pig'||m.kind==='chicken').length;
- if(animalCount < 8){
- mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
+ const animalCount = mobs.filter((m) => m.kind === "pig" || m.kind === "chicken").length;
+ if (animalCount < 8) {
+ mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
}
}
}
-
- // mobs update — mobAI runs on ALL mobs (client-authoritative for server-spawned mobs too)
- {
- // Local mobs
- for(let i=mobs.length-1;i>=0;i--){
+ if (!state.isMultiplayer) {
+ for (let i = mobs.length - 1; i >= 0; i--) {
const m = mobs[i];
mobAI(m, dt);
- if(m.hp<=0) mobs.splice(i,1);
- }
- // Server-spawned mobs: server is authoritative for positions
- // mobAI does NOT run on serverMobs — server handles all physics + AI
- // Only remove dead mobs from local map
- if(isMultiplayer){
- for (const [id, sm] of serverMobs) {
- if(sm.hp <= 0){
- sm.dead = true;
- }
- }
- for (const [id, sm] of serverMobs) {
- if(sm.dead) serverMobs.delete(id);
- }
+ if (m.hp <= 0) mobs.splice(i, 1);
}
}
-
- // particles
- for(let i=parts.length-1;i>=0;i--){
- const p = parts[i];
+ for (let i = parts2.length - 1; i >= 0; i--) {
+ const p = parts2[i];
p.t -= dt;
- p.x += p.vx*dt;
- p.y += p.vy*dt;
- p.vy += GRAV*dt;
- if(p.t <= 0) parts.splice(i,1);
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vy += state.GRAV * dt;
+ if (p.t <= 0) parts2.splice(i, 1);
}
-
- // death
- if(player.hp <= 0){
- deathEl.style.display='flex';
- } else if(deathEl.style.display === 'flex') {
- // Если HP > 0 но экран смерти всё ещё показан - скрываем его
- deathEl.style.display='none';
+ if (player.hp <= 0) {
+ state.deathEl.style.display = "flex";
+ } else if (state.deathEl.style.display === "flex") {
+ state.deathEl.style.display = "none";
}
-
- // render
+ const ctx = state.ctx;
+ const W = state.W;
+ const H = state.H;
+ const camX = state.camX;
+ const camY = state.camY;
+ const TILE2 = state.TILE;
+ const tex2 = state.tex;
const night = isNight();
-
- // sky
- ctx.fillStyle = night ? '#070816' : (isRaining ? '#6B7B8D' : '#87CEEB');
- ctx.fillRect(0,0,W,H);
-
- // clouds (parallax x/y)
+ ctx.fillStyle = night ? "#070816" : state.isRaining ? "#6B7B8D" : "#87CEEB";
+ ctx.fillRect(0, 0, W, H);
ctx.save();
- ctx.translate(-camX*0.5, -camY*0.15);
- ctx.fillStyle = 'rgba(255,255,255,0.65)';
- for(const c of clouds){
+ ctx.translate(-camX * 0.5, -camY * 0.15);
+ ctx.fillStyle = "rgba(255,255,255,0.65)";
+ for (const c of clouds) {
ctx.fillRect(c.x, c.y, c.w, 26);
- ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22);
+ ctx.fillRect(c.x + 20, c.y - 10, c.w * 0.6, 22);
}
ctx.restore();
-
- // world
ctx.save();
ctx.translate(-camX, -camY);
-
- const minGX = Math.floor(camX/TILE)-2;
- const maxGX = Math.floor((camX+W)/TILE)+2;
- const minGY = Math.floor(camY/TILE)-6;
- const maxGY = Math.floor((camY+H)/TILE)+6;
-
- // draw blocks (по массиву, но фильтруем диапазоном)
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
-
+ const minGX = Math.floor(camX / TILE2) - 2;
+ const maxGX = Math.floor((camX + W) / TILE2) + 2;
+ const minGY = Math.floor(camY / TILE2) - 6;
+ const maxGY = Math.floor((camY + H) / TILE2) + 6;
+ const blocks2 = state.blocks;
+ for (const b of blocks2) {
+ if (b.dead) continue;
+ if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
const def = BLOCKS[b.t];
- if(def.alpha){
+ if (def.alpha) {
ctx.save();
ctx.globalAlpha = def.alpha;
- ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
+ ctx.drawImage(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2);
ctx.restore();
} else {
- ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
+ ctx.drawImage(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2);
}
-
- // TNT мигает, если активирован
- if(b.t==='tnt' && b.active && Math.sin(now/60)>0){
- ctx.fillStyle='rgba(255,255,255,0.45)';
- ctx.fillRect(b.gx*TILE, b.gy*TILE, TILE, TILE);
+ if (b.t === "tnt" && b.active && Math.sin(now / 60) > 0) {
+ ctx.fillStyle = "rgba(255,255,255,0.45)";
+ ctx.fillRect(b.gx * TILE2, b.gy * TILE2, TILE2, TILE2);
}
-
- // огонь костра
- if(b.t==='campfire'){
- drawFire(b.gx*TILE, b.gy*TILE, now);
+ if (b.t === "campfire") {
+ drawFire(ctx, b.gx * TILE2, b.gy * TILE2, now);
}
- // Печь — огонь когда обжигает
- if(b.t==='furnace' && activeFurnaces.has(`${b.gx},${b.gy}`)){
- drawFire(b.gx*TILE + 8, b.gy*TILE + 5, now);
+ if (b.t === "furnace" && state.activeFurnaces.has(`${b.gx},${b.gy}`)) {
+ drawFire(ctx, b.gx * TILE2 + 8, b.gy * TILE2 + 5, now);
}
}
-
- // mobs
- const allMobsRender = getAllMobs();
- for(const m of allMobsRender){
- if(m.kind==='zombie'){
- ctx.fillStyle = '#2ecc71';
+ if (state.mining.gx !== null && state.mining.hardness > 0) {
+ const mg = state.mining;
+ const progress = Math.min(1, mg.progress / mg.hardness);
+ const cx = mg.gx * TILE2 - camX + camX;
+ const cy = mg.gy * TILE2 - camY + camY;
+ const wx = mg.gx * TILE2;
+ const wy = mg.gy * TILE2;
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.8, progress);
+ const numCracks = Math.floor(progress * 5);
+ ctx.strokeStyle = "#000";
+ ctx.lineWidth = 2;
+ for (let i = 0; i < numCracks; i++) {
+ ctx.beginPath();
+ ctx.moveTo(wx + TILE2 / 2, wy + TILE2 / 2);
+ const angle = (i * 72 + 36) * Math.PI / 180;
+ ctx.lineTo(wx + TILE2 / 2 + Math.cos(angle) * TILE2 * 0.4, wy + TILE2 / 2 + Math.sin(angle) * TILE2 * 0.4);
+ ctx.stroke();
+ }
+ ctx.fillStyle = `rgba(0,0,0,${progress * 0.4})`;
+ ctx.fillRect(wx, wy, TILE2, TILE2);
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = "#333";
+ ctx.fillRect(wx, wy + TILE2 - 4, TILE2, 4);
+ ctx.fillStyle = "#2ecc71";
+ ctx.fillRect(wx, wy + TILE2 - 4, TILE2 * progress, 4);
+ ctx.restore();
+ }
+ const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs;
+ for (const m of allMobsRender) {
+ if (m.kind === "zombie") {
+ ctx.fillStyle = "#2ecc71";
ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#c0392b';
- ctx.fillRect(m.x+6, m.y+12, 6,6);
- ctx.fillRect(m.x+22, m.y+12, 6,6);
- } else if(m.kind==='pig'){
- ctx.fillStyle = '#ffb6c1';
+ ctx.fillStyle = "#c0392b";
+ ctx.fillRect(m.x + 6, m.y + 12, 6, 6);
+ ctx.fillRect(m.x + 22, m.y + 12, 6, 6);
+ } else if (m.kind === "pig") {
+ ctx.fillStyle = "#ffb6c1";
ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+22, m.y+5, 3,3);
- ctx.fillStyle = '#ff69b4';
- ctx.fillRect(m.x+28, m.y+12, 6,6);
- } else if(m.kind==='chicken'){
- // chicken
- ctx.fillStyle = '#ecf0f1';
+ ctx.fillStyle = "#000";
+ ctx.fillRect(m.x + 22, m.y + 5, 3, 3);
+ ctx.fillStyle = "#ff69b4";
+ ctx.fillRect(m.x + 28, m.y + 12, 6, 6);
+ } else if (m.kind === "chicken") {
+ ctx.fillStyle = "#ecf0f1";
ctx.fillRect(m.x, m.y, m.w, m.h);
- ctx.fillStyle = '#f39c12';
- ctx.fillRect(m.x+18, m.y+10, 6,4);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+8, m.y+6, 3,3);
- } else if(m.kind==='creeper'){
- // creeper
- ctx.fillStyle = '#4CAF50';
+ ctx.fillStyle = "#f39c12";
+ ctx.fillRect(m.x + 18, m.y + 10, 6, 4);
+ ctx.fillStyle = "#000";
+ ctx.fillRect(m.x + 8, m.y + 6, 3, 3);
+ } else if (m.kind === "creeper") {
+ ctx.fillStyle = "#4CAF50";
ctx.fillRect(m.x, m.y, m.w, m.h);
- // Глаза
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+8, m.y+8, 4,4);
- ctx.fillRect(m.x+22, m.y+8, 4,4);
- // Рот
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+12, m.y+20, 10,4);
- // Ноги
- ctx.fillStyle = '#4CAF50';
- ctx.fillRect(m.x+4, m.y+30, 6,20);
- ctx.fillRect(m.x+24, m.y+30, 6,20);
- } else if(m.kind==='skeleton'){
- // skeleton - детализированный
- // Тело
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(m.x+10, m.y+20, 14, 12);
- // Череп
- ctx.fillRect(m.x+8, m.y+0, 18, 18);
- // Глазницы
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+10, m.y+6, 4,4);
- ctx.fillRect(m.x+20, m.y+6, 4,4);
- // Нос
- ctx.fillRect(m.x+15, m.y+12, 4,2);
- // Руки
- ctx.fillStyle = '#ECEFF1';
- ctx.fillRect(m.x+2, m.y+20, 6,14);
- ctx.fillRect(m.x+26, m.y+20, 6,14);
- // Ноги
- ctx.fillRect(m.x+10, m.y+32, 6, 18);
- ctx.fillRect(m.x+18, m.y+32, 6, 18);
- // Лук в руке
+ ctx.fillStyle = "#000";
+ ctx.fillRect(m.x + 8, m.y + 8, 4, 4);
+ ctx.fillRect(m.x + 22, m.y + 8, 4, 4);
+ ctx.fillRect(m.x + 12, m.y + 20, 10, 4);
+ ctx.fillStyle = "#4CAF50";
+ ctx.fillRect(m.x + 4, m.y + 30, 6, 20);
+ ctx.fillRect(m.x + 24, m.y + 30, 6, 20);
+ } else if (m.kind === "skeleton") {
+ ctx.fillStyle = "#ECEFF1";
+ ctx.fillRect(m.x + 10, m.y + 20, 14, 12);
+ ctx.fillRect(m.x + 8, m.y + 0, 18, 18);
+ ctx.fillStyle = "#000";
+ ctx.fillRect(m.x + 10, m.y + 6, 4, 4);
+ ctx.fillRect(m.x + 20, m.y + 6, 4, 4);
+ ctx.fillRect(m.x + 15, m.y + 12, 4, 2);
+ ctx.fillStyle = "#ECEFF1";
+ ctx.fillRect(m.x + 2, m.y + 20, 6, 14);
+ ctx.fillRect(m.x + 26, m.y + 20, 6, 14);
+ ctx.fillRect(m.x + 10, m.y + 32, 6, 18);
+ ctx.fillRect(m.x + 18, m.y + 32, 6, 18);
ctx.save();
ctx.translate(m.x + 30, m.y + 22);
- ctx.strokeStyle = '#8B4513';
+ ctx.strokeStyle = "#8B4513";
ctx.lineWidth = 2;
ctx.beginPath();
- ctx.arc(0, 0, 8, -Math.PI*0.7, Math.PI*0.7);
+ ctx.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7);
ctx.stroke();
- // Тетива
- ctx.strokeStyle = '#ccc';
+ ctx.strokeStyle = "#ccc";
ctx.lineWidth = 1;
ctx.beginPath();
- ctx.moveTo(8*Math.cos(-Math.PI*0.7), 8*Math.sin(-Math.PI*0.7));
- ctx.lineTo(8*Math.cos(Math.PI*0.7), 8*Math.sin(Math.PI*0.7));
+ ctx.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7));
+ ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7));
ctx.stroke();
ctx.restore();
- } else if(m.kind==='scorpion') {
- // скорпион — оранжево-коричневый
- ctx.fillStyle = '#d35400';
- ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-8);
- ctx.fillStyle = '#c0392b';
- ctx.fillRect(m.x+m.w-4, m.y-6, 4, 10);
- ctx.fillRect(m.x+m.w-2, m.y-10, 3, 5);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+4, m.y+6, 3, 3);
- ctx.fillRect(m.x+m.w-7, m.y+6, 3, 3);
- ctx.fillStyle = '#d35400';
- ctx.fillRect(m.x-4, m.y+8, 6, 4);
- ctx.fillRect(m.x+m.w-2, m.y+8, 6, 4);
- } else if(m.kind==='polar_bear') {
- ctx.fillStyle = '#ecf0f1';
- ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-4);
- ctx.fillRect(m.x+8, m.y-2, m.w-16, 10);
- ctx.fillStyle = '#bdc3c7';
- ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 4);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+12, m.y+1, 3, 3);
- ctx.fillRect(m.x+m.w-14, m.y+1, 3, 3);
- ctx.fillStyle = '#ecf0f1';
- ctx.fillRect(m.x+8, m.y-4, 4, 4);
- ctx.fillRect(m.x+m.w-12, m.y-4, 4, 4);
- } else if(m.kind==='slime') {
- const bounce = Math.sin(performance.now()/200)*3;
- ctx.fillStyle = 'rgba(46,204,113,0.8)';
- ctx.fillRect(m.x-1, m.y-1+bounce, m.w+2, m.h+2);
- ctx.fillStyle = 'rgba(39,174,96,0.9)';
- ctx.fillRect(m.x+1, m.y+1+bounce, m.w-2, m.h-2);
- ctx.fillStyle = '#fff';
- ctx.fillRect(m.x+4, m.y+6+bounce, 6, 6);
- ctx.fillRect(m.x+m.w-10, m.y+6+bounce, 6, 6);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+6, m.y+8+bounce, 3, 3);
- ctx.fillRect(m.x+m.w-8, m.y+8+bounce, 3, 3);
- } else if(m.kind==='eagle') {
- ctx.fillStyle = '#8B4513';
- ctx.fillRect(m.x+8, m.y+6, m.w-16, m.h-10);
- const wingY = Math.sin(performance.now()/150)*4;
- ctx.fillRect(m.x-6, m.y+4+wingY, 16, 6);
- ctx.fillRect(m.x+m.w-10, m.y+4-wingY, 16, 6);
- ctx.fillStyle = '#fff';
- ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 6);
- ctx.fillStyle = '#f39c12';
- ctx.fillRect(m.x+m.w/2-1, m.y+6, 4, 3);
- ctx.fillStyle = '#000';
- ctx.fillRect(m.x+m.w/2-1, m.y+3, 2, 2);
}
}
-
- // boat (рисуем первой, чтобы игрок был внутри неё)
- if(boat.active){
- ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE);
+ if (boat.active) {
+ ctx.drawImage(tex2["boat"], boat.x - (TILE2 - boat.w) / 2, boat.y - (TILE2 - boat.h) / 2, TILE2, TILE2);
}
-
- // Lerp other players towards target positions (smooth movement)
- for(const [socketId, p] of otherPlayers){
- if (p.tx !== undefined) {
- p.x += (p.tx - p.x) * 0.25;
- p.y += (p.ty - p.y) * 0.25;
- }
- }
- // other players (multiplayer)
- for(const [socketId, p] of otherPlayers){
- if(heroImg.complete){
- ctx.drawImage(heroImg, p.x - (TILE-player.w)/2, p.y - (TILE-player.h)/2, TILE, TILE);
+ for (const [socketId, p] of state.otherPlayers) {
+ if (state.heroImg.complete) {
+ ctx.drawImage(state.heroImg, p.x - (TILE2 - player.w) / 2, p.y - (TILE2 - player.h) / 2, TILE2, TILE2);
} else {
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, 34, 34);
}
- // Имя игрока (мелко над персонажем)
- ctx.fillStyle = '#fff';
- ctx.font = '12px system-ui';
- ctx.textAlign = 'center';
+ ctx.fillStyle = "#fff";
+ ctx.font = "12px system-ui";
+ ctx.textAlign = "center";
ctx.fillText(p.name, p.x + 17, p.y - 8);
}
-
- // player
- if(heroImg.complete){
- ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE);
+ if (state.heroImg.complete) {
+ ctx.drawImage(state.heroImg, player.x - (TILE2 - player.w) / 2, player.y - (TILE2 - player.h) / 2, TILE2, TILE2);
} else {
- ctx.fillStyle='#fff';
+ ctx.fillStyle = "#fff";
ctx.fillRect(player.x, player.y, player.w, player.h);
}
-
- // projectiles (стрелы)
- for(const p of projectiles){
+ for (const p of projectiles) {
const angle = Math.atan2(p.vy, p.vx);
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(angle);
- ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f';
+ ctx.fillStyle = p.owner === "mob" ? "#c0392b" : "#f1c40f";
ctx.fillRect(-12, -1.5, 24, 3);
- // наконечник
ctx.beginPath();
ctx.moveTo(12, -4);
ctx.lineTo(16, 0);
ctx.lineTo(12, 4);
ctx.closePath();
ctx.fill();
- // оперение
- ctx.fillStyle = '#888';
+ ctx.fillStyle = "#888";
ctx.fillRect(-12, -3, 4, 2);
ctx.fillRect(-12, 1, 4, 2);
ctx.restore();
}
-
- // particles
- for(const p of parts){
+ for (const p of parts2) {
ctx.fillStyle = p.c;
- ctx.fillRect(p.x-2, p.y-2, 4, 4);
+ ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
}
-
- // Стрелы скелета
- for(const m of mobs){
- if(m.kind==='skeleton' && m.shootCooldown > 0.5){
- // Рисуем стрелу
- const arrowX = m.x + m.w/2;
+ for (const m of mobs) {
+ if (m.kind === "skeleton" && m.shootCooldown > 0.5) {
+ const arrowX = m.x + m.w / 2;
const arrowY = m.y + 15;
- const targetX = player.x + player.w/2;
- const targetY = player.y + player.h/2;
+ const targetX = player.x + player.w / 2;
+ const targetY = player.y + player.h / 2;
const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
- const speed = 400;
-
- // Проверяем, попала ли стрела
const dx = targetX - arrowX;
const dy = targetY - arrowY;
const dist = Math.hypot(dx, dy);
-
- // Рисуем стрелу
ctx.save();
ctx.translate(arrowX, arrowY);
ctx.rotate(angle);
- ctx.fillStyle = '#ECEFF1';
+ ctx.fillStyle = "#ECEFF1";
ctx.fillRect(0, -1, 16, 2);
ctx.restore();
-
- // Урон игроку если попали
- if(dist < 150 && player.invuln <= 0){
+ if (dist < 150 && player.invuln <= 0) {
player.hp -= 8;
player.invuln = 0.5;
player.vx += Math.cos(angle) * 300;
player.vy -= 200;
- playSound('hit1');
+ playSound("hit1");
}
}
}
-
- // build ghost
- if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){
+ if (mode() === "build" && mouse.x !== null && !state.craftOpen && player.hp > 0) {
const wx = mouse.x + camX;
const wy = mouse.y + camY;
- const gx = Math.floor(wx/TILE);
- const gy = Math.floor(wy/TILE);
- ctx.strokeStyle = 'rgba(255,255,255,0.9)';
+ const ggx = Math.floor(wx / TILE2);
+ const ggy = Math.floor(wy / TILE2);
+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 2;
- ctx.strokeRect(gx*TILE, gy*TILE, TILE, TILE);
+ ctx.strokeRect(ggx * TILE2, ggy * TILE2, TILE2, TILE2);
}
-
ctx.restore();
-
- // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
- if(night){
- // 1) Рисуем тёмный оверлей на offscreen canvas
- lightC.width = W*dpr;
- lightC.height = H*dpr;
- lightCtx.setTransform(dpr,0,0,dpr,0,0);
- lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
- lightCtx.fillRect(0,0,W,H);
-
- // 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены
- lightCtx.globalCompositeOperation = 'destination-out';
-
- // Функция: рисуем мягкий луч света с затуханием за стенами
- function castLight(sx, sy, radius) {
- const flick = 0.97 + Math.sin(now/300 + sx*0.01)*0.015 + Math.sin(now/470 + sy*0.02)*0.015;
+ if (night) {
+ let castLight = function(sx, sy, radius) {
+ const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06;
const r = radius * flick;
- // 24 луча — мягкий круглый свет
- const steps = 24;
- // Собираем дистанции до стен по лучам
- const dists = new Float32Array(steps);
- for(let i=0; i TILE*0.3){
+ for (let step = TILE2 * 0.5; step < r; step += TILE2 * 0.6) {
+ const lgx = Math.floor((sx + ddx * step) / TILE2);
+ const lgy = Math.floor((sy + ddy * step) / TILE2);
+ const blk = getBlock(lgx, lgy);
+ if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE2 * 0.3) {
maxDist = step;
break;
}
}
dists[i] = maxDist;
}
- // Рисуем сглаженный полигон по dists
- const cx = sx-camX, cy = sy-camY;
- // Центр: яркая точка
+ const cx = sx - camX, cy = sy - camY;
const maxR = Math.max(...dists);
const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
- grad.addColorStop(0, 'rgba(255,255,255,1)');
- grad.addColorStop(0.4, 'rgba(255,255,255,0.8)');
- grad.addColorStop(1, 'rgba(255,255,255,0)');
+ grad.addColorStop(0, "rgba(255,255,255,1)");
+ grad.addColorStop(0.5, "rgba(255,255,255,0.65)");
+ grad.addColorStop(1, "rgba(255,255,255,0)");
lightCtx.fillStyle = grad;
- // Рисуем shape по dists (звездоподобный полигон)
lightCtx.beginPath();
- for(let i=0; i<=steps; i++){
- const idx = i % steps;
- const nextIdx = (i+1) % steps;
- const avgD = (dists[idx] + dists[nextIdx]) / 2;
- const angle = (idx/steps) * Math.PI * 2;
- const px = cx + Math.cos(angle) * dists[idx];
- const py = cy + Math.sin(angle) * dists[idx];
- if(i===0) lightCtx.moveTo(px, py);
+ for (let i = 0; i <= steps2; i++) {
+ const idx = i % steps2;
+ const ang = idx / steps2 * Math.PI * 2;
+ const px = cx + Math.cos(ang) * dists[idx];
+ const py = cy + Math.sin(ang) * dists[idx];
+ if (i === 0) lightCtx.moveTo(px, py);
else lightCtx.lineTo(px, py);
}
lightCtx.closePath();
lightCtx.fill();
- }
-
- // Источники света
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX-5 || b.gx > maxGX+5 || b.gy < minGY-5 || b.gy > maxGY+5) continue;
+ };
+ const lightC = state.lightC;
+ const lightCtx = state.lightCtx;
+ lightC.width = W * state.dpr;
+ lightC.height = H * state.dpr;
+ lightCtx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
+ lightCtx.fillStyle = "rgba(0,0,12,0.82)";
+ lightCtx.fillRect(0, 0, W, H);
+ lightCtx.globalCompositeOperation = "destination-out";
+ for (const b of blocks2) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue;
const def = BLOCKS[b.t];
- if(def.lightRadius){
- castLight(b.gx*TILE + TILE/2, b.gy*TILE + TILE/2, def.lightRadius);
+ if (def.lightRadius) {
+ castLight(b.gx * TILE2 + TILE2 / 2, b.gy * TILE2 + TILE2 / 2, def.lightRadius);
}
}
-
- // 3) Накладываем lightmap на основной canvas
- lightCtx.globalCompositeOperation = 'source-over';
+ lightCtx.globalCompositeOperation = "source-over";
ctx.drawImage(lightC, 0, 0, W, H);
-
- // 4) Тёплый оверлей от источников света (additive, мягкий)
ctx.save();
- ctx.globalCompositeOperation = 'lighter';
- for(const b of blocks){
- if(b.dead) continue;
- if(b.gx < minGX-3 || b.gx > maxGX+3 || b.gy < minGY-3 || b.gy > maxGY+3) continue;
+ ctx.globalCompositeOperation = "lighter";
+ for (const b of blocks2) {
+ if (b.dead) continue;
+ if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue;
const def = BLOCKS[b.t];
- if(def.lightRadius){
- const flick = 0.9 + Math.sin(now/350 + b.gx*3.7)*0.05 + Math.sin(now/530 + b.gy*2.3)*0.05;
- const wx = b.gx*TILE + TILE/2 - camX;
- const wy = b.gy*TILE + TILE/2 - camY;
- const r = def.lightRadius * 0.85;
- const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r);
- grad.addColorStop(0, `rgba(255,180,80,0.28)`);
- grad.addColorStop(0.5, `rgba(255,140,40,0.13)`);
- grad.addColorStop(1, 'rgba(255,100,20,0)');
+ if (def.lightRadius) {
+ const flick = 0.7 + Math.sin(now / 90 + b.gx * 3.7) * 0.15 + Math.sin(now / 140 + b.gy * 2.3) * 0.15;
+ const wx = b.gx * TILE2 + TILE2 / 2 - camX;
+ const wy = b.gy * TILE2 + TILE2 / 2 - camY;
+ const r = def.lightRadius * 0.6 * flick;
+ const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
+ grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`);
+ grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * flick})`);
+ grad.addColorStop(1, "rgba(255,100,20,0)");
ctx.fillStyle = grad;
ctx.beginPath();
- ctx.arc(wx, wy, r, 0, Math.PI*2);
+ ctx.arc(wx, wy, r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
-
- // Дождь (после ночного оверлея)
drawRain();
- if(Math.random()<0.25){
- hpEl.textContent = Math.max(0, Math.ceil(player.hp));
- foodEl.textContent = Math.ceil(player.hunger);
- document.getElementById('o2').textContent = Math.ceil(player.o2);
- sxEl.textContent = Math.floor(player.x/TILE);
- syEl.textContent = Math.floor(player.y/TILE);
- todEl.textContent = night ? 'Ночь' : 'День';
- document.getElementById('xplevel').textContent = player.level;
- const lvXpNext = xpForLevel(player.level + 1);
- const lvXpCur = xpForLevel(player.level);
- const xpInLevel = player.xp - lvXpCur;
- const xpNeeded = lvXpNext - lvXpCur;
- document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
- tempEl.textContent = Math.round(player.temperature) + "°C";
- worldIdEl.textContent = worldId;
- if(isMultiplayer){
- document.getElementById('multiplayerStatus').style.display = 'flex';
- playerCountEl.textContent = otherPlayers.size + 1; // +1 = мы сами
+ if (Math.random() < 0.25) {
+ state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
+ state.foodEl.textContent = Math.ceil(player.hunger);
+ document.getElementById("o2").textContent = Math.ceil(player.o2);
+ state.sxEl.textContent = Math.floor(player.x / TILE2);
+ state.syEl.textContent = Math.floor(player.y / TILE2);
+ state.todEl.textContent = night ? "\u041D\u043E\u0447\u044C" : "\u0414\u0435\u043D\u044C";
+ state.worldIdEl.textContent = state.worldId;
+ if (state.isMultiplayer) {
+ document.getElementById("multiplayerStatus").style.display = "flex";
+ state.playerCountEl.textContent = state.otherPlayers.size + 1;
} else {
- document.getElementById('multiplayerStatus').style.display = 'none';
+ document.getElementById("multiplayerStatus").style.display = "none";
}
}
-
- // Индикатор сна
- if(player.sleeping){
- ctx.fillStyle = 'rgba(0,0,0,0.7)';
+ if (player.sleeping) {
+ ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(0, 0, W, H);
- ctx.fillStyle = '#fff';
- ctx.font = 'bold 32px system-ui';
- ctx.textAlign = 'center';
- ctx.fillText('💤 Спим...', W/2, H/2);
- ctx.font = '18px system-ui';
- ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
+ ctx.fillStyle = "#fff";
+ ctx.font = "bold 32px system-ui";
+ ctx.textAlign = "center";
+ ctx.fillText("\u{1F4A4} \u0421\u043F\u0438\u043C...", W / 2, H / 2);
+ ctx.font = "18px system-ui";
+ ctx.fillText("\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u043D\u0430 \u043A\u0440\u043E\u0432\u0430\u0442\u044C \u0447\u0442\u043E\u0431\u044B \u043F\u0440\u043E\u0441\u043D\u0443\u0442\u044C\u0441\u044F", W / 2, H / 2 + 40);
}
-
- // Рисуем дропы
- // Температурный оверлей (заморозка/тепловой удар)
- if (typeof player.temperature !== 'undefined' && (player.temperature < -5 || player.temperature > 35)) {
- const severity = player.temperature < -5
- ? Math.abs(player.temperature + 5) / 20
- : (player.temperature - 35) / 20;
- const alpha = Math.min(0.35, severity * 0.15);
- if (player.temperature < -5) {
- ctx.fillStyle = 'rgba(100,150,255,' + alpha + ')';
- } else {
- ctx.fillStyle = 'rgba(255,100,50,' + alpha + ')';
- }
- ctx.fillRect(0, 0, W, H);
- }
- drawDrops(ctx);
- // Пикап дропов
- pickupDrops();
- // Popup уровня
- drawLevelUpPopup(ctx);
-
- // Миникарта (обновляем раз в ~4 кадра для оптимизации)
- if(minimapOpen && Math.random() < 0.25){
+ if (state.minimapOpen && Math.random() < 0.25) {
renderMinimap();
}
-
requestAnimationFrame(loop);
}
-
+
+ // src/ui/market.js
+ var marketPanel = document.getElementById("marketPanel");
+ var marketClose = document.getElementById("marketClose");
+ var marketContent = document.getElementById("marketContent");
+ function initMarket() {
+ marketClose.addEventListener("click", () => closeMarket());
+ document.addEventListener("toggleMarket", () => toggleMarket());
+ }
+ function toggleMarket() {
+ if (marketPanel.style.display === "none" || !marketPanel.style.display) {
+ openMarket();
+ } else {
+ closeMarket();
+ }
+ }
+ function closeMarket() {
+ marketPanel.style.display = "none";
+ }
+ function openMarket() {
+ marketPanel.style.display = "block";
+ renderMarket();
+ }
+ function renderMarket() {
+ if (!state.socket) {
+ marketContent.innerHTML = '\u041F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0435 \u043A \u0441\u0435\u0440\u0432\u0435\u0440\u0443...
';
+ return;
+ }
+ state.socket.emit("market_list", {}, (orders) => {
+ if (!orders || !orders.length) {
+ marketContent.innerHTML = `
+ \u041F\u0440\u0435\u0434\u043B\u043E\u0436\u0435\u043D\u0438\u0439 \u043F\u043E\u043A\u0430 \u043D\u0435\u0442
+
+
+
+ `;
+ const btn2 = document.getElementById("createOrderBtn");
+ if (btn2) btn2.addEventListener("click", showCreateOrder);
+ return;
+ }
+ let html = '';
+ html += orders.map((o) => `
+
+
+ ${o.offer_item} \xD7${o.offer_qty}
+ \u21C4
+ ${o.want_item} \xD7${o.want_qty}
+
+
${o.seller || "\u0410\u043D\u043E\u043D\u0438\u043C"}
+
+ `).join("");
+ marketContent.innerHTML = html;
+ const btn = document.getElementById("createOrderBtn");
+ if (btn) btn.addEventListener("click", showCreateOrder);
+ });
+ }
+ function showCreateOrder() {
+ const inv = state.player.inventory || [];
+ const items = inv.filter((slot) => slot && slot.id);
+ marketContent.innerHTML = `
+
+
\u0412\u044B\u0441\u0442\u0430\u0432\u0438\u0442\u044C \u043F\u0440\u0435\u0434\u043C\u0435\u0442
+
+
+
+
+
+
+
+
+
+ `;
+ document.getElementById("cancelOrder").addEventListener("click", () => renderMarket());
+ document.getElementById("submitOrder").addEventListener("click", () => {
+ const offerItem = document.getElementById("offerItem").value;
+ const wantItem = document.getElementById("wantItem").value.trim();
+ if (!offerItem || !wantItem) {
+ alert("\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u043F\u0440\u0435\u0434\u043C\u0435\u0442 \u0438 \u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0447\u0442\u043E \u0445\u043E\u0442\u0438\u0442\u0435 \u043F\u043E\u043B\u0443\u0447\u0438\u0442\u044C");
+ return;
+ }
+ if (state.socket) {
+ state.socket.emit("market_create", {
+ offer_item: offerItem,
+ offer_qty: 1,
+ want_item: wantItem,
+ want_qty: 1
+ });
+ playSound("click");
+ setTimeout(renderMarket, 500);
+ }
+ });
+ }
+
+ // src/ui/respawn.js
+ function initRespawn() {
+ document.getElementById("respawnBtn").onclick = async () => {
+ playSound("click");
+ console.log("=== RESPAWN CLICKED ===");
+ console.log("isMultiplayer:", state.isMultiplayer);
+ console.log("otherPlayers.size:", state.otherPlayers.size);
+ console.log("player.hp before respawn:", state.player.hp);
+ const player = state.player;
+ const deathEl = state.deathEl;
+ if (state.isMultiplayer && state.otherPlayers.size > 0) {
+ console.log("\u041C\u0443\u043B\u044C\u0442\u0438\u043F\u043B\u0435\u0435\u0440 \u0440\u0435\u0436\u0438\u043C - \u0432\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435");
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log("\u0412\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435, HP:", player.hp);
+ } else {
+ console.log("\u041E\u0434\u0438\u043D\u043E\u0447\u043D\u044B\u0439 \u0440\u0435\u0436\u0438\u043C - \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u043C \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435");
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043F\u043E\u0441\u043B\u0435 \u0441\u043C\u0435\u0440\u0442\u0438, final HP:", player.hp);
+ } else {
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log("\u0412\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435, HP:", player.hp);
+ }
+ }
+ console.log("player.hp after respawn logic:", player.hp);
+ console.log("Hiding death screen...");
+ deathEl.style.display = "none";
+ console.log("=== RESPAWN END ===");
+ };
+ }
+
+ // src/ui/share.js
+ function initShare() {
+ document.getElementById("worldId").onclick = () => {
+ const shareUrl = new URL(window.location.href);
+ shareUrl.searchParams.set("world", state.worldId);
+ const shareUrlString = shareUrl.toString();
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(shareUrlString).then(() => {
+ alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u0441\u043A\u043E\u043F\u0438\u0440\u043E\u0432\u0430\u043D\u0430!");
+ }).catch(() => {
+ alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043C\u0438\u0440:\n" + shareUrlString);
+ });
+ } else {
+ alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043C\u0438\u0440:\n" + shareUrlString);
+ }
+ };
+ }
+
+ // src/input/controls.js
+ var inp = state.inp;
+ function bindHold(el, key) {
+ const down = (e) => {
+ e.preventDefault();
+ state.inp[key] = true;
+ };
+ const up = (e) => {
+ e.preventDefault();
+ state.inp[key] = false;
+ };
+ el.addEventListener("pointerdown", down);
+ el.addEventListener("pointerup", up);
+ el.addEventListener("pointerleave", up);
+ }
+ function initControls() {
+ const leftBtn = document.getElementById("left");
+ const rightBtn = document.getElementById("right");
+ const jumpBtn = document.getElementById("jump");
+ const downBtn = document.getElementById("down");
+ if (leftBtn) bindHold(leftBtn, "l");
+ if (rightBtn) bindHold(rightBtn, "r");
+ if (jumpBtn) bindHold(jumpBtn, "j");
+ if (downBtn) bindHold(downBtn, "s");
+ window.addEventListener("keydown", (e) => {
+ if (e.code === "KeyA" || e.code === "ArrowLeft") state.inp.l = true;
+ if (e.code === "KeyD" || e.code === "ArrowRight") state.inp.r = true;
+ if (e.code === "Space" || e.code === "KeyW" || e.code === "ArrowUp") state.inp.j = true;
+ if (e.code === "KeyS" || e.code === "ArrowDown") state.inp.s = true;
+ });
+ window.addEventListener("keyup", (e) => {
+ if (e.code === "KeyA" || e.code === "ArrowLeft") state.inp.l = false;
+ if (e.code === "KeyD" || e.code === "ArrowRight") state.inp.r = false;
+ if (e.code === "Space" || e.code === "KeyW" || e.code === "ArrowUp") state.inp.j = false;
+ if (e.code === "KeyS" || e.code === "ArrowDown") state.inp.s = false;
+ });
+ }
+
+ // src/multiplayer/voice-chat.js
+ var voiceSocket = null;
+ var voiceStream = null;
+ var audioCtx = null;
+ var voiceProcessor = null;
+ var voiceActive = false;
+ var VOICE_SERVER2 = "https://voicegrech.mkn8n.ru";
+ var voiceBtn = document.createElement("div");
+ voiceBtn.innerHTML = '\u{1F3A4}/';
+ voiceBtn.title = "\u0413\u043E\u043B\u043E\u0441\u043E\u0432\u043E\u0439 \u0447\u0430\u0442 (\u0432\u044B\u043A\u043B)";
+ voiceBtn.style.cssText = "position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;";
+ document.querySelector(".ui").appendChild(voiceBtn);
+ var speakingIndicator = document.createElement("div");
+ speakingIndicator.style.cssText = "position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;";
+ speakingIndicator.textContent = "\u{1F50A}";
+ document.querySelector(".ui").appendChild(speakingIndicator);
+ var speakingTimeout = null;
+ voiceBtn.onclick = async () => {
+ if (voiceActive) {
+ voiceActive = false;
+ voiceBtn.innerHTML = '\u{1F3A4}/';
+ voiceBtn.style.background = "#555";
+ if (voiceStream) {
+ voiceStream.getTracks().forEach((t) => t.stop());
+ voiceStream = null;
+ }
+ if (voiceProcessor) {
+ voiceProcessor.disconnect();
+ voiceProcessor = null;
+ }
+ if (audioCtx) {
+ audioCtx.close();
+ audioCtx = null;
+ }
+ if (voiceSocket) {
+ voiceSocket.disconnect();
+ voiceSocket = null;
+ }
+ return;
+ }
+ try {
+ voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24e3 } });
+ audioCtx = new AudioContext({ sampleRate: 24e3 });
+ const source = audioCtx.createMediaStreamSource(voiceStream);
+ voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
+ voiceProcessor.onaudioprocess = (e) => {
+ if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
+ const pcm = e.inputBuffer.getChannelData(0);
+ const int16 = new Int16Array(pcm.length);
+ for (let i = 0; i < pcm.length; i++) {
+ const s = Math.max(-1, Math.min(1, pcm[i]));
+ int16[i] = s < 0 ? s * 32768 : s * 32767;
+ }
+ voiceSocket.emit("voice_data", int16.buffer);
+ };
+ source.connect(voiceProcessor);
+ voiceProcessor.connect(audioCtx.createGain());
+ voiceSocket = io(VOICE_SERVER2, { transports: ["websocket"] });
+ voiceSocket.on("connect", () => {
+ voiceSocket.emit("voice_join", { world_id: state.worldId, x: state.player.x, y: state.player.y, name: state.playerName || "\u0418\u0433\u0440\u043E\u043A" });
+ });
+ voiceSocket.on("voice_in", (payload) => {
+ const { data, meta, volume } = payload;
+ if (!audioCtx || audioCtx.state === "closed") return;
+ const int16 = new Int16Array(data);
+ const float32 = new Float32Array(int16.length);
+ for (let i = 0; i < int16.length; i++) {
+ float32[i] = int16[i] / (int16[i] < 0 ? 32768 : 32767) * volume;
+ }
+ const buf = audioCtx.createBuffer(1, float32.length, 24e3);
+ buf.getChannelData(0).set(float32);
+ const src = audioCtx.createBufferSource();
+ src.buffer = buf;
+ const gain = audioCtx.createGain();
+ gain.gain.value = volume;
+ src.connect(gain).connect(audioCtx.destination);
+ src.start();
+ speakingIndicator.style.display = "block";
+ speakingIndicator.textContent = `\u{1F50A} ${meta.name}`;
+ clearTimeout(speakingTimeout);
+ speakingTimeout = setTimeout(() => {
+ speakingIndicator.style.display = "none";
+ }, 500);
+ });
+ voiceActive = true;
+ voiceBtn.textContent = "\u{1F3A4}";
+ voiceBtn.style.background = "#2ecc71";
+ } catch (e) {
+ console.error("Voice error:", e);
+ voiceBtn.style.background = "#e74c3c";
+ }
+ };
+ function initVoice() {
+ let voicePosT = 0;
+ return {
+ update(dt) {
+ voicePosT += dt;
+ if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
+ voicePosT = 0;
+ voiceSocket.emit("voice_pos", { x: state.player.x, y: state.player.y });
+ }
+ }
+ };
+ }
+
+ // src/ui/inventory.js
+ function renderInventory() {
+ const inventoryGrid = state.inventoryGrid;
+ const inv = state.inv;
+ const selected = state.selected;
+ inventoryGrid.innerHTML = "";
+ const items = Object.keys(inv).filter((id) => inv[id] > 0);
+ for (let i = 0; i < 21; i++) {
+ const slot = document.createElement("div");
+ slot.className = "inv-slot" + (i < items.length && items[i] === selected ? " sel" : "");
+ if (i < items.length) {
+ const id = items[i];
+ if (BLOCKS[id] && tex[id]) {
+ slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ slot.style.backgroundSize = "cover";
+ } else if (itemTex[id]) {
+ slot.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
+ slot.style.backgroundSize = "cover";
+ } else if (ITEMS[id]) {
+ slot.textContent = ITEMS[id].icon || "\u{1F4E6}";
+ } else if (TOOLS[id]) {
+ slot.textContent = TOOLS[id].icon || "\u{1F527}";
+ } else if (id === "iron_armor") {
+ slot.textContent = "\u{1F6E1}\uFE0F";
+ slot.style.background = "linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)";
+ }
+ const count = document.createElement("div");
+ count.className = "inv-count";
+ count.textContent = inv[id];
+ slot.appendChild(count);
+ slot.onclick = () => {
+ playSound("click");
+ state.selected = id;
+ state.recentItems = state.recentItems.filter((item) => item !== id);
+ state.recentItems.unshift(id);
+ state.recentItems = state.recentItems.slice(0, 5);
+ rebuildHotbar();
+ renderInventory();
+ };
+ slot.ondblclick = () => {
+ if (id === "iron_armor" && inv.iron_armor > 0) {
+ if (state.player.equippedArmor === "iron_armor") {
+ state.player.equippedArmor = null;
+ state.player.armor = 0;
+ } else {
+ state.player.equippedArmor = "iron_armor";
+ state.player.armor = BLOCKS["iron_armor"].armor;
+ }
+ playSound("click");
+ renderInventory();
+ }
+ };
+ }
+ inventoryGrid.appendChild(slot);
+ }
+ }
+
+ // src/ui/craft.js
+ function canCraft(r) {
+ const inv = state.inv;
+ for (const res in r.cost) {
+ if ((inv[res] || 0) < r.cost[res]) return false;
+ }
+ return true;
+ }
+ function renderCraft() {
+ const inv = state.inv;
+ const recipesEl = state.recipesEl;
+ recipesEl.innerHTML = "";
+ for (const r of RECIPES) {
+ const row = document.createElement("div");
+ row.className = "recipe";
+ const icon = document.createElement("div");
+ icon.className = "ricon";
+ if (tex[r.out]) {
+ icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
+ } else if (itemTex[r.out]) {
+ icon.style.backgroundImage = `url(${itemTex[r.out].toDataURL()})`;
+ } else if (TOOLS[r.out]) {
+ icon.textContent = TOOLS[r.out].icon || "\u{1F527}";
+ icon.style.fontSize = "24px";
+ icon.style.display = "flex";
+ icon.style.alignItems = "center";
+ icon.style.justifyContent = "center";
+ } else if (ITEMS[r.out]) {
+ icon.textContent = ITEMS[r.out].icon || "\u{1F4E6}";
+ icon.style.fontSize = "24px";
+ icon.style.display = "flex";
+ icon.style.alignItems = "center";
+ icon.style.justifyContent = "center";
+ }
+ const info = document.createElement("div");
+ info.className = "rinfo";
+ const nm = document.createElement("div");
+ nm.className = "rname";
+ const itemName2 = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
+ nm.textContent = `${itemName2} x${r.qty}`;
+ const cs = document.createElement("div");
+ cs.className = "rcost";
+ cs.textContent = Object.keys(r.cost).map((x) => {
+ const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
+ return `${cn}: ${inv[x] || 0}/${r.cost[x]}`;
+ }).join(" ");
+ info.appendChild(nm);
+ info.appendChild(cs);
+ const btn = document.createElement("button");
+ btn.className = "rcraft";
+ btn.textContent = "\u0421\u043E\u0437\u0434\u0430\u0442\u044C";
+ btn.disabled = !canCraft(r);
+ btn.onclick = () => {
+ if (!canCraft(r)) return;
+ playSound("click");
+ for (const res in r.cost) inv[res] -= r.cost[res];
+ inv[r.out] = (inv[r.out] || 0) + r.qty;
+ if (TOOLS[r.out]) addTool(r.out);
+ rebuildHotbar();
+ renderCraft();
+ };
+ row.appendChild(icon);
+ row.appendChild(info);
+ row.appendChild(btn);
+ recipesEl.appendChild(row);
+ }
+ }
+ var craftOpen = false;
+ var inventoryOpen = false;
+ function toggleCraft() {
+ playSound("click");
+ craftOpen = !craftOpen;
+ state.craftPanel.style.display = craftOpen ? "block" : "none";
+ if (craftOpen) {
+ renderCraft();
+ inventoryOpen = false;
+ state.inventoryPanel.style.display = "none";
+ }
+ }
+ function closeCraft() {
+ playSound("click");
+ craftOpen = false;
+ state.craftPanel.style.display = "none";
+ }
+ function toggleInventory() {
+ playSound("click");
+ inventoryOpen = true;
+ state.inventoryPanel.style.display = "block";
+ renderInventory();
+ craftOpen = false;
+ state.craftPanel.style.display = "none";
+ }
+ function closeInventory() {
+ playSound("click");
+ inventoryOpen = false;
+ state.inventoryPanel.style.display = "none";
+ }
+
+ // src/main.js
+ var urlParams2 = new URLSearchParams(window.location.search);
+ state.SERVER_URL = SERVER_URL;
+ state.TELEGRAM_BOT_USERNAME = "Grechkacraft_bot";
+ state.TELEGRAM_APP_SHORT_NAME = "minegrechka";
+ state.playerName = localStorage.getItem("minegrechka_playerName") || null;
+ if (!state.playerName) {
+ state.playerName = prompt("\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0430\u0448\u0435 \u0438\u043C\u044F \u0434\u043B\u044F \u0438\u0433\u0440\u044B:") || "\u0418\u0433\u0440\u043E\u043A";
+ localStorage.setItem("minegrechka_playerName", state.playerName);
+ console.log("Player name set:", state.playerName);
+ }
+ console.log("Current URL:", window.location.href);
+ var worldParam = urlParams2.get("world");
+ console.log("world param:", worldParam);
+ state.worldId = worldParam && worldParam.trim() !== "" ? worldParam : null;
+ console.log("worldId after params:", state.worldId, "type:", typeof state.worldId);
+ if (!state.worldId) {
+ state.worldId = Math.random().toString(36).substring(2, 10);
+ console.log("Generated worldId:", state.worldId);
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set("world", state.worldId);
+ const newUrlString = newUrl.toString();
+ console.log("New URL to set:", newUrlString);
+ if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") {
+ window.history.replaceState(null, "", newUrlString);
+ console.log("URL after replaceState:", window.location.href);
+ } else {
+ console.error("History API not supported!");
+ }
+ } catch (e) {
+ console.error("Error updating URL:", e);
+ }
+ console.log("Generated new worldId for browser:", state.worldId);
+ }
+ console.log("Final worldId:", state.worldId, "Player name:", state.playerName);
+ initCanvas();
+ state.hpEl = document.getElementById("hp");
+ state.foodEl = document.getElementById("food");
+ state.sxEl = document.getElementById("sx");
+ state.syEl = document.getElementById("sy");
+ state.todEl = document.getElementById("tod");
+ state.worldIdEl = document.getElementById("worldId");
+ state.playerCountEl = document.getElementById("playerCount");
+ state.deathEl = document.getElementById("death");
+ state.hotbarEl = document.getElementById("hotbar");
+ state.craftPanel = document.getElementById("craftPanel");
+ state.recipesEl = document.getElementById("recipes");
+ state.inventoryPanel = document.getElementById("inventoryPanel");
+ state.inventoryGrid = document.getElementById("inventoryGrid");
+ state.marketPanel = document.getElementById("marketPanel");
+ state.marketContent = document.getElementById("marketContent");
+ state.tradePanel = document.getElementById("tradePanel");
+ state.tradeContent = document.getElementById("tradeContent");
+ state.multiplayerStatus = document.getElementById("multiplayerStatus");
+ initTextures();
+ state.tex = tex;
+ state.itemTex = itemTex;
+ var craftBtn = document.getElementById("craftBtn");
+ var invToggle = document.getElementById("invToggle");
+ var craftClose = document.getElementById("craftClose");
+ var inventoryClose = document.getElementById("inventoryClose");
+ if (craftBtn) craftBtn.onclick = () => toggleCraft();
+ if (craftClose) craftClose.onclick = () => closeCraft();
+ if (invToggle) invToggle.onclick = () => toggleInventory();
+ if (inventoryClose) inventoryClose.onclick = () => closeInventory();
+ initControls();
+ initMouseHandlers();
+ initChat();
+ initFurnace();
+ initMinimap();
+ initSaveControls();
+ initMarket();
+ initRespawn();
+ initShare();
+ initModes();
+ initSocket();
+ initVoice();
+ rebuildHotbar();
+ initDB().then(async () => {
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435, HP:", state.player.hp);
+ if (state.player.hp <= 0) {
+ console.log("WARNING: HP <= 0 \u043F\u043E\u0441\u043B\u0435 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438, \u0432\u043E\u0437\u0440\u043E\u0436\u0434\u0430\u0435\u043C\u0441\u044F");
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+ state.player.fallStartY = state.player.y;
+ }
+ } else {
+ console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E, \u043D\u0430\u0447\u0438\u043D\u0430\u0435\u043C \u043D\u043E\u0432\u0443\u044E \u0438\u0433\u0440\u0443");
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+ const startGX = 6;
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === "water") {
+ for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy);
+ if (!b || b.dead || b.t === "air" || b.t === "water") continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.player.y = safeGY * state.TILE;
+ state.player.x = startGX * state.TILE;
+ state.player.fallStartY = state.player.y;
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+ console.log("\u041D\u043E\u0432\u0430\u044F \u0438\u0433\u0440\u0430: startGX=", startGX, "surfaceY=", surfaceY, "player.y=", state.player.y);
+ for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
+ genColumn(gx);
+ }
+ }
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden) {
+ saveGame();
+ }
+ });
+ window.addEventListener("beforeunload", () => {
+ saveGame();
+ });
+ }).catch((err) => {
+ console.error("\u041E\u0448\u0438\u0431\u043A\u0430 \u0438\u043D\u0438\u0446\u0438\u0430\u043B\u0438\u0437\u0430\u0446\u0438\u0438:", err);
+ const startGX = 6;
+ genColumn(startGX);
+ state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
+ state.player.fallStartY = state.player.y;
+ for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
+ genColumn(gx);
+ }
+ });
requestAnimationFrame(loop);
})();
diff --git a/game.js.bak.v30 b/game.js.bak.v30
new file mode 100644
index 0000000..8635c7e
--- /dev/null
+++ b/game.js.bak.v30
@@ -0,0 +1,5865 @@
+(() => {
+ // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
+// === Custom modal functions ===
+function customAlert(msg) {
+ const overlay = document.createElement("div");
+ overlay.className = "custom-modal-overlay";
+ const box = document.createElement("div");
+ box.className = "custom-modal-box";
+ const text = document.createElement("div");
+ text.textContent = msg;
+ text.style.marginBottom = "16px";
+ const btn = document.createElement("button");
+ btn.className = "btn-ok";
+ btn.textContent = "OK";
+ btn.onclick = () => overlay.remove();
+ box.appendChild(text);
+ box.appendChild(btn);
+ overlay.appendChild(box);
+ document.querySelector("#game").appendChild(overlay);
+}
+function customConfirm(msg, onYes) {
+ const overlay = document.createElement("div");
+ overlay.className = "custom-modal-overlay";
+ const box = document.createElement("div");
+ box.className = "custom-modal-box";
+ const text = document.createElement("div");
+ text.textContent = msg;
+ text.style.marginBottom = "16px";
+ const btns = document.createElement("div");
+ btns.className = "modal-btns";
+ const yesBtn = document.createElement("button");
+ yesBtn.className = "btn-yes";
+ yesBtn.textContent = "Да";
+ yesBtn.onclick = () => { overlay.remove(); onYes(); };
+ const noBtn = document.createElement("button");
+ noBtn.className = "btn-no";
+ noBtn.textContent = "Отмена";
+ noBtn.onclick = () => overlay.remove();
+ btns.appendChild(yesBtn);
+ btns.appendChild(noBtn);
+ box.appendChild(text);
+ box.appendChild(btns);
+ overlay.appendChild(box);
+ document.querySelector("#game").appendChild(overlay);
+}
+ // Возможность переопределить сервер через query string
+ const urlParams = new URLSearchParams(window.location.search);
+ const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
+ const TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; // Имя бота для share-ссылки
+ const TELEGRAM_APP_SHORT_NAME = 'minegrechka'; // Короткое имя Mini App
+
+ // Защита от mixed content
+ if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
+ console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
+ alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
+ }
+
+ // ==================== TELEGRAM MINI APP ====================
+ const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
+ let tgUser = null;
+ let tgSaveLoaded = false;
+
+ if (isTgWebApp) {
+ try {
+ Telegram.WebApp.ready();
+ Telegram.WebApp.expand();
+ tgUser = Telegram.WebApp.initDataUnsafe?.user || null;
+ console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username);
+ // Set player name from TG
+ if (tgUser && tgUser.first_name) {
+ const tgName = tgUser.username || tgUser.first_name;
+ localStorage.setItem('minegrechka_playerName', tgName);
+ }
+ } catch(e) {
+ console.warn('[TG] WebApp init error:', e);
+ }
+ }
+
+ // ==================== WORLD ID И ИГРОКА ====================
+ let worldId = null;
+ let playerName = localStorage.getItem('minegrechka_playerName') || null;
+
+ // Priority: URL/startParam > TG saved world > new world
+ const worldParam = urlParams.get('world');
+
+ // TG Mini App startParam (deep link: t.me/bot?start=CODE)
+ const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param) ? Telegram.WebApp.initDataUnsafe.start_param.trim() : null;
+ if (tgStartParam && !worldParam) {
+ console.log('[TG] startParam detected, joining world:', tgStartParam);
+ }
+
+ // ==================== START MENU ====================
+ let startMenuResolved = false;
+
+ function generateShortWorldCode() {
+ // Generate readable 6-char code: consonants + digits (easy to copy/share)
+ const c = 'bcdfghjkmnpqrstvwxyz';
+ const d = '23456789';
+ let code = '';
+ for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)];
+ for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)];
+ return code;
+ }
+
+ function showStartMenu() {
+ return new Promise((resolve) => {
+ // If world is in URL — skip menu, auto-join
+ if ((worldParam && worldParam.trim() !== '') || tgStartParam) {
+ worldId = (worldParam && worldParam.trim()) || tgStartParam;
+ console.log('[Menu] Joining world from URL/startParam:', worldId);
+ resolve(worldId);
+ return;
+ }
+
+ // ===== Minecraft-style CSS =====
+ const mcCSS = `
+ .mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;
+ display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;
+ image-rendering:pixelated; }
+ .mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; }
+ .mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%;
+ background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; }
+ .mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000;
+ padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000; }
+ .mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center;
+ border-bottom:4px solid #000; }
+ .mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000;
+ letter-spacing:2px; }
+ .mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; }
+ .mc-body { padding:16px;background:#C6C6C6; }
+ .mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px;
+ font-family:'Courier New',monospace;font-size:16px;font-weight:bold;
+ cursor:pointer;text-align:center;border:3px solid #000;
+ image-rendering:pixelated;box-shadow:3px 3px 0 #000;
+ transition:transform 0.05s,box-shadow 0.05s; }
+ .mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; }
+ .mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; }
+ .mc-btn-green:hover { background:#6DAF4E; }
+ .mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; }
+ .mc-btn-blue:hover { background:#4B73BE; }
+ .mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; }
+ .mc-btn-purple:hover { background:#9555BE; }
+ .mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; }
+ .mc-btn-orange:hover { background:#DA8500; }
+ .mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; }
+ .mc-btn-red:hover { background:#BE3B3B; }
+ .mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; }
+ .mc-btn-gray:hover { background:#666;color:#DDD; }
+ .mc-btn-outline { background:transparent;color:#555;border-color:#555;
+ text-shadow:none;box-shadow:2px 2px 0 #333; }
+ .mc-btn-outline:hover { background:#DDD;color:#222; }
+ .mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace;
+ font-size:16px;background:#FFF;border:3px solid #000;color:#222;
+ box-shadow:inset 2px 2px 0 #999;margin:4px 0; }
+ .mc-input:focus { outline:none;border-color:#FFAA00; }
+ .mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; }
+ .mc-divider { border:none;border-top:2px solid #999;margin:12px 0; }
+ .mc-friend-row { display:flex;justify-content:space-between;align-items:center;
+ padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999;
+ box-shadow:2px 2px 0 #999; }
+ .mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace;
+ font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000;
+ box-shadow:1px 1px 0 #000; }
+ .mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; }
+ .mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; }
+ .mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; }
+ .mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; }
+ .mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; }
+ `;
+
+ const styleEl = document.createElement('style');
+ styleEl.textContent = mcCSS;
+ document.head.appendChild(styleEl);
+
+ const overlay = document.createElement('div');
+ overlay.className = 'mc-overlay';
+ overlay.id = 'startMenu';
+ overlay.innerHTML = '';
+
+ const box = document.createElement('div');
+ box.className = 'mc-box';
+
+ // Title bar
+ const titleBar = document.createElement('div');
+ titleBar.className = 'mc-titlebar';
+ titleBar.innerHTML = '⛏️ GrechkaCraft
' +
+ '' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
';
+ box.appendChild(titleBar);
+
+ const body = document.createElement('div');
+ body.className = 'mc-body';
+
+ // === Buttons ===
+ // New World
+ const btnNew = document.createElement('button');
+ btnNew.className = 'mc-btn mc-btn-green';
+ btnNew.textContent = '🌍 Новый мир';
+ btnNew.onclick = () => {
+ worldId = generateShortWorldCode();
+ overlay.remove();
+ styleEl.remove();
+ resolve(worldId);
+ };
+ body.appendChild(btnNew);
+
+ // Join by code
+ const btnJoin = document.createElement('button');
+ btnJoin.className = 'mc-btn mc-btn-blue';
+ btnJoin.textContent = '🔗 Вступить по коду';
+ btnJoin.onclick = () => {
+ body.innerHTML = '';
+ const lbl = document.createElement('div');
+ lbl.className = 'mc-label';
+ lbl.textContent = 'Код мира:';
+ body.appendChild(lbl);
+ const codeInput = document.createElement('input');
+ codeInput.className = 'mc-input';
+ codeInput.type = 'text';
+ codeInput.placeholder = 'bkh237';
+ codeInput.maxLength = 12;
+ body.appendChild(codeInput);
+ const btnGo = document.createElement('button');
+ btnGo.className = 'mc-btn mc-btn-blue';
+ btnGo.textContent = 'Войти →';
+ btnGo.onclick = () => {
+ const code = codeInput.value.trim().toLowerCase();
+ if (code.length >= 3) {
+ worldId = code;
+ overlay.remove();
+ styleEl.remove();
+ resolve(worldId);
+ }
+ };
+ body.appendChild(btnGo);
+ const btnBack = document.createElement('button');
+ btnBack.className = 'mc-btn mc-btn-gray';
+ btnBack.textContent = '← Назад';
+ btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
+ body.appendChild(btnBack);
+ codeInput.focus();
+ codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
+ };
+ body.appendChild(btnJoin);
+
+ // Continue saved game
+ const hasLocalSave = !!localStorage.getItem('minegrechka_save');
+ if (isTgWebApp || hasLocalSave) {
+ const btnHistory = document.createElement('button');
+ btnHistory.className = 'mc-btn mc-btn-purple';
+ btnHistory.textContent = '💾 Продолжить игру';
+ btnHistory.onclick = async () => {
+ if (isTgWebApp && tgUser) {
+ try {
+ const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
+ const data = await resp.json();
+ if (data.ok && data.world_id) {
+ worldId = data.world_id;
+ tgSaveLoaded = true;
+ overlay.remove();
+ styleEl.remove();
+ resolve(worldId);
+ return;
+ }
+ } catch(e) { console.warn('[TG] Load save failed:', e); }
+ }
+ if (hasLocalSave) {
+ worldId = generateShortWorldCode();
+ overlay.remove();
+ styleEl.remove();
+ resolve(worldId);
+ return;
+ }
+ customAlert('Сохранений не найдено');
+ };
+ body.appendChild(btnHistory);
+ }
+
+ // === TG-only buttons ===
+ if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
+ // Invite friends — with margin
+ const btnInvite = document.createElement('button');
+ btnInvite.className = 'mc-btn mc-btn-outline';
+ btnInvite.style.marginTop = '16px';
+ btnInvite.textContent = '📤 Пригласить друзей';
+ btnInvite.onclick = () => {
+ const code = worldId || generateShortWorldCode();
+ const url = 'https://t.me/Grechkacraft_bot/app?startapp=' + encodeURIComponent(code);
+ if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) {
+ Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code));
+ } else {
+ navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url));
+ }
+ };
+ body.appendChild(btnInvite);
+
+ // Friends — with margin
+ const btnFriends = document.createElement('button');
+ btnFriends.className = 'mc-btn mc-btn-orange';
+ btnFriends.style.marginTop = '8px';
+ btnFriends.textContent = '👥 Друзья';
+ btnFriends.onclick = async () => {
+ try {
+ const resp = await fetch(SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id);
+ const data = await resp.json();
+ body.innerHTML = '';
+
+ // Back button at top
+ const btnBackTop = document.createElement('button');
+ btnBackTop.className = 'mc-btn mc-btn-gray';
+ btnBackTop.textContent = '← Назад';
+ btnBackTop.style.marginBottom = '12px';
+ btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
+ body.appendChild(btnBackTop);
+
+ // Nickname section
+ if (data.nickname) {
+ const nickDiv = document.createElement('div');
+ nickDiv.className = 'mc-friend-row';
+ nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + '';
+ nickDiv.style.background = '#D5E8C5';
+ body.appendChild(nickDiv);
+ } else {
+ const nickLabel = document.createElement('div');
+ nickLabel.className = 'mc-label';
+ nickLabel.style.color = '#9E2B2B';
+ nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:';
+ body.appendChild(nickLabel);
+ const nickRow = document.createElement('div');
+ nickRow.style.display = 'flex';
+ nickRow.style.gap = '4px';
+ nickRow.style.marginBottom = '8px';
+ const nickInput = document.createElement('input');
+ nickInput.className = 'mc-input';
+ nickInput.style.flex = '1';
+ nickInput.type = 'text';
+ nickInput.maxLength = 16;
+ nickInput.placeholder = 'Твой ник';
+ const nickBtn = document.createElement('button');
+ nickBtn.className = 'mc-btn mc-btn-green';
+ nickBtn.style.width = 'auto';
+ nickBtn.style.marginBottom = '0';
+ nickBtn.style.padding = '8px 16px';
+ nickBtn.textContent = '✓';
+ nickBtn.onclick = async () => {
+ const n = nickInput.value.trim().toLowerCase();
+ if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; }
+ const check = await fetch(SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n));
+ const cd = await check.json();
+ if (!cd.available) { showToast('❌ Ник занят'); return; }
+ const set = await fetch(SERVER_URL + '/api/nick/set', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,nickname:n})});
+ const sd = await set.json();
+ if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
+ };
+ nickRow.appendChild(nickInput);
+ nickRow.appendChild(nickBtn);
+ body.appendChild(nickRow);
+ }
+
+ // Friends list
+ if (data.friends && data.friends.length) {
+ const listLabel = document.createElement('div');
+ listLabel.className = 'mc-label';
+ listLabel.textContent = '👥 Друзья:';
+ body.appendChild(listLabel);
+ for (const f of data.friends) {
+ const fDiv = document.createElement('div');
+ fDiv.className = 'mc-friend-row';
+ const statusDot = f.online ? '🟢' : '⚫';
+ let info = statusDot + ' ' + f.nickname + '';
+ if (f.online && f.world_id) info += ' — мир ' + f.world_id;
+ const fInfo = document.createElement('div');
+ fInfo.innerHTML = info;
+ fDiv.appendChild(fInfo);
+ if (f.online && f.world_id) {
+ const joinBtn = document.createElement('button');
+ joinBtn.className = 'mc-friend-btn mc-friend-btn-green';
+ joinBtn.textContent = '🎮 Вступить';
+ joinBtn.onclick = () => { worldId = f.world_id; overlay.remove(); styleEl.remove(); resolve(worldId); };
+ fDiv.appendChild(joinBtn);
+ }
+ body.appendChild(fDiv);
+ }
+ } else if (data.friends && !data.friends.length) {
+ const noFriends = document.createElement('div');
+ noFriends.className = 'mc-label';
+ noFriends.style.color = '#888';
+ noFriends.textContent = 'Пока нет друзей';
+ body.appendChild(noFriends);
+ }
+
+ // Pending requests
+ if (data.pending && data.pending.length) {
+ const pLabel = document.createElement('div');
+ pLabel.className = 'mc-label';
+ pLabel.style.color = '#B86E00';
+ pLabel.textContent = '📨 Запросы в друзья:';
+ body.appendChild(pLabel);
+ for (const p of data.pending) {
+ const pItem = document.createElement('div');
+ pItem.className = 'mc-friend-row';
+ const span = document.createElement('span');
+ span.textContent = p.from;
+ pItem.appendChild(span);
+ const btns = document.createElement('div');
+ btns.style.display = 'flex';
+ btns.style.gap = '4px';
+ const acceptBtn = document.createElement('button');
+ acceptBtn.className = 'mc-friend-btn mc-friend-btn-green';
+ acceptBtn.textContent = '✓';
+ acceptBtn.onclick = async () => {
+ const r = await fetch(SERVER_URL + '/api/friends/accept', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,request_id:p.id})});
+ const d = await r.json();
+ if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
+ };
+ const declineBtn = document.createElement('button');
+ declineBtn.className = 'mc-friend-btn mc-friend-btn-red';
+ declineBtn.textContent = '✕';
+ declineBtn.onclick = async () => {
+ await fetch(SERVER_URL + '/api/friends/decline', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,request_id:p.id})});
+ showToast('Отклонено');
+ btnFriends.onclick();
+ };
+ btns.appendChild(acceptBtn);
+ btns.appendChild(declineBtn);
+ pItem.appendChild(btns);
+ body.appendChild(pItem);
+ }
+ }
+
+ // Add friend
+ body.appendChild(document.createElement('hr'));
+ body.className = 'mc-label';
+ const addLabel = document.createElement('div');
+ addLabel.className = 'mc-label';
+ addLabel.textContent = '➕ Добавить друга по нику:';
+ body.appendChild(addLabel);
+ const addRow = document.createElement('div');
+ addRow.style.display = 'flex';
+ addRow.style.gap = '4px';
+ const addInput = document.createElement('input');
+ addInput.className = 'mc-input';
+ addInput.style.flex = '1';
+ addInput.type = 'text';
+ addInput.maxLength = 16;
+ addInput.placeholder = 'Ник друга';
+ const addBtn = document.createElement('button');
+ addBtn.className = 'mc-btn mc-btn-blue';
+ addBtn.style.width = 'auto';
+ addBtn.style.marginBottom = '0';
+ addBtn.style.padding = '8px 16px';
+ addBtn.textContent = '+';
+ addBtn.onclick = async () => {
+ const nick = addInput.value.trim().toLowerCase();
+ if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; }
+ const r = await fetch(SERVER_URL + '/api/friends/request', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,nickname:nick})});
+ const d = await r.json();
+ if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); }
+ else { showToast('❌ ' + (d.error || 'Ошибка')); }
+ };
+ addRow.appendChild(addInput);
+ addRow.appendChild(addBtn);
+ body.appendChild(addRow);
+
+ // Privacy checkboxes
+ const settings = data.settings || { allow_requests: 1, notify_online: 1 };
+ const settingsDiv = document.createElement('div');
+ settingsDiv.style.marginTop = '12px';
+ const reqLabel = document.createElement('label');
+ reqLabel.className = 'mc-checkbox-row';
+ const reqCb = document.createElement('input');
+ reqCb.className = 'mc-checkbox';
+ reqCb.type = 'checkbox';
+ reqCb.checked = settings.allow_requests;
+ reqCb.onchange = async () => {
+ await fetch(SERVER_URL + '/api/friends/settings', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,allow_requests:reqCb.checked?1:0,notify_online:settings.notify_online})});
+ };
+ reqLabel.appendChild(reqCb);
+ reqLabel.appendChild(document.createTextNode(' Запросы в друзья'));
+ settingsDiv.appendChild(reqLabel);
+ body.appendChild(settingsDiv);
+ } catch(e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); }
+ };
+ body.appendChild(btnFriends);
+ }
+
+ // Player name input
+ if (!playerName) {
+ const nameDivider = document.createElement('hr');
+ nameDivider.className = 'mc-divider';
+ body.appendChild(nameDivider);
+ const nameLabel = document.createElement('div');
+ nameLabel.className = 'mc-label';
+ nameLabel.textContent = 'Твоё имя:';
+ body.appendChild(nameLabel);
+ const nameInput = document.createElement('input');
+ nameInput.className = 'mc-input';
+ nameInput.type = 'text';
+ nameInput.placeholder = 'Ваше имя';
+ nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : '';
+ nameInput.maxLength = 20;
+ nameInput.addEventListener('input', () => {
+ if (nameInput.value.trim()) {
+ playerName = nameInput.value.trim();
+ localStorage.setItem('minegrechka_playerName', playerName);
+ }
+ });
+ body.appendChild(nameInput);
+ }
+
+ box.appendChild(body);
+ overlay.appendChild(box);
+ document.body.appendChild(overlay);
+
+ // Auto-load TG save
+ if (isTgWebApp && tgUser && !worldParam) {
+ fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
+ .then(r => r.json())
+ .then(data => {
+ if (data.ok && data.world_id) {
+ // could update UI but menu is already shown
+ }
+ })
+ .catch(() => {});
+ }
+ });
+ }
+
+ // ==================== GAME LAUNCH ====================
+ // Override the old auto-join logic
+ let gameLaunched = false;
+
+ async function launchGame() {
+ if (gameLaunched) return;
+ gameLaunched = true;
+
+ if (!playerName) {
+ playerName = tgUser ? (tgUser.username || tgUser.first_name || 'Игрок') : 'Игрок';
+ localStorage.setItem('minegrechka_playerName', playerName);
+ }
+
+ // Validate TG initData if in TG context
+ if (isTgWebApp && Telegram.WebApp.initData) {
+ try {
+ const resp = await fetch(SERVER_URL + '/api/tg/auth', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ initData: Telegram.WebApp.initData })
+ });
+ const data = await resp.json();
+ if (data.ok && data.tg_id) {
+ console.log('[TG] Authenticated, tg_id:', data.tg_id);
+ tgUser = tgUser || {};
+ tgUser.id = data.tg_id;
+ }
+ } catch(e) { console.warn('[TG] Auth failed:', e); }
+ }
+
+ // Update URL with world code
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('world', worldId);
+ if (typeof history !== 'undefined' && history.replaceState) {
+ history.replaceState(null, '', newUrl.toString());
+ }
+ } catch(e) {}
+
+ console.log('[Launch] worldId:', worldId, 'player:', playerName, 'tg:', !!tgUser);
+
+ // The rest of the game init will pick up worldId and playerName
+ // Signal that menu is done
+ startMenuResolved = true;
+ window.dispatchEvent(new Event('startMenuDone'));
+ }
+
+ // ==================== SOCKET.IO КЛИЕНТ ====================
+ let socket = null;
+ let isMultiplayer = false; // Флаг для мультиплеерного режима
+ const otherPlayers = new Map(); // socket_id -> {x, y, color}
+ const serverMobs = new Map(); // id -> mob (server-spawned, client-authoritative physics)
+
+ // Helper to get all mobs (local + server-spawned in MP)
+ function getAllMobs() {
+ return isMultiplayer ? mobs.concat(Array.from(serverMobs.values())) : mobs;
+ }
+
+ // Create a client-side mob object from server spawn data with correct properties matching client constructors
+ function createMobFromServer(data) {
+ const kindProps = {
+ zombie: { w: 34, h: 50, hp: 4, speed: 80 + Math.random() * 40, hostile: true, fuse: 0, shootCooldown: 2 },
+ creeper: { w: 34, h: 50, hp: 4, speed: 60 + Math.random() * 30, hostile: true, fuse: 3.2, shootCooldown: 2 },
+ skeleton: { w: 34, h: 50, hp: 4, speed: 70 + Math.random() * 30, hostile: true, fuse: 0, shootCooldown: 0 },
+ pig: { w: 34, h: 24, hp: 2, speed: 40, hostile: false, fuse: 0, shootCooldown: 2 },
+ chicken: { w: 26, h: 22, hp: 1, speed: 55, hostile: false, fuse: 0, shootCooldown: 2 },
+ scorpion: { w: 26, h: 26, hp: 3, speed: 90, hostile: true, fuse: 0, shootCooldown: 0, biome:'desert' },
+ polar_bear:{ w: 40, h: 34, hp: 8, speed: 50, hostile: false, fuse: 0, shootCooldown: 2, biome:'tundra' },
+ slime: { w: 24, h: 24, hp: 2, speed: 30, hostile: true, fuse: 0, shootCooldown: 2, biome:'swamp' },
+ eagle: { w: 30, h: 22, hp: 3, speed: 120, hostile: true, fuse: 0, shootCooldown: 0, biome:'mountains' }
+ };
+ const props = kindProps[data.kind] || kindProps['pig']; // fallback
+ return {
+ id: data.id,
+ kind: data.kind,
+ x: data.x,
+ y: data.y,
+ w: props.w,
+ h: props.h,
+ hp: data.hp || props.hp,
+ maxHp: data.maxHp || data.hp || props.hp,
+ speed: props.speed,
+ hostile: props.hostile,
+ vx: 0,
+ vy: 0,
+ grounded: false,
+ inWater: false,
+ aiT: 0,
+ dir: data.dir || 1,
+ dead: false,
+ fuse: props.fuse,
+ shootCooldown: props.shootCooldown
+ };
+ }
+ let mySocketId = null;
+
+ // Throttle для отправки позиции (10-20 раз в секунду)
+ let lastMoveSendTime = 0;
+ const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
+ let lastSentX = 0, lastSentY = 0;
+
+ function initSocket() {
+ try {
+ socket = io(SERVER_URL, {
+ path: '/socket.io/',
+ transports: ['websocket', 'polling']
+ });
+
+ socket.on('connect', () => {
+ console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
+ mySocketId = socket.id;
+ isMultiplayer = true;
+
+ // Присоединяемся к миру
+ socket.emit('join_world', { world_id: worldId, player_name: playerName, tg_id: (tgUser && tgUser.id) ? tgUser.id : null });
+
+ // TG: отправляем tg_auth + онлайн статус для уведомления друзей
+ if (tgUser && tgUser.id) {
+ socket.emit('tg_auth', { tg_id: tgUser.id });
+ try {
+ fetch(SERVER_URL + '/api/tg/online', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,world_id:worldId,player_name:playerName||'Игрок'})});
+ } catch(e) { console.warn('[TG] online notify error:', e); }
+ }
+
+ // Показываем в UI
+ worldIdEl.textContent = worldId;
+ // XP/Level display
+ const lvXpNext = xpForLevel(player.level + 1);
+ const lvXpCur = xpForLevel(player.level);
+ const xpInLevel = player.xp - lvXpCur;
+ const xpNeeded = lvXpNext - lvXpCur;
+ document.getElementById('xplevel').textContent = player.level;
+ document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
+ multiplayerStatus.style.display = 'block';
+ });
+
+ socket.on('connect_error', (error) => {
+ console.error('Socket connection error:', error);
+ isMultiplayer = false;
+ });
+
+ socket.on('disconnect', () => {
+ console.log('Disconnected from server');
+ isMultiplayer = false;
+ otherPlayers.clear();
+ multiplayerStatus.style.display = 'none';
+ // TG: убираем онлайн статус
+ if (tgUser && tgUser.id) {
+ try { fetch(SERVER_URL + '/api/tg/offline', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id})}); } catch(e) {}
+ }
+ });
+
+ // Обработка world_state
+ socket.on('world_state', (data) => {
+ console.log('Received world_state:', data);
+
+ // Устанавливаем seed и перегенерируем мир если он изменился
+ if (data.seed !== undefined && data.seed !== worldSeed) {
+ const oldSeed = worldSeed;
+ worldSeed = data.seed;
+ console.log('World seed set from', oldSeed, 'to', worldSeed);
+ // Full regeneration with correct seed
+ grid.clear();
+ blocks.length = 0;
+ placedBlocks = [];
+ removedBlocks = [];
+ generated.clear();
+ for (let gx = spawnPoint.gx - 25; gx <= spawnPoint.gx + 25; gx++) {
+ genColumn(gx);
+ decorations(gx);
+ }
+ console.log('World regenerated with seed:', worldSeed);
+ }
+
+ // Применяем блоки — сохраняем в serverOverrides для применения после genColumn
+ if (data.blocks && Array.isArray(data.blocks)) {
+ for (const block of data.blocks) {
+ const key = k(block.gx, block.gy);
+ serverOverrides.set(key, { op: block.op, t: block.t });
+ // Также пробуем применить сразу (если колонна уже сгенерирована)
+ if (block.op === 'set') {
+ setBlock(block.gx, block.gy, block.t, false);
+ } else if (block.op === 'remove') {
+ removeBlock(block.gx, block.gy);
+ }
+ }
+ }
+
+ // Устанавливаем время
+ if (data.time !== undefined) {
+ worldTime = data.time;
+ isNightTime = worldTime > 0.5;
+ }
+
+ // Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt
+ // Это гарантирует совпадение с terrain generation
+ {
+ const startGX = 6;
+ // Генерируем колонну и соседние для безопасного спавна
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ // Ищем ближайшую небудущую позицию сверху вниз от поверхности
+ let safeGY = surfaceY - 1;
+ // Проверяем что над поверхностью воздух (не в воде)
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === 'water') {
+ // Если в воде — ищем поверхность выше уровня моря
+ for (let gy = SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy);
+ if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ spawnPoint.x = startGX * TILE;
+ spawnPoint.y = safeGY * TILE;
+ console.log('Client-side spawn point:', spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
+ }
+
+ // Устанавливаем игрока в точку спавна
+ player.x = spawnPoint.x;
+ player.y = spawnPoint.y;
+ player.vx = 0;
+ player.vy = 0;
+ player.fallStartY = player.y;
+ console.log('Player moved to spawn point:', player.x, player.y);
+
+ // Устанавливаем HP на 100% при каждом подключении к миру
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.invuln = 0;
+ console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
+
+ // Обновляем список игроков
+ if (data.players && Array.isArray(data.players)) {
+ otherPlayers.clear();
+ for (const p of data.players) {
+ if (p.socket_id !== mySocketId) {
+ otherPlayers.set(p.socket_id, {
+ x: p.x,
+ y: p.y,
+ color: getRandomPlayerColor(p.socket_id),
+ name: p.player_name || 'Игрок'
+ });
+ }
+ }
+ // Обновляем счётчик игроков
+ playerCountEl.textContent = data.players.length;
+ }
+ // Server mobs — client-authoritative: create with full client-side properties
+ if (data.mobs && Array.isArray(data.mobs)) {
+ serverMobs.clear();
+ for (const m of data.mobs) {
+ const sm = createMobFromServer(m);
+ // Пересчитываем Y по клиентской surfaceGyAt
+ const spawnGX = Math.floor(sm.x / TILE);
+ genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1);
+ const clientSurfaceY = surfaceGyAt(spawnGX);
+ sm.y = (clientSurfaceY - 1) * TILE;
+ sm.vy = 0;
+ serverMobs.set(m.id, sm);
+ }
+ }
+ // Claims (приватные зоны)
+ if (data.claims && Array.isArray(data.claims)) {
+ claims = data.claims;
+ myClaimCount = claims.filter(function(c) { return c.owner_name === playerName; }).length;
+ console.log('[Claims] Loaded', claims.length, 'claims for world');
+ }
+ });
+
+ // Игрок присоединился
+ socket.on('player_joined', (data) => {
+ console.log('Player joined:', data.socket_id);
+ if (data.socket_id !== mySocketId) {
+ // Генерируем безопасную позицию для нового игрока
+ const spawnGX = 6;
+ genColumn(spawnGX);
+ const surfaceY = surfaceGyAt(spawnGX);
+ const safeSpawnX = spawnGX * TILE;
+ const safeSpawnY = (surfaceY - 1) * TILE;
+
+ otherPlayers.set(data.socket_id, {
+ x: safeSpawnX,
+ y: safeSpawnY,
+ tx: safeSpawnX,
+ ty: safeSpawnY,
+ color: getRandomPlayerColor(data.socket_id),
+ name: data.player_name || 'Игрок'
+ });
+ addChatMessage('Система', `Игрок присоединился`);
+ // Обновляем видимость кнопки сохранения
+ updateSaveButtonVisibility();
+ }
+ });
+
+ // Игрок переместился
+ socket.on('player_moved', (data) => {
+ if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) {
+ const p = otherPlayers.get(data.socket_id);
+ // Lerp: запоминаем целевую позицию, рисуем в рендере с интерполяцией
+ p.tx = data.x;
+ p.ty = data.y;
+ // Обновляем имя, если оно пришло
+ if (data.player_name) {
+ p.name = data.player_name;
+ }
+ }
+ });
+
+ // Игрок покинул
+ socket.on('player_left', (data) => {
+ console.log('Player left:', data.socket_id);
+ otherPlayers.delete(data.socket_id);
+ addChatMessage('Система', `Игрок покинул игру`);
+ // Обновляем видимость кнопки сохранения
+ updateSaveButtonVisibility();
+ });
+
+ // === MOB SYNC (multiplayer) ===
+
+ socket.on('mob_spawned', (data) => {
+ const sm = createMobFromServer(data);
+ // Пересчитываем Y по клиентской surfaceGyAt — сервер может ошибаться
+ const spawnGX = Math.floor(sm.x / TILE);
+ // Убеждаемся что чанк сгенерирован (иначе getBlock=null → моб падает сквозь землю)
+ genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1);
+ const clientSurfaceY = surfaceGyAt(spawnGX);
+ sm.y = (clientSurfaceY - 1) * TILE; // 1 блок выше поверхности (под ногами)
+ sm.vy = 0;
+ sm.grounded = false; // resolveY в mobAI поставит grounded=true
+ serverMobs.set(data.id, sm);
+ });
+
+ socket.on('mob_positions', (arr) => {
+ for (const u of arr) {
+ let sm = serverMobs.get(u.id);
+ if (!sm) {
+ sm = createMobFromServer({ id: u.id, kind: u.kind || 'zombie', x: u.x, y: u.y,
+ hp: u.hp || 1, maxHp: u.hp || 1, dir: u.dir || 1 });
+ serverMobs.set(u.id, sm);
+ }
+ sm.x += (u.x - sm.x) * 0.35;
+ sm.y += (u.y - sm.y) * 0.35;
+ sm.dir = u.dir != null ? u.dir : sm.dir;
+ sm.hp = u.hp != null ? u.hp : sm.hp;
+ sm.fuse = u.fuse != null ? u.fuse : sm.fuse;
+ sm.grounded = !!u.grounded;
+ }
+ });
+
+ socket.on('mob_despawned', (data) => { serverMobs.delete(data.id); });
+
+ socket.on('mob_died', (data) => {
+ const sm = serverMobs.get(data.id);
+ if (sm && data.killer === mySocketId) {
+ // Give loot to the killer
+ if (sm.kind === 'chicken') playSound('hurt_chicken');
+ spawnDrops(sm.x, sm.y, sm.kind);
+ rebuildHotbar();
+ }
+ serverMobs.delete(data.id);
+ });
+
+ socket.on('mob_hurt_ack', (data) => {
+ const sm = serverMobs.get(data.id);
+ if (sm) sm.hp = data.hp;
+ });
+
+ socket.on('mob_explode', (data) => {
+ explodeAt(data.gx, data.gy);
+ serverMobs.delete(data.id);
+ });
+
+ socket.on('mob_shoot', (data) => {
+ projectiles.push({
+ x: data.x, y: data.y, vx: data.vx, vy: data.vy,
+ dmg: data.dmg, owner: 'mob', life: data.life
+ });
+ });
+
+ // Блок изменён
+
+ // Claim events from server
+ socket.on('claim_created', (data) => {
+ playSound('click');
+ showToast('🏴 Территория захвачена! Радиус: ' + data.radius + ' блоков', 3000);
+ });
+
+ socket.on('claim_removed', (data) => {
+ playSound('click');
+ showToast('🏴 Флаг снят — территория свободна', 3000);
+ });
+
+ socket.on('claims_update', (data) => {
+ claims = data;
+ myClaimCount = claims.filter(c => c.owner_name === playerName).length;
+ });
+
+ socket.on('claim_denied', (data) => {
+ showToast('⛔ Территория ' + data.owner + ' — чужая зона!', 3000);
+ });
+
+ socket.on('claim_error', (data) => {
+ showToast('❌ ' + data.msg, 3000);
+ });
+
+ socket.on('block_changed', (data) => {
+ const key = k(data.gx, data.gy);
+ serverOverrides.set(key, { op: data.op, t: data.t });
+ if (data.op === 'set') {
+ setBlock(data.gx, data.gy, data.t, false);
+ } else if (data.op === 'remove') {
+ removeBlock(data.gx, data.gy);
+ }
+ });
+
+ // Сообщение в чат
+ socket.on('chat_message', (data) => {
+ const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
+ addChatMessage(senderName, data.message);
+ });
+
+ // Обновление времени
+ socket.on('time_update', (data) => {
+ if (data.time !== undefined) {
+ worldTime = data.time;
+ isNightTime = worldTime > 0.5;
+ }
+ });
+
+ } catch (e) {
+ console.error('Error initializing socket:', e);
+ isMultiplayer = false;
+ }
+ }
+
+ // Генерация случайного цвета для игрока на основе socket_id
+ function getRandomPlayerColor(socketId) {
+ const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
+ let hash = 0;
+ for (let i = 0; i < socketId.length; i++) {
+ hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return colors[Math.abs(hash) % colors.length];
+ }
+
+ // Отправка позиции игрока (с throttle)
+ function sendPlayerPosition() {
+ if (!isMultiplayer || !socket || !socket.connected) return;
+
+ const now = performance.now() / 1000;
+ if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
+
+ // Отправляем только если позиция изменилась
+ const dx = Math.abs(player.x - lastSentX);
+ const dy = Math.abs(player.y - lastSentY);
+ if (dx < 1 && dy < 1) return;
+
+ lastMoveSendTime = now;
+ lastSentX = player.x;
+ lastSentY = player.y;
+
+ socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName });
+ }
+
+ // Отправка изменения блока
+ function sendBlockChange(gx, gy, t, op) {
+ if (!isMultiplayer || !socket || !socket.connected) return;
+
+ socket.emit('block_change', { gx, gy, t, op });
+ }
+
+
+ // ==================== CLAIMS (ПРИВАТНЫЕ ЗОНЫ) ====================
+ let claims = []; // [{gx, gy, radius, owner_name}]
+ let myClaimCount = 0; // How many flags player has placed
+
+ // Claim border overlay colors
+ const CLAIM_COLORS = [
+ 'rgba(231,76,60,0.3)', // red-ish
+ 'rgba(52,152,219,0.3)', // blue
+ 'rgba(46,204,113,0.3)', // green
+ 'rgba(155,89,182,0.3)', // purple
+ 'rgba(241,196,15,0.3)', // yellow
+ ];
+
+ // ==================== ЧАТ ====================
+ const chatMessages = [];
+ const MAX_CHAT_MESSAGES = 20;
+
+ function addChatMessage(sender, message) {
+ const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+ chatMessages.push({ sender, message, time });
+ if (chatMessages.length > MAX_CHAT_MESSAGES) {
+ chatMessages.shift();
+ }
+ renderChatMessages();
+ }
+
+ function renderChatMessages() {
+ const chatMessagesEl = document.getElementById('chatMessages');
+ if (!chatMessagesEl) return;
+
+ chatMessagesEl.innerHTML = chatMessages.map(m =>
+ `${m.time} ${m.sender}: ${m.message}
`
+ ).join('');
+
+ // Прокручиваем вниз
+ chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
+ }
+
+ function sendChatMessage(message) {
+ if (!message || message.trim() === '') return;
+
+ if (isMultiplayer && socket && socket.connected) {
+ socket.emit('chat_message', { message: message.trim() });
+ } else {
+ addChatMessage('Вы', message.trim());
+ }
+ }
+
+ // ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
+ function shareWorld() {
+ const shareUrl = new URL(window.location.href);
+ shareUrl.searchParams.set('world', worldId);
+ const shareUrlString = shareUrl.toString();
+
+ // Копируем в буфер обмена
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(shareUrlString).then(() => {
+ alert('Ссылка скопирована!');
+ }).catch(() => {
+ alert('Ссылка на мир:\n' + shareUrlString);
+ });
+ } else {
+ alert('Ссылка на мир:\n' + shareUrlString);
+ }
+ }
+
+ // ==================== ИНИЦИАЛИЗАЦИЯ UI ====================
+ let chatOpen = false;
+
+ document.getElementById('chatToggle').onclick = () => {
+ playSound('click');
+ chatOpen = !chatOpen;
+ document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none';
+ if (chatOpen) {
+ document.getElementById('chatInput').focus();
+ }
+ };
+
+ document.getElementById('chatClose').onclick = () => {
+ playSound('click');
+ chatOpen = false;
+ document.getElementById('chatPanel').style.display = 'none';
+ };
+
+ document.getElementById('chatSend').onclick = () => {
+ const input = document.getElementById('chatInput');
+ sendChatMessage(input.value);
+ input.value = '';
+ };
+
+ document.getElementById('chatInput').addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ sendChatMessage(e.target.value);
+ e.target.value = '';
+ }
+ });
+
+ // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ====================
+ // Show start menu first, then init socket
+ showStartMenu().then((wid) => {
+ worldId = wid;
+ // Send tg_id if TG user
+ initSocket();
+ launchGame();
+ });
+
+ // ==================== ЗВУКОВОЙ ДВИЖОК ====================
+ const sounds = {};
+ function loadSound(id, src) {
+ const audio = new Audio();
+ audio.src = src;
+ audio.volume = 0.3;
+ sounds[id] = audio;
+ }
+
+ // Загрузка звуков
+ loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
+ loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
+ loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
+ loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
+ loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
+ loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
+ loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
+ loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
+ loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
+ loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
+ loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
+ loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
+ loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
+ loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
+ loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
+ loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
+ loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
+
+ function playSound(id) {
+ if(sounds[id]) {
+ sounds[id].currentTime = 0;
+ sounds[id].play().catch(e => console.error('Sound error:', e));
+ }
+ }
+
+ // Играем звук при прыжке
+ const gameEl = document.getElementById('game');
+ const canvas = document.getElementById('c');
+ const ctx = canvas.getContext('2d');
+
+ // offscreen light map (не вставляем в DOM)
+ const lightC = document.createElement('canvas');
+ const lightCtx = lightC.getContext('2d');
+
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
+ let W=0, H=0;
+
+ const TILE = 40;
+
+ // Мир
+ const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
+ const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
+ const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
+
+ const heroImg = new Image();
+ heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
+
+ // Состояние инвентаря
+ let showFullInventory = false;
+ let recentItems = []; // Последние 5 выбранных предметов
+
+ const BLOCKS = {
+ air: { n:'Воздух', solid:false },
+ grass: { n:'Трава', c:'#7cfc00', solid:true },
+ dirt: { n:'Грязь', c:'#8b4513', solid:true },
+ stone: { n:'Камень', c:'#7f8c8d', solid:true },
+ sand: { n:'Песок', c:'#f4d06f', solid:true },
+ gravel: { n:'Гравий', c:'#95a5a6', solid:true },
+ clay: { n:'Глина', c:'#74b9ff', solid:true },
+ wood: { n:'Дерево', c:'#d35400', solid:true },
+ planks: { n:'Доски', c:'#e67e22', solid:true },
+ ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
+ leaves: { n:'Листва', c:'#2ecc71', solid:true },
+ glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
+ water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
+ coal: { n:'Уголь', c:'#2c3e50', solid:true },
+ copper_ore:{ n:'Медь', c:'#e17055', solid:true },
+ iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
+ iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
+ gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
+ diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
+ brick: { n:'Кирпич', c:'#c0392b', solid:true },
+ tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
+ campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280 },
+ torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220 },
+ bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
+ flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
+ bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
+ boat: { n:'Лодка', c:'#8B4513', solid:false },
+ furnace: { n:'Печь', c:'#696969', solid:true, smelting:true },
+ flag: { n:'Флаг', c:'#e74c3c', solid:true, claimRadius:15 },
+ // === BIOME BLOCKS ===
+ snow: { n:'Снег', c:'#ecf0f1', solid:true },
+ ice: { n:'Лёд', c:'#74b9ff', solid:true, slip:true },
+ cactus: { n:'Кактус', c:'#27ae60', solid:true, hurt:true },
+ mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true },
+ moss: { n:'Мох', c:'#0a6640', solid:true },
+ swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true },
+ farmland: { n:'Грядка', c:'#8B6914', solid:true, farmable:true },
+ dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true },
+ spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true },
+ // === CROP STAGES ===
+ wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'wheat_stage1' },
+ wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'wheat_stage2' },
+ wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'wheat_stage3' },
+ wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, harvestable:true, harvestItem:'wheat', harvestQty:2 },
+ carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'carrot_stage1' },
+ carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'carrot_stage2' },
+ carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'carrot_stage3' },
+ carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, harvestable:true, harvestItem:'carrot', harvestQty:3 },
+ potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'potato_stage1' },
+ potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'potato_stage2' },
+ potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'potato_stage3' },
+ potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, harvestable:true, harvestItem:'potato', harvestQty:2 }
+ };
+
+ const ITEMS = {
+ meat: { n:'Сырое мясо', icon:'🥩', food:15 },
+ cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
+ arrow: { n:'Стрела', icon:'➡️', stack:64 },
+ chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
+ feather: { n:'Перо', icon:'🪶', stack:64 },
+ bone: { n:'Кость', icon:'🦴', stack:64 },
+ gunpowder: { n:'Порох', icon:'💥', stack:64 },
+ // === FARMING ITEMS ===
+ wheat: { n:'Пшеница', icon:'🌾', stack:64 },
+ bread: { n:'Хлеб', icon:'🍞', food:30 },
+ carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 },
+ potato: { n:'Картофель', icon:'🥔', stack:64 },
+ baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 },
+ mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 },
+ flag: { n:'Флаг', icon:'🏴', stack:5, craft: { planks: 3, iron_ore: 1 } },
+ // === MOB DROP ITEMS ===
+ scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 },
+ polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 },
+ slime_ball: { n:'Слизь', icon:'🟢', stack:64 },
+ eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 },
+ // === NEW ARMOR & TOOLS ===
+ gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 }
+ };
+
+ // Seed мира для детерминированной генерации
+ // Seed: null in MP until server sends it; SP uses random seed
+ let worldSeed = isMultiplayer ? null : Math.floor(Math.random() * 1000000);
+
+ // Отслеживание изменений мира (для оптимизированного сохранения)
+ let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком
+ let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком
+
+ // Серверные изменения — применяются после genColumn чтобы не перезатирались
+ const serverOverrides = new Map(); // key "gx,gy" => {op:'set'|'remove', t?:string}
+
+ // Инструменты
+ const TOOLS = {
+ wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 }, requiredLevel: 1 },
+ stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 }, requiredLevel: 2 },
+ iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 }, requiredLevel: 3 },
+ wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 }, requiredLevel: 1 },
+ stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 }, requiredLevel: 2 },
+ iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 }, requiredLevel: 3 },
+ bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, planks: 2 }, requiredLevel: 4 },
+ diamond_pickaxe: { n:'Алмазная кирка', icon:'⛏️', durability: 500, miningPower: 5, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 },
+ diamond_sword: { n:'Алмазный меч', icon:'⚔️', durability: 400, damage: 18, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 },
+ hoe: { n:'Мотыга', icon:'🔨', durability: 80, tillTo: 'farmland', craft: { wood: 2, planks: 1 }, requiredLevel: 1 }
+ };
+
+ // Текстуры блоков (простые)
+ const tex = {};
+ function makeTex(type) {
+ const t = BLOCKS[type];
+ const c = document.createElement('canvas');
+ c.width = 32; c.height = 32;
+ const g = c.getContext('2d');
+
+ if (type === 'tnt') {
+ g.fillStyle='#c0392b'; g.fillRect(0,0,32,32);
+ g.fillStyle='#fff'; g.fillRect(0,12,32,8);
+ g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20);
+ return c;
+ }
+ if (type === 'campfire') {
+ g.fillStyle='#5d4037'; g.fillRect(4,26,24,6);
+ g.fillStyle='#3e2723'; g.fillRect(7,23,18,4);
+ return c;
+ }
+ if (type === 'torch') {
+ g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18);
+ g.fillStyle='#f39c12'; g.fillRect(12,6,8,8);
+ return c;
+ }
+ if (type === 'glass') {
+ g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32);
+ g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28);
+ g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke();
+ return c;
+ }
+ if (type === 'water') {
+ g.fillStyle = t.c; g.fillRect(0,0,32,32);
+ g.fillStyle = 'rgba(255,255,255,0.08)';
+ g.fillRect(0,6,32,2);
+ return c;
+ }
+ if (type === 'bed') {
+ // Основание кровати
+ g.fillStyle = '#e91e63';
+ g.fillRect(0, 0, 32, 32);
+ // Подушка
+ g.fillStyle = '#f8bbd0';
+ g.fillRect(2, 2, 14, 14);
+ // Одеяло
+ g.fillStyle = '#c2185b';
+ g.fillRect(16, 4, 14, 24);
+ // Детали одеяла
+ g.fillStyle = '#e91e63';
+ g.fillRect(18, 6, 10, 20);
+ return c;
+ }
+ if (type === 'flower') {
+ g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18);
+ g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill();
+ return c;
+ }
+ if (type === 'boat') {
+ // Корпус лодки
+ g.fillStyle = '#8B4513';
+ g.fillRect(2, 12, 28, 8);
+ // Борта
+ g.fillStyle = '#A0522D';
+ g.fillRect(0, 10, 32, 12);
+ // Внутренность
+ g.fillStyle = '#DEB887';
+ g.fillRect(4, 14, 24, 4);
+ // Дно
+ g.fillStyle = '#654321';
+ g.fillRect(2, 20, 28, 4);
+ return c;
+ }
+ if (type === 'ladder') {
+ // Боковые стойки лестницы
+ g.fillStyle = '#8B4513';
+ g.fillRect(4, 0, 4, 32);
+ g.fillRect(24, 0, 4, 32);
+ // Ступени
+ g.fillStyle = '#A0522D';
+ g.fillRect(4, 4, 24, 3);
+ g.fillRect(4, 12, 24, 3);
+ g.fillRect(4, 20, 24, 3);
+ g.fillRect(4, 28, 24, 3);
+ return c;
+ }
+ // === BIOME BLOCK TEXTURES ===
+ if (type === 'snow') {
+ g.fillStyle = '#ecf0f1';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#dfe6e9';
+ for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3);
+ return c;
+ }
+ if (type === 'ice') {
+ g.fillStyle = '#74b9ff';
+ g.fillRect(0, 0, 32, 32);
+ g.strokeStyle = 'rgba(255,255,255,0.4)';
+ g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke();
+ g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke();
+ return c;
+ }
+ if (type === 'cactus') {
+ g.fillStyle = '#27ae60';
+ g.fillRect(6, 2, 20, 28);
+ g.fillStyle = '#2ecc71';
+ g.fillRect(2, 8, 6, 4);
+ g.fillRect(24, 14, 6, 4);
+ g.fillStyle = '#1e8449';
+ g.fillRect(12, 0, 2, 30);
+ g.fillRect(18, 0, 2, 30);
+ return c;
+ }
+ if (type === 'mushroom') {
+ g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12);
+ g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill();
+ g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3);
+ return c;
+ }
+ if (type === 'moss') {
+ g.fillStyle = '#0a6640';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#1e8449';
+ for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3);
+ return c;
+ }
+ if (type === 'swamp_water') {
+ g.fillStyle = 'rgba(100,120,40,0.6)';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = 'rgba(80,100,20,0.3)';
+ g.fillRect(0, 10, 32, 2);
+ g.fillRect(0, 22, 32, 2);
+ return c;
+ }
+ if (type === 'farmland') {
+ g.fillStyle = '#8B6914';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#7a5c10';
+ for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2);
+ g.fillStyle = '#6B4E0A';
+ g.fillRect(8, 2, 2, 28);
+ g.fillRect(18, 2, 2, 28);
+ return c;
+ }
+ if (type === 'dead_bush') {
+ g.fillStyle = '#b2bec3';
+ g.fillRect(12, 16, 2, 16);
+ g.fillRect(8, 14, 8, 2);
+ g.fillRect(16, 12, 8, 2);
+ g.fillRect(6, 18, 4, 2);
+ return c;
+ }
+ if (type === 'spruce_leaves') {
+ g.fillStyle = '#0a6640';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#0d7a4d';
+ for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4);
+ return c;
+ }
+ // CROP STAGES
+ if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) {
+ const st = parseInt(type.charAt(type.length-1));
+ const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] :
+ type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] :
+ ['#a8e6a0','#c8b888','#bfaa78','#dfe6e9'];
+ g.fillStyle = '#5d4037';
+ g.fillRect(15, 18, 2, 14);
+ if (st >= 1) {
+ g.fillStyle = colors[st];
+ g.fillRect(10, 8 + (3-st)*3, 12, 10);
+ } else {
+ g.fillStyle = '#a8e6a0';
+ g.fillRect(14, 22, 4, 4);
+ }
+ return c;
+ }
+
+ g.fillStyle = t.c || '#000';
+ g.fillRect(0,0,32,32);
+
+ g.fillStyle = 'rgba(0,0,0,0.10)';
+ for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4);
+
+ if (type.endsWith('_ore') || type==='coal') {
+ g.fillStyle = 'rgba(0,0,0,0.35)';
+ for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6);
+ }
+ return c;
+ }
+ Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
+
+ // Мир-хранилище
+ const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
+ const blocks = []; // для рендера/перебора видимых
+ function k(gx,gy){ return gx+','+gy; }
+ function getBlock(gx,gy){ return grid.get(k(gx,gy)); }
+ function hasBlock(gx,gy){ return grid.has(k(gx,gy)); }
+ function isSolid(gx,gy){
+ const b = getBlock(gx,gy);
+ if(!b || b.dead) return false;
+ const def = BLOCKS[b.t];
+ return !!def.solid && !def.fluid && !def.decor;
+ }
+ function setBlock(gx,gy,t, isPlayerPlaced = false){
+ const key = k(gx,gy);
+ if(grid.has(key)) return false;
+ const b = { gx, gy, t, dead:false, active:false, fuse:0 };
+ grid.set(key, b);
+ blocks.push(b);
+
+ // Отслеживаем блоки, установленные игроком
+ if(isPlayerPlaced){
+ placedBlocks.push({gx, gy, t});
+ }
+
+ return true;
+ }
+ function removeBlock(gx,gy){
+ const key = k(gx,gy);
+ const b = grid.get(key);
+ if(!b) return null;
+ if(BLOCKS[b.t].unbreakable) return null;
+ grid.delete(key);
+ b.dead = true;
+
+ // Отслеживаем удалённые блоки
+ const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
+ if(wasPlayerPlaced){
+ // Удаляем из placedBlocks
+ placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
+ } else {
+ // Это природный блок - добавляем в removedBlocks
+ removedBlocks.push({gx, gy});
+ }
+
+ return b;
+ }
+
+ // Физика жидкости
+ const waterUpdateQueue = new Set();
+ let waterUpdateTimer = 0;
+ const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
+
+ function updateWaterPhysics(dt){
+ waterUpdateTimer += dt;
+ if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
+ waterUpdateTimer = 0;
+
+ // Ограничиваем количество водных блоков для обработки (оптимизация)
+ const MAX_WATER_BLOCKS_PER_UPDATE = 50;
+ let processedCount = 0;
+
+ // Собираем только видимые водные блоки в очередь (оптимизация)
+ waterUpdateQueue.clear();
+ const minGX = Math.floor(camX/TILE) - 10;
+ const maxGX = Math.floor((camX+W)/TILE) + 10;
+ const minGY = Math.floor(camY/TILE) - 10;
+ const maxGY = Math.floor((camY+H)/TILE) + 10;
+
+ for(const b of blocks){
+ if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
+ if(!b.dead && b.t === 'water' &&
+ b.gx >= minGX && b.gx <= maxGX &&
+ b.gy >= minGY && b.gy <= maxGY){
+ waterUpdateQueue.add(k(b.gx, b.gy));
+ processedCount++;
+ }
+ }
+
+ // Обновляем воду с ограничением глубины распространения
+ const processed = new Set();
+ const toAdd = [];
+ const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
+
+ for(const key of waterUpdateQueue){
+ if(processed.has(key)) continue;
+ const b = grid.get(key);
+ if(!b || b.dead) continue;
+ processed.add(key);
+
+ const gx = b.gx;
+ const gy = b.gy;
+
+ // Проверяем глубину - не распространяем воду слишком глубоко
+ if(gy > SEA_GY + MAX_WATER_DEPTH) continue;
+
+ // Проверяем, можно ли воде упасть вниз
+ const belowKey = k(gx, gy + 1);
+ const below = grid.get(belowKey);
+
+ // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
+ if(!below || below.dead){
+ // Ограничиваем создание новых водных блоков
+ if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление
+ toAdd.push({gx, gy: gy + 1, t: 'water'});
+ processed.add(belowKey);
+ }
+ continue;
+ }
+
+ // Если внизу не вода и не твёрдый блок - вода может течь вниз
+ if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){
+ if(toAdd.length < 20){
+ toAdd.push({gx, gy: gy + 1, t: 'water'});
+ processed.add(belowKey);
+ }
+ continue;
+ }
+
+ // Если внизу твёрдый блок или вода - вода растекается горизонтально
+ // Проверяем левую сторону
+ const leftKey = k(gx - 1, gy);
+ const left = grid.get(leftKey);
+ if(!left || left.dead){
+ if(toAdd.length < 20){
+ toAdd.push({gx: gx - 1, gy, t: 'water'});
+ processed.add(leftKey);
+ }
+ continue;
+ }
+
+ // Проверяем правую сторону
+ const rightKey = k(gx + 1, gy);
+ const right = grid.get(rightKey);
+ if(!right || right.dead){
+ if(toAdd.length < 20){
+ toAdd.push({gx: gx + 1, gy, t: 'water'});
+ processed.add(rightKey);
+ }
+ continue;
+ }
+ }
+
+ // Применяем изменения (только добавляем новые блоки)
+ for(const newData of toAdd){
+ const key = k(newData.gx, newData.gy);
+ if(!grid.has(key)){
+ const b = {
+ gx: newData.gx,
+ gy: newData.gy,
+ t: newData.t,
+ dead: false,
+ active: false,
+ fuse: 0
+ };
+ grid.set(key, b);
+ blocks.push(b);
+ }
+ }
+
+ // Очищаем мёртвые блоки из массива
+ for(let i = blocks.length - 1; i >= 0; i--){
+ if(blocks[i].dead){
+ blocks.splice(i, 1);
+ }
+ }
+ }
+
+ // Инвентарь
+ const inv = {
+ dirt:6, stone:0, sand:0, gravel:0, clay:0,
+ wood:0, planks:0, ladder:0, leaves:0, coal:0,
+ copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0,
+ brick:0, glass:0,
+ tnt:1, campfire:0, torch:0,
+ meat:0, cooked:0, arrow:0,
+ wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0,
+ wood_sword:0, stone_sword:0, iron_sword:0,
+ iron_armor:0, gold_armor:0,
+ bow:0, furnace:0,
+ bed:0, boat:0,
+ iron_ingot:0, gold_ingot:0, copper_ingot:0,
+ diamond_pickaxe:0, diamond_sword:0, hoe:0,
+ wheat:0, bread:0, carrot:0, potato:0, baked_potato:0,
+ scorpion_stinger:0, polar_fur:0, slime_ball:0, eagle_feather:0,
+ snow:0, ice:0, cactus:0, mushroom:0, moss:0, farmland:0,
+ spruce_leaves:0, dead_bush:0,
+ wheat_stage0:0, carrot_stage0:0, potato_stage0:0
+ };
+ let selected = 'dirt';
+
+ // Прочность инструментов: Map<"tooltype_id", {current, max}>
+ // При крафте инструмента создаём запись с max durability
+ const toolDurability = new Map();
+
+ function addTool(type) {
+ const def = TOOLS[type];
+ if (!def) return;
+ const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
+ toolDurability.set(id, { type, current: def.durability, max: def.durability });
+ return id;
+ }
+
+ function getToolDurability(id) {
+ return toolDurability.get(id);
+ }
+
+ // Найти лучший инструмент данного типа в инвентаре
+ function findBestTool(toolType) {
+ if (inv[toolType] <= 0) return null;
+ // Возвращаем первый попавшийся — упрощённо
+ return toolType;
+ }
+
+ // Использовать инструмент (уменьшить прочность). Возвращает true если сломался
+ function useTool(toolType) {
+ // Ищем любой инструмент этого типа с прочностью
+ for (const [id, dur] of toolDurability) {
+ if (dur.type === toolType) {
+ dur.current--;
+ if (dur.current <= 0) {
+ toolDurability.delete(id);
+ inv[toolType]--;
+ rebuildHotbar();
+ return true; // сломался
+ }
+ return false;
+ }
+ }
+ return false;
+ }
+
+ const RECIPES = [
+ { out:'planks', qty:4, cost:{ wood:1 }, requiredLevel:1 },
+ { out:'ladder', qty:3, cost:{ planks:7 }, requiredLevel:1 },
+ { out:'torch', qty:2, cost:{ coal:1, planks:1 }, requiredLevel:1 },
+ { out:'glass', qty:1, cost:{ sand:3 }, requiredLevel:1 },
+ { out:'brick', qty:1, cost:{ stone:2, clay:1 }, requiredLevel:1 },
+ { out:'campfire', qty:1, cost:{ wood:1, coal:1 }, requiredLevel:1 },
+ { out:'tnt', qty:1, cost:{ sand:2, coal:1 }, requiredLevel:8 },
+ { out:'bed', qty:1, cost:{ wood: 3, planks: 3 }, requiredLevel:1 },
+ { out:'boat', qty:1, cost:{ wood: 5 }, requiredLevel:2 },
+ { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:1 },
+ { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 }, requiredLevel:2 },
+ { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 }, requiredLevel:3 },
+ { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 },
+ { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 }, requiredLevel:2 },
+ { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 }, requiredLevel:3 },
+ { out:'iron_armor', qty:1, cost:{ iron_ore: 5 }, requiredLevel:5 },
+ { out:'gold_armor', qty:1, cost:{ gold_ore: 8 }, requiredLevel:6 },
+ { out:'furnace', qty:1, cost:{ stone: 8 }, requiredLevel:3 },
+ { out:'bow', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:4 },
+ { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 }, requiredLevel:1 },
+ // === NEW RECIPES ===
+ { out:'bread', qty:1, cost:{ wheat: 3 }, requiredLevel:1 },
+ { out:'diamond_pickaxe', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 },
+ { out:'diamond_sword', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 },
+ { out:'hoe', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 },
+ { out:'flag', qty:1, cost:{ planks: 5, iron_ore: 1 }, requiredLevel:2 }
+ ];
+
+ // Рецепты печи (обжиг)
+ const SMELTING_RECIPES = [
+ { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло
+ { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич
+ { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток
+ { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток
+ { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток
+ { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное
+ { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 }, // булыжник → камень
+ { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } // картофель → печёная картошка
+ ];
+
+ // Новые предметы от обжига
+ ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
+ ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
+ ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
+ ITEMS.diamond_pickaxe = { n:'Алмазная кирка', icon:'⛏️', durability:500, miningPower:5 };
+ ITEMS.diamond_sword = { n:'Алмазный меч', icon:'⚔️', durability:400, damage:18 };
+ ITEMS.hoe = { n:'Мотыга', icon:'🔨', durability:80, tillTo:'farmland' };
+
+ // Активные печи: Map ключа блока → { recipe, progress, totalTime }
+ const activeFurnaces = new Map();
+
+ // UI
+ const hpEl = document.getElementById('hp');
+ const foodEl = document.getElementById('food');
+ const tempEl = document.getElementById("temp");
+ const sxEl = document.getElementById('sx');
+ const syEl = document.getElementById('sy');
+ const todEl = document.getElementById('tod');
+ const worldIdEl = document.getElementById('worldId');
+ // Toast уведомление
+ function showToast(msg, duration) {
+ duration = duration || 1800;
+ const t = document.createElement('div');
+ t.textContent = msg;
+ t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;';
+ document.body.appendChild(t);
+ setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration);
+ }
+ // Клик на worldId → копировать код мира + toast
+ worldIdEl.addEventListener('click', () => {
+ const code = worldId || '';
+ if (!code) return;
+ const text = code; // копируем только код, не URL
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => showToast('📋 Скопировано: ' + code)).catch(() => showToast(code));
+ } else {
+ // fallback
+ const ta = document.createElement('textarea');
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
+ document.body.appendChild(ta); ta.select();
+ try { document.execCommand('copy'); showToast('📋 Скопировано: ' + code); } catch(e) { showToast(code); }
+ ta.remove();
+ }
+ });
+ const playerCountEl = document.getElementById('playerCount');
+ const hotbarEl = document.getElementById('hotbar');
+ const craftPanel = document.getElementById('craftPanel');
+ const recipesEl = document.getElementById('recipes');
+ const deathEl = document.getElementById('death');
+ const inventoryPanel = document.getElementById('inventoryPanel');
+ const inventoryGrid = document.getElementById('inventoryGrid');
+
+ // ==================== МИНИКАРТА ====================
+ const minimapWrap = document.getElementById('minimapWrap');
+ const minimapCanvas = document.getElementById('minimap');
+ const minimapCtx = minimapCanvas.getContext('2d');
+ let minimapOpen = false;
+
+ document.getElementById('mapToggle').onclick = () => {
+ playSound('click');
+ minimapOpen = !minimapOpen;
+ minimapWrap.style.display = minimapOpen ? 'block' : 'none';
+ };
+
+ // Цвета блоков для миникарты (по 1 пикселю на блок)
+ const MINIMAP_COLORS = {
+ grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
+ gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
+ leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
+ copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
+ diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
+ campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
+ flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410',
+ snow: '#ecf0f1', ice: '#74b9ff', cactus: '#27ae60', mushroom: '#e74c3c',
+ moss: '#0a6640', swamp_water: '#687828', farmland: '#8B6914',
+ dead_bush: '#b2bec3', spruce_leaves: '#0a6640',
+ wheat_stage0: '#a8e6a0', wheat_stage1: '#7dcea0', wheat_stage2: '#b8d730', wheat_stage3: '#f1c40f',
+ carrot_stage0: '#a8e6a0', carrot_stage1: '#f0c27a', carrot_stage2: '#e8a040', carrot_stage3: '#e67e22',
+ potato_stage0: '#a8e6a0', potato_stage1: '#c8b888', potato_stage2: '#bfaa78', potato_stage3: '#dfe6e9'
+ };
+
+ function renderMinimap() {
+ if (!minimapOpen) return;
+ const mW = minimapCanvas.width;
+ const mH = minimapCanvas.height;
+ const scale = 2; // пикселей на блок
+
+ // Область карты — центрирована на игроке
+ const pGX = Math.floor(player.x / TILE);
+ const biome = getCachedBiome(pGX);
+ const pGY = Math.floor(player.y / TILE);
+ const viewW = Math.floor(mW / scale);
+ const viewH = Math.floor(mH / scale);
+ const startGX = pGX - Math.floor(viewW / 2);
+ const startGY = pGY - Math.floor(viewH / 2);
+
+ // Очищаем
+ minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
+ minimapCtx.fillRect(0, 0, mW, mH);
+
+ // Рисуем блоки
+ const imgData = minimapCtx.createImageData(mW, mH);
+ const data = imgData.data;
+
+ for (let dx = 0; dx < viewW; dx++) {
+ for (let dy = 0; dy < viewH; dy++) {
+ const gx = startGX + dx;
+ const gy = startGY + dy;
+ const b = getBlock(gx, gy);
+ if (!b || b.dead || b.t === 'air') continue;
+
+ const color = MINIMAP_COLORS[b.t];
+ if (!color) continue;
+
+ // Парсим hex цвет
+ const r = parseInt(color.slice(1,3), 16);
+ const g = parseInt(color.slice(3,5), 16);
+ const bl = parseInt(color.slice(5,7), 16);
+
+ // Заполняем scale x scale пикселей
+ for (let sx = 0; sx < scale; sx++) {
+ for (let sy = 0; sy < scale; sy++) {
+ const px = dx * scale + sx;
+ const py = dy * scale + sy;
+ if (px >= mW || py >= mH) continue;
+ const idx = (py * mW + px) * 4;
+ data[idx] = r;
+ data[idx+1] = g;
+ data[idx+2] = bl;
+ data[idx+3] = 255;
+ }
+ }
+ }
+ }
+
+ minimapCtx.putImageData(imgData, 0, 0);
+
+ // Игрок — белый пиксель по центру
+ minimapCtx.fillStyle = '#fff';
+ minimapCtx.fillRect(Math.floor(mW/2) - 2, Math.floor(mH/2) - 2, 4, 4);
+
+ // Другие игроки — жёлтые точки
+ for (const [sid, p] of otherPlayers) {
+ const dx = Math.floor(p.x / TILE) - startGX;
+ const dy = Math.floor(p.y / TILE) - startGY;
+ if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
+ minimapCtx.fillStyle = '#f1c40f';
+ minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
+ }
+ }
+
+ // Мобы — красные (враждебные) / зелёные (животные)
+ const allMobsForMap = getAllMobs();
+ for (const m of allMobsForMap) {
+ const dx = Math.floor(m.x / TILE) - startGX;
+ const dy = Math.floor(m.y / TILE) - startGY;
+ if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
+ const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
+ minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
+ minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
+ }
+ }
+ }
+
+ // ==================== ПЕЧЬ (ОБЖИГ) ====================
+ const furnacePanel = document.getElementById('furnacePanel');
+ const furnaceContent = document.getElementById('furnaceContent');
+ let currentFurnaceKey = null; // "gx,gy" текущей открытой печи
+
+ document.getElementById('furnaceClose').onclick = () => {
+ furnacePanel.style.display = 'none';
+ currentFurnaceKey = null;
+ };
+
+ function openFurnaceUI(gx, gy) {
+ currentFurnaceKey = `${gx},${gy}`;
+ furnacePanel.style.display = 'block';
+ renderFurnaceUI();
+ }
+
+ function renderFurnaceUI() {
+ if (!currentFurnaceKey) return;
+
+ // Проверяем что печь всё ещё существует
+ const [fgx, fgy] = currentFurnaceKey.split(',').map(Number);
+ const fb = getBlock(fgx, fgy);
+ if (!fb || fb.t !== 'furnace') {
+ furnacePanel.style.display = 'none';
+ currentFurnaceKey = null;
+ return;
+ }
+
+ // Текущий процесс обжига
+ const active = activeFurnaces.get(currentFurnaceKey);
+
+ let html = '';
+
+ // Доступные рецепты — показываем только те, для которых есть ресурсы
+ for (let i = 0; i < SMELTING_RECIPES.length; i++) {
+ const recipe = SMELTING_RECIPES[i];
+ const haveCount = inv[recipe.in] || 0;
+ const canSmelt = haveCount >= recipe.qty;
+
+ // Иконка результата
+ const outDef = BLOCKS[recipe.out];
+ const outItem = ITEMS[recipe.out];
+ const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
+ const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
+ const inItem = ITEMS[recipe.in];
+ const inDef = BLOCKS[recipe.in];
+ const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
+
+ html += `
`;
+ html += `
${iconStr}
`;
+ html += `
`;
+ html += `
${nameStr}
`;
+ html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+
+ // Текущий прогресс
+ if (active) {
+ const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
+ html += `
`;
+ html += `
🔥 Обжиг: ${pct}%
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+
+ html += '
';
+ furnaceContent.innerHTML = html;
+ }
+
+ // Глобальная функция для кнопки обжига
+ window._smelt = (recipeIdx) => {
+ if (!currentFurnaceKey) return;
+ const recipe = SMELTING_RECIPES[recipeIdx];
+ if ((inv[recipe.in] || 0) < recipe.qty) return;
+
+ // Уже обжигаем в этой печи?
+ if (activeFurnaces.has(currentFurnaceKey)) return;
+
+ // Забираем ресурсы
+ inv[recipe.in] -= recipe.qty;
+
+ // Запускаем обжиг
+ activeFurnaces.set(currentFurnaceKey, {
+ recipe: recipe,
+ progress: 0
+ });
+
+ playSound('fire');
+ rebuildHotbar();
+ renderFurnaceUI();
+ };
+
+ // Тик печей — вызывается в главном цикле
+ function tickFurnaces(dt) {
+ for (const [key, furnace] of activeFurnaces) {
+ furnace.progress += dt;
+ if (furnace.progress >= furnace.recipe.time) {
+ // Обжиг завершён — выдаём результат
+ const outItem = furnace.recipe.out;
+ if (ITEMS[outItem]) {
+ inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
+ } else if (BLOCKS[outItem]) {
+ inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
+ }
+ playSound('stone_build');
+ activeFurnaces.delete(key);
+
+ // Если эта печь открыта — обновляем UI
+ if (key === currentFurnaceKey) {
+ renderFurnaceUI();
+ }
+ }
+ }
+ }
+
+
+
+ // ==================== ГОЛОСОВОЙ ЧАТ v3 ====================
+ // ==================== ГОЛОСОВОЙ ЧАТ v3 ====================
+// Per-speaker architecture, AudioWorklet, VAD, Opus/PCM, spatial audio
+// Replaces lines 1449-1665 in game.js
+
+ let voiceSocket = null;
+ let voiceStream = null;
+ let audioCtx = null;
+ let voiceActive = false;
+ let voiceMode = 'near';
+ const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
+
+ // Voice config
+ const VC = {
+ sampleRate: 16000, // 16kHz — sufficient for voice, saves 33% bandwidth
+ frameMs: 20, // 20ms frames = 320 samples @ 16kHz
+ samplesPerFrame: 320, // 16000 * 0.02
+ vadThreshold: 0.0005, // RMS threshold for voice detection
+ vadHangover: 5, // 100ms hangover after speech ends
+ jbufTargetMs: 80, // Target jitter: 80ms (was 200ms)
+ jbufMinMs: 40,
+ jbufMaxMs: 200,
+ maxSpeakers: 6,
+ voiceRadius: 600,
+ opusBitrate: 16000,
+ posUpdateMs: 200, // Update position every 200ms (was 500ms)
+ };
+
+ // Codec state
+ let voiceCodec = 'pcm'; // 'opus' or 'pcm'
+ let voiceEncoder = null; // WebCodecs AudioEncoder
+ let voiceSeq = 0;
+ let voiceTimestamp = 0;
+ let wasSpeaking = false;
+ let silenceFrames = 0;
+ let captureNode = null;
+ let playbackNode = null;
+
+ // Per-speaker map
+ const remoteSpeakers = new Map(); // socketId → { jitterBuf, lastFrame, gain, panner, lowpass, decoder, codec, x, y, name, mode, speaking, lastActive }
+
+ // ==================== WORKLET CODE (inline strings) ====================
+
+ const voiceCaptureWorkletCode = `
+class VoiceCaptureProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this._buf = new Float32Array(320); // 20ms @ 16kHz
+ this._pos = 0;
+ this._speaking = false;
+ this._silenceFrames = 0;
+ this._hangover = 5; // 100ms
+ this._vadThreshold = 0.0005;
+ this._lastRms = 0;
+ }
+ process(inputs) {
+ const input = inputs[0];
+ if (!input || !input[0]) return true;
+ const ch = input[0];
+ let i = 0;
+ while (i < ch.length) {
+ const rem = this._buf.length - this._pos;
+ const n = Math.min(rem, ch.length - i);
+ this._buf.set(ch.subarray(i, i + n), this._pos);
+ this._pos += n;
+ i += n;
+ if (this._pos >= this._buf.length) {
+ this._processFrame();
+ this._pos = 0;
+ }
+ }
+ return true;
+ }
+ _processFrame() {
+ let sum = 0;
+ for (let i = 0; i < this._buf.length; i++) sum += this._buf[i] * this._buf[i];
+ const rms = Math.sqrt(sum / this._buf.length);
+ this._lastRms = rms;
+
+ if (rms > this._vadThreshold) {
+ this._speaking = true;
+ this._silenceFrames = 0;
+ } else {
+ this._silenceFrames++;
+ if (this._silenceFrames > this._hangover) this._speaking = false;
+ }
+
+ this.port.postMessage({
+ type: 'frame',
+ samples: this._buf.slice(),
+ speaking: this._speaking,
+ rms: rms
+ });
+ }
+}
+registerProcessor('voice-capture', VoiceCaptureProcessor);
+`;
+
+ const voicePlaybackWorkletCode = `
+class VoicePlaybackProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.speakers = new Map(); // id → { ringBuf, readPos, writePos, ready, gain, pan, lastSample, active }
+ this.RING_SIZE = 48000; // 3 seconds @ 16kHz
+ this.port.onmessage = (e) => {
+ const d = e.data;
+ if (d.type === 'addSpeaker') {
+ this.speakers.set(d.id, {
+ ringBuf: new Float32Array(this.RING_SIZE),
+ readPos: 0, writePos: 0, ready: 0,
+ gain: d.gain || 1, pan: d.pan || 0,
+ lastSample: 0, active: false,
+ jbufTarget: 320 * 4, // ~80ms in samples
+ fadeOut: 0
+ });
+ } else if (d.type === 'removeSpeaker') {
+ this.speakers.delete(d.id);
+ } else if (d.type === 'pushFrames') {
+ const sp = this.speakers.get(d.id);
+ if (!sp) return;
+ const frames = d.samples; // Float32Array
+ for (let i = 0; i < frames.length; i++) {
+ sp.ringBuf[sp.writePos] = frames[i];
+ sp.writePos = (sp.writePos + 1) % this.RING_SIZE;
+ if (sp.ready < this.RING_SIZE) sp.ready++;
+ }
+ sp.active = true;
+ sp.fadeOut = 0;
+ } else if (d.type === 'updateSpatial') {
+ const sp = this.speakers.get(d.id);
+ if (sp) {
+ sp.gain = d.gain;
+ sp.pan = d.pan;
+ }
+ }
+ };
+ }
+ process(inputs, outputs) {
+ const output = outputs[0];
+ if (!output || !output[0]) return true;
+ const left = output[0];
+ const right = output[1] || output[0]; // mono fallback
+
+ // Clear output
+ for (let i = 0; i < left.length; i++) { left[i] = 0; right[i] = 0; }
+
+ for (const [id, sp] of this.speakers) {
+ if (sp.ready < 1 && sp.active) {
+ // Fade out over ~20ms (128 samples)
+ sp.fadeOut++;
+ if (sp.fadeOut > 128) { sp.active = false; continue; }
+ const fade = 1 - (sp.fadeOut / 128);
+ const g = sp.gain * fade;
+ const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4);
+ const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4);
+ // Repeat last sample with fade
+ for (let i = 0; i < left.length; i++) {
+ left[i] += sp.lastSample * lg;
+ right[i] += sp.lastSample * rg;
+ }
+ continue;
+ }
+ if (!sp.active && sp.ready < sp.jbufTarget) continue; // Wait for jitter buffer to fill
+ if (!sp.active && sp.ready >= sp.jbufTarget) sp.active = true;
+ if (!sp.active) continue;
+
+ sp.fadeOut = 0;
+ const g = sp.gain;
+ // Constant-power pan law
+ const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4);
+ const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4);
+
+ for (let i = 0; i < left.length; i++) {
+ if (sp.ready > 0) {
+ const s = sp.ringBuf[sp.readPos];
+ sp.lastSample = s;
+ left[i] += s * lg;
+ right[i] += s * rg;
+ sp.readPos = (sp.readPos + 1) % this.RING_SIZE;
+ sp.ready--;
+ } else {
+ // Underrun — fade from last sample
+ sp.lastSample *= 0.85;
+ left[i] += sp.lastSample * lg;
+ right[i] += sp.lastSample * rg;
+ }
+ }
+ }
+ return true;
+ }
+}
+registerProcessor('voice-playback', VoicePlaybackProcessor);
+`;
+
+ // ==================== Opus ENCODE/DECODE ====================
+
+ async function initVoiceEncoder() {
+ if (false && 'AudioEncoder' in window) {
+ try {
+ // Check if Opus is supported
+ const support = await AudioEncoder.isConfigSupported({
+ codec: 'opus',
+ sampleRate: VC.sampleRate,
+ numberOfChannels: 1,
+ bitrate: VC.opusBitrate
+ });
+ if (!support.supported) throw new Error('Opus not supported');
+
+ voiceEncoder = new AudioEncoder({
+ output: (chunk) => {
+ // chunk: EncodedAudioChunk with Opus data
+ const data = new Uint8Array(chunk.byteLength);
+ chunk.copyTo(data);
+ // Send as binary frame via Socket.IO
+ console.log('[voice] TX Opus chunk, seq:', voiceSeq, 'size:', data.buffer.byteLength);
+ voiceSocket.emit('voice_data', {
+ codec: 'opus',
+ data: data.buffer,
+ seq: voiceSeq++,
+ ts: chunk.timestamp,
+ speaking: true
+ });
+ },
+ error: (e) => {
+ console.error('[voice] encoder error:', e);
+ voiceCodec = 'pcm';
+ voiceEncoder = null;
+ }
+ });
+
+ await voiceEncoder.configure({
+ codec: 'opus',
+ sampleRate: VC.sampleRate,
+ numberOfChannels: 1,
+ bitrate: VC.opusBitrate
+ });
+
+ voiceCodec = 'opus';
+ console.log('[voice] WebCodecs Opus encoder initialized @', VC.opusBitrate, 'bps');
+ return;
+ } catch (e) {
+ console.warn('[voice] WebCodecs Opus not available, PCM fallback:', e.message);
+ }
+ }
+ voiceCodec = 'pcm';
+ console.log('[voice] Using PCM @', VC.sampleRate, 'Hz');
+ }
+
+ async function initDecoderForSpeaker(speakerId, codec) {
+ const sp = remoteSpeakers.get(speakerId);
+ if (!sp) return;
+
+ if (codec === 'opus' && 'AudioDecoder' in window) {
+ try {
+ const decoder = new AudioDecoder({
+ output: (audioData) => {
+ const samples = new Float32Array(audioData.numberOfFrames);
+ audioData.copyTo(samples, { planeIndex: 0 });
+ audioData.close();
+ // Push decoded PCM to worklet
+ if (playbackNode) {
+ playbackNode.port.postMessage({
+ type: 'pushFrames',
+ id: speakerId,
+ samples: samples
+ });
+ }
+ },
+ error: (e) => {
+ console.error('[voice] decoder error for', speakerId, e);
+ sp.codec = 'pcm'; // Fallback to PCM
+ }
+ });
+
+ await decoder.configure({
+ codec: 'opus',
+ sampleRate: VC.sampleRate,
+ numberOfChannels: 1
+ });
+
+ sp.decoder = decoder;
+ sp.codec = 'opus';
+ console.log('[voice] Opus decoder initialized for', speakerId);
+ } catch (e) {
+ console.warn('[voice] Opus decoder failed, PCM fallback:', e.message);
+ sp.codec = 'pcm';
+ }
+ } else {
+ sp.codec = 'pcm';
+ }
+ }
+
+ // ==================== RemoteSpeaker helper ====================
+
+ function createRemoteSpeaker(socketId, name, codec) {
+ const sp = {
+ id: socketId,
+ name: name || '???',
+ codec: codec || 'pcm',
+ decoder: null,
+ x: 0, y: 0,
+ mode: 'near',
+ speaking: false,
+ lastActive: Date.now(),
+ // Audio nodes per speaker (managed on main thread for smooth ramps)
+ gainNode: audioCtx.createGain(),
+ pannerNode: audioCtx.createStereoPanner(),
+ lowpassNode: audioCtx.createBiquadFilter(),
+ };
+ // lowpass for distance muffling
+ sp.lowpassNode.type = 'lowpass';
+ sp.lowpassNode.frequency.value = 4000;
+ sp.lowpassNode.Q.value = 0.7;
+ // Connect: lowpass → panner → gain → (connected to mixer later)
+ sp.lowpassNode.connect(sp.pannerNode);
+ sp.pannerNode.connect(sp.gainNode);
+ sp.gainNode.gain.value = 1.0; // Start with volume (worklet needs non-zero gain)
+
+ remoteSpeakers.set(socketId, sp);
+ // Register speaker in playback worklet
+ if (playbackNode) {
+ playbackNode.port.postMessage({ type: 'addSpeaker', id: socketId, gain: 1.0, pan: 0 });
+ console.log('[voice] addSpeaker posted to worklet, id:', socketId?.substring(0,8), 'gain:', sp.gainNode.gain.value);
+ }
+ initDecoderForSpeaker(socketId, codec);
+ return sp;
+ }
+
+ function removeRemoteSpeaker(socketId) {
+ const sp = remoteSpeakers.get(socketId);
+ if (!sp) return;
+ try { sp.gainNode.disconnect(); } catch(e) {}
+ try { sp.pannerNode.disconnect(); } catch(e) {}
+ try { sp.lowpassNode.disconnect(); } catch(e) {}
+ if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} }
+ remoteSpeakers.delete(socketId);
+ if (playbackNode) {
+ playbackNode.port.postMessage({ type: 'removeSpeaker', id: socketId });
+ }
+ }
+
+ function updateSpeakerSpatial(sp) {
+ if (!audioCtx) return;
+ // Calculate distance-based audio
+ const dx = sp.x - player.x;
+ const dy = sp.y - player.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ const now = audioCtx.currentTime;
+ const rampTime = 0.05; // 50ms smooth ramp
+
+ if (sp.mode === 'world' && voiceMode === 'world') {
+ // World mode: full volume, no panning, no distance filter
+ sp.gainNode.gain.cancelScheduledValues(now);
+ sp.gainNode.gain.linearRampToValueAtTime(1.0, now + rampTime);
+ sp.pannerNode.pan.cancelScheduledValues(now);
+ sp.pannerNode.pan.linearRampToValueAtTime(0, now + rampTime);
+ sp.lowpassNode.frequency.cancelScheduledValues(now);
+ sp.lowpassNode.frequency.linearRampToValueAtTime(4000, now + rampTime);
+ } else if (dist > VC.voiceRadius) {
+ // Out of range
+ sp.gainNode.gain.cancelScheduledValues(now);
+ sp.gainNode.gain.linearRampToValueAtTime(0, now + rampTime);
+ } else {
+ // Near mode with spatial audio
+ const volume = 1 / (1 + dist / 150); // Perceptual falloff
+ const pan = Math.max(-1, Math.min(1, dx / 300)); // Stereo pan based on X
+ const cutoff = 4000 - (3800 * (dist / VC.voiceRadius)); // Muffle at distance
+
+ sp.gainNode.gain.cancelScheduledValues(now);
+ sp.gainNode.gain.linearRampToValueAtTime(volume, now + rampTime);
+ sp.pannerNode.pan.cancelScheduledValues(now);
+ sp.pannerNode.pan.linearRampToValueAtTime(pan, now + rampTime);
+ sp.lowpassNode.frequency.cancelScheduledValues(now);
+ sp.lowpassNode.frequency.linearRampToValueAtTime(Math.max(200, cutoff), now + rampTime);
+ }
+
+ // Update worklet spatial params
+ if (playbackNode) {
+ const currentGain = sp.gainNode.gain.value;
+ const currentPan = sp.pannerNode.pan.value;
+ if (currentGain > 0.01) console.log('[voice] updateSpatial, id:', sp.id?.substring(0,8), 'gain:', currentGain.toFixed(3), 'pan:', currentPan.toFixed(3));
+ playbackNode.port.postMessage({
+ type: 'updateSpatial',
+ id: sp.id,
+ gain: currentGain,
+ pan: currentPan
+ });
+ }
+ }
+
+ // ==================== UI ====================
+
+ const voiceBtn = document.createElement('div');
+ voiceBtn.innerHTML = '🎤/';
+ voiceBtn.title = 'Голосовой чат (выкл)';
+ voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
+ document.querySelector('.ui').appendChild(voiceBtn);
+
+ const voiceModeBtn = document.createElement('div');
+ voiceModeBtn.innerHTML = '📢';
+ voiceModeBtn.title = 'Режим: рядом (600px)';
+ voiceModeBtn.style.cssText = 'position:absolute;top:74px;right:190px;width:48px;height:52px;border-radius:12px;background:#3498db;z-index:200;display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;border:2px solid rgba(255,255,255,0.7);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;color:#fff;font-weight:bold;';
+ document.querySelector('.ui').appendChild(voiceModeBtn);
+ voiceModeBtn.onclick = () => {
+ if (voiceMode === 'near') {
+ voiceMode = 'world';
+ voiceModeBtn.innerHTML = '🌍';
+ voiceModeBtn.title = 'Режим: весь мир';
+ voiceModeBtn.style.background = '#e67e22';
+ } else {
+ voiceMode = 'near';
+ voiceModeBtn.innerHTML = '📢';
+ voiceModeBtn.title = 'Режим: рядом (600px)';
+ voiceModeBtn.style.background = '#3498db';
+ }
+ if (voiceSocket && voiceSocket.connected) {
+ voiceSocket.emit('voice_mode', { mode: voiceMode });
+ }
+ // Update spatial for all speakers
+ for (const [id, sp] of remoteSpeakers) updateSpeakerSpatial(sp);
+ };
+
+ // Speaking indicators per players
+ const speakingIndicator = document.createElement('div');
+ speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;max-width:200px;line-height:1.4;';
+ document.querySelector('.ui').appendChild(speakingIndicator);
+ let speakingTimeouts = new Map(); // socketId → timeout
+
+ // Codec indicator
+ const codecIndicator = document.createElement('div');
+ codecIndicator.style.cssText = 'position:absolute;top:180px;right:10px;z-index:200;font-size:10px;color:rgba(255,255,255,0.5);pointer-events:none;';
+ codecIndicator.textContent = '';
+ document.querySelector('.ui').appendChild(codecIndicator);
+
+ // ==================== VOICE ON/OFF ====================
+
+ voiceBtn.onclick = async () => {
+ if (voiceActive) {
+ // === DISABLE ===
+ voiceActive = false;
+ voiceBtn.innerHTML = '🎤/';
+ voiceBtn.style.background = '#555';
+ codecIndicator.textContent = '';
+
+ // Clean up capture
+ if (captureNode) { captureNode.disconnect(); captureNode = null; }
+ if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; }
+
+ // Clean up playback
+ if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
+
+ // Clean up per-speaker nodes
+ for (const [id, sp] of remoteSpeakers) {
+ try { sp.gainNode.disconnect(); } catch(e) {}
+ try { sp.pannerNode.disconnect(); } catch(e) {}
+ try { sp.lowpassNode.disconnect(); } catch(e) {}
+ if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} }
+ }
+ remoteSpeakers.clear();
+
+ // Clean up encoder
+ if (voiceEncoder) { try { voiceEncoder.close(); } catch(e) {} voiceEncoder = null; }
+
+ if (audioCtx) { audioCtx.close(); audioCtx = null; }
+ if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
+ speakingIndicator.style.display = 'none';
+ return;
+ }
+
+ // === ENABLE ===
+ try {
+ voiceStream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ echoCancellationType: 'browser',
+ noiseSuppressionType: 'browser',
+ sampleRate: { ideal: VC.sampleRate },
+ channelCount: { ideal: 1 }
+ }
+ });
+
+ audioCtx = new AudioContext({ sampleRate: VC.sampleRate });
+ if (audioCtx.state === 'suspended') await audioCtx.resume();
+ console.log('[voice] AudioContext:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
+
+ // Register worklets via Blob URLs
+ const captureURL = URL.createObjectURL(new Blob([voiceCaptureWorkletCode], { type: 'application/javascript' }));
+ const playbackURL = URL.createObjectURL(new Blob([voicePlaybackWorkletCode], { type: 'application/javascript' }));
+
+ await audioCtx.audioWorklet.addModule(captureURL);
+ await audioCtx.audioWorklet.addModule(playbackURL);
+ URL.revokeObjectURL(captureURL);
+ URL.revokeObjectURL(playbackURL);
+ console.log('[voice] AudioWorklets registered');
+
+ // Connect mic → capture worklet
+ const micSource = audioCtx.createMediaStreamSource(voiceStream);
+ captureNode = new AudioWorkletNode(audioCtx, 'voice-capture');
+ micSource.connect(captureNode);
+ // Do NOT connect captureNode to destination — we don't hear ourselves
+
+ // Connect playback worklet → per-speaker gain nodes → destination
+ playbackNode = new AudioWorkletNode(audioCtx, 'voice-playback', { numberOfOutputs: 1, outputChannelCount: [2] });
+ playbackNode.connect(audioCtx.destination);
+ // Per-speaker nodes will connect between playback worklet output and destination
+ // Actually: playback worklet mixes internally → stereo output → destination
+ // Per-speaker Web Audio nodes (lowpass, pan, gain) used for spatial UPDATES only (values sent to worklet via messages)
+ // The actual mixing happens inside the worklet
+
+ // Connect to voice server FIRST so capture frames have a socket
+ voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
+
+ // Handle capture frames
+ voiceSeq = 0;
+ voiceTimestamp = 0;
+ wasSpeaking = false;
+ voiceActive = true; // Enable capture BEFORE onmessage handler
+ let _frameCount = 0;
+ captureNode.port.onmessage = (e) => {
+ const { type, samples, speaking, rms } = e.data;
+ if (type !== 'frame') return;
+ _frameCount++;
+ if (_frameCount <= 5 || _frameCount % 100 === 0) {
+ console.log('[voice] capture frame #', _frameCount, 'rms:', rms?.toFixed(4), 'speaking:', speaking, 'voiceActive:', voiceActive, 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected, 'codec:', voiceCodec);
+ }
+ if (!voiceActive || !voiceSocket || !voiceSocket.connected) {
+
+ return;
+ }
+
+ voiceTimestamp += VC.samplesPerFrame; // 320 samples * (1/16000) = 20ms per frame
+
+ if (!speaking && !wasSpeaking) {
+ // Complete silence — don't transmit
+ silenceFrames++;
+ return;
+ }
+
+ if (voiceCodec === 'opus' && voiceEncoder) {
+ // Encode with Opus via WebCodecs
+ try {
+ const audioData = new AudioData({
+ format: 'float32-planar',
+ sampleRate: VC.sampleRate,
+ numberOfFrames: samples.length,
+ numberOfChannels: 1,
+ timestamp: voiceTimestamp * (1000000 / VC.sampleRate), // microseconds
+ data: samples
+ });
+ voiceEncoder.encode(audioData);
+ audioData.close();
+ } catch (err) {
+ // Fallback: send as PCM
+ sendPCMFrame(samples, speaking, wasSpeaking);
+ }
+ } else {
+ sendPCMFrame(samples, speaking, wasSpeaking);
+ }
+
+ wasSpeaking = speaking;
+ silenceFrames = 0;
+ };
+
+ function sendPCMFrame(samples, isSpeaking, wasSp) {
+ const int16 = new Int16Array(samples.length);
+ for (let i = 0; i < samples.length; i++) {
+ const s = Math.max(-1, Math.min(1, samples[i]));
+ int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
+ }
+ voiceSocket.emit('voice_data', {
+ codec: 'pcm',
+ data: int16.buffer,
+ seq: voiceSeq++,
+ ts: performance.now(),
+ speaking: isSpeaking || wasSp
+ });
+ }
+
+ // Initialize encoder
+ await initVoiceEncoder();
+ codecIndicator.textContent = voiceCodec === 'opus' ? '🔊 Opus' : '🔊 PCM';
+
+ // Voice socket already created above
+
+ voiceSocket.on('connect', () => {
+ console.log('[voice] Connected, id:', voiceSocket.id, 'codec:', voiceCodec);
+ voiceSocket.emit('voice_join', {
+ world_id: worldId,
+ x: player.x, y: player.y,
+ name: playerName || 'Игрок',
+ mode: voiceMode,
+ codec: voiceCodec
+ });
+ });
+
+ voiceSocket.on('connect_error', (err) => {
+ console.error('[voice] Connect error:', err.message);
+ });
+
+ voiceSocket.on('voice_in', (payload) => {
+ console.log('[voice] RX voice_in, codec:', payload.codec, 'dataSize:', payload.data?.byteLength || payload.data?.length || 'N/A', 'from:', payload.meta?.from?.substring(0,8));
+ const { data, meta } = payload;
+ if (!audioCtx || audioCtx.state === 'closed') { console.warn('[voice] audioCtx missing/closed'); return; }
+
+ let sp = remoteSpeakers.get(meta.from);
+ if (!sp) {
+ // New speaker — create per-speaker audio nodes
+ sp = createRemoteSpeaker(meta.from, meta.name, meta.codec || 'pcm');
+ }
+
+ // Update position
+ sp.x = meta.x || 0;
+ sp.y = meta.y || 0;
+ sp.mode = meta.mode || 'near';
+ sp.lastActive = Date.now();
+ updateSpeakerSpatial(sp);
+
+ // Decode and push to worklet
+ if ((meta.codec || 'pcm') === 'opus' && sp.decoder) {
+ // Opus decode
+ try {
+ const uint8 = new Uint8Array(data);
+ const chunk = new EncodedAudioChunk({
+ type: 'key', // Opus frames are self-decodable
+ timestamp: performance.now() * 1000, // microseconds
+ data: uint8
+ });
+ sp.decoder.decode(chunk);
+ } catch (err) {
+ // Opus decode failed, try PCM fallback
+ decodeAndPushPCM(sp.id, data);
+ }
+ } else {
+ decodeAndPushPCM(sp.id, data);
+ }
+
+ // Update speaking indicator
+ sp.speaking = true;
+ speakingIndicator.style.display = 'block';
+ speakingIndicator.textContent = '🔊 ' + sp.name;
+ clearTimeout(speakingTimeouts.get(meta.from));
+ speakingTimeouts.set(meta.from, setTimeout(() => {
+ sp.speaking = false;
+ // Check if anyone is still speaking
+ let anyoneSpeaking = false;
+ for (const [, s] of remoteSpeakers) { if (s.speaking) { anyoneSpeaking = true; break; } }
+ if (!anyoneSpeaking) speakingIndicator.style.display = 'none';
+ speakingTimeouts.delete(meta.from);
+ }, 1500));
+ });
+
+ voiceSocket.on('voice_leave', (data) => {
+ removeRemoteSpeaker(data.id);
+ });
+
+ voiceSocket.on('disconnect', () => {
+ console.log('[voice] Disconnected');
+ // Clear all speakers on disconnect
+ for (const [id] of remoteSpeakers) removeRemoteSpeaker(id);
+ });
+
+ // voiceActive already set to true above
+ voiceBtn.textContent = '🎤';
+ voiceBtn.style.background = '#2ecc71';
+ console.log('[voice] Voice chat ACTIVE, codec:', voiceCodec);
+
+ } catch(e) {
+ console.error('[voice] Error:', e);
+ voiceBtn.style.background = '#e74c3c';
+ }
+ };
+
+ // ==================== PCM decode helper ====================
+
+ function decodeAndPushPCM(speakerId, buffer) {
+ console.log('[voice] decodeAndPushPCM, id:', speakerId?.substring(0,8), 'bufSize:', buffer?.byteLength || buffer?.length || 'N/A');
+ const int16 = new Int16Array(buffer);
+ const float32 = new Float32Array(int16.length);
+ for (let i = 0; i < int16.length; i++) {
+ float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
+ }
+ if (playbackNode) {
+ playbackNode.port.postMessage({
+ type: 'pushFrames',
+ id: speakerId,
+ samples: float32
+ });
+ }
+ }
+
+ // ==================== Voice position update ====================
+
+ let voicePosT = 0;
+
+ // Клик на часы для включения ночи
+ todEl.style.cursor = 'pointer';
+ todEl.onclick = () => {
+ playSound('click');
+ worldTime = 0.6; // Устанавливаем ночь
+ isNightTime = true;
+ };
+
+ function rebuildHotbar(){
+ hotbarEl.innerHTML='';
+
+ // Показываем последние 5 выбранных предметов (если они есть в инвентаре)
+ const items = recentItems.filter(id => inv[id] > 0).slice(0, 5);
+
+ for(const id of items){
+ const s = document.createElement('div');
+ s.className = 'slot'+(id===selected?' sel':'');
+ if(BLOCKS[id]) {
+ s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ s.style.backgroundSize = 'cover';
+ } else if(ITEMS[id]) {
+ s.textContent = ITEMS[id].icon;
+ } else if(TOOLS[id]) {
+ s.textContent = TOOLS[id].icon;
+ } else if(id === 'iron_armor') {
+ s.textContent = '🛡️';
+ s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
+ }
+ const c = document.createElement('div');
+ c.className='count';
+ c.textContent = inv[id];
+ s.appendChild(c);
+ s.onclick = () => {
+ playSound('click'); // Звук клика по инвентарю
+ if(selected === id) {
+ // Повторный клик — снимаем выбор, возвращаем к первому блоку
+ selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
+ } else {
+ selected = id;
+ // Обновляем список последних предметов
+ recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
+ recentItems.unshift(id); // Добавляем в начало
+ recentItems = recentItems.slice(0, 5); // Оставляем только 5
+ }
+ rebuildHotbar();
+ };
+
+ // Показываем индикатор надетой брони
+ if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') {
+ const equipped = document.createElement('div');
+ equipped.className = 'equipped-indicator';
+ equipped.textContent = '✓';
+ s.appendChild(equipped);
+ }
+
+ // Durability bar для инструментов
+ if(TOOLS[id] && inv[id] > 0) {
+ // Находим текущую прочность
+ let curDur = 0, maxDur = TOOLS[id].durability;
+ for (const [tid, dur] of toolDurability) {
+ if (dur.type === id) {
+ curDur = dur.current;
+ maxDur = dur.max;
+ break;
+ }
+ }
+ if (maxDur > 0) {
+ const bar = document.createElement('div');
+ bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
+ const fill = document.createElement('div');
+ const pct = curDur / maxDur;
+ const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
+ fill.style.cssText = `width:${pct*100}%;height:100%;background:${color};`;
+ bar.appendChild(fill);
+ s.appendChild(bar);
+ }
+ }
+ hotbarEl.appendChild(s);
+ }
+ }
+
+ function renderInventory() {
+ inventoryGrid.innerHTML = '';
+
+ // Создаём сетку инвентаря 7x3
+ const items = Object.keys(inv).filter(id => inv[id] > 0);
+
+ // Добавляем пустые слоты для полной сетки
+ for(let i = 0; i < 21; i++) {
+ const slot = document.createElement('div');
+ slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
+
+ if(i < items.length) {
+ const id = items[i];
+ if(BLOCKS[id]) {
+ slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ slot.style.backgroundSize = 'cover';
+ } else if(ITEMS[id]) {
+ slot.textContent = ITEMS[id].icon;
+ } else if(TOOLS[id]) {
+ slot.textContent = TOOLS[id].icon;
+ } else if(id === 'iron_armor') {
+ slot.textContent = '🛡️';
+ slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
+ }
+
+ const count = document.createElement('div');
+ count.className = 'inv-count';
+ count.textContent = inv[id];
+ slot.appendChild(count);
+
+ slot.onclick = () => {
+ playSound('click'); // Звук клика по инвентарю
+ if(selected === id) {
+ selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
+ } else {
+ selected = id;
+ recentItems = recentItems.filter(item => item !== id);
+ recentItems.unshift(id);
+ recentItems = recentItems.slice(0, 5);
+ }
+ rebuildHotbar();
+ renderInventory();
+ };
+
+ // Двойной клик для надевания брони
+ slot.ondblclick = () => {
+ if(id === 'iron_armor' && inv.iron_armor > 0) {
+ // Если уже надета броня - снимаем её
+ if(player.equippedArmor === 'iron_armor') {
+ player.equippedArmor = null;
+ player.armor = 0;
+ console.log('[ARMOR] Iron armor unequipped');
+ } else {
+ // Надеваем броню
+ player.equippedArmor = 'iron_armor';
+ player.armor = BLOCKS['iron_armor'].armor;
+ console.log('[ARMOR] Iron armor equipped - armor:', player.armor);
+ }
+ playSound('click');
+ renderInventory();
+ }
+ if(id === 'gold_armor' && inv.gold_armor > 0) {
+ if(player.equippedArmor === 'gold_armor') {
+ player.equippedArmor = null;
+ player.armor = 0;
+ } else {
+ player.equippedArmor = 'gold_armor';
+ player.armor = ITEMS['gold_armor'].armor;
+ }
+ playSound('click');
+ renderInventory();
+ }
+ };
+ }
+
+ inventoryGrid.appendChild(slot);
+ }
+ }
+
+ function canCraft(r){
+ console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost);
+ for(const res in r.cost){
+ const have = inv[res] || 0;
+ const need = r.cost[res];
+ console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need);
+ if(have < need) return false;
+ }
+ return true;
+ }
+ function renderCraft(){
+ recipesEl.innerHTML='';
+ for(const r of RECIPES){
+ const row = document.createElement('div');
+ row.className='recipe';
+ const reqLv = r.requiredLevel || 1;
+ const locked = player.level < reqLv;
+ const icon = document.createElement('div');
+ icon.className='ricon';
+ // Иконка — блок, инструмент или предмет
+ if(tex[r.out]){
+ icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
+ } else if(TOOLS[r.out]){
+ icon.textContent = TOOLS[r.out].icon;
+ icon.style.fontSize = '24px';
+ icon.style.display = 'flex';
+ icon.style.alignItems = 'center';
+ icon.style.justifyContent = 'center';
+ } else if(ITEMS[r.out]){
+ icon.textContent = ITEMS[r.out].icon;
+ icon.style.fontSize = '24px';
+ icon.style.display = 'flex';
+ icon.style.alignItems = 'center';
+ icon.style.justifyContent = 'center';
+ }
+ const info = document.createElement('div');
+ info.className='rinfo';
+ const nm = document.createElement('div');
+ nm.className='rname';
+ const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
+ nm.textContent = `${itemName} x${r.qty}` + (locked ? ` (Lv.${reqLv})` : '');
+ if(locked) { nm.style.color = '#888'; nm.style.textDecoration = 'line-through'; }
+ const cs = document.createElement('div');
+ cs.className='rcost';
+ cs.textContent = Object.keys(r.cost).map(x => {
+ const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
+ return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`;
+ }).join(' ');
+ if(locked) cs.style.color = '#666';
+ info.appendChild(nm); info.appendChild(cs);
+ const btn = document.createElement('button');
+ btn.className='rcraft';
+ btn.textContent='Создать';
+ btn.disabled = !canCraft(r) || locked;
+ if(locked) btn.title = `Требуется уровень ${reqLv}`;
+ btn.onclick = () => {
+ if(!canCraft(r) || locked) return;
+ playSound('click');
+ for(const res in r.cost) inv[res]-=r.cost[res];
+ inv[r.out] = (inv[r.out]||0) + r.qty;
+ if(TOOLS[r.out]) addTool(r.out);
+ rebuildHotbar();
+ renderCraft();
+ };
+ row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
+ recipesEl.appendChild(row);
+ }
+ }
+
+ let craftOpen=false;
+ let inventoryOpen = false;
+
+ document.getElementById('craftBtn').onclick = () => {
+ playSound('click'); // Звук клика по кнопке
+ craftOpen = !craftOpen;
+ craftPanel.style.display = craftOpen ? 'block' : 'none';
+ if(craftOpen) {
+ renderCraft();
+ // Закрываем инвентарь если открыт крафт
+ inventoryOpen = false;
+ inventoryPanel.style.display = 'none';
+ }
+ };
+ document.getElementById('craftClose').onclick = () => {
+ playSound('click'); // Звук клика по кнопке
+ craftOpen = false;
+ craftPanel.style.display = 'none';
+ };
+
+ // Кнопка открытия инвентаря
+ document.getElementById('invToggle').onclick = () => {
+ playSound('click'); // Звук клика по кнопке
+ inventoryOpen = !inventoryOpen;
+ inventoryPanel.style.display = inventoryOpen ? 'block' : 'none';
+ if(inventoryOpen) {
+ renderInventory();
+ // Закрываем крафт если открыт инвентарь
+ craftOpen = false;
+ craftPanel.style.display = 'none';
+ }
+ };
+
+ document.getElementById('inventoryClose').onclick = () => {
+ playSound('click'); // Звук клика по кнопке
+ inventoryOpen = false;
+ inventoryPanel.style.display = 'none';
+ };
+ // Меню ⋯ (Сохранить / Новый мир / Рынок)
+ let dropdownOpen = false;
+ const menuBtn = document.getElementById('menuBtn');
+ const menuDropdown = document.getElementById('menuDropdown');
+ const saveItem = document.getElementById('saveItem');
+ const resetItem = document.getElementById('resetItem');
+ const marketItem = document.getElementById('marketItem');
+
+ // Убить старый onclick (предотвращает race condition)
+ menuBtn.onclick = null;
+
+ // Toggle dropdown
+ menuBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ dropdownOpen = !dropdownOpen;
+ menuDropdown.classList.toggle('open', dropdownOpen);
+ });
+
+ // Закрытие по клику вне
+ document.addEventListener('click', () => {
+ if (dropdownOpen) {
+ dropdownOpen = false;
+ menuDropdown.classList.remove('open');
+ }
+ });
+
+ // Предотвращаем закрытие при клике внутри дропдауна
+ menuDropdown.addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
+
+ // 💾 Сохранить
+ saveItem.addEventListener('click', () => {
+ playSound('click');
+ saveGame();
+ dropdownOpen = false;
+ menuDropdown.classList.remove('open');
+ customAlert('Игра сохранена!');
+ });
+
+ // 🔄 Новый мир
+ resetItem.addEventListener('click', () => {
+ dropdownOpen = false;
+ menuDropdown.classList.remove('open');
+ customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
+ playSound('click');
+
+ try {
+ localStorage.removeItem(SAVE_KEY);
+ console.log('Сохранение удалено из localStorage');
+ } catch (e) {
+ console.warn('Ошибка удаления сохранения:', e);
+ }
+
+ inMemorySave = null;
+ worldId = Math.random().toString(36).substring(2, 10);
+ console.log('Новый worldId после сброса:', worldId);
+
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('world', worldId);
+ if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
+ window.history.replaceState(null, '', newUrl.toString());
+ console.log('URL обновлён:', newUrl.toString());
+ }
+ } catch (e) {
+ console.error('Ошибка обновления URL:', e);
+ }
+
+ location.reload();
+ });
+ });
+
+ // 🏪 Рынок
+ marketItem.addEventListener('click', () => {
+ dropdownOpen = false;
+ menuDropdown.classList.remove('open');
+ toggleMarket();
+ });
+
+ // Показываем пункт сохранения только если играем одни
+ function updateSaveButtonVisibility() {
+ if (isMultiplayer && otherPlayers.size > 0) {
+ saveItem.style.display = 'none';
+ } else {
+ saveItem.style.display = 'block';
+ }
+ }
+
+ // ==================== РЫНОК ПРЕДЛОЖЕНИЙ ====================
+ const marketPanel = document.getElementById('marketPanel');
+ const marketClose = document.getElementById('marketClose');
+ const marketContent = document.getElementById('marketContent');
+ let marketOpen = false;
+
+ function toggleMarket() {
+ marketOpen = !marketOpen;
+ marketPanel.style.display = marketOpen ? 'block' : 'none';
+ if (marketOpen) renderMarket();
+ }
+
+ function closeMarket() {
+ marketOpen = false;
+ marketPanel.style.display = 'none';
+ }
+
+ function renderMarket() {
+ if (!socket) {
+ marketContent.innerHTML = 'Подключение...
';
+ return;
+ }
+ // Запрос ордеров с сервера
+ socket.emit('market_list', {}, (orders) => {
+ if (!orders || !orders.length) {
+ marketContent.innerHTML = 'Предложений пока нет
' +
+ '';
+ const btn = document.getElementById('createOrderBtn');
+ if (btn) btn.onclick = showCreateOrder;
+ return;
+ }
+ let html = '';
+ html += orders.map(o => '' + o.offer_item + ' ×' + o.offer_qty + ' ⇄ ' + o.want_item + ' ×' + o.want_qty + '' + (o.seller||'Аноним') + '
').join('');
+ marketContent.innerHTML = html;
+ const btn = document.getElementById('createOrderBtn');
+ if (btn) btn.onclick = showCreateOrder;
+ });
+ }
+
+ function showCreateOrder() {
+ const inv = playerInventory || [];
+ const items = inv.filter(s => s && s.id);
+ marketContent.innerHTML = 'Выставить предмет
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
';
+ document.getElementById('cancelOrder').onclick = () => renderMarket();
+ document.getElementById('submitOrder').onclick = () => {
+ const offerItem = document.getElementById('offerItem').value;
+ const wantItem = document.getElementById('wantItem').value.trim();
+ if (!offerItem || !wantItem) { customAlert('Выберите предмет и укажите что хотите'); return; }
+ if (socket) { socket.emit('market_create', { offer_item: offerItem, offer_qty: 1, want_item: wantItem, want_qty: 1 }); playSound('click'); setTimeout(renderMarket, 500); }
+ };
+ }
+
+ marketClose.onclick = () => closeMarket();
+
+// Режимы
+ const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}];
+ let modeIdx=0;
+ const modeBtn = document.getElementById('modeBtn');
+ function mode(){ return MODES[modeIdx].id; }
+ modeBtn.onclick = () => {
+ playSound('click'); // Звук клика по кнопке режима
+ modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon;
+ };
+
+ // День/ночь (автоматический цикл)
+ let isNightTime = false;
+
+ // Управление
+ const inp = { l:false, r:false, j:false, s:false };
+ function bindHold(el, key){
+ const down=(e)=>{ e.preventDefault(); inp[key]=true; };
+ const up=(e)=>{ e.preventDefault(); inp[key]=false; };
+ el.addEventListener('pointerdown', down);
+ el.addEventListener('pointerup', up);
+ el.addEventListener('pointerleave', up);
+ }
+ const leftBtn = document.getElementById('left');
+ const rightBtn = document.getElementById('right');
+ const jumpBtn = document.getElementById('jump');
+ const downBtn = document.getElementById('down');
+
+ if(leftBtn) bindHold(leftBtn,'l');
+ if(rightBtn) bindHold(rightBtn,'r');
+ if(jumpBtn) bindHold(jumpBtn,'j');
+ if(downBtn) bindHold(downBtn,'s');
+
+ window.addEventListener('keydown', (e)=>{
+ if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true;
+ if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true;
+ if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true;
+ if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true;
+ });
+ window.addEventListener('keyup', (e)=>{
+ if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false;
+ if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false;
+ if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false;
+ if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false;
+ });
+
+ // Лодка
+ const boat = {
+ x: 0, y: 0,
+ w: 34, h: 34,
+ vx: 0, vy: 0,
+ active: false,
+ inWater: false
+ };
+
+ // Функция для расчёта урона с учётом брони
+ function calculateDamage(baseDamage) {
+ // Броня снижает урон пропорционально
+ // armor: 0 = без брони (100% урона)
+ // armor: 0.5 = железная броня (50% урона)
+ const reduction = player.armor;
+ const actualDamage = baseDamage * (1 - reduction);
+ console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
+ return actualDamage;
+ }
+
+ // Игрок
+ const player = {
+ x: 6*TILE, y: 0*TILE,
+ w: 34, h: 34,
+ vx: 0, vy: 0,
+ grounded: false,
+ inWater: false,
+ headInWater: false,
+ hp: 100,
+ hunger: 100,
+ o2: 100,
+ invuln: 0,
+ slowTimer: 0, // яд скорпиона — замедление
+ fallStartY: 0,
+ lastStepTime: 0,
+ sleeping: false,
+ inBoat: false,
+ armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
+ equippedArmor: null, // Тип надетой брони
+ xp: 0,
+ level: 1
+ };
+
+ // Сохраняем начальную позицию для возрождения
+ const spawnPoint = { x: 6*TILE, y: 0*TILE };
+
+ // Система дропов с мобов
+ const drops = []; // {x, y, vy, item, qty, age}
+ let levelUpPopup = null; // {text, timer}
+
+ function xpForLevel(lv) {
+ if (lv <= 1) return 0;
+ const thresholds = [0, 50, 150, 300, 500, 800, 1200, 1700, 2300, 3000];
+ if (lv - 1 < thresholds.length) return thresholds[lv - 1];
+ return Math.floor(3000 + (lv - 10) * (lv - 10) * 50 + (lv - 10) * 200);
+ }
+
+ function getMobLoot(kind) {
+ const table = {
+ chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
+ pig: [{item:'meat',min:1,max:2,chance:1}],
+ zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
+ skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
+ creeper: [{item:'gunpowder',min:1,max:2,chance:1}],
+ scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
+ polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
+ slime: [{item:'slime_ball',min:1,max:2,chance:1}],
+ eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}]
+ };
+ return table[kind] || [];
+ }
+
+ function getMobXP(kind) {
+ const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15, scorpion:8, polar_bear:20, slime:5, eagle:15 };
+ return xpTable[kind] || 0;
+ }
+
+ function spawnDrops(mx, my, kind) {
+ const loot = getMobLoot(kind);
+ for (const entry of loot) {
+ if (Math.random() > entry.chance) continue;
+ const qty = entry.min + Math.floor(Math.random() * (entry.max - entry.min + 1));
+ if (qty <= 0) continue;
+ drops.push({
+ x: mx + (Math.random() - 0.5) * 20,
+ y: my + (Math.random() - 0.5) * 10,
+ vy: -1 - Math.random() * 2,
+ item: entry.item,
+ qty: qty,
+ age: 0, xpValue: getMobXP(kind)
+ });
+ }
+ }
+
+ const LEVEL_UNLOCKS = {
+ 2: 'Каменные инструменты, Лодка',
+ 3: 'Железные инструменты, Печь',
+ 4: 'Лук и стрелы',
+ 5: 'Железная броня',
+ 6: 'Золотая броня',
+ 7: 'Алмазные инструменты',
+ 8: 'TNT'
+ };
+
+ function grantXP(amount) {
+ player.xp += amount;
+ while (player.xp >= xpForLevel(player.level + 1)) {
+ player.level++;
+ const unlock = LEVEL_UNLOCKS[player.level] || '';
+ levelUpPopup = { text: '⭐ Уровень ' + player.level + '!' + (unlock ? ' ' + unlock : ''), timer: 240 };
+ }
+ }
+
+ function pickupDrops() {
+ for (let i = drops.length - 1; i >= 0; i--) {
+ const d = drops[i];
+ const dx = player.x + player.w/2 - d.x;
+ const dy = player.y + player.h/2 - d.y;
+ if (dx*dx + dy*dy < 30*30) {
+ if (!inv[d.item]) inv[d.item] = 0;
+ inv[d.item] += d.qty;
+ if (d.xpValue) grantXP(Math.ceil(d.xpValue * 0.3));
+ drops.splice(i, 1);
+ rebuildHotbar();
+ }
+ }
+ }
+
+ function drawDrops(ctx) {
+ for (let i = drops.length - 1; i >= 0; i--) {
+ const d = drops[i];
+ d.age++;
+ if (d.age > 3600) { drops.splice(i, 1); continue; } // 60 sec at 60fps
+ // Bounce animation
+ const bounce = Math.abs(Math.sin(d.age * 0.05)) * 6;
+ const dy = d.y - bounce;
+ const sx = d.x - camX;
+ const sy = dy - camY;
+ // Skip if off screen
+ if (sx < -40 || sx > W + 40 || sy < -40 || sy > H + 40) continue;
+ // Glow effect
+ ctx.save();
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = '#ffff00';
+ ctx.beginPath();
+ ctx.arc(sx, sy, 14, 0, Math.PI*2);
+ ctx.fill();
+ ctx.restore();
+ // Item icon
+ ctx.save();
+ ctx.font = '16px system-ui';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const itemDef = ITEMS[d.item];
+ const icon = itemDef ? itemDef.icon : '🎁';
+ const label = d.qty > 1 ? icon + '×' + d.qty : icon;
+ ctx.fillText(label, sx, sy);
+ ctx.restore();
+ }
+ }
+
+ function drawLevelUpPopup(ctx) {
+ if (!levelUpPopup) return;
+ levelUpPopup.timer--;
+ if (levelUpPopup.timer <= 0) { levelUpPopup = null; return; }
+ const alpha = Math.min(1, levelUpPopup.timer / 60);
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = '#FFD700';
+ ctx.font = 'bold 36px system-ui';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.strokeStyle = '#000';
+ ctx.lineWidth = 3;
+ ctx.strokeText(levelUpPopup.text, W/2, H/2 - 60);
+ ctx.fillText(levelUpPopup.text, W/2, H/2 - 60);
+ ctx.restore();
+ }
+
+ // Система сохранения игры (localStorage + in-memory fallback)
+ const SAVE_KEY = 'minegrechka_save';
+ let db = null; // Оставляем для совместимости, но не используем
+ let inMemorySave = null; // Запасное сохранение в памяти
+
+ // Инициализация (localStorage + in-memory fallback)
+ function initDB(){
+ return new Promise((resolve) => {
+ console.log('Используем localStorage для сохранений (sandbox режим)');
+ resolve(null);
+ });
+ }
+
+ // Детерминированный генератор псевдослучайных чисел на основе seed
+ function seededRandom(gx, gy){
+ const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453;
+ return n - Math.floor(n);
+ }
+
+ function saveGame(){
+ const saveData = {
+ version: 2,
+ worldSeed: worldSeed,
+ player: {
+ x: player.x,
+ y: player.y,
+ hp: player.hp,
+ hunger: player.hunger,
+ o2: player.o2,
+ xp: player.xp,
+ level: player.level
+ },
+ temperature: player.temperature,
+ inventory: inv,
+ time: worldTime,
+ isNight: isNightTime,
+ // Сохраняем только изменения
+ placedBlocks: placedBlocks.slice(),
+ removedBlocks: removedBlocks.slice()
+ };
+
+ const saveSize = JSON.stringify(saveData).length;
+ console.log('Сохранение: player HP:', player.hp, 'hunger:', player.hunger, 'o2:', player.o2);
+
+ // Пробуем сохранить в localStorage (основной метод для персистентности)
+ try {
+ localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
+ console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
+ // TG: also save to server
+ if (tgUser && tgUser.id) {
+ try {
+ fetch(SERVER_URL + '/api/tg/save', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tg_id: tgUser.id, save_data: JSON.stringify(saveData), world_id: worldId, player_name: playerName })
+ }).catch(e2 => console.warn('[TG] Save to server failed:', e2));
+ } catch(tgE) {}
+ }
+ } catch(e){
+ console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
+
+ // Если localStorage недоступен, используем in-memory fallback
+ inMemorySave = saveData;
+ console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
+ }
+ }
+
+ function loadGame(){
+ return new Promise(async (resolve, reject) => {
+ // TG: try server save first
+ if (tgUser && tgUser.id && tgSaveLoaded) {
+ try {
+ const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
+ const data = await resp.json();
+ if (data.ok && data.save_data) {
+ const parsed = JSON.parse(data.save_data);
+ console.log('[TG] Loaded save from server, HP:', parsed.player?.hp);
+ tgSaveLoaded = parsed; // store for later use
+ resolve(parsed);
+ return;
+ }
+ } catch(e) { console.warn('[TG] Load from server failed:', e); }
+ }
+ // Пробуем localStorage
+ try {
+ const localSave = localStorage.getItem(SAVE_KEY);
+ if(localSave){
+ const parsed = JSON.parse(localSave);
+ console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
+ resolve(parsed);
+ return;
+ }
+ } catch(e){
+ console.warn('Ошибка доступа к localStorage:', e);
+ }
+
+ // Если localStorage недоступен, используем in-memory сохранение
+ if(inMemorySave){
+ console.log('Загружено из in-memory сохранения, player HP:', inMemorySave.player?.hp);
+ resolve(inMemorySave);
+ return;
+ }
+
+ console.log('Сохранение не найдено');
+ resolve(null);
+ });
+ }
+
+ // Миграция с версии 1 на версию 2
+ function migrateV1toV2(saveData){
+ console.log('Миграция сохранения с версии 1 на версию 2...');
+
+ // Сохраняем seed из текущей игры (так как v1 его не хранил)
+ saveData.worldSeed = worldSeed;
+
+ // Инициализируем массивы изменений
+ saveData.placedBlocks = [];
+ saveData.removedBlocks = [];
+
+ // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
+ // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
+ // и при загрузке просто перегенерируем мир
+
+ // Удаляем старые данные
+ delete saveData.generatedBlocks;
+
+ saveData.version = 2;
+ console.log('Миграция завершена');
+ }
+
+ async function applySave(saveData){
+ if(!saveData) return;
+
+ console.log('=== applySave START ===');
+ console.log('player HP before applySave:', player.hp);
+ console.log('saveData.player.hp:', saveData.player?.hp);
+
+ // Миграция версий
+ if(saveData.version === 1){
+ player.temperature = saveData.player.temperature !== undefined ? saveData.player.temperature : 15;
+ migrateV1toV2(saveData);
+ }
+
+ // Восстанавливаем seed
+ if(saveData.worldSeed !== undefined){
+ worldSeed = saveData.worldSeed;
+ }
+
+ // Восстанавливаем игрока
+ if(saveData.player){
+ player.x = saveData.player.x;
+ player.y = saveData.player.y;
+ player.hunger = saveData.player.hunger;
+ player.o2 = saveData.player.o2;
+ player.xp = saveData.player.xp || 0;
+ player.level = saveData.player.level || 1;
+
+ // Обновляем spawnPoint на позицию из сохранения
+ spawnPoint.x = player.x;
+ spawnPoint.y = player.y;
+
+ // Проверяем HP из сохранения - если <= 0, устанавливаем 100
+ const savedHP = saveData.player.hp;
+ console.log('Saved HP from file:', savedHP);
+ if(savedHP <= 0){
+ console.log('WARNING: Saved HP is <= 0, setting to 100!');
+ player.hp = 100;
+ } else {
+ player.hp = savedHP;
+ }
+ console.log('player HP after restore:', player.hp);
+ console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y);
+ } else {
+ console.log('No player data in save, setting default HP: 100');
+ player.hp = 100;
+ }
+
+ console.log('=== applySave END ===');
+
+ // Восстанавливаем инвентарь
+ if(saveData.inventory){
+ for(const key in saveData.inventory){
+ inv[key] = saveData.inventory[key];
+ }
+ }
+
+ // Восстанавливаем время
+ if(saveData.time !== undefined){
+ worldTime = saveData.time;
+ }
+
+ // Восстанавливаем день/ночь
+ if(saveData.isNight !== undefined){
+ isNightTime = saveData.isNight;
+ }
+
+ // Перегенерируем мир по seed
+ regenerateVisibleChunks();
+
+ // Применяем изменения (только для v2)
+ if(saveData.version === 2){
+ // Применяем блоки, установленные игроком
+ for(const block of saveData.placedBlocks){
+ setBlock(block.gx, block.gy, block.t, true);
+ }
+
+ // Применяем удалённые блоки
+ for(const block of saveData.removedBlocks){
+ removeBlock(block.gx, block.gy);
+ }
+
+ // Восстанавливаем массивы изменений
+ placedBlocks = saveData.placedBlocks || [];
+ removedBlocks = saveData.removedBlocks || [];
+ }
+
+ rebuildHotbar();
+ console.log('Игра загружена');
+ }
+
+ // Камера (двухосевая)
+ let camX=0, camY=0;
+
+ // День/ночь
+ let worldTime=0;
+ const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза
+
+ // Облака
+ const clouds = Array.from({length:10}, ()=>({
+ x: Math.random()*2000,
+ y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается
+ w: 80+Math.random()*120,
+ s: 12+Math.random()*20
+ }));
+
+ // Дождь
+ let isRaining = false;
+ let rainIntensity = 0; // 0..1
+ const snowflakes = []; // снежинки для тундры
+ const MAX_SNOWFLAKES = 150;
+ const raindrops = [];
+ const MAX_RAINDROPS = 200;
+
+ // ==================== РОСТ КУЛЬТУР ====================
+ const growthTimers = {}; // ключ: "gx,gy" → { stage:0-3, growTimer:X }
+
+ // Старая функция заменена — теперь погода через биомы (см. weatherState выше)
+ // updateWeather(dt) вызывается из основного цикла — биом-зависимая
+
+ // Интеграция: определяем isRaining из weatherState для визуализации
+ function syncWeatherVisual() {
+ isRaining = (weatherState.type === 'rain' || weatherState.type === 'storm');
+ if (weatherState.type === 'clear') {
+ rainIntensity *= 0.95; // плавное затухание
+ if (rainIntensity < 0.01) rainIntensity = 0;
+ }
+ }
+
+ function updateRain(dt) {
+ syncWeatherVisual();
+ // Дождь
+ if ((weatherState.type === 'rain' || weatherState.type === 'storm') && rainIntensity < weatherState.intensity * 0.6) {
+ rainIntensity += dt * 0.3;
+ } else if (weatherState.type === 'clear' || weatherState.type === 'snow' || weatherState.type === 'fog') {
+ rainIntensity = Math.max(0, rainIntensity - dt * 0.5);
+ }
+ if (rainIntensity < 0.01) {
+ raindrops.length = 0;
+ } else {
+ const spawnRate = Math.floor(rainIntensity * 80 * dt);
+ for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) {
+ raindrops.push({
+ x: camX + Math.random() * W,
+ y: camY - 20,
+ vy: 400 + Math.random() * 200,
+ len: 8 + Math.random() * 12
+ });
+ }
+ for (let i = raindrops.length - 1; i >= 0; i--) {
+ const d = raindrops[i];
+ d.y += d.vy * dt;
+ d.x -= 30 * dt;
+ if (d.y > camY + H + 20) raindrops.splice(i, 1);
+ }
+ }
+ // Снег
+ if (weatherState.type === 'snow') {
+ const spawnRate = Math.floor(weatherState.intensity * 30 * dt);
+ for (let i = 0; i < spawnRate && snowflakes.length < MAX_SNOWFLAKES; i++) {
+ snowflakes.push({
+ x: camX + Math.random() * W,
+ y: camY - 10,
+ vy: 40 + Math.random() * 60,
+ vx: (Math.random() - 0.5) * 30,
+ size: 2 + Math.random() * 3
+ });
+ }
+ }
+ for (let i = snowflakes.length - 1; i >= 0; i--) {
+ const s = snowflakes[i];
+ s.y += s.vy * dt;
+ s.x += s.vx * dt + Math.sin(s.y * 0.02) * 10 * dt;
+ if (s.y > camY + H + 20 || weatherState.type !== 'snow') snowflakes.splice(i, 1);
+ }
+ }
+
+ function drawRain() {
+ // Дождь
+ if (raindrops.length > 0) {
+ ctx.save();
+ ctx.strokeStyle = (weatherState.type === 'storm') ? 'rgba(174,194,224,0.7)' : 'rgba(174,194,224,0.5)';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ for (const d of raindrops) {
+ ctx.moveTo(d.x, d.y);
+ ctx.lineTo(d.x - 3, d.y + d.len);
+ }
+ ctx.stroke();
+ ctx.restore();
+ }
+ // Снег
+ if (snowflakes.length > 0) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(255,255,255,0.8)';
+ for (const s of snowflakes) {
+ ctx.beginPath();
+ ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+ // Гроза — вспышка
+ if (weatherState.type === 'storm' && Math.random() < 0.003) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.fillRect(camX, camY, W, H);
+ ctx.restore();
+ }
+ // Туман — серый оверлей
+ if (weatherState.type === 'fog') {
+ ctx.save();
+ ctx.fillStyle = 'rgba(200,200,200,0.4)';
+ ctx.fillRect(camX, camY, W, H);
+ ctx.restore();
+ }
+ }
+
+ // Частицы (взрыв)
+ const parts = [];
+ function spawnExplosion(x,y, power){
+ const n = Math.floor(16 + power*10);
+ for(let i=0;i 100){
+ playSound('splash');
+ }
+ }
+
+ function resolveY(e){
+ // Всегда пересчитываем grounded (не держим "липким")
+ e.grounded = false;
+
+ const x1 = e.x + 2;
+ const x2 = e.x + e.w - 2;
+
+ // Проверяем, находится ли игрок на лестнице (по центру)
+ const cx = e.x + e.w/2;
+ const cy = e.y + e.h/2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+
+ // Если на лестнице - можно двигаться вверх/вниз
+ if(onLadder){
+ e.grounded = true;
+
+ // Если нажимаем прыжок на лестнице - поднимаемся
+ if(inp.j){
+ e.vy = -200;
+ }
+ // Если нажимаем вниз - спускаемся
+ else if(inp.s){
+ e.vy = 100;
+ }
+ // Иначе - остаёмся на месте (нет гравитации)
+ else {
+ e.vy = 0;
+ }
+ return;
+ }
+
+ // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
+ const leftGX = Math.floor((e.x - 4) / TILE);
+ const rightGX = Math.floor((e.x + e.w + 4) / TILE);
+ const playerGY = Math.floor((e.y + e.h/2) / TILE);
+
+ const leftBlock = getBlock(leftGX, playerGY);
+ const rightBlock = getBlock(rightGX, playerGY);
+ const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
+ const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
+
+ // Если рядом есть лестница и игрок прыгает - притягиваем к ней
+ if((leftLadder || rightLadder) && inp.j && e.vy < 0){
+ // Перемещаем игрока к лестнице
+ if(leftLadder && e.x > leftGX * TILE + TILE/2){
+ e.x = leftGX * TILE + TILE/2 - e.w/2;
+ } else if(rightLadder && e.x < rightGX * TILE + TILE/2){
+ e.x = rightGX * TILE + TILE/2 - e.w/2;
+ }
+ e.grounded = true;
+ e.vy = -150; // меньший прыжок при запрыгивании на лестницу
+ return;
+ }
+
+ // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
+ // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
+ if(e.vy >= 0){
+ const probeY = e.y + e.h + 1;
+ const gy = Math.floor(probeY / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+
+ if(isSolid(gxA, gy) || isSolid(gxB, gy)){
+ e.y = gy * TILE - e.h; // прижимаем к полу
+ e.vy = 0;
+ e.grounded = true;
+
+ // урон от падения — только игроку и только не в воде
+ if(e === player && !player.inWater){
+ const fallTiles = (e.y - e.fallStartY) / TILE;
+ if(fallTiles > 6) {
+ const damage = calculateDamage((fallTiles - 6) * 10);
+ player.hp -= damage;
+ }
+ }
+ if(e === player) e.fallStartY = e.y;
+ }
+ }
+
+ // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
+ if(e.vy < 0 && e === player){
+ const gy = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+
+ // Проверяем, есть ли блок рядом с игроком
+ if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){
+ e.y = (gy + 1) * TILE;
+ e.vy = 0;
+ e.grounded = true;
+ if(e === player) e.fallStartY = e.y;
+ console.log("Jumped onto block!");
+ }
+ }
+
+ // 2) Если движемся вверх — проверяем потолок
+ if(e.vy < 0){
+ const gy = Math.floor(e.y / TILE);
+ const gxA = Math.floor(x1 / TILE);
+ const gxB = Math.floor(x2 / TILE);
+ if(isSolid(gxA, gy) || isSolid(gxB, gy)){
+ e.y = (gy + 1) * TILE;
+ e.vy = 0;
+ }
+ }
+ }
+
+ function resolveX(e){
+ const y1 = e.y + 2;
+ const y2 = e.y + e.h - 2;
+
+ // Проверяем, находимся ли мы на лестнице
+ const cx = e.x + e.w/2;
+ const cy = e.y + e.h/2;
+ const gx = Math.floor(cx / TILE);
+ const gy = Math.floor(cy / TILE);
+ const b = getBlock(gx, gy);
+ const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
+
+ if(e.vx > 0){
+ const gx = Math.floor((e.x + e.w)/TILE);
+ const gyA = Math.floor(y1/TILE);
+ const gyB = Math.floor(y2/TILE);
+ const solidA = isSolid(gx, gyA);
+ const solidB = isSolid(gx, gyB);
+
+ if(solidA || solidB){
+ e.x = gx*TILE - e.w;
+ e.vx = 0;
+ }
+ } else if(e.vx < 0){
+ const gx = Math.floor(e.x/TILE);
+ const gyA = Math.floor(y1/TILE);
+ const gyB = Math.floor(y2/TILE);
+ const solidA = isSolid(gx, gyA);
+ const solidB = isSolid(gx, gyB);
+
+ if(solidA || solidB){
+ e.x = (gx+1)*TILE;
+ e.vx = 0;
+ }
+ }
+ }
+
+ // TNT логика: цепь + усиление
+ const activeTNT = new Set(); // хранит key
+ function activateTNT(b, fuse=3.2){
+ if(b.dead) return;
+ if(b.active) return;
+ b.active=true;
+ b.fuse=fuse;
+ activeTNT.add(k(b.gx,b.gy));
+ }
+
+ function explodeAt(gx,gy){
+ const center = getBlock(gx,gy);
+ if(!center) return;
+
+ // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
+ let bonus = 0;
+ for(let x=gx-2; x<=gx+2; x++){
+ for(let y=gy-2; y<=gy+2; y++){
+ const b = getBlock(x,y);
+ if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){
+ bonus += 0.8;
+ activateTNT(b, 0.12); // цепь
+ }
+ }
+ }
+
+ const power = 1 + bonus; // условная мощность
+ const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах
+ const dmgR = 150 + bonus*60; // радиус урона в пикселях
+
+ removeBlock(gx,gy);
+ activeTNT.delete(k(gx,gy));
+ playSound('explode1'); // Звук взрыва
+ spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power);
+
+ for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){
+ for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){
+ const d = Math.hypot(x-gx, y-gy);
+ if(d > radius) continue;
+ const b = getBlock(x,y);
+ if(!b || b.dead) continue;
+ if(BLOCKS[b.t].fluid) continue;
+ if(BLOCKS[b.t].unbreakable) continue;
+ if(b.t==='tnt') { activateTNT(b, 0.12); continue; }
+ removeBlock(x,y);
+ if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа
+ }
+ }
+ rebuildHotbar();
+
+ // урон
+ const hurt = (e)=>{
+ const dx = (e.x+e.w/2) - (gx*TILE+TILE/2);
+ const dy = (e.y+e.h/2) - (gy*TILE+TILE/2);
+ const dist = Math.hypot(dx,dy);
+ if(dist < dmgR){
+ const dmg = (dmgR - dist) * 0.06 * power;
+ if(e === player) {
+ const actualDamage = calculateDamage(dmg);
+ player.hp -= actualDamage;
+ } else {
+ e.hp -= dmg;
+ }
+ e.vx += (dx/dist || 0) * 600;
+ e.vy -= 320;
+ }
+ };
+ hurt(player);
+ mobs.forEach(hurt);
+ }
+
+ // Взаимодействие мышь/тап
+ const mouse = { x:null, y:null };
+ canvas.addEventListener('pointermove', (e)=>{
+ const r = canvas.getBoundingClientRect();
+ mouse.x = e.clientX - r.left;
+ mouse.y = e.clientY - r.top;
+ });
+
+ canvas.addEventListener('pointerdown', (e)=>{
+ if(craftOpen) return;
+ if(player.hp<=0) return;
+
+ const r = canvas.getBoundingClientRect();
+ const sx = e.clientX - r.left;
+ const sy = e.clientY - r.top;
+
+ const wx = sx + camX;
+ const wy = sy + camY;
+
+ const gx = Math.floor(wx / TILE);
+ const gy = Math.floor(wy / TILE);
+
+ // Пробуждение: клик по любой кровати когда спишь
+ const b = getBlock(gx,gy);
+ if(player.sleeping && b && b.t==='bed'){
+ player.sleeping = false;
+ return;
+ }
+
+ if(player.sleeping) return; // Нельзя взаимодействовать во время сна
+
+ // Клик по печи — открываем панель обжига
+ if(b && b.t === 'furnace' && mode() === 'mine'){
+ openFurnaceUI(gx, gy);
+ return;
+ }
+
+ // клик по мобу (в режиме mine)
+ if(mode()==='mine'){
+ // Check all mobs (local + server-spawned) using getAllMobs
+ const allClickMobs = getAllMobs();
+ for(let i = allClickMobs.length - 1; i >= 0; i--){
+ const m = allClickMobs[i];
+ if(m.dead) continue;
+ if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
+ let dmg = 1;
+ const swordTypes = ['iron_sword','stone_sword','wood_sword'];
+ for (const st of swordTypes) {
+ if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
+ }
+ m.hp -= dmg;
+ m.vx += (m.x - player.x) * 2;
+ m.vy -= 200;
+ playSound('attack');
+ // Server-spawned mob: emit hurt to server for relay, handle death locally
+ if(m.id !== undefined && isMultiplayer){
+ socket.emit('mob_hurt', { id: m.id, dmg });
+ if(m.hp <= 0){
+ socket.emit('mob_died', { id: m.id });
+ }
+ }
+ if(m.hp<=0){
+ if(m.kind === 'chicken') playSound('hurt_chicken');
+ spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
+ // Remove from the correct array
+ if(m.id !== undefined){
+ serverMobs.delete(m.id);
+ } else {
+ const localIdx = mobs.indexOf(m);
+ if(localIdx >= 0) mobs.splice(localIdx, 1);
+ }
+ rebuildHotbar();
+ }
+ return;
+ }
+ }
+ }
+
+ // Лук — стреляем стрелой
+ if(selected === 'bow' && inv.bow > 0 && inv.arrow > 0){
+ const aimX = wx - player.x - player.w/2;
+ const aimY = wy - player.y - player.h/2;
+ const angle = Math.atan2(aimY, aimX);
+ projectiles.push({
+ x: player.x + player.w/2,
+ y: player.y + player.h/3,
+ vx: Math.cos(angle) * 550,
+ vy: Math.sin(angle) * 550,
+ dmg: 10,
+ owner: 'player',
+ life: 4
+ });
+ inv.arrow--;
+ useTool('bow');
+ playSound('hit1');
+ rebuildHotbar();
+ return;
+ }
+
+ // еда (предмет)
+ if(ITEMS[selected] && inv[selected]>0){
+ const it = ITEMS[selected];
+ if(player.hp < 100 || player.hunger < 100){
+ playSound('eat1');
+ player.hunger = Math.min(100, player.hunger + it.food);
+ player.hp = Math.min(100, player.hp + 15);
+ inv[selected]--;
+ rebuildHotbar();
+ }
+ return;
+ }
+
+ //Посадка семян на грядку
+ if(b && b.t === 'farmland' && mode()==='build'){
+ const seedMap = { wheat: 'wheat_stage0', carrot: 'carrot_stage0', potato: 'potato_stage0' };
+ if(seedMap[selected] && inv[selected] > 0){
+ inv[selected]--;
+ const cropType = seedMap[selected];
+ setBlock(gx, gy-1, cropType);
+ growthTimers[gx+','+(gy-1)] = { stage:0, growTimer: 10+Math.random()*5 };
+ sendBlockChange(gx, gy-1, cropType, 'add');
+ playSound('cloth1');
+ rebuildHotbar();
+ return;
+ }
+ }
+
+ // Мотыга — превращает grass/dirt в farmland (в любом режиме)
+ if(selected === 'hoe' && inv.hoe > 0 && b){
+ if(b.t === 'grass' || b.t === 'dirt'){
+ setBlock(gx, gy, 'farmland');
+ sendBlockChange(gx, gy, b.t, 'remove');
+ sendBlockChange(gx, gy, 'farmland', 'add');
+ useTool('hoe');
+ playSound('cloth1');
+ rebuildHotbar();
+ return;
+ }
+ }
+
+ // жарка на костре: выбран meat + клик по campfire
+ if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){
+ playSound('fire'); // Звук при жарке на костре
+ inv.meat--; inv.cooked++;
+ rebuildHotbar();
+ return;
+ }
+
+ // Сон на кровати: клик по bed
+ if(b && b.t==='bed' && isNight()){
+ player.sleeping = true;
+ saveGame(); // Сохраняем при отходе ко сну
+ return;
+ }
+
+ if(mode()==='mine'){
+ if(!b) return;
+ if(BLOCKS[b.t].fluid) return;
+ // Клик на урожайную культуру — сбор
+ if(BLOCKS[b.t].harvestable){
+ const hInfo = BLOCKS[b.t];
+ inv[hInfo.harvestItem] = (inv[hInfo.harvestItem]||0) + hInfo.harvestQty;
+ removeBlock(gx, gy);
+ sendBlockChange(gx, gy, b.t, 'remove');
+ delete growthTimers[gx+','+gy];
+ playSound('cloth1');
+ rebuildHotbar();
+ return;
+ }
+ if(BLOCKS[b.t].decor) return;
+
+ if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу
+
+ const removed = removeBlock(gx,gy);
+ if(removed){
+ inv[removed.t] = (inv[removed.t]||0) + 1;
+
+ // Тратим прочность кирки (если есть в инвентаре)
+ const pickTypes = ['iron_pickaxe','stone_pickaxe','wood_pickaxe'];
+ for (const pt of pickTypes) {
+ if (inv[pt] > 0) {
+ const broke = useTool(pt);
+ if (broke) playSound('cloth1'); // звук поломки
+ break;
+ }
+ }
+
+ // Отправляем изменение блока на сервер
+ sendBlockChange(gx, gy, removed.t, 'remove');
+
+ // Звуки при добыче блоков
+ if(removed.t === 'glass') playSound('glass1');
+ else if(removed.t === 'sand') playSound('sand1');
+ else if(removed.t === 'snow') playSound('snow1');
+ else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
+ else if(removed.t === 'wood') playSound('wood1');
+ else playSound('cloth1');
+
+ rebuildHotbar();
+ }
+ return;
+ }
+
+ if(mode()==='build'){
+ if(inv[selected] <= 0) return;
+ if(!BLOCKS[selected]) return;
+ if(b) return; // занято
+
+ // Проверяем, ставим ли лодку
+ if(selected === 'boat'){
+ // Лодку можно ставить только на воду
+ const waterBelow = getBlock(gx, gy+1);
+ if(!waterBelow || waterBelow.t !== 'water'){
+ return;
+ }
+
+ // Создаём лодку
+ boat.x = gx * TILE;
+ boat.y = gy * TILE;
+ boat.vx = 0;
+ boat.vy = 0;
+ boat.active = true;
+ boat.inWater = true;
+
+ // Сажаем игрока в лодку
+ player.inBoat = true;
+ player.x = boat.x;
+ player.y = boat.y;
+ player.vx = 0;
+ player.vy = 0;
+
+ playSound('splash');
+ inv[selected]--;
+ rebuildHotbar();
+ return;
+ }
+
+ // запрет ставить в игрока
+ const bx = gx*TILE, by = gy*TILE;
+ const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y);
+ if(overlap) return;
+
+ setBlock(gx,gy,selected, true); // true = блок установлен игроком
+ inv[selected]--;
+
+ // Отправляем изменение блока на сервер
+ sendBlockChange(gx, gy, selected, 'set');
+
+ // Звук при строительстве
+ if(selected === 'stone' || selected === 'brick') playSound('stone_build');
+ else if(selected === 'wood' || selected === 'planks') playSound('wood_build');
+ else if(selected === 'glass') playSound('glass1');
+ else if(selected === 'sand') playSound('sand1');
+ else if(selected === 'snow') playSound('snow1');
+ else if(selected === 'dirt' || selected === 'grass') playSound('cloth1');
+
+ rebuildHotbar();
+ return;
+ }
+ });
+
+ // ==================== БИОМЫ ====================
+ const BIOMES = {
+ plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 },
+ desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 },
+ tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 },
+ swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 },
+ mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 }
+ };
+
+ function getBiome(gx) {
+ const temp = Math.sin(gx*0.003 + worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + worldSeed*0.02)*0.3 + 0.5;
+ const humid = Math.sin(gx*0.004 + worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + worldSeed*0.02 + 2000)*0.3 + 0.5;
+ const mtVal = Math.sin(gx*0.001 + worldSeed*0.005)*0.5 + 0.5;
+ if (temp > 0.7) return 'desert';
+ if (temp < 0.3) return 'tundra';
+ if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp';
+ if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains';
+ return 'plains';
+ }
+
+ const biomeCache = {};
+ function getCachedBiome(gx) {
+ const chunk = Math.floor(gx / 8); // cache per 8-tile chunk for smoother biomes
+ if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8);
+ return biomeCache[chunk];
+ }
+
+ // ==================== ПОГОДА ====================
+ const weatherState = { type: 'clear', intensity: 0, timer: 0, duration: 180, nextChange: 120 + Math.random()*180 };
+ const BIOME_WEATHER = {
+ plains: { clear:0.50, rain:0.30, storm:0.10, snow:0, fog:0.10 },
+ desert: { clear:0.80, rain:0.05, storm:0, snow:0, fog:0.15 },
+ tundra: { clear:0.20, rain:0, storm:0.10, snow:0.60, fog:0.10 },
+ swamp: { clear:0.20, rain:0.30, storm:0.10, snow:0, fog:0.40 },
+ mountains:{ clear:0.40, rain:0.30, storm:0.10, snow:0.10, fog:0.10 }
+ };
+
+ function updateWeather(dt) {
+ weatherState.timer += dt;
+ if (weatherState.timer >= weatherState.nextChange) {
+ weatherState.timer = 0;
+ weatherState.nextChange = 60 + Math.random() * 240;
+ const biome = getCachedBiome(Math.floor(player.x / TILE));
+ const probs = BIOME_WEATHER[biome] || BIOME_WEATHER.plains;
+ const r = Math.random();
+ let cum = 0;
+ if ((cum += probs.clear) > r) { weatherState.type = 'clear'; }
+ else if ((cum += probs.rain) > r) { weatherState.type = 'rain'; }
+ else if ((cum += probs.storm) > r) { weatherState.type = 'storm'; }
+ else if ((cum += probs.snow) > r) { weatherState.type = 'snow'; }
+ else { weatherState.type = 'fog'; }
+ weatherState.duration = 60 + Math.random() * 300;
+ }
+ // Intensity interpolation
+ const target = (weatherState.type === 'clear') ? 0 : 1;
+ weatherState.intensity += (target - weatherState.intensity) * dt * 0.5;
+ }
+
+ function getWeatherSpeedMultiplier() {
+ if (weatherState.type === 'rain') return 0.85;
+ if (weatherState.type === 'snow') return 0.7;
+ if (weatherState.type === 'storm') return 0.85;
+ return 1;
+ }
+
+ function isOutdoorLight(lx, ly) {
+ // Check if position is outdoors (no block above)
+ const aboveGy = Math.floor(ly / TILE) - 1;
+ const aboveGx = Math.floor(lx / TILE);
+ const above = getBlock(aboveGx, aboveGy);
+ return !above || !BLOCKS[above.t]?.solid;
+ }
+
+ // ==================== СТРУКТУРЫ МИРА ====================
+ function placeStructure(startGx, startGy, pattern) {
+ for (let dy = 0; dy < pattern.length; dy++) {
+ for (let dx = 0; dx < pattern[dy].length; dx++) {
+ const bt = pattern[dy][dx];
+ if (bt && bt !== 'air') {
+ setBlock(startGx + dx, startGy + dy, bt);
+ }
+ }
+ }
+ }
+
+ // Пирамида в пустыне
+ const PYRAMID_PATTERN = [
+ ['sand','sand','sand','sand','sand','sand','sand'],
+ ['sand','stone','stone','stone','stone','stone','sand'],
+ ['sand','stone','air','air','air','stone','sand'],
+ ['sand','stone','air','air','air','stone','sand'],
+ ['sand','stone','air','air','air','stone','sand'],
+ ['sand','sand','stone','stone','stone','sand','sand']
+ ];
+
+ // Дом в равнине
+ const HOUSE_PATTERN = [
+ ['air','planks','planks','planks','planks','air'],
+ ['planks','air','air','air','air','planks'],
+ ['planks','air','torch','air','air','planks'],
+ ['planks','air','air','air','air','planks'],
+ ['planks','planks','air','planks','planks','planks']
+ ];
+
+ // Хижина в болоте
+ const HUT_PATTERN = [
+ ['air','wood','wood','wood','air'],
+ ['wood','air','air','air','wood'],
+ ['wood','air','torch','air','wood'],
+ ['moss','moss','air','moss','moss']
+ ];
+
+ // ==================== ГЕНЕРАЦИЯ ====================
+ const generated = new Set(); // gx already generated
+ function surfaceGyAt(gx) {
+ const biome = getCachedBiome(gx);
+ // Base noise (same for all biomes)
+ const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8;
+ const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12;
+ const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6;
+ const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4;
+ const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5;
+ let h;
+ switch(biome) {
+ case 'desert':
+ h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // flatter, slightly higher
+ break;
+ case 'tundra':
+ h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // gentle rolling
+ break;
+ case 'swamp':
+ h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // very flat, near sea level
+ h = Math.max(h, SEA_GY - 3); // never too deep
+ break;
+ case 'mountains':
+ h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // tall peaks
+ break;
+ default: // plains
+ h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5);
+ }
+ return h;
+ }
+
+ function genColumn(gx) {
+ if(generated.has(gx)) return;
+ if(worldSeed == null) return; // wait for server seed in multiplayer
+ generated.add(gx);
+
+ const sgy = surfaceGyAt(gx);
+ const biome = getCachedBiome(gx);
+
+ // === Вода и поверхность ===
+ if(sgy > SEA_GY) {
+ // ниже уровня моря — заливаем водой
+ for(let gy = SEA_GY; gy < sgy; gy++) {
+ const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water';
+ setBlock(gx, gy, blockType);
+ }
+ setBlock(gx, sgy, 'sand');
+ } else {
+ // поверхность
+ const b = BIOMES[biome];
+ setBlock(gx, sgy, b.surface);
+ // болото: случайные лужи болотной воды
+ if(biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) {
+ setBlock(gx, sgy-1, 'swamp_water');
+ }
+ // тундра: лёд на воде рядом
+ if(biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) {
+ setBlock(gx, SEA_GY-1, 'ice');
+ }
+ }
+
+ // === Подповерхностные слои ===
+ for(let gy = sgy+1; gy <= BEDROCK_GY; gy++) {
+ if(gy === BEDROCK_GY) { setBlock(gx,gy,'bedrock'); continue; }
+
+ let t = BIOMES[biome].subsurface;
+
+ // глубже — камень
+ if(gy > sgy + 3) t = 'stone';
+
+ // пустыня: sand глубже
+ if(biome === 'desert' && gy <= sgy + 6) t = 'sand';
+
+ // болото: глина ближе к поверхности
+ if(biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay';
+
+ // горы: gravel
+ if(biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) t = 'gravel';
+
+ // общие руды
+ const depth = gy - sgy;
+ const r = seededRandom(gx, gy);
+ if(t === 'stone') {
+ if(r < 0.06) t = 'coal';
+ else if(r < 0.10) t = 'copper_ore';
+ else if(r < 0.13) t = 'iron_ore';
+ else if(depth > 40 && r < 0.145) t = 'gold_ore';
+ else if(depth > 70 && r < 0.152) t = 'diamond_ore';
+ }
+
+ setBlock(gx, gy, t);
+ }
+
+ // === Растительность и декор ===
+ const b = BIOMES[biome];
+ const top = getBlock(gx, sgy);
+
+ // цветы (только plain)
+ if(biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) {
+ setBlock(gx, sgy-1, 'flower');
+ }
+
+ // деревья
+ if(b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) {
+ if(biome === 'tundra') {
+ // ёлки (треугольные, 3-5 высоты)
+ const th = 3 + Math.floor(seededRandom(gx, sgy) * 3);
+ for(let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood');
+ // крона (треугольник)
+ for(let row = 0; row < th; row++) {
+ const w = Math.min(row + 1, 2);
+ for(let dx = -w; dx <= w; dx++) {
+ const ly = sgy-1-th+row;
+ if(ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves');
+ }
+ }
+ } else if(biome === 'swamp') {
+ // болотное дерево (короче, с мхом)
+ setBlock(gx, sgy-1, 'wood');
+ setBlock(gx, sgy-2, 'moss');
+ setBlock(gx-1, sgy-2, 'leaves');
+ setBlock(gx+1, sgy-2, 'leaves');
+ } else {
+ // обычное дерево (plains)
+ setBlock(gx, sgy-1, 'wood');
+ setBlock(gx, sgy-2, 'wood');
+ setBlock(gx, sgy-3, 'leaves');
+ setBlock(gx-1, sgy-3, 'leaves');
+ setBlock(gx+1, sgy-3, 'leaves');
+ }
+ }
+
+ // кактусы (пустыня)
+ if(biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) {
+ const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2);
+ for(let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus');
+ }
+
+ // грибы (болото)
+ if(biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) {
+ setBlock(gx, sgy-1, 'mushroom');
+ }
+
+ // сухие кусты (пустыня)
+ if(biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) {
+ setBlock(gx, sgy-1, 'dead_bush');
+ }
+
+ // === Структуры мира ===
+ // Пирамида в пустыне
+ if(biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) {
+ placeStructure(gx, sgy - 5, PYRAMID_PATTERN);
+ }
+ // Дом в равнине
+ if(biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) {
+ placeStructure(gx, sgy - 4, HOUSE_PATTERN);
+ }
+ // Хижина в болоте
+ if(biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) {
+ placeStructure(gx, sgy - 3, HUT_PATTERN);
+ }
+
+ // Применяем серверные оверрайды для этой колонны
+ const colPrefix = gx + ',';
+ for (const [key, ov] of serverOverrides) {
+ if (!key.startsWith(colPrefix)) continue;
+ if (ov.op === 'remove') {
+ const b = grid.get(key);
+ if (b) { grid.delete(key); b.dead = true; }
+ } else if (ov.op === 'set') {
+ if (!grid.has(key)) {
+ const gy = parseInt(key.split(',')[1]);
+ const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
+ grid.set(key, nb);
+ blocks.push(nb);
+ }
+ }
+ }
+ }
+
+ // Перегенерация видимых чанков (используется при загрузке сохранения)
+ function regenerateVisibleChunks(){
+ const gx0 = Math.floor(camX/TILE);
+ for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
+ // Принудительно перегенерируем колонну
+ generated.delete(gx);
+ genColumn(gx);
+ }
+ }
+
+ function ensureGenAroundCamera(){
+ const gx0 = Math.floor(camX/TILE);
+ for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
+ genColumn(gx);
+ }
+ }
+
+ // Лут с дерева/листвы: дерево -> wood; листья -> leaves
+ // (уже в mine добавляется inv[type] автоматически)
+
+ // Рисование костра: огонь поверх текстуры
+ function drawFire(wx,wy,now){
+ const baseX = wx;
+ const baseY = wy;
+ const flick = 1.5 + (Math.sin(now/200)+1)*1;
+ ctx.fillStyle = 'rgba(255,140,0,0.85)';
+ ctx.beginPath();
+ ctx.moveTo(baseX+10, baseY+30);
+ ctx.lineTo(baseX+20, baseY+30-flick);
+ ctx.lineTo(baseX+30, baseY+30);
+ ctx.fill();
+
+ ctx.fillStyle = 'rgba(255,230,150,0.75)';
+ ctx.beginPath();
+ ctx.moveTo(baseX+14, baseY+30);
+ ctx.lineTo(baseX+20, baseY+30-(flick*0.7));
+ ctx.lineTo(baseX+26, baseY+30);
+ ctx.fill();
+ }
+
+ // Моб AI
+ function mobAI(m, dt){
+ updateWaterFlag(m);
+
+ if(m.kind==='zombie'){
+ // активность ночью
+ const night = isNight();
+ if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
+ const dir = Math.sign((player.x) - m.x);
+ m.vx = dir * m.speed;
+ if(m.inWater && Math.random()<0.06) m.vy = -260;
+ // атака
+ if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
+ Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
+ player.invuln <= 0){
+ const damage = calculateDamage(15);
+ player.hp -= damage;
+ player.invuln = 0.8;
+ player.vx += dir*420;
+ player.vy -= 260;
+ playSound('hit1'); // Звук при атаке зомби
+ }
+ } else if(m.kind==='creeper'){
+ // активность ночью
+ const night = isNight();
+ if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
+ const dir = Math.sign((player.x) - m.x);
+ const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
+
+ // Движение к игроку
+ m.vx = dir * m.speed;
+ if(m.inWater && Math.random()<0.06) m.vy = -260;
+
+ // Взрыв если близко к игроку
+ if(dist < 60){
+ m.fuse -= dt;
+ if(m.fuse <= 0){
+ explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE));
+ m.hp = 0;
+ }
+ } else {
+ // Поджигаем если очень близко
+ if(dist < 40){
+ m.fuse = 0.5; // Быстрый взрыв
+ }
+ }
+ } else if(m.kind==='skeleton'){
+ // активность ночью
+ const night = isNight();
+ if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
+ const dir = Math.sign((player.x) - m.x);
+ const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
+
+ // Движение к игроку
+ m.vx = dir * m.speed;
+ if(m.inWater && Math.random()<0.06) m.vy = -260;
+
+ // Стрельба стрелами
+ m.shootCooldown -= dt;
+ if(dist < 300 && m.shootCooldown <= 0){
+ m.shootCooldown = 2.0;
+ const dx = (player.x+player.w/2) - (m.x+m.w/2);
+ const dy = (player.y+player.h/2) - (m.y+m.h/2);
+ const angle = Math.atan2(dy, dx);
+ const speed = 450;
+ projectiles.push({
+ x: m.x + m.w/2,
+ y: m.y + m.h/3,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ dmg: 6,
+ owner: 'mob',
+ life: 3
+ });
+ }
+ } else if(m.kind==='scorpion') {
+ // Скорпион — бежит к игроку, яд (замедление)
+ const dir = Math.sign((player.x) - m.x);
+ m.vx = dir * m.speed;
+ if(m.inWater && Math.random()<0.06) m.vy = -260;
+ // Яд при касании — замедление на 3 сек
+ if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
+ Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
+ player.invuln <= 0){
+ const damage = calculateDamage(8);
+ player.hp -= damage;
+ player.invuln = 0.8;
+ player.slowTimer = 3; // замедление
+ player.vx += dir*300;
+ player.vy -= 200;
+ playSound('hit1');
+ }
+ } else if(m.kind==='polar_bear') {
+ // Белый медведь — нейтрален, атакует если ударили (hostile пока нет, атакует через proximity)
+ m.aiT -= dt;
+ if(m.aiT <= 0){
+ m.aiT = 2.0 + Math.random()*3;
+ m.dir = Math.random()<0.5 ? -1 : 1;
+ if(Math.random()<0.3) m.dir = 0;
+ }
+ m.vx = m.dir * m.speed;
+ if(m.inWater) m.vy = -120;
+ } else if(m.kind==='slime') {
+ // Слизь — прыгает к игроку
+ const dir = Math.sign((player.x+player.w/2) - (m.x+m.w/2));
+ m.aiT -= dt;
+ if(m.aiT <= 0){
+ m.aiT = 1.5 + Math.random()*1.5;
+ m.dir = dir;
+ // Прыжок
+ m.vy = -200;
+ }
+ m.vx = m.dir * m.speed;
+ } else if(m.kind==='eagle') {
+ // Орёл — летает, атакует пикированием
+ const dx = (player.x+player.w/2) - (m.x+m.w/2);
+ const dy = (player.y+player.h/2) - (m.y+m.h/2);
+ const dist = Math.hypot(dx, dy);
+ if(dist < 400) {
+ // Пикирует на игрока
+ m.vx = Math.sign(dx) * m.speed;
+ m.vy = dy > 0 ? 60 : -60; // летит вниз к игроку
+ } else {
+ // Патрулирует
+ m.aiT -= dt;
+ if(m.aiT <= 0){
+ m.aiT = 2+Math.random()*3;
+ m.dir = Math.random()<0.5 ? -1 : 1;
+ }
+ m.vx = m.dir * m.speed * 0.5;
+ m.vy = Math.sin(performance.now()/1000) * 30; // мягкое покачивание
+ }
+ // Атака при касании
+ if(dist < 30 && player.invuln <= 0){
+ const damage = calculateDamage(10);
+ player.hp -= damage;
+ player.invuln = 0.8;
+ player.vy -= 200;
+ playSound('hit1');
+ }
+ // Орёл не падает — летающий моб
+ m.vy *= 0.5;
+ m.grounded = false;
+ } else {
+ // животные (pig, chicken)
+ m.aiT -= dt;
+ if(m.aiT <= 0){
+ m.aiT = 1.8 + Math.random()*2.5;
+ m.dir = Math.random()<0.5 ? -1 : 1;
+ if(Math.random()<0.25) m.dir = 0;
+ }
+ m.vx = m.dir * (m.kind==='chicken' ? 55 : 40);
+ if(m.inWater) m.vy = -120;
+ }
+
+ // физика моба
+ const g = m.inWater ? GRAV_WATER : GRAV;
+ m.vy += g*dt;
+
+ m.y += m.vy*dt; m.grounded=false; resolveY(m);
+ m.x += m.vx*dt; resolveX(m);
+ }
+
+ function isNight(){
+ // Автоматический цикл: ночь когда worldTime > 0.5
+ return worldTime > 0.5;
+ }
+
+ // Respawn
+ document.getElementById('respawnBtn').onclick = async () => {
+ playSound('click'); // Звук клика по кнопке
+
+ console.log('=== RESPAWN CLICKED ===');
+ console.log('isMultiplayer:', isMultiplayer);
+ console.log('otherPlayers.size:', otherPlayers.size);
+ console.log('player.hp before respawn:', player.hp);
+
+ // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
+ if (isMultiplayer && otherPlayers.size > 0) {
+ console.log('Мультиплеер режим - возрождение в начальной точке');
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = spawnPoint.x;
+ player.y = spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log('Возрождение в начальной точке, HP:', player.hp);
+ } else {
+ console.log('Одиночный режим - загружаем последнее сохранение');
+ // Одиночный режим - загружаем последнее сохранение
+ const loadedSave = await loadGame();
+ if(loadedSave){
+ await applySave(loadedSave);
+ console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
+ } else {
+ // Если сохранения нет, возрождаемся в начальной точке
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = spawnPoint.x;
+ player.y = spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log('Возрождение в начальной точке, HP:', player.hp);
+ }
+ }
+
+ console.log('player.hp after respawn logic:', player.hp);
+ console.log('Hiding death screen...');
+ deathEl.style.display='none';
+ console.log('=== RESPAWN END ===');
+ };
+
+ // Resize
+ function resize(){
+ W = gameEl.clientWidth;
+ H = gameEl.clientHeight;
+ canvas.width = W*dpr;
+ canvas.height = H*dpr;
+ lightC.width = W*dpr;
+ lightC.height = H*dpr;
+ ctx.setTransform(dpr,0,0,dpr,0,0);
+ }
+ window.addEventListener('resize', resize);
+
+ // init
+ resize();
+ rebuildHotbar();
+
+ // Инициализируем и загружаем сохранение
+ initDB().then(async () => {
+ // Пытаемся загрузить сохранённую игру
+ const loadedSave = await loadGame();
+ if(loadedSave){
+ await applySave(loadedSave);
+ console.log('Загружено сохранение, HP:', player.hp);
+
+ // Проверяем HP после загрузки - если <= 0, возрождаемся
+ if (player.hp <= 0) {
+ console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.x = spawnPoint.x;
+ player.y = spawnPoint.y;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.fallStartY = player.y;
+ }
+ } else {
+ console.log('Сохранение не найдено, начинаем новую игру');
+
+ // Инициализируем игрока для новой игры
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+
+ // старт — на поверхности (используем ту же логику что и в world_state)
+ const startGX = 6;
+ for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
+ const surfaceY = surfaceGyAt(startGX);
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === 'water') {
+ for (let gy = SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy);
+ if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ player.y = safeGY * TILE;
+ player.x = startGX * TILE;
+ player.fallStartY = player.y;
+
+ // Обновляем spawnPoint, чтобы возрождение было на поверхности
+ spawnPoint.x = player.x;
+ spawnPoint.y = player.y;
+
+ console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp);
+ console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y);
+
+ // Генерируем карту вокруг стартовой позиции при инициализации
+ for(let gx = startGX - 50; gx <= startGX + 50; gx++){
+ genColumn(gx);
+ }
+ }
+
+ // Автосейв при скрытии страницы (защита от потери прогресса)
+ document.addEventListener('visibilitychange', () => {
+ if(document.hidden){
+ saveGame();
+ }
+ });
+
+ // Автосейв перед закрытием страницы (защита от потери прогресса)
+ window.addEventListener('beforeunload', () => {
+ saveGame();
+ });
+ }).catch(err => {
+ console.error('Ошибка инициализации:', err);
+ // При ошибке начинаем новую игру
+ const startGX = 6;
+ genColumn(startGX);
+ player.y = (surfaceGyAt(startGX)-1)*TILE;
+ player.fallStartY = player.y;
+
+ for(let gx = startGX - 50; gx <= startGX + 50; gx++){
+ genColumn(gx);
+ }
+ });
+
+ // main loop
+ let last = performance.now();
+ let prevJump = false;
+ // При возврате на вкладку — сбрасываем last чтобы не было скачка dt
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) last = performance.now();
+ });
+ function loop(now){
+ const rawDt = Math.min(0.05, (now-last)/1000);
+ last = now;
+ // Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
+ const PHYSICS_STEP = 0.016;
+ const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
+ const dt = rawDt / steps;
+
+ const jumpPressed = inp.j && !prevJump;
+ prevJump = inp.j;
+
+ // Ускорение времени во время сна
+ if(player.sleeping && isNight()){
+ worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
+ // Восстанавливаем здоровье во время сна
+ player.hp = Math.min(100, player.hp + dt * 20);
+ // Автоматическое пробуждение когда наступает день
+ if(!isNight()){
+ player.sleeping = false;
+ }
+ } else {
+ worldTime += dt / DAY_LEN;
+ }
+ if(worldTime >= 1) worldTime -= 1;
+
+ // камера следует за игроком по X/Y
+ camX = Math.floor((player.x + player.w/2) - W/2);
+ camY = Math.floor((player.y + player.h/2) - H/2);
+
+ ensureGenAroundCamera();
+
+ // clouds parallax
+ for(const c of clouds){
+ c.x -= c.s * dt;
+ if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700;
+ }
+
+ // Погода (биом-зависимая)
+ updateWeather(dt);
+
+ // player
+ updateWaterFlag(player);
+
+ // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223]
+ if(player.headInWater){
+ player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза
+ if(player.o2 === 0){
+ const damage = calculateDamage(4*dt);
+ player.hp -= damage;
+ }
+ } else {
+ player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза
+ }
+
+ // голод убывает, но HP не отнимает (как просили)
+ player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза
+
+
+ // ========== ТЕМПЕРАТУРА ==========
+ if (player.temperature === undefined) player.temperature = 15;
+ const BIOME_TEMP = { tundra: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 };
+ const UNDERGROUND_TEMP = 15;
+ const COLD_THRESHOLD = -5;
+ const HEAT_THRESHOLD = 35;
+ const HEAT_RADIUS = 5;
+ const pGX = Math.floor(player.x / TILE);
+ const biome = getCachedBiome(pGX);
+ const pGY = Math.floor(player.y / TILE);
+ const sGY = surfaceGyAt(pGX);
+ const isUndergroundTemp = (pGY - sGY) > 2; // player deeper than 2 blocks below surface
+ let targetTemp = BIOME_TEMP[biome] || 15;
+ if (isUndergroundTemp) targetTemp = UNDERGROUND_TEMP;
+ if (isNight() && !isUndergroundTemp) targetTemp -= 10;
+ if ((weatherState.type === 'rain' || weatherState.type === 'storm') && !isUndergroundTemp) targetTemp -= 5;
+ player.temperature += (targetTemp - player.temperature) * dt * 0.3;
+ let nearHeat = false, nearCool = false;
+ for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) {
+ for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) {
+ const b = getBlock(pGX + dx, pGY + dy);
+ if (!b || b.dead) continue;
+ if (b.t === 'campfire' || b.t === 'torch') nearHeat = true;
+ if (b.t === 'water' && biome === 'desert') nearCool = true;
+ }
+ }
+ if (nearHeat) {
+ player.temperature += (30 - player.temperature) * dt * 2;
+ if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt);
+ }
+ if (nearCool) {
+ player.temperature += (15 - player.temperature) * dt * 1.5;
+ if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) {
+ player.hp = Math.min(100, player.hp + 8 * dt);
+ }
+ }
+ if (isUndergroundTemp && !nearHeat) {
+ if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) {
+ player.hp = Math.min(100, player.hp + 2 * dt);
+ }
+ }
+ const aboveBlk = getBlock(pGX, pGY - 1);
+ const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass';
+ if (inShade && !isUndergroundTemp && !nearCool) {
+ player.temperature -= 5 * dt;
+ if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) {
+ player.hp = Math.min(100, player.hp + 1 * dt);
+ }
+ }
+ if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) {
+ const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15;
+ player.hp -= 1 * severity * dt;
+ if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt);
+ }
+ if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) {
+ const severity = (player.temperature - HEAT_THRESHOLD) / 15;
+ player.hp -= 1 * severity * dt;
+ if (severity > 0.5) player.hunger -= 0.5 * severity * dt;
+ }
+ // Игрок не может двигаться во время сна
+ if(player.sleeping){
+ player.vx = 0;
+ player.vy = 0;
+ } else {
+ const dir = (inp.r?1:0) - (inp.l?1:0);
+ const speedMult = getWeatherSpeedMultiplier() * (player.slowTimer > 0 ? 0.4 : 1.0);
+ if(dir) player.vx = dir * MOVE * speedMult;
+ else player.vx *= 0.82;
+ }
+
+ // Звук шагов при движении по земле
+ if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){
+ const stepInterval = 0.35; // Интервал между шагами в секундах
+ if(now/1000 - player.lastStepTime > stepInterval){
+ playSound('step');
+ player.lastStepTime = now/1000;
+ }
+ }
+
+ // прыжок/плавание (новая логика)
+ if(player.inBoat){
+ // Игрок в лодке - лодка следует за игроком
+ const dir = (inp.r?1:0) - (inp.l?1:0);
+ if(dir) boat.vx = dir * MOVE;
+ else boat.vx *= 0.95;
+
+ // Лодка плавает на воде
+ boat.vy = 0;
+
+ // Игрок следует за лодкой (сидит внутри неё)
+ player.x = boat.x + 2; // Игрок по центру лодки
+ player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
+ player.vx = boat.vx;
+ player.vy = boat.vy;
+ player.grounded = true;
+ player.inWater = false; // Игрок не в воде когда в лодке
+
+ // Прыжок из лодки (высадка)
+ if(jumpPressed){
+ // Возвращаем лодку в инвентарь
+ inv.boat = (inv.boat || 0) + 1;
+
+ player.inBoat = false;
+ boat.active = false;
+ player.y += TILE; // Прыгаем из лодки
+ player.vy = -JUMP * 0.5;
+ playSound('splash');
+ }
+
+ } else if(player.inWater){
+ // сопротивление в воде
+ player.vx *= 0.90;
+ player.vy *= 0.92;
+
+ // Если не нажимаем прыжок - тонем (гравитация в воде)
+ if(!jumpPressed && !inp.j){
+ // Применяем гравитацию в воде - игрок тонет
+ player.vy += GRAV_WATER * dt;
+ } else {
+ // Если нажимаем прыжок - поднимаемся на поверхность
+ if(jumpPressed){
+ player.vy = Math.min(player.vy, -520); // рывок вверх
+ } else if(inp.j){
+ // если держим — мягкое всплытие
+ player.vy = Math.min(player.vy, -260);
+ }
+ }
+
+ } else {
+ // обычный прыжок (только по нажатию)
+ if(jumpPressed && player.grounded && !player.sleeping){
+ player.vy = -JUMP;
+ player.grounded = false;
+ player.fallStartY = player.y;
+ }
+ }
+
+ // Гравитация применяется только вне воды и вне лодки
+ if(!player.inWater && !player.inBoat){
+ player.vy += GRAV*dt;
+ }
+
+ // Обновляем позицию лодки
+ if(boat.active){
+ boat.x += boat.vx * dt;
+ boat.y += boat.vy * dt;
+
+ // Лодка не выходит за пределы воды
+ const boatGX = Math.floor(boat.x / TILE);
+ const boatGY = Math.floor(boat.y / TILE);
+ const below = getBlock(boatGX, boatGY + 1);
+
+ if(!below || below.t !== 'water'){
+ // Если лодка вышла из воды - выкидываем игрока
+ inv.boat = (inv.boat || 0) + 1;
+ player.inBoat = false;
+ boat.active = false;
+ player.y += TILE;
+ player.vy = -200;
+ playSound('splash');
+ }
+ }
+
+ // Проверяем, не доплыл ли игрок из лодки
+ if(player.inBoat && !boat.active){
+ inv.boat = (inv.boat || 0) + 1;
+ player.inBoat = false;
+ player.y += TILE;
+ player.vy = -200;
+ playSound('splash');
+ }
+
+ // Sub-stepped physics: применяем движение мелкими шагами
+ for (let step = 0; step < steps; step++) {
+ player.y += player.vy*dt;
+ resolveY(player);
+ player.x += player.vx*dt;
+ resolveX(player);
+ }
+
+ // Отправляем позицию на сервер (мультиплеер)
+ sendPlayerPosition();
+
+ // Обновляем физику воды
+ updateWaterPhysics(dt);
+
+ // Погода и дождь
+ updateWeather(dt);
+ updateRain(dt);
+
+ player.invuln = Math.max(0, player.invuln - dt);
+ if(player.slowTimer > 0) player.slowTimer = Math.max(0, player.slowTimer - dt);
+
+ // Рост культур
+ for(const key of Object.keys(growthTimers)){
+ const tile = growthTimers[key];
+ if(tile.stage < 3){
+ tile.growTimer -= dt;
+ if(tile.growTimer <= 0){
+ tile.stage++;
+ tile.growTimer = 8 + Math.random()*6;
+ // Обновляем визуальный блок
+ const [gxStr, gyStr] = key.split(',');
+ const gx = parseInt(gxStr), gy = parseInt(gyStr);
+ const curBlock = getBlock(gx, gy);
+ if(curBlock && curBlock.t !== curBlock.t.replace(/_stage\d/, '_stage'+tile.stage)){
+ // Вычисляем следующую стадию
+ const baseType = curBlock.t.replace(/_stage\d/, '');
+ const nextType = baseType + '_stage' + tile.stage;
+ setBlock(gx, gy, nextType);
+ sendBlockChange(gx, gy, nextType, 'add');
+ // Проверяем созрел ли
+ if(tile.stage >= 3) delete growthTimers[key];
+ }
+ }
+ }
+ }
+
+ // Voice position update
+ voicePosT += dt;
+ if(voicePosT > 0.5 && voiceSocket && voiceSocket.connected){
+ voicePosT = 0;
+ voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
+ }
+
+ // Furnace tick
+ tickFurnaces(dt);
+
+ // Обновляем UI печи если открыта
+ if(currentFurnaceKey && Math.random() < 0.1){
+ renderFurnaceUI();
+ }
+
+ // Projectile tick (стрелы)
+ for(let i = projectiles.length-1; i>=0; i--){
+ const p = projectiles[i];
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vy += 400 * dt; // гравитация
+ p.life -= dt;
+
+ // Столкновение с блоком
+ const gx = Math.floor(p.x / TILE);
+ const gy = Math.floor(p.y / TILE);
+ const blk = getBlock(gx, gy);
+ if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid){
+ // Врезался в стену
+ if(p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50%
+ projectiles.splice(i, 1);
+ continue;
+ }
+
+ // Столкновение с сущностью
+ if(p.owner === 'mob'){
+ // Попал в игрока
+ if(p.x > player.x && p.x < player.x+player.w && p.y > player.y && p.y < player.y+player.h){
+ if(player.invuln <= 0){
+ player.hp -= calculateDamage(p.dmg);
+ player.invuln = 0.4;
+ player.vx += p.vx * 0.3;
+ player.vy -= 150;
+ playSound('hit1');
+ }
+ projectiles.splice(i, 1);
+ continue;
+ }
+ } else {
+ // Попал в моба — check all mobs (client-authoritative)
+ const allArrowMobs = getAllMobs();
+ for(let j = allArrowMobs.length - 1; j >= 0; j--){
+ const m = allArrowMobs[j];
+ if(m.dead) continue;
+ if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){
+ m.hp -= p.dmg;
+ m.vx += p.vx * 0.2;
+ m.vy -= 200;
+ // Server-spawned mob: emit arrow hit to server for relay
+ if(m.id !== undefined && isMultiplayer){
+ socket.emit('mob_arrow_hit', { id: m.id, dmg: p.dmg, vx: p.vx });
+ if(m.hp <= 0){
+ socket.emit('mob_died', { id: m.id });
+ }
+ }
+ if(m.hp <= 0){
+ spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
+ // Remove from the correct array
+ if(m.id !== undefined){
+ serverMobs.delete(m.id);
+ } else {
+ const localIdx = mobs.indexOf(m);
+ if(localIdx >= 0) mobs.splice(localIdx, 1);
+ }
+ rebuildHotbar();
+ }
+ projectiles.splice(i, 1);
+ break;
+ }
+ }
+ }
+
+ // Таймаут
+ if(p.life <= 0) projectiles.splice(i, 1);
+ }
+
+ // TNT tick
+ for(const key of Array.from(activeTNT)){
+ const b = grid.get(key);
+ if(!b || b.dead){ activeTNT.delete(key); continue; }
+ b.fuse -= dt;
+ if(b.fuse <= 0){
+ explodeAt(b.gx,b.gy);
+ }
+ }
+
+ // mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events)
+ spawnT += dt;
+ if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){
+ spawnT = 0;
+
+ // Выбираем сторону спавна (левая или правая)
+ const spawnLeft = Math.random() < 0.5;
+ const gx = spawnLeft
+ ? Math.floor((camX - 200)/TILE)
+ : Math.floor((camX + W + 200)/TILE);
+
+ genColumn(gx);
+ const sgy = surfaceGyAt(gx);
+ const wx = gx*TILE + 4;
+ const wy = (sgy-1)*TILE; // 1 блок выше поверхности
+
+ // не спавнить в воде
+ const top = getBlock(gx, sgy);
+ if(top && top.t==='water') {
+ // skip
+ } else {
+ const night = isNight();
+ if(night){
+ // Ночью спавним враждебных мобов (максимум 12 хостайл)
+ const hostileCount = mobs.filter(m => m.kind==='zombie'||m.kind==='creeper'||m.kind==='skeleton').length;
+ if(hostileCount < 12){
+ const rand = Math.random();
+ if(rand < 0.35){
+ mobs.push(new Zombie(wx, wy));
+ } else if(rand < 0.55){
+ mobs.push(new Creeper(wx, wy));
+ } else {
+ mobs.push(new Skeleton(wx, wy));
+ }
+ }
+ }
+ // Животные спавнятся и днём и ночью (с лимитом)
+ const animalCount = mobs.filter(m => m.kind==='pig'||m.kind==='chicken').length;
+ if(animalCount < 8){
+ mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
+ }
+ }
+ }
+
+ // mobs update — mobAI runs on ALL mobs (client-authoritative for server-spawned mobs too)
+ {
+ // Local mobs
+ for(let i=mobs.length-1;i>=0;i--){
+ const m = mobs[i];
+ mobAI(m, dt);
+ if(m.hp<=0) mobs.splice(i,1);
+ }
+ // Server-spawned mobs: server is authoritative for positions
+ // mobAI does NOT run on serverMobs — server handles all physics + AI
+ // Only remove dead mobs from local map
+ if(isMultiplayer){
+ for (const [id, sm] of serverMobs) {
+ if(sm.hp <= 0){
+ sm.dead = true;
+ }
+ }
+ for (const [id, sm] of serverMobs) {
+ if(sm.dead) serverMobs.delete(id);
+ }
+ }
+ }
+
+ // particles
+ for(let i=parts.length-1;i>=0;i--){
+ const p = parts[i];
+ p.t -= dt;
+ p.x += p.vx*dt;
+ p.y += p.vy*dt;
+ p.vy += GRAV*dt;
+ if(p.t <= 0) parts.splice(i,1);
+ }
+
+ // death
+ if(player.hp <= 0){
+ deathEl.style.display='flex';
+ } else if(deathEl.style.display === 'flex') {
+ // Если HP > 0 но экран смерти всё ещё показан - скрываем его
+ deathEl.style.display='none';
+ }
+
+ // render
+ const night = isNight();
+
+ // sky
+ ctx.fillStyle = night ? '#070816' : (isRaining ? '#6B7B8D' : '#87CEEB');
+ ctx.fillRect(0,0,W,H);
+
+ // clouds (parallax x/y)
+ ctx.save();
+ ctx.translate(-camX*0.5, -camY*0.15);
+ ctx.fillStyle = 'rgba(255,255,255,0.65)';
+ for(const c of clouds){
+ ctx.fillRect(c.x, c.y, c.w, 26);
+ ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22);
+ }
+ ctx.restore();
+
+ // world
+ ctx.save();
+ ctx.translate(-camX, -camY);
+
+ const minGX = Math.floor(camX/TILE)-2;
+ const maxGX = Math.floor((camX+W)/TILE)+2;
+ const minGY = Math.floor(camY/TILE)-6;
+ const maxGY = Math.floor((camY+H)/TILE)+6;
+
+ // draw blocks (по массиву, но фильтруем диапазоном)
+ for(const b of blocks){
+ if(b.dead) continue;
+ if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
+
+ const def = BLOCKS[b.t];
+ if(def.alpha){
+ ctx.save();
+ ctx.globalAlpha = def.alpha;
+ ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
+ ctx.restore();
+ } else {
+ ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
+ }
+
+ // TNT мигает, если активирован
+ if(b.t==='tnt' && b.active && Math.sin(now/60)>0){
+ ctx.fillStyle='rgba(255,255,255,0.45)';
+ ctx.fillRect(b.gx*TILE, b.gy*TILE, TILE, TILE);
+ }
+
+ // огонь костра
+ if(b.t==='campfire'){
+ drawFire(b.gx*TILE, b.gy*TILE, now);
+ }
+ // Печь — огонь когда обжигает
+ if(b.t==='furnace' && activeFurnaces.has(`${b.gx},${b.gy}`)){
+ drawFire(b.gx*TILE + 8, b.gy*TILE + 5, now);
+ }
+ }
+
+ // mobs
+ const allMobsRender = getAllMobs();
+ for(const m of allMobsRender){
+ if(m.kind==='zombie'){
+ ctx.fillStyle = '#2ecc71';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ ctx.fillStyle = '#c0392b';
+ ctx.fillRect(m.x+6, m.y+12, 6,6);
+ ctx.fillRect(m.x+22, m.y+12, 6,6);
+ } else if(m.kind==='pig'){
+ ctx.fillStyle = '#ffb6c1';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+22, m.y+5, 3,3);
+ ctx.fillStyle = '#ff69b4';
+ ctx.fillRect(m.x+28, m.y+12, 6,6);
+ } else if(m.kind==='chicken'){
+ // chicken
+ ctx.fillStyle = '#ecf0f1';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ ctx.fillStyle = '#f39c12';
+ ctx.fillRect(m.x+18, m.y+10, 6,4);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+8, m.y+6, 3,3);
+ } else if(m.kind==='creeper'){
+ // creeper
+ ctx.fillStyle = '#4CAF50';
+ ctx.fillRect(m.x, m.y, m.w, m.h);
+ // Глаза
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+8, m.y+8, 4,4);
+ ctx.fillRect(m.x+22, m.y+8, 4,4);
+ // Рот
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+12, m.y+20, 10,4);
+ // Ноги
+ ctx.fillStyle = '#4CAF50';
+ ctx.fillRect(m.x+4, m.y+30, 6,20);
+ ctx.fillRect(m.x+24, m.y+30, 6,20);
+ } else if(m.kind==='skeleton'){
+ // skeleton - детализированный
+ // Тело
+ ctx.fillStyle = '#ECEFF1';
+ ctx.fillRect(m.x+10, m.y+20, 14, 12);
+ // Череп
+ ctx.fillRect(m.x+8, m.y+0, 18, 18);
+ // Глазницы
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+10, m.y+6, 4,4);
+ ctx.fillRect(m.x+20, m.y+6, 4,4);
+ // Нос
+ ctx.fillRect(m.x+15, m.y+12, 4,2);
+ // Руки
+ ctx.fillStyle = '#ECEFF1';
+ ctx.fillRect(m.x+2, m.y+20, 6,14);
+ ctx.fillRect(m.x+26, m.y+20, 6,14);
+ // Ноги
+ ctx.fillRect(m.x+10, m.y+32, 6, 18);
+ ctx.fillRect(m.x+18, m.y+32, 6, 18);
+ // Лук в руке
+ ctx.save();
+ ctx.translate(m.x + 30, m.y + 22);
+ ctx.strokeStyle = '#8B4513';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(0, 0, 8, -Math.PI*0.7, Math.PI*0.7);
+ ctx.stroke();
+ // Тетива
+ ctx.strokeStyle = '#ccc';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(8*Math.cos(-Math.PI*0.7), 8*Math.sin(-Math.PI*0.7));
+ ctx.lineTo(8*Math.cos(Math.PI*0.7), 8*Math.sin(Math.PI*0.7));
+ ctx.stroke();
+ ctx.restore();
+ } else if(m.kind==='scorpion') {
+ // скорпион — оранжево-коричневый
+ ctx.fillStyle = '#d35400';
+ ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-8);
+ ctx.fillStyle = '#c0392b';
+ ctx.fillRect(m.x+m.w-4, m.y-6, 4, 10);
+ ctx.fillRect(m.x+m.w-2, m.y-10, 3, 5);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+4, m.y+6, 3, 3);
+ ctx.fillRect(m.x+m.w-7, m.y+6, 3, 3);
+ ctx.fillStyle = '#d35400';
+ ctx.fillRect(m.x-4, m.y+8, 6, 4);
+ ctx.fillRect(m.x+m.w-2, m.y+8, 6, 4);
+ } else if(m.kind==='polar_bear') {
+ ctx.fillStyle = '#ecf0f1';
+ ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-4);
+ ctx.fillRect(m.x+8, m.y-2, m.w-16, 10);
+ ctx.fillStyle = '#bdc3c7';
+ ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 4);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+12, m.y+1, 3, 3);
+ ctx.fillRect(m.x+m.w-14, m.y+1, 3, 3);
+ ctx.fillStyle = '#ecf0f1';
+ ctx.fillRect(m.x+8, m.y-4, 4, 4);
+ ctx.fillRect(m.x+m.w-12, m.y-4, 4, 4);
+ } else if(m.kind==='slime') {
+ const bounce = Math.sin(performance.now()/200)*3;
+ ctx.fillStyle = 'rgba(46,204,113,0.8)';
+ ctx.fillRect(m.x-1, m.y-1+bounce, m.w+2, m.h+2);
+ ctx.fillStyle = 'rgba(39,174,96,0.9)';
+ ctx.fillRect(m.x+1, m.y+1+bounce, m.w-2, m.h-2);
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(m.x+4, m.y+6+bounce, 6, 6);
+ ctx.fillRect(m.x+m.w-10, m.y+6+bounce, 6, 6);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+6, m.y+8+bounce, 3, 3);
+ ctx.fillRect(m.x+m.w-8, m.y+8+bounce, 3, 3);
+ } else if(m.kind==='eagle') {
+ ctx.fillStyle = '#8B4513';
+ ctx.fillRect(m.x+8, m.y+6, m.w-16, m.h-10);
+ const wingY = Math.sin(performance.now()/150)*4;
+ ctx.fillRect(m.x-6, m.y+4+wingY, 16, 6);
+ ctx.fillRect(m.x+m.w-10, m.y+4-wingY, 16, 6);
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 6);
+ ctx.fillStyle = '#f39c12';
+ ctx.fillRect(m.x+m.w/2-1, m.y+6, 4, 3);
+ ctx.fillStyle = '#000';
+ ctx.fillRect(m.x+m.w/2-1, m.y+3, 2, 2);
+ }
+ }
+
+ // boat (рисуем первой, чтобы игрок был внутри неё)
+ if(boat.active){
+ ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE);
+ }
+
+ // Lerp other players towards target positions (smooth movement)
+ for(const [socketId, p] of otherPlayers){
+ if (p.tx !== undefined) {
+ p.x += (p.tx - p.x) * 0.25;
+ p.y += (p.ty - p.y) * 0.25;
+ }
+ }
+ // Claim borders (приватные зоны)
+ if (claims.length > 0) {
+ for (const claim of claims) {
+ const isOwn = claim.owner_name === playerName;
+ const colorIdx = claims.indexOf(claim) % 5;
+ const borderColors = ["rgba(231,76,60,0.6)", "rgba(52,152,219,0.6)", "rgba(46,204,113,0.6)", "rgba(155,89,182,0.6)", "rgba(241,196,15,0.6)"];
+ const fillColors = ["rgba(231,76,60,0.06)", "rgba(52,152,219,0.06)", "rgba(46,204,113,0.06)", "rgba(155,89,182,0.06)", "rgba(241,196,15,0.06)"];
+ const borderColor = isOwn ? "rgba(46,204,113,0.7)" : borderColors[colorIdx];
+ const fillColor = isOwn ? "rgba(46,204,113,0.06)" : fillColors[colorIdx];
+
+ const left = (claim.gx - claim.radius) * TILE - camX;
+ const top_ = (claim.gy - claim.radius) * TILE - camY;
+ const w = (claim.radius * 2 + 1) * TILE;
+ const h = (claim.radius * 2 + 1) * TILE;
+
+ ctx.fillStyle = fillColor;
+ ctx.fillRect(left, top_, w, h);
+ ctx.strokeStyle = borderColor;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([8, 4]);
+ ctx.strokeRect(left, top_, w, h);
+ ctx.setLineDash([]);
+
+ // Owner label at top of claim
+ if (claim.owner_name) {
+ ctx.fillStyle = isOwn ? "#2ecc71" : "#e74c3c";
+ ctx.font = "12px monospace";
+ ctx.textAlign = "center";
+ ctx.fillText((isOwn ? "🏠 " : "⛔ ") + claim.owner_name, left + w/2, top_ + 14);
+ ctx.textAlign = "start";
+ }
+ }
+ }
+
+ // other players (multiplayer)
+ for(const [socketId, p] of otherPlayers){
+ if(heroImg.complete){
+ ctx.drawImage(heroImg, p.x - (TILE-player.w)/2, p.y - (TILE-player.h)/2, TILE, TILE);
+ } else {
+ ctx.fillStyle = p.color;
+ ctx.fillRect(p.x, p.y, 34, 34);
+ }
+ // Имя игрока (мелко над персонажем)
+ ctx.fillStyle = '#fff';
+ ctx.font = '12px system-ui';
+ ctx.textAlign = 'center';
+ ctx.fillText(p.name, p.x + 17, p.y - 8);
+ }
+
+ // player
+ if(heroImg.complete){
+ ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE);
+ } else {
+ ctx.fillStyle='#fff';
+ ctx.fillRect(player.x, player.y, player.w, player.h);
+ }
+
+ // projectiles (стрелы)
+ for(const p of projectiles){
+ const angle = Math.atan2(p.vy, p.vx);
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.rotate(angle);
+ ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f';
+ ctx.fillRect(-12, -1.5, 24, 3);
+ // наконечник
+ ctx.beginPath();
+ ctx.moveTo(12, -4);
+ ctx.lineTo(16, 0);
+ ctx.lineTo(12, 4);
+ ctx.closePath();
+ ctx.fill();
+ // оперение
+ ctx.fillStyle = '#888';
+ ctx.fillRect(-12, -3, 4, 2);
+ ctx.fillRect(-12, 1, 4, 2);
+ ctx.restore();
+ }
+
+ // particles
+ for(const p of parts){
+ ctx.fillStyle = p.c;
+ ctx.fillRect(p.x-2, p.y-2, 4, 4);
+ }
+
+ // Стрелы скелета
+ for(const m of mobs){
+ if(m.kind==='skeleton' && m.shootCooldown > 0.5){
+ // Рисуем стрелу
+ const arrowX = m.x + m.w/2;
+ const arrowY = m.y + 15;
+ const targetX = player.x + player.w/2;
+ const targetY = player.y + player.h/2;
+ const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
+ const speed = 400;
+
+ // Проверяем, попала ли стрела
+ const dx = targetX - arrowX;
+ const dy = targetY - arrowY;
+ const dist = Math.hypot(dx, dy);
+
+ // Рисуем стрелу
+ ctx.save();
+ ctx.translate(arrowX, arrowY);
+ ctx.rotate(angle);
+ ctx.fillStyle = '#ECEFF1';
+ ctx.fillRect(0, -1, 16, 2);
+ ctx.restore();
+
+ // Урон игроку если попали
+ if(dist < 150 && player.invuln <= 0){
+ player.hp -= 8;
+ player.invuln = 0.5;
+ player.vx += Math.cos(angle) * 300;
+ player.vy -= 200;
+ playSound('hit1');
+ }
+ }
+ }
+
+ // build ghost
+ if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){
+ const wx = mouse.x + camX;
+ const wy = mouse.y + camY;
+ const gx = Math.floor(wx/TILE);
+ const gy = Math.floor(wy/TILE);
+ ctx.strokeStyle = 'rgba(255,255,255,0.9)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(gx*TILE, gy*TILE, TILE, TILE);
+ }
+
+ ctx.restore();
+
+ // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
+ if(night){
+ // 1) Рисуем тёмный оверлей на offscreen canvas
+ lightC.width = W*dpr;
+ lightC.height = H*dpr;
+ lightCtx.setTransform(dpr,0,0,dpr,0,0);
+ lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
+ lightCtx.fillRect(0,0,W,H);
+
+ // 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены
+ lightCtx.globalCompositeOperation = 'destination-out';
+
+ // Функция: рисуем мягкий луч света с затуханием за стенами
+ function castLight(sx, sy, radius) {
+ const flick = 0.97 + Math.sin(now/300 + sx*0.01)*0.015 + Math.sin(now/470 + sy*0.02)*0.015;
+ const r = radius * flick;
+ // 24 луча — мягкий круглый свет
+ const steps = 24;
+ // Собираем дистанции до стен по лучам
+ const dists = new Float32Array(steps);
+ for(let i=0; i TILE*0.3){
+ maxDist = step;
+ break;
+ }
+ }
+ dists[i] = maxDist;
+ }
+ // Рисуем сглаженный полигон по dists
+ const cx = sx-camX, cy = sy-camY;
+ // Центр: яркая точка
+ const maxR = Math.max(...dists);
+ const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
+ grad.addColorStop(0, 'rgba(255,255,255,1)');
+ grad.addColorStop(0.4, 'rgba(255,255,255,0.8)');
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
+ lightCtx.fillStyle = grad;
+ // Рисуем shape по dists (звездоподобный полигон)
+ lightCtx.beginPath();
+ for(let i=0; i<=steps; i++){
+ const idx = i % steps;
+ const nextIdx = (i+1) % steps;
+ const avgD = (dists[idx] + dists[nextIdx]) / 2;
+ const angle = (idx/steps) * Math.PI * 2;
+ const px = cx + Math.cos(angle) * dists[idx];
+ const py = cy + Math.sin(angle) * dists[idx];
+ if(i===0) lightCtx.moveTo(px, py);
+ else lightCtx.lineTo(px, py);
+ }
+ lightCtx.closePath();
+ lightCtx.fill();
+ }
+
+ // Источники света
+ for(const b of blocks){
+ if(b.dead) continue;
+ if(b.gx < minGX-5 || b.gx > maxGX+5 || b.gy < minGY-5 || b.gy > maxGY+5) continue;
+ const def = BLOCKS[b.t];
+ if(def.lightRadius){
+ castLight(b.gx*TILE + TILE/2, b.gy*TILE + TILE/2, def.lightRadius);
+ }
+ }
+
+ // 3) Накладываем lightmap на основной canvas
+ lightCtx.globalCompositeOperation = 'source-over';
+ ctx.drawImage(lightC, 0, 0, W, H);
+
+ // 4) Тёплый оверлей от источников света (additive, мягкий)
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ for(const b of blocks){
+ if(b.dead) continue;
+ if(b.gx < minGX-3 || b.gx > maxGX+3 || b.gy < minGY-3 || b.gy > maxGY+3) continue;
+ const def = BLOCKS[b.t];
+ if(def.lightRadius){
+ const flick = 0.9 + Math.sin(now/350 + b.gx*3.7)*0.05 + Math.sin(now/530 + b.gy*2.3)*0.05;
+ const wx = b.gx*TILE + TILE/2 - camX;
+ const wy = b.gy*TILE + TILE/2 - camY;
+ const r = def.lightRadius * 0.85;
+ const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r);
+ grad.addColorStop(0, `rgba(255,180,80,0.28)`);
+ grad.addColorStop(0.5, `rgba(255,140,40,0.13)`);
+ grad.addColorStop(1, 'rgba(255,100,20,0)');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(wx, wy, r, 0, Math.PI*2);
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+ }
+
+ // Дождь (после ночного оверлея)
+ drawRain();
+ if(Math.random()<0.25){
+ hpEl.textContent = Math.max(0, Math.ceil(player.hp));
+ foodEl.textContent = Math.ceil(player.hunger);
+ document.getElementById('o2').textContent = Math.ceil(player.o2);
+ sxEl.textContent = Math.floor(player.x/TILE);
+ syEl.textContent = Math.floor(player.y/TILE);
+ todEl.textContent = night ? 'Ночь' : 'День';
+ document.getElementById('xplevel').textContent = player.level;
+ const lvXpNext = xpForLevel(player.level + 1);
+ const lvXpCur = xpForLevel(player.level);
+ const xpInLevel = player.xp - lvXpCur;
+ const xpNeeded = lvXpNext - lvXpCur;
+ document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
+ tempEl.textContent = Math.round(player.temperature) + "°C";
+ worldIdEl.textContent = worldId;
+ if(isMultiplayer){
+ document.getElementById('multiplayerStatus').style.display = 'flex';
+ playerCountEl.textContent = otherPlayers.size + 1; // +1 = мы сами
+ } else {
+ document.getElementById('multiplayerStatus').style.display = 'none';
+ }
+ }
+
+ // Индикатор сна
+ if(player.sleeping){
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
+ ctx.fillRect(0, 0, W, H);
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold 32px system-ui';
+ ctx.textAlign = 'center';
+ ctx.fillText('💤 Спим...', W/2, H/2);
+ ctx.font = '18px system-ui';
+ ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
+ }
+
+ // Рисуем дропы
+ // Температурный оверлей (заморозка/тепловой удар)
+ if (typeof player.temperature !== 'undefined' && (player.temperature < -5 || player.temperature > 35)) {
+ const severity = player.temperature < -5
+ ? Math.abs(player.temperature + 5) / 20
+ : (player.temperature - 35) / 20;
+ const alpha = Math.min(0.35, severity * 0.15);
+ if (player.temperature < -5) {
+ ctx.fillStyle = 'rgba(100,150,255,' + alpha + ')';
+ } else {
+ ctx.fillStyle = 'rgba(255,100,50,' + alpha + ')';
+ }
+ ctx.fillRect(0, 0, W, H);
+ }
+ drawDrops(ctx);
+ // Пикап дропов
+ pickupDrops();
+ // Popup уровня
+ drawLevelUpPopup(ctx);
+
+ // Миникарта (обновляем раз в ~4 кадра для оптимизации)
+ if(minimapOpen && Math.random() < 0.25){
+ renderMinimap();
+ }
+
+ requestAnimationFrame(loop);
+ }
+
+ requestAnimationFrame(loop);
+})();
diff --git a/game3d.js b/game3d.js
new file mode 100644
index 0000000..518e447
--- /dev/null
+++ b/game3d.js
@@ -0,0 +1,6757 @@
+// GrechkaCraft — auto-bundled from ES modules
+(function() {
+"use strict";
+
+// === core/constants.js ===
+// ==================== КОНСТАНТЫ ИГРЫ ====================
+
+const TILE = 40;
+
+// Мир
+const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
+const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
+const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
+
+// Физика
+const GRAV = 2200;
+const GRAV_WATER = 550;
+const MOVE = 320;
+const JUMP = 760;
+
+// День/ночь
+const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза
+
+// Сохранение
+const SAVE_KEY = 'minegrechka_save';
+
+// Сеть
+const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
+
+// Погода
+const MAX_RAINDROPS = 200;
+
+// Чат
+const MAX_CHAT_MESSAGES = 50;
+
+// Голосовой чат
+const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru';
+
+// === core/state.js ===
+// Все мутабельные переменные игры в одном объекте состояния
+const state = {
+ // Константы (для доступа из модулей)
+ TILE,
+ SEA_GY,
+ BEDROCK_GY,
+ GEN_MARGIN_X,
+ GRAV,
+ GRAV_WATER,
+ MOVE,
+ JUMP,
+ DAY_LEN,
+ SAVE_KEY,
+ MOVE_SEND_INTERVAL,
+ MAX_RAINDROPS,
+ MAX_CHAT_MESSAGES,
+ VOICE_SERVER,
+
+ // Камера
+ camX: 0,
+ camY: 0,
+ W: 0,
+ H: 0,
+ dpr: 1,
+
+ // Canvas/DOM (заполняется в main.js)
+ gameEl: null,
+ canvas: null,
+ ctx: null,
+ lightC: null,
+ lightCtx: null,
+
+ // День/ночь
+ worldTime: 0.30, // старт в полдень (0 = рассвет, 0.5+ = ночь)
+ isNightTime: false,
+ dayNightCycle: true,
+
+ // Мультиплеер
+ isMultiplayer: false,
+ mySocketId: null,
+ socket: null,
+ voiceSocket: null,
+ voicePosT: 0,
+
+ // Сервер
+ SERVER_URL: '',
+ TELEGRAM_BOT_USERNAME: '',
+ TELEGRAM_APP_SHORT_NAME: '',
+
+ // Игрок
+ playerName: null,
+ worldId: null,
+
+ // Инвентарь/UI
+ selected: "dirt",
+ showFullInventory: false,
+ craftOpen: false,
+ inventoryOpen: false,
+ chatOpen: false,
+ minimapOpen: false,
+ modeIdx: 0,
+ currentFurnaceKey: null,
+
+ // Мир
+ blocksVersion: 0,
+ worldSeed: Math.floor(Math.random() * 1000000),
+
+ // Погода
+ weatherType: "clear",
+ isRaining: false,
+ rainIntensity: 0,
+ weatherTimer: 0,
+ weatherChangeInterval: 60 + Math.random() * 120,
+ snowflakes: [],
+ lightningFlash: 0,
+ lightningTimer: 0,
+
+ // Мобы/спавн
+ spawnT: 0,
+
+ // Цикл
+ last: 0,
+ prevJump: false,
+
+ // Сеть — throttle отправки позиции
+ lastMoveSendTime: 0,
+ lastSentX: 0,
+ lastSentY: 0,
+
+ // Mining progress
+ mining: { gx: null, gy: null, progress: 0, hardness: 0 },
+
+ // Trade/Market
+ myTgId: null,
+ tradePanel: null,
+ tradeContent: null,
+ marketPanel: null,
+ marketContent: null,
+
+ // DOM-ссылки (заполняются в main.js)
+ hpEl: null,
+ foodEl: null,
+ sxEl: null,
+ syEl: null,
+ todEl: null,
+ worldIdEl: null,
+ playerCountEl: null,
+ deathEl: null,
+ hotbarEl: null,
+ craftPanel: null,
+ recipesEl: null,
+ inventoryPanel: null,
+ inventoryGrid: null,
+ multiplayerStatus: null,
+ tempEl: null,
+
+ // Игрок
+ player: {
+ x: 6 * TILE,
+ y: 0 * TILE,
+ z: 0,
+ w: 34,
+ h: 34,
+ d: 34,
+ vx: 0,
+ vy: 0,
+ vz: 0,
+ grounded: false,
+ inWater: false,
+ headInWater: false,
+ hp: 100,
+ hunger: 100,
+ o2: 100,
+ invuln: 0,
+ fallStartY: 0,
+ lastStepTime: 0,
+ sleeping: false,
+ inBoat: false,
+ armor: 0,
+ equippedArmor: null,
+ temperature: 15,
+ slowTimer: 0
+ },
+
+ // Точка спавна
+ spawnPoint: { x: 6 * TILE, y: 0 * TILE },
+
+ // Инвентарь
+ inv: {
+ dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0,
+ wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0,
+ copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0,
+ brick: 0, glass: 0,
+ tnt: 1, campfire: 0, torch: 0,
+ meat: 0, cooked: 0, arrow: 0,
+ wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0,
+ wood_sword: 0, stone_sword: 0, iron_sword: 0,
+ wood_axe: 0, stone_axe: 0, iron_axe: 0,
+ wood_shovel: 0, stone_shovel: 0, iron_shovel: 0,
+ iron_armor: 0, gold_armor: 0,
+ bow: 0, furnace: 0,
+ bed: 0, boat: 0,
+ iron_ingot: 0, gold_ingot: 0, copper_ingot: 0,
+ cobblestone: 0,
+ // New items
+ chicken_meat: 0, feather: 0, bone: 0, gunpowder: 0,
+ wheat: 0, bread: 0, carrot: 0, potato: 0, baked_potato: 0,
+ mushroom_stew: 0,
+ scorpion_stinger: 0, polar_fur: 0, slime_ball: 0, eagle_feather: 0,
+ snow: 0, ice: 0, cactus: 0, mushroom: 0, moss: 0, farmland: 0,
+ spruce_leaves: 0, dead_bush: 0, swamp_water: 0,
+ wheat_seeds: 0, wheat_stage0: 0, carrot_stage0: 0, potato_stage0: 0
+ },
+
+ // Лодка
+ boat: {
+ x: 0, y: 0,
+ w: 34, h: 34,
+ vx: 0, vy: 0,
+ active: false,
+ inWater: false
+ },
+
+ // Ввод
+ inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false, l: false, r: false, j: false, s: false },
+
+ // Мышь
+ mouse: { x: 0, y: 0 },
+
+ // Другие игроки (MP)
+ otherPlayers: new Map(),
+
+ // Серверные мобы (MP)
+ serverMobs: new Map(),
+
+ // Мобы
+ mobs: [],
+
+ // Снаряды
+ projectiles: [],
+
+ // Отслеживание изменений мира
+ placedBlocks: [],
+ removedBlocks: [],
+
+ // Серверные изменения
+ serverOverrides: new Map(),
+
+ // Чат
+ chatMessages: [],
+
+ // Погода — капли
+ raindrops: [],
+
+ // Облака
+ clouds: Array.from({ length: 10 }, () => ({
+ x: Math.random() * 2000,
+ y: -200 - Math.random() * 260,
+ w: 80 + Math.random() * 120,
+ s: 12 + Math.random() * 20
+ })),
+
+ // Частицы
+ parts: [],
+
+ // Активный TNT
+ activeTNT: new Set(),
+
+ // Прочность инструментов
+ toolDurability: new Map(),
+ protections: new Map(),
+
+ // Последние выбранные предметы
+ recentItems: [],
+
+ // Активные печи
+ activeFurnaces: new Map(),
+ cropTimers: new Map(),
+
+ // Сгенерированные колонны
+ generated: new Set(),
+
+ // Изображение героя
+ heroImg: new Image(),
+
+ // Сетка мира
+ grid: new Map(),
+ blocks: [],
+
+ // In-memory save fallback
+ inMemorySave: null,
+};
+
+// === config.js ===
+// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
+// This module only sets SERVER_URL. Player name and world ID are handled in main.js
+// to avoid duplicate prompts.
+
+const urlParams = new URLSearchParams(window.location.search);
+const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
+
+// Защита от mixed content
+if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
+ console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
+ alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
+}
+
+// === ui/start-menu.js ===
+// ==================== START MENU ====================
+
+
+// Detect Telegram WebApp
+const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
+let tgUser = null;
+let tgSaveLoaded = false;
+
+if (isTgWebApp) {
+ try {
+ Telegram.WebApp.ready();
+ Telegram.WebApp.expand();
+ tgUser = Telegram.WebApp.initDataUnsafe?.user || null;
+ console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username);
+ // Set player name from TG
+ if (tgUser && tgUser.first_name) {
+ const tgName = tgUser.username || tgUser.first_name;
+ localStorage.setItem('minegrechka_playerName', tgName);
+ }
+ } catch (e) {
+ console.warn('[TG] WebApp init error:', e);
+ }
+}
+
+// Export TG info for other modules
+
+
+// ==================== HELPERS ====================
+
+function generateShortWorldCode() {
+ // Generate readable 6-char code: consonants + digits (easy to copy/share)
+ const c = 'bcdfghjkmnpqrstvwxyz';
+ const d = '23456789';
+ let code = '';
+ for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)];
+ for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)];
+ return code;
+}
+
+function showToast(msg, duration) {
+ duration = duration || 1800;
+ const t = document.createElement('div');
+ t.textContent = msg;
+ t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;';
+ document.body.appendChild(t);
+ setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration);
+}
+
+function customAlert(msg) {
+ const overlay = document.createElement('div');
+ overlay.className = 'custom-modal-overlay';
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;';
+ const box = document.createElement('div');
+ box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
+ const text = document.createElement('div');
+ text.textContent = msg;
+ text.style.marginBottom = '16px';
+ text.style.fontWeight = 'bold';
+ text.style.fontSize = '16px';
+ const btn = document.createElement('button');
+ btn.textContent = 'OK';
+ btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
+ btn.onclick = () => overlay.remove();
+ box.appendChild(text);
+ box.appendChild(btn);
+ overlay.appendChild(box);
+ document.body.appendChild(overlay);
+}
+
+// ==================== MAIN START MENU ====================
+
+// Minecraft-style CSS for the start menu
+const mcCSS = `
+ .mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;
+ display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;
+ image-rendering:pixelated; }
+ .mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; }
+ .mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%;
+ background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; }
+ .mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000;
+ padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000;max-height:90vh;display:flex;flex-direction:column; }
+ .mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center;
+ border-bottom:4px solid #000; }
+ .mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000;
+ letter-spacing:2px; }
+ .mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; }
+ .mc-body { padding:16px;background:#C6C6C6;max-height:80vh;overflow-y:auto;-webkit-overflow-scrolling:touch; }
+ .mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px;
+ font-family:'Courier New',monospace;font-size:16px;font-weight:bold;
+ cursor:pointer;text-align:center;border:3px solid #000;
+ image-rendering:pixelated;box-shadow:3px 3px 0 #000;
+ transition:transform 0.05s,box-shadow 0.05s; }
+ .mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; }
+ .mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; }
+ .mc-btn-green:hover { background:#6DAF4E; }
+ .mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; }
+ .mc-btn-blue:hover { background:#4B73BE; }
+ .mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; }
+ .mc-btn-purple:hover { background:#9555BE; }
+ .mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; }
+ .mc-btn-orange:hover { background:#DA8500; }
+ .mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; }
+ .mc-btn-red:hover { background:#BE3B3B; }
+ .mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; }
+ .mc-btn-gray:hover { background:#666;color:#DDD; }
+ .mc-btn-outline { background:transparent;color:#555;border-color:#555;
+ text-shadow:none;box-shadow:2px 2px 0 #333; }
+ .mc-btn-outline:hover { background:#DDD;color:#222; }
+ .mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace;
+ font-size:16px;background:#FFF;border:3px solid #000;color:#222;
+ box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; }
+ .mc-input:focus { outline:none;border-color:#FFAA00; }
+ .mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; }
+ .mc-divider { border:none;border-top:2px solid #999;margin:12px 0; }
+ .mc-friend-row { display:flex;justify-content:space-between;align-items:center;
+ padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999;
+ box-shadow:2px 2px 0 #999; }
+ .mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace;
+ font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000;
+ box-shadow:1px 1px 0 #000; }
+ .mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; }
+ .mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; }
+ .mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; }
+ .mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; }
+ .mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; }
+`;
+
+function showStartMenu() {
+ return new Promise(async (resolve) => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const worldParam = urlParams.get('world');
+ const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param)
+ ? Telegram.WebApp.initDataUnsafe.start_param.trim()
+ : null;
+
+ // If world is in URL or TG start param — skip menu, auto-join
+ if ((worldParam && worldParam.trim() !== '') || tgStartParam) {
+ const worldId = (worldParam && worldParam.trim()) || tgStartParam;
+ console.log('[Menu] Joining world from URL/startParam:', worldId);
+
+ // Set player name from TG or localStorage or default
+ if (!state.playerName) {
+ if (isTgWebApp && tgUser) {
+ state.playerName = tgUser.username || tgUser.first_name || 'Игрок';
+ } else {
+ state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
+ }
+ localStorage.setItem('minegrechka_playerName', state.playerName);
+ }
+
+ resolve(worldId);
+ return;
+ }
+
+ // Inject CSS
+ const styleEl = document.createElement('style');
+ styleEl.textContent = mcCSS;
+ document.head.appendChild(styleEl);
+
+ // Build overlay
+ const overlay = document.createElement('div');
+ overlay.className = 'mc-overlay';
+ overlay.id = 'startMenu';
+ overlay.innerHTML = '';
+
+ const box = document.createElement('div');
+ box.className = 'mc-box';
+
+ // Title bar
+ const titleBar = document.createElement('div');
+ titleBar.className = 'mc-titlebar';
+ titleBar.innerHTML = '⛏️ GrechkaCraft
' +
+ '' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
';
+ box.appendChild(titleBar);
+
+ const body = document.createElement('div');
+ body.className = 'mc-body';
+
+ // Mutable worldId holder
+ let selectedWorldId = null;
+
+ // === Buttons ===
+
+ // 🌍 New World
+ const btnNew = document.createElement('button');
+ btnNew.className = 'mc-btn mc-btn-green';
+ btnNew.textContent = '🌍 Новый мир';
+ btnNew.onclick = () => {
+ selectedWorldId = generateShortWorldCode();
+ overlay.remove();
+ styleEl.remove();
+ resolve(selectedWorldId);
+ };
+ body.appendChild(btnNew);
+
+ // 🔗 Join by code
+ const btnJoin = document.createElement('button');
+ btnJoin.className = 'mc-btn mc-btn-blue';
+ btnJoin.textContent = '🔗 Вступить по коду';
+ btnJoin.onclick = () => {
+ body.innerHTML = '';
+ const lbl = document.createElement('div');
+ lbl.className = 'mc-label';
+ lbl.textContent = 'Код мира:';
+ body.appendChild(lbl);
+ const codeInput = document.createElement('input');
+ codeInput.className = 'mc-input';
+ codeInput.type = 'text';
+ codeInput.placeholder = 'bkh237';
+ codeInput.maxLength = 12;
+ body.appendChild(codeInput);
+ const btnGo = document.createElement('button');
+ btnGo.className = 'mc-btn mc-btn-blue';
+ btnGo.textContent = 'Войти →';
+ btnGo.onclick = () => {
+ const code = codeInput.value.trim().toLowerCase();
+ if (code.length >= 3) {
+ selectedWorldId = code;
+ overlay.remove();
+ styleEl.remove();
+ resolve(selectedWorldId);
+ }
+ };
+ body.appendChild(btnGo);
+ const btnBack = document.createElement('button');
+ btnBack.className = 'mc-btn mc-btn-gray';
+ btnBack.textContent = '← Назад';
+ btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
+ body.appendChild(btnBack);
+ codeInput.focus();
+ codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
+ };
+ body.appendChild(btnJoin);
+
+ // 💾 Continue saved game
+ const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save');
+ if (isTgWebApp || hasLocalSave) {
+ const btnHistory = document.createElement('button');
+ btnHistory.className = 'mc-btn mc-btn-purple';
+ btnHistory.textContent = '💾 Продолжить игру';
+ btnHistory.onclick = async () => {
+ if (isTgWebApp && tgUser) {
+ try {
+ const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
+ const data = await resp.json();
+ if (data.ok && data.world_id) {
+ selectedWorldId = data.world_id;
+ tgSaveLoaded = true;
+ overlay.remove();
+ styleEl.remove();
+ resolve(selectedWorldId);
+ return;
+ }
+ } catch (e) { console.warn('[TG] Load save failed:', e); }
+ }
+ if (hasLocalSave) {
+ // Continue with a new world code (the save will be loaded separately by initDB/loadGame)
+ selectedWorldId = generateShortWorldCode();
+ overlay.remove();
+ styleEl.remove();
+ resolve(selectedWorldId);
+ return;
+ }
+ customAlert('Сохранений не найдено');
+ };
+ body.appendChild(btnHistory);
+ }
+
+ // === TG-only buttons ===
+ if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
+ // 📤 Invite friends
+ const btnInvite = document.createElement('button');
+ btnInvite.className = 'mc-btn mc-btn-outline';
+ btnInvite.style.marginTop = '16px';
+ btnInvite.textContent = '📤 Пригласить друзей';
+ btnInvite.onclick = () => {
+ const code = selectedWorldId || generateShortWorldCode();
+ const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code);
+ if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) {
+ Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code));
+ } else {
+ navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url));
+ }
+ };
+ body.appendChild(btnInvite);
+
+ // 👥 Friends
+ const btnFriends = document.createElement('button');
+ btnFriends.className = 'mc-btn mc-btn-orange';
+ btnFriends.style.marginTop = '8px';
+ btnFriends.textContent = '👥 Друзья';
+ btnFriends.onclick = async () => {
+ try {
+ const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id);
+ const data = await resp.json();
+ body.innerHTML = '';
+
+ // Back button at top
+ const btnBackTop = document.createElement('button');
+ btnBackTop.className = 'mc-btn mc-btn-gray';
+ btnBackTop.textContent = '← Назад';
+ btnBackTop.style.marginBottom = '12px';
+ btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
+ body.appendChild(btnBackTop);
+
+ // Nickname section
+ if (data.nickname) {
+ const nickDiv = document.createElement('div');
+ nickDiv.className = 'mc-friend-row';
+ nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + '';
+ nickDiv.style.background = '#D5E8C5';
+ body.appendChild(nickDiv);
+ } else {
+ const nickLabel = document.createElement('div');
+ nickLabel.className = 'mc-label';
+ nickLabel.style.color = '#9E2B2B';
+ nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:';
+ body.appendChild(nickLabel);
+ const nickRow = document.createElement('div');
+ nickRow.style.display = 'flex';
+ nickRow.style.gap = '4px';
+ nickRow.style.marginBottom = '8px';
+ const nickInput = document.createElement('input');
+ nickInput.className = 'mc-input';
+ nickInput.style.flex = '1';
+ nickInput.type = 'text';
+ nickInput.maxLength = 16;
+ nickInput.placeholder = 'Твой ник';
+ const nickBtn = document.createElement('button');
+ nickBtn.className = 'mc-btn mc-btn-green';
+ nickBtn.style.width = 'auto';
+ nickBtn.style.marginBottom = '0';
+ nickBtn.style.padding = '8px 16px';
+ nickBtn.textContent = '✓';
+ nickBtn.onclick = async () => {
+ const n = nickInput.value.trim().toLowerCase();
+ if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; }
+ const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n));
+ const cd = await check.json();
+ if (!cd.available) { showToast('❌ Ник занят'); return; }
+ const setResp = await fetch(state.SERVER_URL + '/api/nick/set', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tg_id: tgUser.id, nickname: n })
+ });
+ const sd = await setResp.json();
+ if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
+ };
+ nickRow.appendChild(nickInput);
+ nickRow.appendChild(nickBtn);
+ body.appendChild(nickRow);
+ }
+
+ // Friends list
+ if (data.friends && data.friends.length) {
+ const listLabel = document.createElement('div');
+ listLabel.className = 'mc-label';
+ listLabel.textContent = '👥 Друзья:';
+ body.appendChild(listLabel);
+ for (const f of data.friends) {
+ const fDiv = document.createElement('div');
+ fDiv.className = 'mc-friend-row';
+ const statusDot = f.online ? '🟢' : '⚫';
+ let info = statusDot + ' ' + f.nickname + '';
+ if (f.online && f.world_id) info += ' — мир ' + f.world_id;
+ const fInfo = document.createElement('div');
+ fInfo.innerHTML = info;
+ fDiv.appendChild(fInfo);
+ if (f.online && f.world_id) {
+ const joinBtn = document.createElement('button');
+ joinBtn.className = 'mc-friend-btn mc-friend-btn-green';
+ joinBtn.textContent = '🎮 Вступить';
+ joinBtn.onclick = () => {
+ selectedWorldId = f.world_id;
+ overlay.remove();
+ styleEl.remove();
+ resolve(selectedWorldId);
+ };
+ fDiv.appendChild(joinBtn);
+ }
+ body.appendChild(fDiv);
+ }
+ } else if (data.friends && !data.friends.length) {
+ const noFriends = document.createElement('div');
+ noFriends.className = 'mc-label';
+ noFriends.style.color = '#888';
+ noFriends.textContent = 'Пока нет друзей';
+ body.appendChild(noFriends);
+ }
+
+ // Pending friend requests
+ if (data.pending && data.pending.length) {
+ const pLabel = document.createElement('div');
+ pLabel.className = 'mc-label';
+ pLabel.style.color = '#B86E00';
+ pLabel.textContent = '📨 Запросы в друзья:';
+ body.appendChild(pLabel);
+ for (const p of data.pending) {
+ const pItem = document.createElement('div');
+ pItem.className = 'mc-friend-row';
+ const span = document.createElement('span');
+ span.textContent = p.from;
+ pItem.appendChild(span);
+ const btns = document.createElement('div');
+ btns.style.display = 'flex';
+ btns.style.gap = '4px';
+ const acceptBtn = document.createElement('button');
+ acceptBtn.className = 'mc-friend-btn mc-friend-btn-green';
+ acceptBtn.textContent = '✓';
+ acceptBtn.onclick = async () => {
+ const r = await fetch(state.SERVER_URL + '/api/friends/accept', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
+ });
+ const d = await r.json();
+ if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
+ };
+ const declineBtn = document.createElement('button');
+ declineBtn.className = 'mc-friend-btn mc-friend-btn-red';
+ declineBtn.textContent = '✕';
+ declineBtn.onclick = async () => {
+ await fetch(state.SERVER_URL + '/api/friends/decline', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
+ });
+ showToast('Отклонено');
+ btnFriends.onclick();
+ };
+ btns.appendChild(acceptBtn);
+ btns.appendChild(declineBtn);
+ pItem.appendChild(btns);
+ body.appendChild(pItem);
+ }
+ }
+
+ // Add friend
+ body.appendChild(document.createElement('hr'));
+ const addLabel = document.createElement('div');
+ addLabel.className = 'mc-label';
+ addLabel.textContent = '➕ Добавить друга по нику:';
+ body.appendChild(addLabel);
+ const addRow = document.createElement('div');
+ addRow.style.display = 'flex';
+ addRow.style.gap = '4px';
+ const addInput = document.createElement('input');
+ addInput.className = 'mc-input';
+ addInput.style.flex = '1';
+ addInput.type = 'text';
+ addInput.maxLength = 16;
+ addInput.placeholder = 'Ник друга';
+ const addBtn = document.createElement('button');
+ addBtn.className = 'mc-btn mc-btn-blue';
+ addBtn.style.width = 'auto';
+ addBtn.style.marginBottom = '0';
+ addBtn.style.padding = '8px 16px';
+ addBtn.textContent = '+';
+ addBtn.onclick = async () => {
+ const nick = addInput.value.trim().toLowerCase();
+ if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; }
+ const r = await fetch(state.SERVER_URL + '/api/friends/request', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tg_id: tgUser.id, nickname: nick })
+ });
+ const d = await r.json();
+ if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); }
+ else { showToast('❌ ' + (d.error || 'Ошибка')); }
+ };
+ addRow.appendChild(addInput);
+ addRow.appendChild(addBtn);
+ body.appendChild(addRow);
+
+ // Privacy checkboxes
+ const settings = data.settings || { allow_requests: 1, notify_online: 1 };
+ const settingsDiv = document.createElement('div');
+ settingsDiv.style.marginTop = '12px';
+ const reqLabel = document.createElement('label');
+ reqLabel.className = 'mc-checkbox-row';
+ const reqCb = document.createElement('input');
+ reqCb.className = 'mc-checkbox';
+ reqCb.type = 'checkbox';
+ reqCb.checked = settings.allow_requests;
+ reqCb.onchange = async () => {
+ await fetch(state.SERVER_URL + '/api/friends/settings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tg_id: tgUser.id,
+ allow_requests: reqCb.checked ? 1 : 0,
+ notify_online: settings.notify_online
+ })
+ });
+ };
+ reqLabel.appendChild(reqCb);
+ reqLabel.appendChild(document.createTextNode(' Запросы в друзья'));
+ settingsDiv.appendChild(reqLabel);
+ body.appendChild(settingsDiv);
+ } catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); }
+ };
+ body.appendChild(btnFriends);
+ }
+
+ // Player name input — always visible, with Play button
+ {
+ const nameDivider = document.createElement('hr');
+ nameDivider.className = 'mc-divider';
+ body.appendChild(nameDivider);
+ const nameLabel = document.createElement('div');
+ nameLabel.className = 'mc-label';
+ nameLabel.textContent = 'Твоё имя:';
+ body.appendChild(nameLabel);
+ const nameRow = document.createElement('div');
+ nameRow.style.cssText = 'display:flex;gap:6px;';
+ const nameInput = document.createElement('input');
+ nameInput.className = 'mc-input';
+ nameInput.style.flex = '1';
+ nameInput.type = 'text';
+ nameInput.placeholder = 'Игрок';
+ nameInput.value = state.playerName || localStorage.getItem('minegrechka_playerName') || (tgUser ? (tgUser.username || tgUser.first_name || '') : '');
+ nameInput.maxLength = 20;
+ nameRow.appendChild(nameInput);
+
+ const playBtn = document.createElement('button');
+ playBtn.className = 'mc-btn mc-btn-green';
+ playBtn.style.cssText = 'width:auto;margin-bottom:0;padding:10px 20px;white-space:nowrap;';
+ playBtn.textContent = '▶ Играть';
+ playBtn.onclick = () => {
+ const name = nameInput.value.trim() || 'Игрок';
+ state.playerName = name;
+ localStorage.setItem('minegrechka_playerName', name);
+ // If no worldId yet, generate one and start
+ if (!selectedWorldId && !state.worldId) {
+ selectedWorldId = generateShortWorldCode();
+ }
+ overlay.remove();
+ styleEl.remove();
+ resolve(selectedWorldId || state.worldId);
+ };
+ nameRow.appendChild(playBtn);
+ body.appendChild(nameRow);
+
+ // Allow Enter key to start game
+ nameInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') playBtn.click();
+ });
+
+ // Auto-focus input on mobile
+ setTimeout(() => nameInput.focus(), 100);
+ }
+
+ box.appendChild(body);
+ overlay.appendChild(box);
+ document.body.appendChild(overlay);
+
+ // Set default player name if still empty
+ if (!state.playerName) {
+ state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
+ localStorage.setItem('minegrechka_playerName', state.playerName);
+ }
+
+ // Auto-load TG save info (background fetch)
+ if (isTgWebApp && tgUser && !worldParam) {
+ fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
+ .then(r => r.json())
+ .then(data => {
+ if (data.ok && data.world_id) {
+ // could update UI but menu is already shown
+ }
+ })
+ .catch(() => {});
+ }
+ });
+}
+
+// === core/canvas.js ===
+// ==================== ХОЛСТ 3D (WebGL) — GrechkaCraft v2 ====================
+// Тот же контракт, что canvas.js: выставляет state.W/H, хранит canvas.
+// WebGL-контекст создаёт render3d (лениво, при первом кадре).
+
+
+function initCanvas() {
+ const gameEl = document.getElementById('game');
+ const canvas = document.getElementById('c');
+
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
+
+ function resize() {
+ state.W = gameEl.clientWidth;
+ state.H = gameEl.clientHeight;
+ }
+
+ window.addEventListener('resize', resize);
+ resize();
+
+ state.gameEl = gameEl;
+ state.canvas = canvas;
+ state.ctx = null;
+ state.lightC = null;
+ state.lightCtx = null;
+ state.dpr = dpr;
+}
+
+function getCanvas() { return state.canvas; }
+function getCtx() { return null; }
+function getLightC() { return null; }
+function getLightCtx() { return null; }
+
+// === data/blocks.js ===
+const BLOCKS = {
+ air: { n:'Воздух', solid:false, hardness:0 },
+ grass: { n:'Трава', c:'#7cfc00', solid:true, hardness:3, toolType:'shovel' },
+ dirt: { n:'Грязь', c:'#8b4513', solid:true, hardness:3, toolType:'shovel' },
+ stone: { n:'Камень', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
+ sand: { n:'Песок', c:'#f4d06f', solid:true, hardness:2, toolType:'shovel' },
+ gravel: { n:'Гравий', c:'#95a5a6', solid:true, hardness:3, toolType:'shovel' },
+ clay: { n:'Глина', c:'#74b9ff', solid:true, hardness:3, toolType:'shovel' },
+ wood: { n:'Дерево', c:'#d35400', solid:true, hardness:4, toolType:'axe' },
+ planks: { n:'Доски', c:'#e67e22', solid:true, hardness:3, toolType:'axe' },
+ ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true, hardness:1, toolType:null },
+ leaves: { n:'Листва', c:'#2ecc71', solid:true, hardness:1, toolType:'axe', minTool:null },
+ glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55, hardness:1, toolType:null },
+ water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true, hardness:0 },
+ coal: { n:'Уголь', c:'#2c3e50', solid:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
+ copper_ore:{ n:'Медь', c:'#e17055', solid:true, hardness:10, toolType:'pickaxe', minTool:'wood' },
+ iron_ore: { n:'Железо', c:'#dcdde1', solid:true, hardness:12, toolType:'pickaxe', minTool:'stone' },
+ iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5, hardness:0 },
+ gold_ore: { n:'Золото', c:'#f1c40f', solid:true, hardness:15, toolType:'pickaxe', minTool:'stone' },
+ diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' },
+ brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' },
+ tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null },
+ campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null },
+ torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null },
+ bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity },
+ flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 },
+ bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' },
+ boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 },
+ furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
+ cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
+ flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 },
+ // === BIOME BLOCKS ===
+ snow: { n:'Снег', c:'#ecf0f1', solid:true, hardness:1, toolType:'shovel' },
+ ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' },
+ cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null },
+ mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 },
+ moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null },
+ swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 },
+ farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' },
+ dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 },
+ spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' },
+ // === CROP STAGES ===
+ wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' },
+ wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' },
+ wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' },
+ wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 },
+ carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' },
+ carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' },
+ carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' },
+ carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 },
+ potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' },
+ potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' },
+ potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' },
+ potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 },
+};
+
+// === world/world-storage3d.js ===
+// ==================== ХРАНИЛИЩЕ МИРА 3D — GrechkaCraft v3 ====================
+// Ключ блока: "x,y,z". Единый источник для рендера, физики, сети и сейвов.
+// Мир по всем трём осям: X — вперёд, Y — вверх, Z — вбок (глубина мира).
+
+
+
+const grid = state.grid; // Map: "x,y,z" => { gx, gy, gz, t, dead }
+const blocks = state.blocks; // Array для перебора/рендера
+
+function k3(gx, gy, gz) { return gx + ',' + gy + ',' + gz; }
+
+function getBlock(gx, gy, gz) {
+ const b = grid.get(k3(gx, gy, gz));
+ if (b && b.dead) return null;
+ return b || null;
+}
+
+function hasBlock(gx, gy, gz) { return grid.has(k3(gx, gy, gz)); }
+
+function isSolid(gx, gy, gz) {
+ const b = getBlock(gx, gy, gz);
+ if (!b) return false;
+ const def = BLOCKS[b.t];
+ return !!def.solid && !def.fluid && !def.decor && !def.climbable;
+}
+
+// Непрозрачный ли блок (для отсечения внутренних граней не используется — мы рендерим кубы)
+function isOpaque(gx, gy, gz) {
+ const b = getBlock(gx, gy, gz);
+ if (!b) return false;
+ const def = BLOCKS[b.t];
+ return !!def.solid && !def.fluid && !def.decor && !def.climbable;
+}
+
+function setBlock(gx, gy, gz, t, isPlayerPlaced = false) {
+ const key = k3(gx, gy, gz);
+ if (grid.has(key)) return false;
+ const b = { gx, gy, gz, t, dead: false, active: false, fuse: 0 };
+ grid.set(key, b);
+ blocks.push(b);
+ if (isPlayerPlaced) {
+ state.placedBlocks.push({ gx, gy, gz, t });
+ }
+ state.blocksVersion = (state.blocksVersion || 0) + 1;
+ return true;
+}
+
+function removeBlock(gx, gy, gz) {
+ const key = k3(gx, gy, gz);
+ const b = grid.get(key);
+ if (!b) return null;
+ if (BLOCKS[b.t].unbreakable) return null;
+ grid.delete(key);
+ b.dead = true;
+ const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy && pb.gz === gz);
+ if (wasPlayerPlaced) {
+ state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy && pb.gz === gz));
+ } else {
+ state.removedBlocks.push({ gx, gy, gz });
+ }
+ state.blocksVersion = (state.blocksVersion || 0) + 1;
+ return b;
+}
+
+// === world/generation3d.js ===
+// ==================== ГЕНЕРАЦИЯ 3D — GrechkaCraft v3 ====================
+// Объёмный мир: столбцы (x,z), рельеф из 2D-шума, пещеры, руды, растительность.
+// Та же система биомов, что в 2D-игре, но по двум координатам.
+
+
+
+
+// Радиус мира по Z — насколько далеко вбок генерируем вокруг игрока
+const WORLD_DEPTH = 20; // полу-ширина: z от -20 до +20
+const CHUNK = 8; // шаг генерации
+
+// ========== ШУМ (детерминированный, от worldSeed) ==========
+function hash2(x, z) {
+ let h = x * 374761393 + z * 668265263 + state.worldSeed * 1274126177;
+ h = (h ^ (h >> 13)) * 1274126177;
+ return ((h ^ (h >> 16)) >>> 0) / 4294967295;
+}
+
+function smooth(t) { return t * t * (3 - 2 * t); }
+
+function valueNoise2(x, z, scale) {
+ const fx = x / scale, fz = z / scale;
+ const x0 = Math.floor(fx), z0 = Math.floor(fz);
+ const tx = smooth(fx - x0), tz = smooth(fz - z0);
+ const a = hash2(x0, z0), b = hash2(x0 + 1, z0);
+ const c = hash2(x0, z0 + 1), d = hash2(x0 + 1, z0 + 1);
+ return (a * (1 - tx) + b * tx) * (1 - tz) + (c * (1 - tx) + d * tx) * tz;
+}
+
+function fbm2(x, z, octaves = 4) {
+ let sum = 0, amp = 0.5, freq = 1, norm = 0;
+ for (let i = 0; i < octaves; i++) {
+ sum += valueNoise2(x * freq, z * freq, 1) * amp;
+ norm += amp;
+ amp *= 0.5;
+ freq *= 2;
+ }
+ return sum / norm;
+}
+
+// ========== БИОМЫ (те же пять, что в 2D) ==========
+const BIOMES = {
+ plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, treeChance:0.11 },
+ desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, treeChance:0 },
+ tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, treeChance:0.05 },
+ swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, treeChance:0.09 },
+ mountains: { name:'Горы', surface:'stone', subsurface:'stone', trees:false, treeChance:0 },
+};
+
+function getBiome(gx, gz) {
+ const s = state.worldSeed;
+ const temp = fbm2(gx * 0.6, gz * 0.6 + 11, 3) * 0.5 + Math.sin(gx * 0.003 + s * 0.01) * 0.25 + 0.4;
+ const humid = fbm2(gx * 0.5 + 500, gz * 0.5 + 700, 3) * 0.5 + Math.cos(gx * 0.006 + s * 0.02) * 0.2 + 0.4;
+ const mt = fbm2(gx * 0.25 + 900, gz * 0.25 + 300, 3);
+ if (mt > 0.78) return 'mountains';
+ if (temp > 0.66) return 'desert';
+ if (temp < 0.34) return 'tundra';
+ if (humid > 0.66) return 'swamp';
+ return 'plains';
+}
+
+const biomeCache = new Map();
+function getCachedBiome(gx, gz) {
+ const cx = Math.floor(gx / CHUNK), cz = Math.floor(gz / CHUNK);
+ const key = cx + ',' + cz;
+ if (!biomeCache.has(key)) biomeCache.set(key, getBiome(cx * CHUNK, cz * CHUNK));
+ return biomeCache.get(key);
+}
+
+// ========== РЕЛЬЕФ ==========
+// Высота поверхности в колонке (x,z). Одна и та же функция на сервере и клиенте.
+function surfaceGyAt(gx, gz) {
+ const biome = getCachedBiome(gx, gz);
+ const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8;
+ const n2 = Math.sin(gz * 0.021 - state.worldSeed * 0.0015) * 7;
+ const n3 = Math.sin((gx + gz) * 0.012 + state.worldSeed * 0.002) * 10;
+ const n4 = fbm2(gx * 0.5, gz * 0.5, 3) * 9;
+ let h;
+ switch (biome) {
+ case 'desert': h = 8 + n1 * 0.5 + n3 * 0.3 + n4 * 0.2; break;
+ case 'mountains': h = 16 + n3 * 1.1 + n4 * 1.2; break;
+ case 'tundra': h = 9 + n1 * 0.6 + n3 * 0.5 + n4 * 0.4; break;
+ case 'swamp': h = 4 + n1 * 0.4 + n2 * 0.6 + n4 * 0.3; break;
+ default: h = 10 + n1 * 0.7 + n2 * 0.7 + n3 * 0.4 + n4 * 0.4;
+ }
+ return Math.max(2, Math.min(BEDROCK_GY - 12, Math.round(h)));
+}
+
+function seaLevel() { return SEA_GY; }
+
+// ========== ПЕЩЕРЫ ==========
+function inCave(gx, gy, gz) {
+ if (gy < 3) return false;
+ const c1 = fbm2(gx * 0.9 + 11, gz * 0.9 + gy * 1.7, 2);
+ const c2 = fbm2(gx * 0.9 - 40, gz * 0.9 + gy * 1.7 + 90, 2);
+ // две «трубки» шума: пересечение даёт объёмные полости
+ return c1 > 0.62 && c2 > 0.60 && gy < surfaceGyAt(gx, gz) - 3;
+}
+
+// ========== РУДЫ ==========
+function oreAt(gx, gy, gz) {
+ if (gy < 2) return null;
+ const depth = gy / BEDROCK_GY;
+ const r = hash2(gx * 7 + gy, gz * 13);
+ const r2 = hash2(gx * 3 - gy, gz * 5 + 17);
+ if (depth < 0.30 && r > 0.982) return 'coal';
+ if (depth < 0.26 && r > 0.990 && r2 > 0.6) return 'copper_ore';
+ if (depth < 0.20 && r > 0.988 && r2 > 0.4) return 'iron_ore';
+ if (depth < 0.13 && r > 0.994) return 'gold_ore';
+ if (depth < 0.09 && r > 0.9965) return 'diamond_ore';
+ return null;
+}
+
+// ========== ДЕРЕВО (по XY, заполняет и Z) ==========
+function growTree(gx, gy, gz, biome) {
+ const def = BIOMES[biome];
+ if (!def || !def.trees) return;
+ if (hash2(gx * 31, gz * 17) > def.treeChance) return;
+ const h = 4 + Math.floor(hash2(gx * 5, gz * 11) * 3);
+ const leafType = biome === 'tundra' || biome === 'swamp' ? 'spruce_leaves' : 'leaves';
+ for (let i = 1; i <= h; i++) {
+ if (!getBlock(gx, gy - i, gz)) setBlock(gx, gy - i, gz, 'wood');
+ }
+ // крона объёмом: псевдосфера
+ const cy = gy - h;
+ const rad = 2;
+ for (let dy = -2; dy <= 1; dy++) {
+ for (let dz = -rad; dz <= rad; dz++) {
+ for (let dx = -rad; dx <= rad; dx++) {
+ const dist = dx * dx + dy * dy * 2 + dz * dz;
+ if (dist > rad * rad + 1) continue;
+ const yy = cy + dy;
+ if (!getBlock(gx + dx, yy, gz + dz)) setBlock(gx + dx, yy, gz + dz, leafType);
+ }
+ }
+ }
+}
+
+function growDecor(gx, gy, gz, biome) {
+ const def = BIOMES[biome];
+ const r = hash2(gx * 23 + 5, gz * 29 + 7);
+ if (def.flowers && r > 0.96 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'flower');
+ if ((biome === 'swamp' || biome === 'plains') && r > 0.985 && !getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'mushroom');
+ if (biome === 'desert' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 1 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'cactus');
+ if (biome === 'tundra' && r > 0.978 && !getBlock(gx, gy, gz) && gy + 2 < surfaceGyAt(gx, gz)) setBlock(gx, gy, gz, 'ice');
+}
+
+const generated = new Set(); // "cx,cz" — уже сгенерированные чанки
+
+// ========== ГЕНЕРАЦИЯ ЧАНКА ==========
+function genChunk(cx, cz) {
+ const key = cx + ',' + cz;
+ if (generated.has(key)) return false;
+ generated.add(key);
+ const x0 = cx * CHUNK, z0 = cz * CHUNK;
+ for (let x = x0; x < x0 + CHUNK; x++) {
+ for (let z = z0; z < z0 + CHUNK; z++) {
+ genColumn(x, z);
+ }
+ }
+ return true;
+}
+
+function genColumn(gx, gz) {
+ const biome = getCachedBiome(gx, gz);
+ const top = surfaceGyAt(gx, gz);
+ const sea = seaLevel();
+
+ // Генерируем до bedrock, но не ниже уровня «пещерной зоны» — экономим память
+ for (let gy = Math.min(BEDROCK_GY - 1, 44); gy >= 0; gy--) {
+ // бедрок внизу
+ if (gy >= BEDROCK_GY - 2) {
+ if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, 'bedrock');
+ continue;
+ }
+ if (gy > top) {
+ // над поверхностью: вода до уровня моря
+ if (gy <= sea) {
+ const t = (biome === 'swamp') ? 'swamp_water' : 'water';
+ if (!getBlock(gx, gy, gz)) setBlock(gx, gy, gz, t);
+ }
+ continue;
+ }
+ // пещеры — только под поверхностью
+ if (gy < top - 2 && inCave(gx, gy, gz)) continue;
+
+ const ore = oreAt(gx, gy, gz);
+ if (ore) { setBlock(gx, gy, gz, ore); continue; }
+
+ const def = BIOMES[biome];
+ if (gy === top) setBlock(gx, gy, gz, def.surface);
+ else if (gy > top - (biome === 'desert' || biome === 'mountains' ? 5 : 3)) setBlock(gx, gy, gz, def.subsurface);
+ else setBlock(gx, gy, gz, 'stone');
+ }
+
+ // вода в пещерах не заливаем — это гравитация
+ // растительность
+ const above = getBlock(gx, top, gz);
+ if (above && !BLOCKS_NO_SOLID(above.t)) growDecor(gx, top, gz, biome);
+ if (above && !BLOCKS_NO_SOLID(above.t) && hash2(gx * 13, gz * 17) > 0.93) growTree(gx, top, gz, biome);
+}
+
+function BLOCKS_NO_SOLID(t) { return t === 'water' || t === 'swamp_water' || t === 'air'; }
+
+// =========─ ГЕНЕРАЦИЯ ВОКРУГ ИГРОКА =========─
+// Держим чанки сгенерированными вокруг позиции игрока по X и Z.
+const genChunks = new Set();
+function ensureGenAroundCamera() {
+ const px = Math.floor(state.player.x / TILE);
+ const pz = Math.floor(state.player.z / TILE);
+ const rx = 3, rz = Math.ceil(WORLD_DEPTH / CHUNK) + 1;
+ const cx = Math.floor(px / CHUNK), cz = Math.floor(pz / CHUNK);
+ for (let dx = -rx; dx <= rx; dx++) {
+ for (let dz = -rz; dz <= rz; dz++) {
+ const kx = cx + dx, kz = cz + dz;
+ const key = kx + ',' + kz;
+ if (genChunks.has(key)) continue;
+ genChunks.add(key);
+ genChunk(kx, kz);
+ }
+ }
+ // чистим слишком далёкие
+ for (const key of genChunks) {
+ const [a, b] = key.split(',').map(Number);
+ if (Math.abs(a - cx) > rx + 2 || Math.abs(b - cz) > rz + 2) {
+ genChunks.delete(key);
+ generated.delete(key);
+ }
+ }
+}
+
+function resetGenerationCache() {
+ generated.clear();
+ genChunks.clear();
+ biomeCache.clear();
+}
+
+// === audio/sound-engine.js ===
+// Звуковой движок
+const sounds = {};
+
+
+
+function loadSound(id, src) {
+ const audio = new Audio();
+ audio.src = src;
+ audio.volume = 0.3;
+ sounds[id] = audio;
+}
+
+// Загрузка звуков
+loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
+loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
+loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
+loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
+loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
+loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
+loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
+loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
+loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
+loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
+loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
+loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
+loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
+loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
+loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
+loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
+loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
+
+function playSound(id) {
+ if (sounds[id]) {
+ sounds[id].currentTime = 0;
+ sounds[id].play().catch(e => console.error('Sound error:', e));
+ }
+}
+
+// === ui/chat.js ===
+// ==================== ЧАТ ====================
+
+
+
+
+const chatMessages = [];
+
+function addChatMessage(sender, message) {
+ const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
+ chatMessages.push({ sender, message, time });
+ if (chatMessages.length > MAX_CHAT_MESSAGES) {
+ chatMessages.shift();
+ }
+ renderChatMessages();
+}
+
+function renderChatMessages() {
+ const chatMessagesEl = document.getElementById('chatMessages');
+ if (!chatMessagesEl) return;
+
+ chatMessagesEl.innerHTML = chatMessages.map(m =>
+ `${m.time} ${m.sender}: ${m.message}
`
+ ).join('');
+
+ // Прокручиваем вниз
+ chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
+}
+
+function sendChatMessage(message) {
+ if (!message || message.trim() === '') return;
+
+ if (state.isMultiplayer && state.socket && state.socket.connected) {
+ state.socket.emit('chat_message', { message: message.trim() });
+ } else {
+ addChatMessage('Вы', message.trim());
+ }
+}
+
+function initChat() {
+ document.getElementById('chatToggle').onclick = () => {
+ playSound('click');
+ state.chatOpen = !state.chatOpen;
+ document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none';
+ if (state.chatOpen) {
+ document.getElementById('chatInput').focus();
+ }
+ };
+
+ document.getElementById('chatClose').onclick = () => {
+ playSound('click');
+ state.chatOpen = false;
+ document.getElementById('chatPanel').style.display = 'none';
+ };
+
+ document.getElementById('chatSend').onclick = () => {
+ const input = document.getElementById('chatInput');
+ sendChatMessage(input.value);
+ input.value = '';
+ };
+
+ document.getElementById('chatInput').addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ sendChatMessage(e.target.value);
+ e.target.value = '';
+ }
+ });
+}
+
+// === entities/player.js ===
+// Функция для расчёта урона с учётом брони
+function calculateDamage(baseDamage) {
+ const reduction = state.player.armor;
+ const actualDamage = baseDamage * (1 - reduction);
+ return actualDamage;
+}
+
+// Инициализация игрока
+function initPlayer() {
+ state.player = {
+ x: 6 * TILE,
+ y: 0 * TILE,
+ w: 34,
+ h: 34,
+ vx: 0,
+ vy: 0,
+ grounded: false,
+ inWater: false,
+ headInWater: false,
+ hp: 100,
+ hunger: 100,
+ o2: 100,
+ invuln: 0,
+ fallStartY: 0,
+ lastStepTime: 0,
+ sleeping: false,
+ inBoat: false,
+ armor: 0,
+ equippedArmor: null,
+ temperature: 15,
+ slowTimer: 0
+ };
+
+ state.spawnPoint = { x: 6 * TILE, y: 0 * TILE };
+}
+
+// === data/items.js ===
+const ITEMS = {
+ meat: { n:'Сырое мясо', icon:'🥩', food:15 },
+ cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
+ arrow: { n:'Стрела', icon:'➡️', stack:64 },
+ chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
+ feather: { n:'Перо', icon:'🪶', stack:64 },
+ bone: { n:'Кость', icon:'🦴', stack:64 },
+ gunpowder: { n:'Порох', icon:'💥', stack:64 },
+ // === FARMING ITEMS ===
+ wheat: { n:'Пшеница', icon:'🌾', stack:64 },
+ bread: { n:'Хлеб', icon:'🍞', food:30 },
+ carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 },
+ potato: { n:'Картофель', icon:'🥔', stack:64 },
+ baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 },
+ wheat_seeds: { n:'Семена пшеницы', icon:'🌱', stack:64 },
+ mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 },
+ // === MOB DROP ITEMS ===
+ scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 },
+ polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 },
+ slime_ball: { n:'Слизь', icon:'🟢', stack:64 },
+ eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 },
+ // === ARMOR ===
+ gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 },
+};
+
+// Runtime extensions — предметы от обжига
+ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
+ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
+ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
+
+// Axes and shovels
+ITEMS.wood_axe = { n:'Деревянный топор', icon:'🪓' };
+ITEMS.stone_axe = { n:'Каменный топор', icon:'🪓' };
+ITEMS.iron_axe = { n:'Железный топор', icon:'🪓' };
+ITEMS.wood_shovel = { n:'Деревянная лопата', icon:'🔱' };
+ITEMS.stone_shovel = { n:'Каменная лопата', icon:'🔱' };
+ITEMS.iron_shovel = { n:'Железная лопата', icon:'🔱' };
+
+// === data/tools.js ===
+const TOOLS = {
+ wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', damage:1, durability:60, toolType:'pickaxe', toolTier:'wood', speed:2 },
+ stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', damage:2, durability:132, toolType:'pickaxe', toolTier:'stone', speed:4 },
+ iron_pickaxe: { n:'Железная кирка', icon:'⛏️', damage:3, durability:250, toolType:'pickaxe', toolTier:'iron', speed:6 },
+ wood_sword: { n:'Деревянный меч', icon:'🗡️', damage:4, durability:60 },
+ stone_sword: { n:'Каменный меч', icon:'🗡️', damage:5, durability:132 },
+ iron_sword: { n:'Железный меч', icon:'🗡️', damage:7, durability:250 },
+ wood_axe: { n:'Деревянный топор', icon:'🪓', damage:3, durability:60, toolType:'axe', toolTier:'wood', speed:2 },
+ stone_axe: { n:'Каменный топор', icon:'🪓', damage:4, durability:132, toolType:'axe', toolTier:'stone', speed:4 },
+ iron_axe: { n:'Железный топор', icon:'🪓', damage:5, durability:250, toolType:'axe', toolTier:'iron', speed:6 },
+ wood_shovel: { n:'Деревянная лопата', icon:'🔱', damage:1, durability:60, toolType:'shovel', toolTier:'wood', speed:2 },
+ stone_shovel: { n:'Каменная лопата', icon:'🔱', damage:2, durability:132, toolType:'shovel', toolTier:'stone', speed:4 },
+ iron_shovel: { n:'Железная лопата', icon:'🔱', damage:3, durability:250, toolType:'shovel', toolTier:'iron', speed:6 },
+ bow: { n:'Лук', icon:'🏹', damage:10, durability:200 },
+};
+
+
+
+
+function addTool(type) {
+ const maxDur = TOOLS[type]?.durability || 60;
+ const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
+ state.toolDurability.set(key, { type, current: maxDur, max: maxDur });
+}
+
+function useTool(type) {
+ for (const [tid, dur] of state.toolDurability) {
+ if (dur.type === type && dur.current > 0) {
+ dur.current--;
+ if (dur.current <= 0) {
+ state.toolDurability.delete(tid);
+ state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
+ return true; // broke
+ }
+ return false; // used but not broke
+ }
+ }
+ return false; // no durability entry found
+}
+
+function getBestTool(blockType) {
+ const block = BLOCKS[blockType];
+ if (!block || !block.toolType) return null;
+ const toolType = block.toolType;
+ for (const tier of ['iron', 'stone', 'wood']) {
+ const toolId = `${tier}_${toolType}`;
+ if (TOOLS[toolId] && state.inv[toolId] > 0) return toolId;
+ }
+ return null;
+}
+
+function toolTierLessThan(toolTier, minTier) {
+ const tiers = { wood: 1, stone: 2, iron: 3 };
+ return (tiers[toolTier] || 0) < (tiers[minTier] || 0);
+}
+
+function getMiningSpeed(blockType) {
+ const block = BLOCKS[blockType];
+ if (!block) return 1;
+ const toolId = getBestTool(blockType);
+ if (toolId) {
+ const tool = TOOLS[toolId];
+ if (tool.toolType === block.toolType) return tool.speed;
+ }
+ return 1; // Bare hands = 1 click per hit
+}
+
+// === render/textures.js ===
+// Текстуры блоков (простые)
+
+
+const tex = {};
+
+// --- Item/tool sprite generator ---\n
+const itemTex = {};
+
+function drawArrow(ctx, color) {
+ ctx.fillStyle = '#8B4513';
+ ctx.fillRect(8, 14, 16, 3);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.moveTo(24, 15);
+ ctx.lineTo(18, 10);
+ ctx.lineTo(18, 20);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = '#e74c3c';
+ ctx.beginPath();
+ ctx.moveTo(8, 15);
+ ctx.lineTo(4, 10);
+ ctx.lineTo(10, 14);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(8, 16);
+ ctx.lineTo(4, 21);
+ ctx.lineTo(10, 17);
+ ctx.closePath();
+ ctx.fill();
+}
+
+function drawPickaxe(ctx, headColor, handleColor) {
+ ctx.fillStyle = handleColor || '#8B4513';
+ ctx.save();
+ ctx.translate(16, 16);
+ ctx.rotate(Math.PI / 4);
+ ctx.fillRect(-2, 0, 4, 18);
+ ctx.fillStyle = headColor;
+ ctx.fillRect(-8, -3, 16, 5);
+ ctx.fillRect(-8, -3, 3, 8);
+ ctx.fillRect(5, -3, 3, 8);
+ ctx.restore();
+}
+
+function drawAxe(ctx, headColor, handleColor) {
+ ctx.fillStyle = handleColor || '#8B4513';
+ ctx.save();
+ ctx.translate(16, 16);
+ ctx.rotate(Math.PI / 4);
+ ctx.fillRect(-2, 0, 4, 18);
+ ctx.fillStyle = headColor;
+ ctx.fillRect(-2, -8, 10, 8);
+ ctx.fillRect(-2, -8, 4, 12);
+ ctx.restore();
+}
+
+function drawShovel(ctx, headColor, handleColor) {
+ ctx.fillStyle = handleColor || '#8B4513';
+ ctx.save();
+ ctx.translate(16, 16);
+ ctx.rotate(Math.PI / 4);
+ ctx.fillRect(-2, 0, 4, 18);
+ ctx.fillStyle = headColor;
+ ctx.fillRect(-6, -8, 12, 8);
+ ctx.fillRect(-4, -12, 8, 4);
+ ctx.restore();
+}
+
+function drawSword(ctx, bladeColor, guardColor) {
+ ctx.fillStyle = '#8B4513';
+ ctx.fillRect(14, 18, 4, 10);
+ ctx.fillStyle = guardColor || '#f1c40f';
+ ctx.fillRect(10, 16, 12, 3);
+ ctx.fillStyle = bladeColor;
+ ctx.beginPath();
+ ctx.moveTo(16, 2);
+ ctx.lineTo(12, 16);
+ ctx.lineTo(20, 16);
+ ctx.closePath();
+ ctx.fill();
+}
+
+function drawBow(ctx) {
+ ctx.strokeStyle = '#8B4513';
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.arc(12, 16, 12, -Math.PI * 0.7, Math.PI * 0.7);
+ ctx.stroke();
+ ctx.strokeStyle = '#ddd';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(12, 4);
+ ctx.lineTo(12, 28);
+ ctx.stroke();
+}
+
+function drawIngot(ctx, color) {
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.moveTo(6, 12);
+ ctx.lineTo(26, 12);
+ ctx.lineTo(24, 24);
+ ctx.lineTo(8, 24);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.fillRect(8, 8, 16, 4);
+}
+
+function drawMeat(ctx, cooked) {
+ ctx.fillStyle = cooked ? '#8B4513' : '#e74c3c';
+ ctx.beginPath();
+ ctx.ellipse(16, 18, 10, 8, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#f5f5dc';
+ ctx.fillRect(8, 6, 3, 12);
+ ctx.fillRect(14, 4, 3, 8);
+}
+
+function makeItemTex(id) {
+ const c = document.createElement('canvas');
+ c.width = 32; c.height = 32;
+ const g = c.getContext('2d');
+
+ const tierColors = {
+ wood: { head: '#a0522d', blade: '#a0522d' },
+ stone: { head: '#7f8c8d', blade: '#95a5a6' },
+ iron: { head: '#bdc3c7', blade: '#ecf0f1' },
+ };
+
+ switch (id) {
+ case 'arrow':
+ drawArrow(g, '#7f8c8d');
+ break;
+ case 'bow':
+ drawBow(g);
+ break;
+ case 'wood_pickaxe':
+ drawPickaxe(g, tierColors.wood.head, tierColors.wood.blade);
+ break;
+ case 'stone_pickaxe':
+ drawPickaxe(g, tierColors.stone.head);
+ break;
+ case 'iron_pickaxe':
+ drawPickaxe(g, tierColors.iron.head);
+ break;
+ case 'wood_axe':
+ drawAxe(g, tierColors.wood.head);
+ break;
+ case 'stone_axe':
+ drawAxe(g, tierColors.stone.head);
+ break;
+ case 'iron_axe':
+ drawAxe(g, tierColors.iron.head);
+ break;
+ case 'wood_shovel':
+ drawShovel(g, tierColors.wood.head);
+ break;
+ case 'stone_shovel':
+ drawShovel(g, tierColors.stone.head);
+ break;
+ case 'iron_shovel':
+ drawShovel(g, tierColors.iron.head);
+ break;
+ case 'wood_sword':
+ drawSword(g, tierColors.wood.blade, '#a0522d');
+ break;
+ case 'stone_sword':
+ drawSword(g, tierColors.stone.blade, '#7f8c8d');
+ break;
+ case 'iron_sword':
+ drawSword(g, tierColors.iron.blade, '#bdc3c7');
+ break;
+ case 'iron_ingot':
+ drawIngot(g, '#bdc3c7');
+ break;
+ case 'gold_ingot':
+ drawIngot(g, '#f1c40f');
+ break;
+ case 'copper_ingot':
+ drawIngot(g, '#e67e22');
+ break;
+ case 'meat':
+ drawMeat(g, false);
+ break;
+ case 'cooked':
+ drawMeat(g, true);
+ break;
+ case 'wheat':
+ g.fillStyle = '#f1c40f';
+ g.fillRect(12, 8, 3, 18);
+ g.fillRect(8, 6, 10, 4);
+ break;
+ case 'bread':
+ g.fillStyle = '#d4a030';
+ g.fillRect(6, 12, 20, 12);
+ g.fillStyle = '#c08820';
+ g.fillRect(6, 12, 20, 3);
+ break;
+ case 'carrot':
+ g.fillStyle = '#e67e22';
+ g.fillRect(14, 10, 4, 18);
+ g.fillStyle = '#27ae60';
+ g.fillRect(12, 6, 8, 5);
+ break;
+ case 'potato':
+ g.fillStyle = '#d4a050';
+ g.beginPath();
+ g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
+ g.fill();
+ g.fillStyle = '#b88a40';
+ g.fillRect(10, 14, 2, 1);
+ g.fillRect(20, 16, 2, 1);
+ break;
+ case 'baked_potato':
+ g.fillStyle = '#c08820';
+ g.beginPath();
+ g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
+ g.fill();
+ g.fillStyle = '#f5e6cc';
+ g.fillRect(12, 12, 8, 6);
+ break;
+ case 'mushroom_stew':
+ g.fillStyle = '#8B4513';
+ g.fillRect(10, 16, 12, 12);
+ g.fillStyle = '#e74c3c';
+ g.beginPath();
+ g.ellipse(16, 14, 8, 5, 0, Math.PI, 0);
+ g.fill();
+ break;
+ case 'scorpion_stinger':
+ g.fillStyle = '#d35400';
+ g.beginPath();
+ g.moveTo(16, 4);
+ g.lineTo(10, 28);
+ g.lineTo(22, 28);
+ g.closePath();
+ g.fill();
+ break;
+ case 'polar_fur':
+ g.fillStyle = '#ecf0f1';
+ g.fillRect(6, 6, 20, 20);
+ g.fillStyle = '#bdc3c7';
+ g.fillRect(12, 12, 8, 8);
+ break;
+ case 'slime_ball':
+ g.fillStyle = 'rgba(46,204,113,0.8)';
+ g.beginPath();
+ g.arc(16, 16, 10, 0, Math.PI * 2);
+ g.fill();
+ g.fillStyle = '#fff';
+ g.fillRect(10, 10, 4, 4);
+ g.fillRect(18, 10, 4, 4);
+ break;
+ case 'eagle_feather':
+ g.fillStyle = '#8B4513';
+ g.fillRect(14, 4, 4, 24);
+ g.fillStyle = '#ecf0f1';
+ g.fillRect(10, 6, 12, 10);
+ break;
+ case 'chicken_meat':
+ drawMeat(g, false);
+ g.fillStyle = '#fff';
+ g.fillRect(12, 8, 4, 4);
+ break;
+ case 'feather':
+ g.fillStyle = '#ecf0f1';
+ g.fillRect(14, 6, 4, 20);
+ g.fillStyle = '#bbb';
+ g.fillRect(12, 8, 8, 8);
+ break;
+ case 'bone':
+ g.fillStyle = '#f5f5dc';
+ g.fillRect(12, 10, 8, 14);
+ g.fillRect(8, 8, 4, 4);
+ g.fillRect(20, 8, 4, 4);
+ g.fillRect(8, 22, 4, 4);
+ g.fillRect(20, 22, 4, 4);
+ break;
+ case 'gunpowder':
+ g.fillStyle = '#555';
+ g.fillRect(8, 10, 6, 6);
+ g.fillRect(18, 14, 6, 6);
+ g.fillRect(12, 20, 6, 6);
+ break;
+ case 'gold_armor':
+ g.fillStyle = '#f1c40f';
+ g.fillRect(6, 6, 20, 20);
+ g.fillStyle = '#f39c12';
+ g.fillRect(10, 4, 12, 6);
+ g.fillRect(4, 10, 6, 16);
+ g.fillRect(22, 10, 6, 16);
+ break;
+ case 'wheat_seeds':
+ g.fillStyle = '#7dcea0';
+ g.fillRect(14, 16, 4, 10);
+ g.fillStyle = '#a8e6a0';
+ g.fillRect(10, 8, 4, 4);
+ g.fillRect(18, 6, 4, 4);
+ g.fillRect(14, 4, 4, 4);
+ break;
+ default:
+ g.fillStyle = '#7f8c8d';
+ g.fillRect(4, 4, 24, 24);
+ g.fillStyle = 'rgba(255,255,255,0.2)';
+ g.fillRect(4, 4, 24, 4);
+ }
+ return c;
+}
+
+function makeTex(type) {
+ const t = BLOCKS[type];
+ const c = document.createElement('canvas');
+ c.width = 32; c.height = 32;
+ const g = c.getContext('2d');
+
+ if (type === 'tnt') {
+ g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
+ g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
+ return c;
+ }
+ if (type === 'campfire') {
+ g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
+ g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
+ return c;
+ }
+ if (type === 'torch') {
+ g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
+ g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
+ return c;
+ }
+ if (type === 'glass') {
+ g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
+ g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
+ g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
+ return c;
+ }
+ if (type === 'water') {
+ g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
+ g.fillStyle = 'rgba(255,255,255,0.08)';
+ g.fillRect(0, 6, 32, 2);
+ return c;
+ }
+ if (type === 'bed') {
+ g.fillStyle = '#e91e63';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#f8bbd0';
+ g.fillRect(2, 2, 14, 14);
+ g.fillStyle = '#c2185b';
+ g.fillRect(16, 4, 14, 24);
+ g.fillStyle = '#e91e63';
+ g.fillRect(18, 6, 10, 20);
+ return c;
+ }
+ if (type === 'flower') {
+ g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
+ g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
+ return c;
+ }
+ if (type === 'boat') {
+ g.fillStyle = '#8B4513';
+ g.fillRect(2, 12, 28, 8);
+ g.fillStyle = '#A0522D';
+ g.fillRect(0, 10, 32, 12);
+ g.fillStyle = '#DEB887';
+ g.fillRect(4, 14, 24, 4);
+ g.fillStyle = '#654321';
+ g.fillRect(2, 20, 28, 4);
+ return c;
+ }
+ if (type === 'ladder') {
+ g.fillStyle = '#8B4513';
+ g.fillRect(4, 0, 4, 32);
+ g.fillRect(24, 0, 4, 32);
+ g.fillStyle = '#A0522D';
+ g.fillRect(4, 4, 24, 3);
+ g.fillRect(4, 12, 24, 3);
+ g.fillRect(4, 20, 24, 3);
+ g.fillRect(4, 28, 24, 3);
+ return c;
+ }
+ // === BIOME BLOCK TEXTURES ===
+ if (type === 'snow') {
+ g.fillStyle = '#ecf0f1';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#dfe6e9';
+ for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3);
+ return c;
+ }
+ if (type === 'ice') {
+ g.fillStyle = '#74b9ff';
+ g.fillRect(0, 0, 32, 32);
+ g.strokeStyle = 'rgba(255,255,255,0.4)';
+ g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke();
+ g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke();
+ return c;
+ }
+ if (type === 'cactus') {
+ g.fillStyle = '#27ae60';
+ g.fillRect(6, 2, 20, 28);
+ g.fillStyle = '#2ecc71';
+ g.fillRect(2, 8, 6, 4);
+ g.fillRect(24, 14, 6, 4);
+ g.fillStyle = '#1e8449';
+ g.fillRect(12, 0, 2, 30);
+ g.fillRect(18, 0, 2, 30);
+ return c;
+ }
+ if (type === 'mushroom') {
+ g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12);
+ g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill();
+ g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3);
+ return c;
+ }
+ if (type === 'moss') {
+ g.fillStyle = '#0a6640';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#1e8449';
+ for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3);
+ return c;
+ }
+ if (type === 'swamp_water') {
+ g.fillStyle = 'rgba(100,120,40,0.6)';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = 'rgba(80,100,20,0.3)';
+ g.fillRect(0, 10, 32, 2);
+ g.fillRect(0, 22, 32, 2);
+ return c;
+ }
+ if (type === 'farmland') {
+ g.fillStyle = '#8B6914';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#7a5c10';
+ for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2);
+ g.fillStyle = '#6B4E0A';
+ g.fillRect(8, 2, 2, 28);
+ g.fillRect(18, 2, 2, 28);
+ return c;
+ }
+ if (type === 'dead_bush') {
+ g.fillStyle = '#b2bec3';
+ g.fillRect(12, 16, 2, 16);
+ g.fillRect(8, 14, 8, 2);
+ g.fillRect(16, 12, 8, 2);
+ g.fillRect(6, 18, 4, 2);
+ return c;
+ }
+ if (type === 'spruce_leaves') {
+ g.fillStyle = '#0a6640';
+ g.fillRect(0, 0, 32, 32);
+ g.fillStyle = '#0d7a4d';
+ for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4);
+ return c;
+ }
+ // CROP STAGES
+ if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) {
+ const st = parseInt(type.charAt(type.length-1));
+ const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] :
+ type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] :
+ ['#a8e6a0','#c8b888','#bfaa78','#dfe6e9'];
+ g.fillStyle = '#5d4037';
+ g.fillRect(15, 18, 2, 14);
+ if (st >= 1) {
+ g.fillStyle = colors[st];
+ g.fillRect(10, 8 + (3-st)*3, 12, 10);
+ } else {
+ g.fillStyle = '#a8e6a0';
+ g.fillRect(14, 22, 4, 4);
+ }
+ return c;
+ }
+
+ g.fillStyle = t.c || '#000';
+ g.fillRect(0, 0, 32, 32);
+
+ g.fillStyle = 'rgba(0,0,0,0.10)';
+ for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
+
+ if (type.endsWith('_ore') || type === 'coal') {
+ g.fillStyle = 'rgba(0,0,0,0.35)';
+ for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
+ }
+ return c;
+}
+
+function initTextures() {
+ Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
+ // Item/tool sprites
+ const itemIds = [
+ 'arrow','bow',
+ 'wood_pickaxe','stone_pickaxe','iron_pickaxe',
+ 'wood_axe','stone_axe','iron_axe',
+ 'wood_shovel','stone_shovel','iron_shovel',
+ 'wood_sword','stone_sword','iron_sword',
+ 'iron_ingot','gold_ingot','copper_ingot',
+ 'meat','cooked',
+ 'wheat','bread','carrot','potato','baked_potato','mushroom_stew','wheat_seeds',
+ 'chicken_meat','feather','bone','gunpowder',
+ 'scorpion_stinger','polar_fur','slime_ball','eagle_feather',
+ 'gold_armor',
+ ];
+ itemIds.forEach(id => { itemTex[id] = makeItemTex(id); });
+}
+
+// === ui/hotbar.js ===
+// Hotbar UI — 3-slot Minecraft-style bottom bar + selected item indicator
+
+
+
+
+
+
+
+const HOTBAR_SLOTS = 3;
+
+let selectedItemEl = null;
+
+function addToRecentItems(id) {
+ if (!id) return;
+ state.recentItems = state.recentItems.filter(item => item !== id);
+ state.recentItems.unshift(id);
+ state.recentItems = state.recentItems.slice(0, 5);
+}
+
+function renderSlotIcon(el, id) {
+ if (BLOCKS[id] && tex[id]) {
+ el.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ el.style.backgroundSize = 'cover';
+ } else if (itemTex[id]) {
+ el.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
+ el.style.backgroundSize = 'cover';
+ } else if (ITEMS[id]) {
+ el.textContent = ITEMS[id].icon || '📦';
+ } else if (TOOLS[id]) {
+ el.textContent = TOOLS[id].icon || '🔧';
+ } else if (id === 'iron_armor') {
+ el.textContent = '🛡️';
+ el.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
+ } else {
+ el.textContent = id.charAt(0).toUpperCase();
+ el.style.background = '#555';
+ el.style.color = '#fff';
+ el.style.fontWeight = '700';
+ el.style.fontSize = '14px';
+ }
+}
+
+function updateSelectedItem() {
+ if (!selectedItemEl) selectedItemEl = document.getElementById('selectedItem');
+ if (!selectedItemEl) return;
+ const sel = state.selected;
+ if (!sel || !state.inv[sel] || state.inv[sel] <= 0) {
+ selectedItemEl.style.display = 'none';
+ return;
+ }
+ selectedItemEl.style.display = 'flex';
+ // Clear previous content
+ selectedItemEl.textContent = '';
+ selectedItemEl.style.backgroundImage = '';
+ selectedItemEl.style.background = 'rgba(0,0,0,0.45)';
+ // Render icon
+ if (BLOCKS[sel] && tex[sel]) {
+ selectedItemEl.style.backgroundImage = `url(${tex[sel].toDataURL()})`;
+ selectedItemEl.style.backgroundSize = 'cover';
+ } else if (itemTex[sel]) {
+ selectedItemEl.style.backgroundImage = `url(${itemTex[sel].toDataURL()})`;
+ selectedItemEl.style.backgroundSize = 'cover';
+ } else if (ITEMS[sel]) {
+ selectedItemEl.textContent = ITEMS[sel].icon || '📦';
+ } else if (TOOLS[sel]) {
+ selectedItemEl.textContent = TOOLS[sel].icon || '🔧';
+ } else {
+ selectedItemEl.textContent = sel.charAt(0).toUpperCase();
+ }
+}
+
+function rebuildHotbar() {
+ const hotbarEl = state.hotbarEl;
+ const inv = state.inv;
+ const selected = state.selected;
+ const toolDurability = state.toolDurability;
+
+ hotbarEl.innerHTML = '';
+
+ // Get up to 3 recent items with count > 0
+ const items = state.recentItems.filter(id => inv[id] > 0).slice(0, HOTBAR_SLOTS);
+
+ // If selected item not in list and has count > 0, push to front
+ if (inv[selected] > 0 && !items.includes(selected)) {
+ items.unshift(selected);
+ if (items.length > HOTBAR_SLOTS) items.pop();
+ }
+
+ // Always render HOTBAR_SLOTS slots (fill empties)
+ for (let si = 0; si < HOTBAR_SLOTS; si++) {
+ const id = items[si] || null;
+ const s = document.createElement('div');
+ s.className = 'slot' + (id && id === selected ? ' sel' : '');
+
+ // Slot number indicator (top-left)
+ const numEl = document.createElement('div');
+ numEl.style.cssText = 'position:absolute;top:1px;left:3px;font-size:10px;color:rgba(255,255,255,0.55);font-weight:700;pointer-events:none;text-shadow:1px 1px 1px rgba(0,0,0,0.7);';
+ numEl.textContent = (si + 1).toString();
+ s.appendChild(numEl);
+
+ if (id) {
+ renderSlotIcon(s, id);
+
+ const c = document.createElement('div');
+ c.className = 'count';
+ c.textContent = inv[id];
+ s.appendChild(c);
+ s.onclick = () => {
+ playSound('click');
+ state.selected = id;
+ // Move to front of recentItems
+ state.recentItems = state.recentItems.filter(item => item !== id);
+ state.recentItems.unshift(id);
+ rebuildHotbar();
+ };
+
+ if (id === 'iron_armor' && state.player.equippedArmor === 'iron_armor') {
+ const equipped = document.createElement('div');
+ equipped.className = 'equipped-indicator';
+ equipped.textContent = '✓';
+ s.appendChild(equipped);
+ }
+
+ // Durability bar
+ if (TOOLS[id] && inv[id] > 0) {
+ let curDur = 0, maxDur = TOOLS[id].durability;
+ for (const [tid, dur] of toolDurability) {
+ if (dur.type === id) {
+ curDur = dur.current;
+ maxDur = dur.max;
+ break;
+ }
+ }
+ if (maxDur > 0) {
+ const bar = document.createElement('div');
+ bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
+ const fill = document.createElement('div');
+ const pct = curDur / maxDur;
+ const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
+ fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`;
+ bar.appendChild(fill);
+ s.appendChild(bar);
+ }
+ }
+ }
+ hotbarEl.appendChild(s);
+ }
+
+ // Update selected item indicator above hotbar
+ updateSelectedItem();
+}
+
+// === world/tnt3d.js ===
+// ==================== TNT В 3D — GrechkaCraft v3 ====================
+// Взрыв сферический: сносит блоки в радиусе по всем трём осям.
+
+
+
+
+
+function activateTNT3D(b, fuseSeconds) {
+ b.active = true;
+ b.fuse = fuseSeconds;
+ playSound('fuse');
+}
+
+function explodeTNT3D(b, st) {
+ const TILE = st.TILE;
+ const cx = b.gx, cy = b.gy, cz = b.gz || 0;
+ const radius = 3.2;
+ const r2 = radius * radius;
+
+ // убрать сам блок
+ removeBlock(cx, cy, cz);
+
+ const toRemove = [];
+ const r = Math.ceil(radius);
+ for (let dx = -r; dx <= r; dx++) {
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dz = -r; dz <= r; dz++) {
+ const d2 = dx * dx + dy * dy + dz * dz;
+ if (d2 > r2) continue;
+ const blk = getBlock(cx + dx, cy + dy, cz + dz);
+ if (!blk) continue;
+ const def = BLOCKS[blk.t];
+ if (!def) continue;
+ if (def.unbreakable) continue;
+ // взрывная сила падает с расстоянием
+ if (d2 > r2 * 0.55 && blk.t !== 'dirt' && blk.t !== 'sand' && blk.t !== 'grass') continue;
+ toRemove.push({ gx: cx + dx, gy: cy + dy, gz: cz + dz, t: blk.t });
+ }
+ }
+ }
+ for (const rem of toRemove) removeBlock(rem.gx, rem.gy, rem.gz);
+
+ // частицы
+ const parts = st.parts || (st.parts = []);
+ const n = 30;
+ for (let i = 0; i < n; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const p = Math.acos(2 * Math.random() - 1);
+ const sp = 60 + Math.random() * 180;
+ parts.push({
+ x: (cx + 0.5) * TILE,
+ y: (cy + 0.5) * TILE,
+ z: (cz + 0.5) * TILE,
+ vx: Math.sin(p) * Math.cos(a) * sp,
+ vy: Math.cos(p) * sp,
+ vz: Math.sin(p) * Math.sin(a) * sp,
+ life: 0.6 + Math.random() * 0.5,
+ c: Math.random() > 0.5 ? '#ff7a1a' : '#f1c40f',
+ });
+ }
+
+ // урон мобам и игроку в радиусе
+ const dmg = (e) => {
+ const ex = (e.x + e.w / 2) / TILE, ey = (e.y + e.h / 2) / TILE, ez = ((e.z || 0) + e.d / 2) / TILE;
+ const d = Math.hypot(ex - cx, ey - cy, ez - cz);
+ if (d < radius + 1) {
+ const f = 1 - d / (radius + 1);
+ e.hp -= 14 * f;
+ e.vx += (ex - cx) * 220;
+ e.vy -= 300 * f;
+ e.vz = (e.vz || 0) + (ez - cz) * 220;
+ }
+ };
+ for (const m of st.mobs) dmg(m);
+ const p = st.player;
+ dmg(p);
+ if (p.hp <= 0 && p.invuln <= 0) { p.hp = 0; p.invuln = 1.5; }
+
+ playSound('explode');
+
+ // взрывные частицы убрать по таймеру
+ st.explosionFlash = 0.4;
+ setBlock(cx, cy, cz, 'air');
+ void setBlock;
+}
+
+// === ui/inventory.js ===
+// Inventory UI
+
+
+
+
+
+
+
+
+function renderInventory() {
+ const inventoryGrid = state.inventoryGrid;
+ const inv = state.inv;
+ const selected = state.selected;
+ inventoryGrid.innerHTML = '';
+
+ const items = Object.keys(inv).filter(id => inv[id] > 0);
+
+ for (let i = 0; i < 21; i++) {
+ const slot = document.createElement('div');
+ slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
+
+ if (i < items.length) {
+ const id = items[i];
+ // Canvas sprite: block > itemTex > emoji fallback > unknown item fallback
+ if (BLOCKS[id] && tex[id]) {
+ slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
+ slot.style.backgroundSize = 'cover';
+ } else if (itemTex[id]) {
+ slot.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
+ slot.style.backgroundSize = 'cover';
+ } else if (ITEMS[id]) {
+ slot.textContent = ITEMS[id].icon || '📦';
+ } else if (TOOLS[id]) {
+ slot.textContent = TOOLS[id].icon || '🔧';
+ } else if (id === 'iron_armor') {
+ slot.textContent = '🛡️';
+ slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
+ } else {
+ // Unknown item fallback — grey box with first letter
+ slot.textContent = id.charAt(0).toUpperCase();
+ slot.style.background = '#555';
+ slot.style.color = '#fff';
+ slot.style.fontWeight = '700';
+ slot.style.fontSize = '14px';
+ }
+
+ const count = document.createElement('div');
+ count.className = 'inv-count';
+ count.textContent = inv[id];
+ slot.appendChild(count);
+
+ slot.onclick = () => {
+ playSound('click');
+ state.selected = id;
+ state.recentItems = state.recentItems.filter(item => item !== id);
+ state.recentItems.unshift(id);
+ state.recentItems = state.recentItems.slice(0, 5);
+ rebuildHotbar();
+ renderInventory();
+ };
+
+ // Двойной клик для надевания брони
+ slot.ondblclick = () => {
+ if (id === 'iron_armor' && inv.iron_armor > 0) {
+ if (state.player.equippedArmor === 'iron_armor') {
+ state.player.equippedArmor = null;
+ state.player.armor = 0;
+ } else {
+ state.player.equippedArmor = 'iron_armor';
+ state.player.armor = BLOCKS['iron_armor'].armor;
+ }
+ playSound('click');
+ renderInventory();
+ }
+ };
+ }
+
+ inventoryGrid.appendChild(slot);
+ }
+}
+
+// === game/save.js ===
+// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ====================
+
+
+
+
+
+
+const SAVE_STORAGE_KEY = state.SAVE_KEY;
+let db = null; // Оставляем для совместимости, но не используем
+
+// Инициализация (localStorage + in-memory fallback)
+function initDB() {
+ return new Promise((resolve) => {
+ console.log('Используем localStorage для сохранений (sandbox режим)');
+ resolve(null);
+ });
+}
+
+function saveGame() {
+ const saveData = {
+ version: 2,
+ worldSeed: state.worldSeed,
+ player: {
+ x: state.player.x,
+ y: state.player.y,
+ hp: state.player.hp,
+ hunger: state.player.hunger,
+ o2: state.player.o2
+ },
+ inventory: state.inv,
+ time: state.worldTime,
+ isNight: state.isNightTime,
+ dayNightCycle: state.dayNightCycle,
+ // Сохраняем только изменения
+ placedBlocks: state.placedBlocks.slice(),
+ removedBlocks: state.removedBlocks.slice()
+ };
+
+ const saveSize = JSON.stringify(saveData).length;
+ console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.player.o2);
+
+ // Пробуем сохранить в localStorage (основной метод для персистентности)
+ try {
+ localStorage.setItem(SAVE_STORAGE_KEY, JSON.stringify(saveData));
+ console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
+ } catch (e) {
+ console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
+
+ // Если localStorage недоступен, используем in-memory fallback
+ state.inMemorySave = saveData;
+ console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
+ }
+}
+
+function loadGame() {
+ return new Promise((resolve, reject) => {
+ // Пробуем localStorage
+ try {
+ const localSave = localStorage.getItem(SAVE_STORAGE_KEY);
+ if (localSave) {
+ const parsed = JSON.parse(localSave);
+ console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
+ resolve(parsed);
+ return;
+ }
+ } catch (e) {
+ console.warn('Ошибка доступа к localStorage:', e);
+ }
+
+ // Если localStorage недоступен, используем in-memory сохранение
+ if (state.inMemorySave) {
+ console.log('Загружено из in-memory сохранения, player HP:', state.inMemorySave.player?.hp);
+ resolve(state.inMemorySave);
+ return;
+ }
+
+ console.log('Сохранение не найдено');
+ resolve(null);
+ });
+}
+
+// Миграция с версии 1 на версию 2
+function migrateV1toV2(saveData) {
+ console.log('Миграция сохранения с версии 1 на версию 2...');
+
+ // Сохраняем seed из текущей игры (так как v1 его не хранил)
+ saveData.worldSeed = state.worldSeed;
+
+ // Инициализируем массивы изменений
+ saveData.placedBlocks = [];
+ saveData.removedBlocks = [];
+
+ // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
+ // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
+ // и при загрузке просто перегенерируем мир
+
+ // Удаляем старые данные
+ delete saveData.generatedBlocks;
+
+ saveData.version = 2;
+ console.log('Миграция завершена');
+}
+
+function applySave(saveData) {
+ if (!saveData) return;
+
+ console.log('=== applySave START ===');
+ console.log('player HP before applySave:', state.player.hp);
+ console.log('saveData.player.hp:', saveData.player?.hp);
+
+ // Миграция версий
+ if (saveData.version === 1) {
+ migrateV1toV2(saveData);
+ }
+
+ // Восстанавливаем seed
+ if (saveData.worldSeed !== undefined) {
+ state.worldSeed = saveData.worldSeed;
+ }
+
+ // Восстанавливаем игрока
+ if (saveData.player) {
+ state.player.x = saveData.player.x;
+ state.player.y = saveData.player.y;
+ state.player.hunger = saveData.player.hunger;
+ state.player.o2 = saveData.player.o2;
+
+ // Обновляем spawnPoint на позицию из сохранения
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+
+ // Проверяем HP из сохранения - если <= 0, устанавливаем 100
+ const savedHP = saveData.player.hp;
+ console.log('Saved HP from file:', savedHP);
+ if (savedHP <= 0) {
+ console.log('WARNING: Saved HP is <= 0, setting to 100!');
+ state.player.hp = 100;
+ } else {
+ state.player.hp = savedHP;
+ }
+ console.log('player HP after restore:', state.player.hp);
+ console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y);
+ } else {
+ console.log('No player data in save, setting default HP: 100');
+ state.player.hp = 100;
+ }
+
+ console.log('=== applySave END ===');
+
+ // Восстанавливаем инвентарь
+ if (saveData.inventory) {
+ for (const key in saveData.inventory) {
+ state.inv[key] = saveData.inventory[key];
+ }
+ }
+
+ // Восстанавливаем время
+ if (saveData.time !== undefined) {
+ state.worldTime = saveData.time;
+ }
+
+ // Восстанавливаем день/ночь
+ if (saveData.isNight !== undefined) {
+ state.isNightTime = saveData.isNight;
+ }
+ if (saveData.dayNightCycle !== undefined) {
+ state.dayNightCycle = saveData.dayNightCycle;
+ }
+
+ // Перегенерируем мир по seed
+ resetGenerationCache();
+ ensureGenAroundCamera();
+
+ // Применяем изменения (только для v2)
+ if (saveData.version === 2) {
+ // Применяем блоки, установленные игроком
+ for (const block of saveData.placedBlocks) {
+ setBlock(block.gx, block.gy, block.gz || 0, block.t, true);
+ }
+
+ // Применяем удалённые блоки
+ for (const block of saveData.removedBlocks) {
+ removeBlock(block.gx, block.gy, block.gz || 0);
+ }
+
+ // Восстанавливаем массивы изменений
+ state.placedBlocks = saveData.placedBlocks || [];
+ state.removedBlocks = saveData.removedBlocks || [];
+ }
+
+ // Fill recentItems from inventory if empty (hotbar won.t render otherwise)
+ if (state.recentItems.length === 0) {
+ state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5);
+ }
+ rebuildHotbar();
+ renderInventory();
+ console.log('Игра загружена');
+}
+
+// === ui/share.js ===
+// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
+
+
+
+
+function initShare() {
+ // Keep worldId click-to-copy as before
+ const worldIdEl = document.getElementById('worldId');
+ if (worldIdEl) {
+ worldIdEl.onclick = () => shareWorld();
+ }
+}
+
+function shareWorld() {
+ const shareUrl = new URL(window.location.href);
+ shareUrl.searchParams.set('world', state.worldId);
+ const url = shareUrl.toString();
+
+ // In Telegram WebApp — use native share
+ if (isTgWebApp && window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.switchInlineQuery) {
+ const inviteUrl = 'https://t.me/' + (state.TELEGRAM_BOT_USERNAME || 'grechka_mkn8n_bot') + '/app?startapp=' + encodeURIComponent(state.worldId);
+ Telegram.WebApp.openTelegramLink(
+ 'https://t.me/share/url?url=' + encodeURIComponent(inviteUrl) +
+ '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Код мира: ' + state.worldId)
+ );
+ return;
+ }
+
+ // Otherwise — show custom modal with URL + copy button
+ const overlay = document.createElement('div');
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:100000;display:flex;align-items:center;justify-content:center;';
+
+ const box = document.createElement('div');
+ box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:20px;max-width:400px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
+
+ const title = document.createElement('div');
+ title.textContent = '📤 Пригласить друзей';
+ title.style.cssText = 'font-size:18px;font-weight:bold;margin-bottom:12px;color:#222;';
+ box.appendChild(title);
+
+ const codeLabel = document.createElement('div');
+ codeLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;';
+ codeLabel.textContent = 'Код мира:';
+ box.appendChild(codeLabel);
+
+ const codeBox = document.createElement('div');
+ codeBox.style.cssText = 'font-size:20px;font-weight:bold;background:#fff;border:3px solid #000;padding:8px 12px;margin-bottom:12px;letter-spacing:2px;text-align:center;color:#5A8F3C;';
+ codeBox.textContent = state.worldId;
+ box.appendChild(codeBox);
+
+ const linkLabel = document.createElement('div');
+ linkLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;';
+ linkLabel.textContent = 'Ссылка:';
+ box.appendChild(linkLabel);
+
+ const linkBox = document.createElement('input');
+ linkBox.type = 'text';
+ linkBox.readOnly = true;
+ linkBox.value = url;
+ linkBox.style.cssText = 'width:100%;padding:8px;font-family:Courier New,monospace;font-size:13px;background:#fff;border:3px solid #000;color:#222;margin-bottom:12px;box-sizing:border-box;';
+ linkBox.onclick = () => linkBox.select();
+ box.appendChild(linkBox);
+
+ const btnRow = document.createElement('div');
+ btnRow.style.cssText = 'display:flex;gap:8px;';
+
+ const btnCopy = document.createElement('button');
+ btnCopy.textContent = '📋 Скопировать';
+ btnCopy.style.cssText = 'flex:1;padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
+ btnCopy.onclick = () => {
+ navigator.clipboard.writeText(url).then(() => {
+ showToast('📋 Ссылка скопирована!');
+ overlay.remove();
+ }).catch(() => {
+ linkBox.select();
+ document.execCommand('copy');
+ showToast('📋 Ссылка скопирована!');
+ overlay.remove();
+ });
+ };
+ btnRow.appendChild(btnCopy);
+
+ const btnCode = document.createElement('button');
+ btnCode.textContent = '📋 Код';
+ btnCode.style.cssText = 'padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#3C5F9E;color:#fff;box-shadow:2px 2px 0 #000;';
+ btnCode.onclick = () => {
+ navigator.clipboard.writeText(state.worldId).then(() => {
+ showToast('📋 Код скопирован: ' + state.worldId);
+ }).catch(() => {
+ showToast('Код: ' + state.worldId);
+ });
+ };
+ btnRow.appendChild(btnCode);
+
+ const btnClose = document.createElement('button');
+ btnClose.textContent = '✕';
+ btnClose.style.cssText = 'padding:10px 14px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#9E2B2B;color:#fff;box-shadow:2px 2px 0 #000;';
+ btnClose.onclick = () => overlay.remove();
+ btnRow.appendChild(btnClose);
+
+ box.appendChild(btnRow);
+ overlay.appendChild(box);
+
+ // Close on click outside box
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
+
+ document.body.appendChild(overlay);
+}
+
+// === ui/save-controls.js ===
+// ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ====================
+
+
+
+
+
+
+let dropdownOpen = false;
+
+function initSaveControls() {
+ const menuBtn = document.getElementById('menuBtn');
+ const menuDropdown = document.getElementById('menuDropdown');
+ const saveItem = document.getElementById('saveItem');
+ const resetItem = document.getElementById('resetItem');
+ const marketItem = document.getElementById('marketItem');
+ const shareItem = document.getElementById('shareItem');
+ const settingsItem = document.getElementById('settingsItem');
+ const quitItem = document.getElementById('quitItem');
+
+ // Убить возможный старый onclick
+ if (menuBtn) menuBtn.onclick = null;
+
+ // Тогл меню
+ if (menuBtn) {
+ menuBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ dropdownOpen = !dropdownOpen;
+ if (menuDropdown) menuDropdown.classList.toggle('open', dropdownOpen);
+ });
+ }
+
+ // Закрытие по клику вне
+ document.addEventListener('click', () => {
+ if (dropdownOpen) {
+ dropdownOpen = false;
+ if (menuDropdown) menuDropdown.classList.remove('open');
+ }
+ });
+
+ if (menuDropdown) {
+ menuDropdown.addEventListener('click', (e) => e.stopPropagation());
+ }
+
+ // 💾 Сохранить
+ if (saveItem) {
+ saveItem.addEventListener('click', () => {
+ playSound('click');
+ saveGame();
+ dropdownOpen = false;
+ if (menuDropdown) menuDropdown.classList.remove('open');
+ alert('Игра сохранена!');
+ });
+ }
+
+ // 🔄 Новый мир
+ if (resetItem) {
+ resetItem.addEventListener('click', () => {
+ if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
+ playSound('click');
+ try { localStorage.removeItem(state.SAVE_KEY); } catch(e) {}
+ state.inMemorySave = null;
+ state.worldId = Math.random().toString(36).substring(2, 10);
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('world', state.worldId);
+ window.history.replaceState(null, '', newUrl.toString());
+ } catch(e) {}
+ location.reload();
+ } else {
+ dropdownOpen = false;
+ if (menuDropdown) menuDropdown.classList.remove('open');
+ }
+ });
+ }
+
+ // 🏪 Рынок
+ if (marketItem) {
+ marketItem.addEventListener('click', () => {
+ dropdownOpen = false;
+ if (menuDropdown) menuDropdown.classList.remove('open');
+ document.dispatchEvent(new CustomEvent('toggleMarket'));
+ });
+ }
+
+ // 📤 Пригласить
+ if (shareItem) {
+ shareItem.addEventListener('click', () => {
+ dropdownOpen = false;
+ if (menuDropdown) menuDropdown.classList.remove('open');
+ shareWorld();
+ });
+ }
+
+ // ⚙️ Настройки
+ if (settingsItem) {
+ settingsItem.addEventListener('click', () => {
+ dropdownOpen = false;
+ menuDropdown.classList.remove('open');
+ const panel = document.getElementById('settingsPanel');
+ if (!panel) return;
+ // Sync controls with current state
+ const toggle = document.getElementById('dayNightToggle');
+ const timeSlider = document.getElementById('timeSlider');
+ const wSlider = document.getElementById('weatherIntensity');
+ if (toggle) toggle.checked = state.dayNightCycle !== false;
+ if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100);
+ if (wSlider) wSlider.value = Math.round(state.rainIntensity * 100);
+ // Sync weather buttons — highlight the active type
+ const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear');
+ const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1);
+ for (const bid of ['weatherClear','weatherRain','weatherSnow','weatherThunder']) {
+ const b = document.getElementById(bid);
+ if (b) b.style.background = (bid === activeId) ? '#3498db' : '#555';
+ }
+ panel.style.display = 'block';
+ });
+ }
+
+ // Закрытие настроек
+ const settingsClose = document.getElementById('settingsClose');
+ if (settingsClose) {
+ settingsClose.addEventListener('click', () => {
+ const settingsPanel = document.getElementById('settingsPanel');
+ if (settingsPanel) settingsPanel.style.display = 'none';
+ });
+ }
+
+ // Тогл день/ночи
+ const dayNightToggle = document.getElementById('dayNightToggle');
+ if (dayNightToggle) {
+ dayNightToggle.addEventListener('change', () => {
+ state.dayNightCycle = dayNightToggle.checked;
+ saveGame();
+ });
+ }
+
+ // 🏠 Главное меню
+ if (quitItem) {
+ quitItem.addEventListener('click', () => {
+ dropdownOpen = false;
+ if (menuDropdown) menuDropdown.classList.remove('open');
+ saveGame();
+ try {
+ const url = new URL(window.location.href);
+ url.searchParams.delete('world');
+ window.location.href = url.toString();
+ } catch(e) { location.reload(); }
+ });
+ }
+}
+
+function updateSaveButtonVisibility() {
+ const saveItem = document.getElementById('saveItem');
+ if (!saveItem) return;
+ saveItem.style.display = (state.isMultiplayer && state.otherPlayers.size > 0) ? 'none' : 'block';
+}
+
+// === multiplayer/socket-helpers.js ===
+// Socket helpers: цвет, отправка позиции/блоков
+
+
+
+
+
+// Генерация случайного цвета для игрока на основе socket_id
+function getRandomPlayerColor(socketId) {
+ const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
+ let hash = 0;
+ for (let i = 0; i < socketId.length; i++) {
+ hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return colors[Math.abs(hash) % colors.length];
+}
+
+// Throttle для отправки позиции (10-20 раз в секунду)
+let lastMoveSendTime = 0;
+
+let lastSentX = 0, lastSentY = 0;
+
+// Отправка позиции игрока (с throttle)
+function sendPlayerPosition() {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+
+ const now = performance.now() / 1000;
+ if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
+
+ // Отправляем только если позиция изменилась
+ const dx = Math.abs(state.player.x - lastSentX);
+ const dy = Math.abs(state.player.y - lastSentY);
+ if (dx < 1 && dy < 1) return;
+
+ lastMoveSendTime = now;
+ lastSentX = state.player.x;
+ lastSentY = state.player.y;
+
+ state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName });
+}
+
+// Отправка изменения блока
+function sendBlockChange(gx, gy, t, op) {
+ if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
+
+ state.socket.emit('block_change', { gx, gy, t, op });
+}
+
+// === ui/trade.js ===
+// Trade UI — личная торговля (лицом к лицу)
+
+
+
+
+
+
+
+function itemName(id) {
+ return BLOCKS[id]?.n || TOOLS[id]?.n || ITEMS[id]?.n || id;
+}
+function itemIcon(id) {
+ if (BLOCKS[id]) return '🟫';
+ if (TOOLS[id]) return TOOLS[id].icon || '🔧';
+ if (ITEMS[id]) return ITEMS[id].icon || '📦';
+ return '📦';
+}
+
+let tradeOpen = false;
+let currentTradeId = null;
+let myOffer = {};
+let partnerOffer = {};
+let iConfirmed = false;
+
+function isTradeOpen() { return tradeOpen; }
+
+function initTradeSocket(socket) {
+ // Incoming trade request
+ socket.on('trade_request', (data) => {
+ playSound('click');
+ // Show trade request modal
+ const overlay = document.createElement('div');
+ overlay.id = 'tradeRequestOverlay';
+ overlay.className = 'custom-modal-overlay';
+ overlay.innerHTML = `
+
+
🤝 ${data.from_name} хочет торговать
+
+
+
+
+
`;
+ document.getElementById('game').appendChild(overlay);
+
+ document.getElementById('tradeAcceptBtn').addEventListener('click', () => {
+ overlay.remove();
+ socket.emit('trade_accept', { from_socket_id: data.from });
+ });
+ document.getElementById('tradeDeclineBtn').addEventListener('click', () => {
+ overlay.remove();
+ socket.emit('trade_decline', { from_socket_id: data.from });
+ });
+ });
+
+ socket.on('trade_start', (data) => {
+ currentTradeId = data.trade_id;
+ myOffer = {};
+ partnerOffer = {};
+ iConfirmed = false;
+ tradeOpen = true;
+ renderTradeUI(data.partner_name);
+ state.tradePanel.style.display = 'block';
+ });
+
+ socket.on('trade_update', (data) => {
+ partnerOffer = data.player1Offer || data.player2Offer || {};
+ // Determine which offer is the partner's
+ const isP1 = state.socket && state.socket.id === data.player1Offer;
+ // The partner's offer is the one we didn't send
+ // Actually the server sends both — we figure out which is ours
+ // Simpler: just use the data directly
+ if (state.mySocketId) {
+ // We need to know which offer in the update is ours vs partner's
+ // The trade_update event doesn't tell us, but we know myOffer already
+ // So partnerOffer = the one that's not myOffer
+ }
+ renderTradeOffers();
+ });
+
+ socket.on('trade_status', (data) => {
+ // Update confirm buttons
+ const confirmBtn = document.getElementById('tradeConfirmBtn');
+ if (confirmBtn) {
+ const amIPlayer1 = true; // We need to track this
+ if (iConfirmed) {
+ confirmBtn.textContent = '✅ Ожидание...';
+ confirmBtn.disabled = true;
+ confirmBtn.style.background = '#555';
+ }
+ }
+ // Show partner confirmation status
+ const partnerStatus = document.getElementById('partnerConfirmStatus');
+ if (partnerStatus) {
+ const partnerConfirmed = data.player1Confirm || data.player2Confirm;
+ partnerStatus.textContent = partnerConfirmed ? '✅ Подтвердил' : '⏳ Ещё не подтвердил';
+ }
+ });
+
+ socket.on('trade_complete', (data) => {
+ // Add received items to inventory
+ const received = data.received || {};
+ for (const [item, qty] of Object.entries(received)) {
+ if (qty > 0) {
+ state.inv[item] = (state.inv[item] || 0) + qty;
+ }
+ }
+ // Remove sent items from inventory
+ const sent = data.sent || {};
+ for (const [item, qty] of Object.entries(sent)) {
+ if (qty > 0) {
+ state.inv[item] = (state.inv[item] || 0) - qty;
+ if (state.inv[item] < 0) state.inv[item] = 0;
+ }
+ }
+ addChatMessage('Система', '🤝 Сделка завершена!');
+ closeTrade();
+ });
+
+ socket.on('trade_declined', () => {
+ addChatMessage('Система', 'Торговля отклонена');
+ closeTrade();
+ });
+
+ socket.on('trade_cancelled', () => {
+ addChatMessage('Система', 'Торговля отменена');
+ closeTrade();
+ });
+
+ socket.on('trade_error', (data) => {
+ addChatMessage('Система', '❌ ' + data.msg);
+ });
+}
+
+function renderTradeUI(partnerName) {
+ const panel = state.tradePanel;
+ const content = state.tradeContent;
+ content.innerHTML = `
+ 🤝 Торговля с ${partnerName}
+
+
+
Вы предлагаете:
+
+
+
+
⇄
+
+
Партнёр предлагает:
+
+ Ожидание...
+
+
+
+ ⏳ Партнёр ещё не подтвердил
+
+
+
+
`;
+
+ document.getElementById('tradeAddItemBtn')?.addEventListener('click', showAddItemDialog);
+ document.getElementById('tradeConfirmBtn')?.addEventListener('click', confirmTrade);
+ document.getElementById('tradeCancelBtn')?.addEventListener('click', cancelTrade);
+}
+
+function renderTradeOffers() {
+ const myItems = document.getElementById('myOfferItems');
+ if (myItems) {
+ const keys = Object.keys(myOffer).filter(k => myOffer[k] > 0);
+ if (keys.length === 0) {
+ myItems.innerHTML = 'Ничего';
+ } else {
+ myItems.innerHTML = keys.map(k => `${itemIcon(k)} ${itemName(k)} x${myOffer[k]}
`).join('');
+ }
+ }
+}
+
+function showAddItemDialog() {
+ const inv = state.inv;
+ const items = Object.keys(inv).filter(id => inv[id] > 0);
+ if (items.length === 0) {
+ addChatMessage('Система', 'Инвентарь пуст!');
+ return;
+ }
+
+ const overlay = document.createElement('div');
+ overlay.id = 'tradeItemPicker';
+ overlay.className = 'custom-modal-overlay';
+ let html = 'Что предложить?
';
+ for (const id of items) {
+ // Show already offered count
+ const offered = myOffer[id] || 0;
+ const available = inv[id] - offered;
+ if (available <= 0) continue;
+ html += `
+ ${itemIcon(id)}
+ ${itemName(id)}
+ x${available}
+
`;
+ }
+ html += '
';
+ overlay.innerHTML = html;
+ document.getElementById('game').appendChild(overlay);
+
+ overlay.querySelectorAll('.trade-pick-item').forEach(el => {
+ el.addEventListener('click', () => {
+ const itemId = el.dataset.item;
+ // Ask quantity
+ const available = inv[itemId] - (myOffer[itemId] || 0);
+ const qty = Math.min(available, available > 1 ? promptForQty(itemId, available) : 1);
+ if (qty > 0) {
+ myOffer[itemId] = (myOffer[itemId] || 0) + qty;
+ renderTradeOffers();
+ // Reset confirm when offer changes
+ iConfirmed = false;
+ const btn = document.getElementById('tradeConfirmBtn');
+ if (btn) { btn.textContent = '✅ Подтвердить'; btn.disabled = false; btn.style.background = '#2ecc71'; }
+ }
+ overlay.remove();
+ });
+ });
+}
+
+function promptForQty(itemId, max) {
+ // Simple: just use 1 for now, can enhance later with a proper dialog
+ const val = parseInt(prompt(`Сколько ${itemName(itemId)}? (макс: ${max})`, '1'));
+ return isNaN(val) || val < 1 ? 1 : Math.min(val, max);
+}
+
+function confirmTrade() {
+ if (!currentTradeId || !state.socket) return;
+
+ // Deduct offered items from inventory
+ for (const [item, qty] of Object.entries(myOffer)) {
+ if (qty > 0) {
+ if (state.inv[item] < qty) {
+ addChatMessage('Система', `Недостаточно ${itemName(item)}!`);
+ return;
+ }
+ }
+ }
+
+ // Send offer + confirm
+ state.socket.emit('trade_offer', { trade_id: currentTradeId, offer: myOffer });
+
+ // Small delay then confirm
+ setTimeout(() => {
+ state.socket.emit('trade_confirm', { trade_id: currentTradeId });
+ iConfirmed = true;
+ const btn = document.getElementById('tradeConfirmBtn');
+ if (btn) { btn.textContent = '✅ Ожидание...'; btn.disabled = true; btn.style.background = '#555'; }
+ }, 200);
+}
+
+function cancelTrade() {
+ if (!currentTradeId || !state.socket) return;
+ state.socket.emit('trade_cancel', { trade_id: currentTradeId });
+ closeTrade();
+}
+
+function closeTrade() {
+ tradeOpen = false;
+ currentTradeId = null;
+ myOffer = {};
+ partnerOffer = {};
+ iConfirmed = false;
+ if (state.tradePanel) state.tradePanel.style.display = 'none';
+ // Remove any pick overlay
+ document.getElementById('tradeItemPicker')?.remove();
+}
+
+function requestTradeWithPlayer(targetName) {
+ if (!state.socket || !state.isMultiplayer) {
+ addChatMessage('Система', 'Торговля доступна только в мультиплеере');
+ return;
+ }
+ state.socket.emit('trade_request', { target_name: targetName });
+ addChatMessage('Система', `Запрос отправлен ${targetName}...`);
+}
+
+// === multiplayer/socket.js ===
+// Socket.IO клиент — инициализация и все обработчики
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// Mob sizes for server mob sync (server doesn't send w/h)
+const MOB_SIZES = {
+ pig: { w: 34, h: 24 },
+ chicken: { w: 26, h: 22 },
+ zombie: { w: 34, h: 50 },
+ creeper: { w: 34, h: 50 },
+ skeleton: { w: 34, h: 50 },
+ scorpion: { w: 26, h: 26 },
+ polar_bear: { w: 40, h: 34 },
+ slime: { w: 24, h: 24 },
+ eagle: { w: 30, h: 22 },
+};
+
+function applyMobSize(sm) {
+ const s = MOB_SIZES[sm.kind];
+ if (s && !sm.w) { sm.w = s.w; sm.h = s.h; }
+ return sm;
+}
+
+let socket = null;
+
+
+
+function initSocket() {
+ try {
+ socket = io(SERVER_URL, {
+ path: '/socket.io/',
+ transports: ['websocket', 'polling']
+ });
+
+ // Store socket on state for other modules
+ state.socket = socket;
+
+ socket.on('connect', () => {
+ console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`);
+ state.mySocketId = socket.id;
+ state.isMultiplayer = true;
+
+ // Присоединяемся к миру
+ socket.emit('join_world', { world_id: state.worldId, player_name: state.playerName });
+
+ // Показываем в UI
+ if (state.worldIdEl) state.worldIdEl.textContent = state.worldId;
+ if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'block';
+
+ // Initialize trade socket handlers
+ initTradeSocket(socket);
+ });
+
+ socket.on('connect_error', (error) => {
+ console.error('Socket connection error:', error);
+ state.isMultiplayer = false;
+ });
+
+ socket.on('disconnect', () => {
+ console.log('Disconnected from server');
+ state.isMultiplayer = false;
+ state.otherPlayers.clear();
+ if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'none';
+ });
+
+ // Обработка world_state
+ socket.on('world_state', (data) => {
+ console.log('Received world_state:', data);
+
+ if (data.seed !== undefined && data.seed !== state.worldSeed) {
+ const oldSeed = state.worldSeed;
+ state.worldSeed = data.seed;
+ console.log('World seed changed from', oldSeed, 'to', state.worldSeed);
+
+ state.generated.clear();
+ grid.clear();
+ blocks.length = 0;
+ state.placedBlocks = [];
+ state.removedBlocks = [];
+ console.log('World regenerated with new seed:', state.worldSeed);
+ }
+
+ if (data.blocks && Array.isArray(data.blocks)) {
+ for (const block of data.blocks) {
+ const key = k(block.gx, block.gy, block.gz || 0);
+ state.serverOverrides.set(key, { op: block.op, t: block.t });
+ if (block.op === 'set') {
+ setBlock(block.gx, block.gy, block.gz || 0, block.t, false);
+ } else if (block.op === 'remove') {
+ removeBlock(block.gx, block.gy, block.gz || 0);
+ }
+ }
+ }
+
+ if (data.time !== undefined) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+
+ // Spawn point
+ {
+ const startGX = 6;
+ for (let dx = -4; dx <= 4; dx++) for (let dz = -24; dz <= 24; dz++) genColumn(startGX + dx, dz);
+ const surfaceY = surfaceGyAt(startGX, Math.floor((state.player.z || 0) / state.TILE));
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock(startGX, surfaceY - 1);
+ if (aboveBlock && aboveBlock.t === 'water') {
+ for (let gy = SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy, Math.floor((state.player.z || 0) / state.TILE));
+ if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.spawnPoint.x = startGX * TILE;
+ state.spawnPoint.y = safeGY * TILE;
+ console.log('Client-side spawn point:', state.spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
+ }
+
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = 0;
+ state.player.vy = 0;
+ state.player.fallStartY = state.player.y;
+ console.log('Player moved to spawn point:', state.player.x, state.player.y);
+
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.invuln = 0;
+ console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
+
+ if (data.players && Array.isArray(data.players)) {
+ state.otherPlayers.clear();
+ for (const p of data.players) {
+ if (p.socket_id !== state.mySocketId) {
+ state.otherPlayers.set(p.socket_id, {
+ x: p.x,
+ y: p.y,
+ color: getRandomPlayerColor(p.socket_id),
+ name: p.player_name || 'Игрок'
+ });
+ }
+ }
+ state.playerCountEl.textContent = data.players.length;
+ }
+ // Server mobs
+ if (data.mobs && Array.isArray(data.mobs)) {
+ state.serverMobs.clear();
+ for (const m of data.mobs) {
+ let sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 };
+ applyMobSize(sm);
+ state.serverMobs.set(m.id, sm);
+ }
+ }
+
+ // Set myTgId from server if provided
+ if (data.tg_id) state.myTgId = data.tg_id;
+ });
+
+ socket.on('player_joined', (data) => {
+ console.log('Player joined:', data.socket_id);
+ if (data.socket_id !== state.mySocketId) {
+ const spawnGX = 6;
+ genColumn(spawnGX, Math.floor((state.player.z || 0) / state.TILE));
+ const surfaceY = surfaceGyAt(spawnGX, Math.floor((state.player.z || 0) / state.TILE));
+ const safeSpawnX = spawnGX * TILE;
+ const safeSpawnY = (surfaceY - 1) * TILE;
+
+ state.otherPlayers.set(data.socket_id, {
+ x: safeSpawnX,
+ y: safeSpawnY,
+ color: getRandomPlayerColor(data.socket_id),
+ name: data.player_name || 'Игрок'
+ });
+ addChatMessage('Система', `Игрок присоединился`);
+ updateSaveButtonVisibility();
+ }
+ });
+
+ socket.on('player_moved', (data) => {
+ if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
+ const p = state.otherPlayers.get(data.socket_id);
+ p.x = data.x;
+ p.y = data.y;
+ if (data.player_name) {
+ p.name = data.player_name;
+ }
+ }
+ });
+
+ socket.on('player_left', (data) => {
+ console.log('Player left:', data.socket_id);
+ state.otherPlayers.delete(data.socket_id);
+ addChatMessage('Система', `Игрок покинул игру`);
+ updateSaveButtonVisibility();
+ });
+
+ // === MOB SYNC ===
+ socket.on('mob_spawned', (data) => {
+ let sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 };
+ applyMobSize(sm);
+ state.serverMobs.set(data.id, sm);
+ });
+
+ socket.on('mob_positions', (arr) => {
+ for (const u of arr) {
+ const sm = state.serverMobs.get(u.id);
+ if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; }
+ }
+ });
+
+ socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); });
+
+ socket.on('mob_died', (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm && data.killer === state.mySocketId) {
+ if (sm.kind === 'chicken') playSound('hurt_chicken');
+ state.inv.meat += (sm.kind === 'chicken' ? 1 : 2);
+ if (sm.kind === 'skeleton') {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ rebuildHotbar();
+ }
+ state.serverMobs.delete(data.id);
+ });
+
+ socket.on('mob_hurt_ack', (data) => {
+ const sm = state.serverMobs.get(data.id);
+ if (sm) sm.hp = data.hp;
+ });
+
+ socket.on('mob_explode', (data) => {
+ explodeTNT3D({ gx: data.gx, gy: data.gy, gz: data.gz || 0 }, state);
+ state.serverMobs.delete(data.id);
+ });
+
+ socket.on('mob_shoot', (data) => {
+ state.projectiles.push({
+ x: data.x, y: data.y, vx: data.vx, vy: data.vy,
+ dmg: data.dmg, owner: 'mob', life: data.life
+ });
+ });
+
+ socket.on('block_changed', (data) => {
+ const key = k(data.gx, data.gy, data.gz || 0);
+ state.serverOverrides.set(key, { op: data.op, t: data.t });
+ if (data.op === 'set') {
+ setBlock(data.gx, data.gy, data.gz || 0, data.t, false);
+ } else if (data.op === 'remove') {
+ removeBlock(data.gx, data.gy, data.gz || 0);
+ }
+ });
+
+ socket.on('chat_message', (data) => {
+ const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
+ addChatMessage(senderName, data.message);
+ });
+
+ socket.on('time_update', (data) => {
+ if (data.time !== undefined) {
+ state.worldTime = data.time;
+ state.isNightTime = state.worldTime > 0.5;
+ }
+ });
+
+ } catch (e) {
+ console.error('Error initializing socket:', e);
+ state.isMultiplayer = false;
+ }
+}
+
+// === physics/water-detect3d.js ===
+// ==================== ВОДА 3D — GrechkaCraft v3 ====================
+// Та же логика 2D-игры (кислород, всплеск, болото), но с учётом Z.
+
+
+
+
+
+function blockAt3(px, py, pz) {
+ const gx = Math.floor(px / TILE);
+ const gy = Math.floor(py / TILE);
+ const gz = Math.floor(pz / TILE);
+ return getBlock(gx, gy, gz);
+}
+
+function isWaterAt(px, py, pz) {
+ const b = blockAt3(px, py, pz);
+ return !!(b && (b.t === 'water' || b.t === 'swamp_water'));
+}
+
+function isSwampWaterAt(px, py, pz) {
+ const b = blockAt3(px, py, pz);
+ return !!(b && b.t === 'swamp_water');
+}
+
+// Обновляет флаги воды на сущности (игрок или моб).
+// dt передаётся явно — в 2D-версии он был неявной переменной цикла.
+function updateWaterFlag3(e, dt) {
+ const cx = e.x + e.w / 2;
+ const cz = (e.z || 0) + (e.d || e.w) / 2;
+ const wasInWater = e.inWater;
+
+ const mid = isWaterAt(cx, e.y + e.h / 2, cz);
+ const feet = isWaterAt(cx, e.y + e.h - 2, cz);
+ e.inWater = mid || feet;
+
+ e.inSwampWater = isSwampWaterAt(cx, e.y + e.h / 2, cz);
+ e.headInWater = isWaterAt(cx, e.y + 4, cz);
+
+ if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
+ playSound('splash');
+ }
+ if (e === state.player && e.inSwampWater) {
+ state.player.hp -= 3 * (typeof dt === 'number' ? dt : 0.016);
+ }
+}
+
+// Лодка тонет, если под ней нет воды (с учётом Z).
+function updateWaterPhysics3(dt) {
+ void dt;
+}
+
+// === physics/collision3d.js ===
+// ==================== ФИЗИКА 3D — GrechkaCraft v3 ====================
+// Осевое разрешение столкновений по X → Z → Y, как в Minecraft.
+// Автошаг на 1 блок, прыжок, лестницы, плавание, лодка.
+
+
+
+
+
+const EPS = 0.001;
+
+// Собственная AABB-коробка сущности: x/z — центр, y — низ (как в 2D-игре: e.x, e.y = левый-ниний)
+function boxOf(e) {
+ return {
+ x0: e.x, x1: e.x + e.w,
+ y0: e.y, y1: e.y + e.h,
+ z0: e.z, z1: e.z + e.d,
+ };
+}
+
+function solidAt(gx, gy, gz) {
+ const b = getBlock(gx, gy, gz);
+ if (!b) return false;
+ const def = BLOCKS[b.t];
+ if (!def) return false;
+ if (def.fluid) return false;
+ if (def.decor || def.climbable) return false;
+ return !!def.solid;
+}
+
+function climbableAt(gx, gy, gz) {
+ const b = getBlock(gx, gy, gz);
+ return !!(b && BLOCKS[b.t] && BLOCKS[b.t].climbable);
+}
+
+function fluidAt(gx, gy, gz) {
+ const b = getBlock(gx, gy, gz);
+ return !!(b && BLOCKS[b.t] && BLOCKS[b.t].fluid);
+}
+
+// Пересекает ли коробка сущности твёрдый блок
+function hitsSolid(bx) {
+ const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE);
+ const y0 = Math.floor(bx.y0 / TILE), y1 = Math.floor((bx.y1 - EPS) / TILE);
+ const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE);
+ for (let gx = x0; gx <= x1; gx++) {
+ for (let gy = y0; gy <= y1; gy++) {
+ for (let gz = z0; gz <= z1; gz++) {
+ if (solidAt(gx, gy, gz)) return true;
+ }
+ }
+ }
+ return false;
+}
+
+// Стоит ли сущность на чём-то (для grounded)
+function checkGrounded(e) {
+ const bx = boxOf(e);
+ const gy = Math.floor((bx.y1 + 1) / TILE);
+ const x0 = Math.floor(bx.x0 / TILE), x1 = Math.floor((bx.x1 - EPS) / TILE);
+ const z0 = Math.floor(bx.z0 / TILE), z1 = Math.floor((bx.z1 - EPS) / TILE);
+ for (let gx = x0; gx <= x1; gx++) {
+ for (let gz = z0; gz <= z1; gz++) {
+ if (solidAt(gx, gy, gz)) return true;
+ }
+ }
+ return false;
+}
+
+function onLadder(e) {
+ const cx = Math.floor((e.x + e.w / 2) / TILE);
+ const cy = Math.floor((e.y + e.h / 2) / TILE);
+ const cz = Math.floor((e.z + e.d / 2) / TILE);
+ return climbableAt(cx, cy, cz);
+}
+
+function inFluid(e) {
+ const cx = Math.floor((e.x + e.w / 2) / TILE);
+ const cy = Math.floor((e.y + e.h * 0.4) / TILE);
+ const cz = Math.floor((e.z + e.d / 2) / TILE);
+ return fluidAt(cx, cy, cz);
+}
+
+// Разрешение одной оси. delta — смещение за кадр.
+function moveAxis(e, axis, delta) {
+ if (delta === 0) return false;
+ const bx = boxOf(e);
+ if (axis === 'x') { bx.x0 += delta; bx.x1 += delta; }
+ else if (axis === 'z') { bx.z0 += delta; bx.z1 += delta; }
+ else { bx.y0 += delta; bx.y1 += delta; }
+
+ if (!hitsSolid(bx)) {
+ if (axis === 'x') e.x += delta;
+ else if (axis === 'z') e.z += delta;
+ else e.y += delta;
+ return false;
+ }
+
+ const dir = Math.sign(delta);
+
+ // Автошаг вверх на 1 блок
+ if ((axis === 'x' || axis === 'z') && e.grounded) {
+ const savedX = e.x, savedY = e.y, savedZ = e.z;
+ if (axis === 'x') e.x += delta; else e.z += delta;
+ e.y -= TILE;
+ if (!hitsSolid(boxOf(e))) {
+ return true; // шаг удался, продолжим движение
+ }
+ e.x = savedX; e.y = savedY; e.z = savedZ;
+ }
+
+ // Откат к границе блока
+ // шаг за блок, пока не пересечение
+ const stepPx = dir * 0.5;
+ let moved = 0;
+ for (let i = 0; i < TILE * 4; i++) {
+ const test = boxOf(e);
+ if (axis === 'x') { test.x0 += stepPx; test.x1 += stepPx; }
+ else if (axis === 'z') { test.z0 += stepPx; test.z1 += stepPx; }
+ else { test.y0 += stepPx; test.y1 += stepPx; }
+ if (hitsSolid(test)) break;
+ moved += stepPx;
+ if (axis === 'x') e.x += stepPx;
+ else if (axis === 'z') e.z += stepPx;
+ else e.y += stepPx;
+ }
+
+ if (axis === 'x') e.vx = 0;
+ else if (axis === 'z') e.vz = 0;
+ else e.vy = 0;
+ return true;
+}
+
+function resolveX(e, dt) { return moveAxis(e, 'x', e.vx * dt); }
+function resolveZ(e, dt) { return moveAxis(e, 'z', (e.vz || 0) * dt); }
+function resolveY(e, dt) {
+ const hit = moveAxis(e, 'y', e.vy * dt);
+ e.grounded = false;
+ if (hit && e.vy >= 0) {
+ e.grounded = checkGrounded(e);
+ } else if (!hit) {
+ e.grounded = checkGrounded(e);
+ }
+ return hit;
+}
+
+// === data/recipes.js ===
+const RECIPES = [
+ { out:'planks', qty:4, cost:{ wood:1 } },
+ { out:'ladder', qty:3, cost:{ planks:7 } },
+ { out:'torch', qty:2, cost:{ coal:1, planks:1 } },
+ { out:'glass', qty:1, cost:{ sand:3 } },
+ { out:'brick', qty:1, cost:{ stone:2, clay:1 } },
+ { out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
+ { out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
+ { out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
+ { out:'boat', qty:1, cost:{ wood: 5 } },
+ { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
+ { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
+ { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
+ { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
+ { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
+ { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
+ { out:'wood_axe', qty:1, cost:{ wood:3, planks:2 } },
+ { out:'stone_axe', qty:1, cost:{ wood:1, stone:3 } },
+ { out:'iron_axe', qty:1, cost:{ wood:1, iron_ore:2 } },
+ { out:'wood_shovel', qty:1, cost:{ wood:1, planks:1 } },
+ { out:'stone_shovel', qty:1, cost:{ wood:1, stone:2 } },
+ { out:'iron_shovel', qty:1, cost:{ wood:1, iron_ore:1 } },
+ { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
+ { out:'gold_armor', qty:1, cost:{ gold_ore: 8 } },
+ { out:'furnace', qty:1, cost:{ stone: 8 } },
+ { out:'bow', qty:1, cost:{ wood: 3, planks: 2 } },
+ { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } },
+ { out:'flag', qty:1, cost:{ planks:3, iron_ore:1 } },
+ { out:'bread', qty:1, cost:{ wheat: 3 } },
+ { out:'farmland', qty:1, cost:{ dirt:1 } },
+ { out:'wheat_seeds', qty:1, cost:{ farmland:1, dirt:1 } },
+];
+
+// Рецепты печи (обжиг)
+const SMELTING_RECIPES = [
+ { in:'sand', qty:1, out:'glass', outQty:1, time:3 },
+ { in:'clay', qty:1, out:'brick', outQty:1, time:3 },
+ { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 },
+ { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 },
+ { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 },
+ { in:'meat', qty:1, out:'cooked', outQty:1, time:2 },
+ { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 },
+ { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 }
+];
+
+// === ui/furnace.js ===
+// ==================== ПЕЧЬ (ОБЖИГ) ====================
+
+
+
+
+
+
+
+
+const furnacePanel = document.getElementById('furnacePanel');
+const furnaceContent = document.getElementById('furnaceContent');
+
+function initFurnace() {
+ document.getElementById('furnaceClose').onclick = () => {
+ furnacePanel.style.display = 'none';
+ state.currentFurnaceKey = null;
+ };
+}
+
+function openFurnaceUI(gx, gy, gz) {
+ state.currentFurnaceKey = `${gx},${gy},${gz || 0}`;
+ furnacePanel.style.display = 'block';
+ renderFurnaceUI();
+}
+
+function renderFurnaceUI() {
+ if (!state.currentFurnaceKey) return;
+
+ // Проверяем что печь всё ещё существует
+ const [fgx, fgy, fgz] = state.currentFurnaceKey.split(',').map(Number);
+ const fb = getBlock(fgx, fgy, fgz || 0);
+ if (!fb || fb.t !== 'furnace') {
+ furnacePanel.style.display = 'none';
+ state.currentFurnaceKey = null;
+ return;
+ }
+
+ // Текущий процесс обжига
+ const active = state.activeFurnaces.get(state.currentFurnaceKey);
+
+ let html = '';
+
+ // Доступные рецепты — показываем только те, для которых есть ресурсы
+ for (let i = 0; i < SMELTING_RECIPES.length; i++) {
+ const recipe = SMELTING_RECIPES[i];
+ const haveCount = state.inv[recipe.in] || 0;
+ const canSmelt = haveCount >= recipe.qty;
+
+ // Иконка результата
+ const outDef = BLOCKS[recipe.out];
+ const outItem = ITEMS[recipe.out];
+ const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
+ const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
+ const inItem = ITEMS[recipe.in];
+ const inDef = BLOCKS[recipe.in];
+ const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
+
+ html += `
`;
+ html += `
${iconStr}
`;
+ html += `
`;
+ html += `
${nameStr}
`;
+ html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+
+ // Текущий прогресс
+ if (active) {
+ const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
+ html += `
`;
+ html += `
🔥 Обжиг: ${pct}%
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ }
+
+ html += '
';
+ furnaceContent.innerHTML = html;
+}
+
+// Глобальная функция для кнопки обжига
+window._smelt = (recipeIdx) => {
+ if (!state.currentFurnaceKey) return;
+ const recipe = SMELTING_RECIPES[recipeIdx];
+ if ((state.inv[recipe.in] || 0) < recipe.qty) return;
+
+ // Уже обжигаем в этой печи?
+ if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
+
+ // Забираем ресурсы
+ state.inv[recipe.in] -= recipe.qty;
+
+ // Запускаем обжиг
+ state.activeFurnaces.set(state.currentFurnaceKey, {
+ recipe: recipe,
+ progress: 0
+ });
+
+ playSound('fire');
+ rebuildHotbar();
+ renderFurnaceUI();
+};
+
+// Тик печей — вызывается в главном цикле
+function tickFurnaces(dt) {
+ for (const [key, furnace] of state.activeFurnaces) {
+ furnace.progress += dt;
+ if (furnace.progress >= furnace.recipe.time) {
+ // Обжиг завершён — выдаём результат
+ const outItem = furnace.recipe.out;
+ if (ITEMS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ } else if (BLOCKS[outItem]) {
+ state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
+ }
+ playSound('stone_build');
+ state.activeFurnaces.delete(key);
+
+ // Если эта печь открыта — обновляем UI
+ if (key === state.currentFurnaceKey) {
+ renderFurnaceUI();
+ }
+ }
+ }
+}
+
+// === entities/mob-ai3d.js ===
+// ==================== МОБЫ 3D: ИИ + ФИЗИКА — GrechkaCraft v3 ====================
+// Вся логика из 2D-игры (все 9 видов, ночные/дневные, лут), но цель —
+// настоящая 3D-точка игрока (x,y,z) и прыжки через стены по всем осям.
+
+
+
+
+
+
+
+
+
+function isNight() {
+ return state.worldTime > 0.5;
+}
+
+// Центры сущностей в тайлах
+function cx(e) { return (e.x + e.w / 2) / TILE; }
+function cy(e) { return (e.y + e.h / 2) / TILE; }
+function cz(e) { return ((e.z || 0) + (e.d || e.w) / 2) / TILE; }
+
+function playerC() {
+ const p = state.player;
+ return { x: cx(p), y: cy(p), z: cz(p) };
+}
+
+function dist3(a, b) {
+ return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
+}
+
+// Тянет моба к игроку по обеим горизонтальным осям (X и Z)
+function steerToPlayer(m, pc) {
+ const dx = pc.x - cx(m);
+ const dz = pc.z - cz(m);
+ const len = Math.hypot(dx, dz) || 1;
+ m.vx = (dx / len) * (m.speed || 60);
+ m.vz = (dz / len) * (m.speed || 60);
+ m.dir = dx >= 0 ? 1 : -1;
+ m.faceYaw = Math.atan2(dz, dx);
+}
+
+function touchPlayer(m, pc, damage, knock = 1) {
+ const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
+ if (d < 1.6 && state.player.invuln <= 0) {
+ state.player.hp -= calculateDamage(damage);
+ state.player.invuln = 0.8;
+ const dx = state.player.x - m.x;
+ const dz = (state.player.z || 0) - (m.z || 0);
+ const len = Math.hypot(dx, dz) || 1;
+ state.player.vx += (dx / len) * 420 * knock;
+ state.player.vz += (dz / len) * 420 * knock;
+ state.player.vy -= 260 * knock;
+ playSound('hit1');
+ return true;
+ }
+ return false;
+}
+
+function mobAI(m, dt) {
+ updateWaterFlag3(m, dt);
+ const pc = playerC();
+ const night = isNight();
+
+ // ---------- ночные мобы днём горят ----------
+ if ((m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton') && !night) {
+ m.hp -= 30 * dt;
+ m.vx *= 0.5;
+ m.vz *= 0.5;
+ return;
+ }
+
+ if (m.kind === 'zombie') {
+ steerToPlayer(m, pc);
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ touchPlayer(m, pc, 15);
+ } else if (m.kind === 'creeper') {
+ steerToPlayer(m, pc);
+ const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ if (d < 3) {
+ m.fuse -= dt;
+ if (m.fuse <= 0) {
+ explodeTNT3D({ gx: Math.floor(cx(m)), gy: Math.floor(cy(m)), gz: Math.floor(cz(m)) }, state);
+ m.hp = 0;
+ }
+ } else if (d < 2.5) {
+ m.fuse = 0.5;
+ }
+ } else if (m.kind === 'skeleton') {
+ steerToPlayer(m, pc);
+ const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
+ m.shootCooldown -= dt;
+ if (d < 15 && m.shootCooldown <= 0) {
+ m.shootCooldown = 2.0;
+ const ox = m.x + m.w / 2, oy = m.y + m.h / 3, oz = (m.z || 0) + (m.d || m.w) / 2;
+ const tx = state.player.x + state.player.w / 2;
+ const ty = state.player.y + state.player.h / 2;
+ const tz = (state.player.z || 0) + (state.player.d || state.player.w) / 2;
+ // полёт стрелы по 3D
+ const ddx = tx - ox, ddy = ty - oy, ddz = tz - oz;
+ const dist = Math.hypot(ddx, ddy, ddz) || 1;
+ const speed = 450;
+ state.projectiles.push({
+ x: ox, y: oy, z: oz,
+ vx: ddx / dist * speed,
+ vy: ddy / dist * speed,
+ vz: ddz / dist * speed,
+ dmg: 6, owner: 'mob', life: 3,
+ });
+ }
+ } else if (m.kind === 'scorpion') {
+ steerToPlayer(m, pc);
+ if (m.inWater && Math.random() < 0.06) m.vy = -260;
+ if (touchPlayer(m, pc, 8)) state.player.slowTimer = 3;
+ } else if (m.kind === 'polar_bear') {
+ // нейтрален: бродит
+ m.aiT -= dt;
+ if (m.aiT <= 0) {
+ m.aiT = 2.0 + Math.random() * 3;
+ const a = Math.random() * Math.PI * 2;
+ m.walkX = Math.cos(a);
+ m.walkZ = Math.sin(a);
+ if (Math.random() < 0.3) { m.walkX = 0; m.walkZ = 0; }
+ }
+ m.vx = (m.walkX || 0) * m.speed;
+ m.vz = (m.walkZ || 0) * m.speed;
+ if (m.inWater) m.vy = -120;
+ touchPlayer(m, pc, 10, 1.2);
+ } else if (m.kind === 'slime') {
+ // прыгает к игроку
+ m.aiT -= dt;
+ if (m.aiT <= 0) {
+ m.aiT = 1.5 + Math.random() * 1.5;
+ steerToPlayer(m, pc);
+ m.vy = -200;
+ }
+ touchPlayer(m, pc, 8);
+ } else if (m.kind === 'eagle') {
+ // летает, пикирует
+ const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
+ if (d < 20) {
+ const dx = pc.x - cx(m), dz = pc.z - cz(m), dy = pc.y - cy(m);
+ const hl = Math.hypot(dx, dz) || 1;
+ m.vx = dx / hl * m.speed;
+ m.vz = dz / hl * m.speed;
+ m.vy = dy > 0 ? 60 : -60;
+ } else {
+ m.aiT -= dt;
+ if (m.aiT <= 0) {
+ m.aiT = 2 + Math.random() * 3;
+ const a = Math.random() * Math.PI * 2;
+ m.walkX = Math.cos(a);
+ m.walkZ = Math.sin(a);
+ }
+ m.vx = (m.walkX || 0) * m.speed * 0.5;
+ m.vz = (m.walkZ || 0) * m.speed * 0.5;
+ m.vy = Math.sin(performance.now() / 1000) * 30;
+ }
+ if (d < 1.6 && state.player.invuln <= 0) {
+ state.player.hp -= calculateDamage(10);
+ state.player.invuln = 0.8;
+ state.player.vy -= 200;
+ playSound('hit1');
+ }
+ m.vy *= 0.5;
+ m.flying = true;
+ } else {
+ // животные (pig, chicken): бродят
+ m.aiT -= dt;
+ if (m.aiT <= 0) {
+ m.aiT = 1.8 + Math.random() * 2.5;
+ const a = Math.random() * Math.PI * 2;
+ m.walkX = Math.cos(a);
+ m.walkZ = Math.sin(a);
+ if (Math.random() < 0.25) { m.walkX = 0; m.walkZ = 0; }
+ }
+ const sp = m.kind === 'chicken' ? 55 : 40;
+ m.vx = (m.walkX || 0) * sp;
+ m.vz = (m.walkZ || 0) * sp;
+ if (m.inWater) m.vy = -120;
+ }
+
+ // ---------- прыжок через препятствие по всем осям ----------
+ if (m.grounded && !m.flying && m.kind !== 'eagle') {
+ const speed = Math.hypot(m.vx || 0, m.vz || 0);
+ if (speed > 5) {
+ const gx = Math.floor((m.x + (m.vx > 0 ? m.w + 2 : -2)) / TILE);
+ const gz = Math.floor(((m.z || 0) + (m.vz > 0 ? (m.d || m.w) + 2 : -2)) / TILE);
+ const gy = Math.floor((m.y + m.h - 1) / TILE);
+ const bx = getBlock(gx, gy, Math.floor(cz(m)));
+ const bz = getBlock(Math.floor(cx(m)), gy, gz);
+ if (bx || bz) m.vy = -280;
+ }
+ }
+
+ // ---------- ФИЗИКА ----------
+ if (!m.flying) {
+ const g = m.inWater ? GRAV_WATER : GRAV;
+ m.vy += g * dt;
+ }
+ m.y += m.vy * dt;
+ m.x += (m.vx || 0) * dt;
+ m.z += (m.vz || 0) * dt;
+ resolveX(m, dt);
+ resolveZ(m, dt);
+ resolveY(m, dt);
+
+ // орёл не падает
+ if (m.flying) m.grounded = false;
+}
+
+// Лут — та же таблица, что в 2D
+function getMobLoot(kind) {
+ const table = {
+ chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
+ pig: [{item:'meat',min:1,max:2,chance:1}],
+ zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
+ skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
+ creeper: [{item:'gunpowder',min:1,max:2,chance:1}],
+ scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
+ polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
+ slime: [{item:'slime_ball',min:1,max:2,chance:1}],
+ eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}],
+ };
+ return table[kind] || [];
+}
+
+// === world/weather.js ===
+// ==================== ПОГОДА (biome-aware) ====================
+
+
+
+
+
+// Возможные погоды по биому
+const BIOME_WEATHER = {
+ snow: ['clear', 'snow', 'blizzard'],
+ tundra: ['clear', 'snow', 'blizzard'],
+ forest: ['clear', 'rain', 'storm'],
+ plains: ['clear', 'rain'],
+ desert: ['clear', 'clear', 'rain'],
+ swamp: ['clear', 'rain', 'storm'],
+ mountains: ['clear', 'rain', 'snow']
+};
+
+// Помощник интерполяции цветов
+function lerpColor(a, b, t) {
+ const ar = parseInt(a.slice(1,3),16), ag = parseInt(a.slice(3,5),16), ab = parseInt(a.slice(5,7),16);
+ const br = parseInt(b.slice(1,3),16), bg = parseInt(b.slice(3,5),16), bb = parseInt(b.slice(5,7),16);
+ const r = Math.round(ar + (br - ar) * t);
+ const g = Math.round(ag + (bg - ag) * t);
+ const bl = Math.round(ab + (bb - ab) * t);
+ return '#' + ((1<<24)|(r<<16)|(g<<8)|bl).toString(16).slice(1);
+}
+
+// Цвета неба по биому, погоде и времени суток
+function skyColor(night, biome, weatherType, worldTime) {
+ function weatherSky() {
+ switch (weatherType) {
+ case 'blizzard': return '#8a9bae';
+ case 'storm': return '#3d4f5f';
+ case 'rain': return '#6B7B8D';
+ case 'snow': return '#b8c6d4';
+ case 'clear': return null;
+ default: return null;
+ }
+ }
+
+ const dayColor = (biome === 'desert') ? '#f4d9a0' : (biome === 'tundra' || biome === 'snow') ? '#c4d4e0' : (biome === 'swamp') ? '#4a6741' : '#87CEEB';
+ const dawnColor = '#FF9940';
+ const duskMidColor = '#FF6B35';
+ const duskEndColor = '#1a0533';
+ const nightColor = '#070816';
+
+ let skyColorResult;
+
+ if (worldTime < 0.05) {
+ const t = worldTime / 0.05;
+ skyColorResult = lerpColor(dawnColor, dayColor, t);
+ } else if (worldTime < 0.45) {
+ skyColorResult = dayColor;
+ } else if (worldTime < 0.55) {
+ const t = (worldTime - 0.45) / 0.1;
+ if (t < 0.5) {
+ skyColorResult = lerpColor(dayColor, duskMidColor, t * 2);
+ } else {
+ skyColorResult = lerpColor(duskMidColor, duskEndColor, (t - 0.5) * 2);
+ }
+ } else {
+ skyColorResult = nightColor;
+ if (weatherType === 'blizzard') return '#1a1a2e';
+ if (weatherType === 'storm') return '#0a0a15';
+ return nightColor;
+ }
+
+ const wc = weatherSky();
+ if (wc) {
+ const blendT = 0.6;
+ skyColorResult = lerpColor(skyColorResult, wc, blendT);
+ }
+
+ return skyColorResult;
+}
+
+function updateWeather(dt) {
+ state.weatherTimer += dt;
+ if (state.weatherTimer >= state.weatherChangeInterval) {
+ state.weatherTimer = 0;
+ state.weatherChangeInterval = 60 + Math.random() * 120;
+
+ // Определяем биом по позиции игрока
+ const playerGx = Math.floor(state.player.x / state.TILE);
+ const biome = getCachedBiome(playerGx, Math.floor((state.player.z || 0) / state.TILE));
+ const weathers = BIOME_WEATHER[biome] || ['clear'];
+
+ const night = isNight();
+ const weights = weathers.map(w => {
+ if (w === 'clear') return night ? 2 : 4;
+ if (w === 'rain') return night ? 3 : 2;
+ if (w === 'snow') return night ? 2 : 3;
+ if (w === 'storm') return night ? 3 : 1;
+ if (w === 'blizzard') return night ? 3 : 1;
+ return 1;
+ });
+ const total = weights.reduce((a, b) => a + b, 0);
+ let r = Math.random() * total;
+ state.weatherType = weathers[0];
+ for (let i = 0; i < weathers.length; i++) {
+ r -= weights[i];
+ if (r <= 0) { state.weatherType = weathers[i]; break; }
+ }
+
+ state.isRaining = (state.weatherType === 'rain' || state.weatherType === 'storm');
+ }
+
+ // Плавная интерполяция интенсивности дождя
+ const rainTarget = (state.weatherType === 'rain' || state.weatherType === 'storm') ? 0.6 : 0;
+ state.rainIntensity += (rainTarget - state.rainIntensity) * dt * 0.5;
+ if (state.rainIntensity < 0.01) state.rainIntensity = 0;
+
+ // Молния (только при storm)
+ if (state.weatherType === 'storm') {
+ state.lightningTimer -= dt;
+ if (state.lightningTimer <= 0) {
+ state.lightningFlash = 0.25;
+ state.lightningTimer = 4 + Math.random() * 12;
+ }
+ }
+ if (state.lightningFlash > 0) {
+ state.lightningFlash -= dt;
+ if (state.lightningFlash < 0) state.lightningFlash = 0;
+ }
+
+ // Снежинки (при snow / blizzard)
+ if (state.weatherType === 'snow' || state.weatherType === 'blizzard') {
+ const spawnCount = state.weatherType === 'blizzard' ? Math.floor(80 * dt) : Math.floor(30 * dt);
+ const maxFlakes = state.weatherType === 'blizzard' ? 600 : 250;
+ for (let i = 0; i < spawnCount && state.snowflakes.length < maxFlakes; i++) {
+ state.snowflakes.push({
+ x: state.camX + Math.random() * state.W,
+ y: state.camY - 20,
+ vy: 40 + Math.random() * 60,
+ vx: (state.weatherType === 'blizzard' ? -60 - Math.random() * 80 : -10 - Math.random() * 20),
+ size: 2 + Math.random() * 3,
+ wobble: Math.random() * Math.PI * 2
+ });
+ }
+ }
+ // Обновление снежинок
+ for (let i = state.snowflakes.length - 1; i >= 0; i--) {
+ const s = state.snowflakes[i];
+ s.y += s.vy * dt;
+ s.x += s.vx * dt;
+ s.wobble += dt * 3;
+ s.x += Math.sin(s.wobble) * 15 * dt;
+ if (s.y > state.camY + state.H + 20 || s.x < state.camX - 50 || s.x > state.camX + state.W + 50) {
+ state.snowflakes.splice(i, 1);
+ }
+ }
+ // Убираем снег при смене погоды
+ if (state.weatherType !== 'snow' && state.weatherType !== 'blizzard' && !state.isRaining) {
+ if (state.snowflakes.length > 0) {
+ state.snowflakes.splice(0, Math.ceil(state.snowflakes.length * dt * 2));
+ }
+ }
+}
+
+function updateRain(dt) {
+ if (!state.isRaining || state.rainIntensity < 0.01) {
+ if (state.raindrops.length > 0) {
+ state.raindrops.splice(0, Math.ceil(state.raindrops.length * dt * 3));
+ }
+ return;
+ }
+ const spawnRate = Math.floor(state.rainIntensity * 60 * dt);
+ for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) {
+ state.raindrops.push({
+ x: state.camX + Math.random() * state.W,
+ y: state.camY - 20,
+ vy: 400 + Math.random() * 200,
+ len: 8 + Math.random() * 12
+ });
+ }
+ for (let i = state.raindrops.length - 1; i >= 0; i--) {
+ const d = state.raindrops[i];
+ d.y += d.vy * dt;
+ d.x -= 30 * dt;
+ if (d.y > state.camY + state.H + 20) {
+ state.raindrops.splice(i, 1);
+ }
+ }
+}
+
+function drawWeather(ctx) {
+ if (state.raindrops.length > 0) {
+ ctx.save();
+ ctx.strokeStyle = 'rgba(174,194,224,0.5)';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ for (const d of state.raindrops) {
+ ctx.moveTo(d.x, d.y);
+ ctx.lineTo(d.x - 3, d.y + d.len);
+ }
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ if (state.snowflakes.length > 0) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(255,255,255,0.85)';
+ for (const s of state.snowflakes) {
+ ctx.beginPath();
+ ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ if (state.lightningFlash > 0) {
+ ctx.save();
+ ctx.fillStyle = `rgba(255,255,255,${Math.min(1, state.lightningFlash * 4)})`;
+ ctx.fillRect(0, 0, state.W, state.H);
+ ctx.restore();
+ }
+}
+
+// === entities/mobs3d.js ===
+// Сущности: животные + зомби + новые мобы
+
+class Entity {
+ constructor(x, y, w, h) {
+ this.x = x;
+ this.y = y;
+ this.z = 0; // третье измерение: глубина мира
+ this.w = w;
+ this.h = h;
+ this.d = w; // размер по Z
+ this.vx = 0;
+ this.vy = 0;
+ this.vz = 0;
+ this.hp = 3;
+ this.grounded = false;
+ this.inWater = false;
+ this.aiT = 0;
+ this.dir = 1;
+ }
+}
+
+class Pig extends Entity {
+ constructor(x, y, z) { super(x, y, 34, 24); this.kind = 'pig'; this.z = z || 0; this.hp = 2; }
+}
+
+class Chicken extends Entity {
+ constructor(x, y, z) { super(x, y, 26, 22); this.kind = 'chicken'; this.z = z || 0; this.hp = 1; }
+}
+
+class Zombie extends Entity {
+ constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'zombie'; this.z = z || 0; this.hp = 4; this.speed = 80 + Math.random() * 40; }
+}
+
+class Creeper extends Entity {
+ constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'creeper'; this.z = z || 0; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; }
+}
+
+class Skeleton extends Entity {
+ constructor(x, y, z) { super(x, y, 34, 50); this.kind = 'skeleton'; this.z = z || 0; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; }
+}
+
+class Scorpion extends Entity {
+ constructor(x, y, z) { super(x, y, 26, 26); this.kind = 'scorpion'; this.z = z || 0; this.hp = 3; this.speed = 90; this.fuse = 0; this.shootCooldown = 0; }
+}
+
+class PolarBear extends Entity {
+ constructor(x, y, z) { super(x, y, 40, 34); this.kind = 'polar_bear'; this.z = z || 0; this.hp = 8; this.speed = 50; this.fuse = 0; this.shootCooldown = 2; }
+}
+
+class Slime extends Entity {
+ constructor(x, y, z) { super(x, y, 24, 24); this.kind = 'slime'; this.z = z || 0; this.hp = 2; this.speed = 30; this.fuse = 0; this.shootCooldown = 2; }
+}
+
+class Eagle extends Entity {
+ constructor(x, y, z) { super(x, y, 30, 22); this.kind = 'eagle'; this.z = z || 0; this.hp = 3; this.speed = 120; this.fuse = 0; this.shootCooldown = 0; }
+}
+
+// === game/modes.js ===
+// ==================== РЕЖИМЫ ====================
+
+
+
+const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }];
+
+
+
+function mode() {
+ return MODES[state.modeIdx].id;
+}
+
+function initModes() {
+ const modeBtn = document.getElementById('modeBtn');
+ modeBtn.onclick = () => {
+ playSound('click'); // Звук клика по кнопке режима
+ state.modeIdx = (state.modeIdx + 1) % MODES.length;
+ modeBtn.textContent = MODES[state.modeIdx].icon;
+ };
+}
+
+// === input/mouse-handler3d.js ===
+// ==================== ВЗАИМОДЕЙСТВИЕ 3D (мышь/тап) — GrechkaCraft v3 ====================
+// Ключевое отличие от 2D: блок добывается/ставится ТОЛЬКО если игрок рядом
+// (в пределах REACH). Касание экрана вдали — лишь поворот камеры.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// ---------- ДИСТАНЦИЯ ДОБЫЧИ (как в майнкрафте) ----------
+const REACH = 5.2; // тайлов от центра игрока до центра блока
+const REACH_SQ = REACH * REACH;
+
+function distToBlockSq(gx, gy, gz) {
+ const p = state.player;
+ const TILE = state.TILE;
+ const px = (p.x + p.w / 2) / TILE;
+ const py = (p.y + p.h / 2) / TILE;
+ const pz = ((p.z || 0) + (p.d || p.w) / 2) / TILE;
+ const dx = gx + 0.5 - px;
+ const dy = gy + 0.5 - py;
+ const dz = (gz || 0) + 0.5 - pz;
+ return dx * dx + dy * dy + dz * dz;
+}
+
+function withinReach(gx, gy, gz) {
+ return distToBlockSq(gx, gy, gz) <= REACH_SQ;
+}
+
+// Моб тоже бьётся только вблизи
+function withinReachEntity(e) {
+ const p = state.player;
+ const TILE = state.TILE;
+ const px = (p.x + p.w / 2) / TILE;
+ const py = (p.y + p.h / 2) / TILE;
+ const pz = ((p.z || 0) + (p.d || p.w) / 2) / TILE;
+ const ex = (e.x + e.w / 2) / TILE;
+ const ey = (e.y + e.h / 2) / TILE;
+ const ez = ((e.z || 0) + (e.d || e.w) / 2) / TILE;
+ const dx = ex - px, dy = ey - py, dz = ez - pz;
+ return dx * dx + dy * dy + dz * dz <= REACH_SQ;
+}
+
+const CROP_SEED_MAP = {
+ wheat_seeds: 'wheat_stage0',
+ carrot: 'carrot_stage0',
+ potato: 'potato_stage0',
+};
+const CROP_HARVEST_MAP = { wheat_stage3: 'wheat', carrot_stage3: 'carrot', potato_stage3: 'potato' };
+
+function handleBlockDrop(removed, inv) {
+ if (removed.t === 'grass') {
+ if (Math.random() < 0.5) inv.wheat_seeds = (inv.wheat_seeds || 0) + 1;
+ return;
+ }
+ const harvestItem = CROP_HARVEST_MAP[removed.t];
+ const seedItem = CROP_SEED_MAP[removed.t];
+ if (harvestItem) {
+ inv[harvestItem] = (inv[harvestItem] || 0) + (BLOCKS[removed.t].harvestQty || 1);
+ addToRecentItems(harvestItem);
+ if (seedItem) { inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); }
+ } else if (seedItem) {
+ inv[seedItem] = (inv[seedItem] || 0) + 1;
+ addToRecentItems(seedItem);
+ } else {
+ inv[removed.t] = (inv[removed.t] || 0) + 1;
+ addToRecentItems(removed.t);
+ }
+}
+
+const mouse = { x: null, y: null };
+
+let miningHeld = false;
+let miningInterval = null;
+
+function stopMining() {
+ miningHeld = false;
+ if (miningInterval) { clearInterval(miningInterval); miningInterval = null; }
+}
+
+// Основное действие. coords — результат рейкаста; face — нормаль грани.
+function handleTap(wx, wy, gz, face) {
+ if (state.craftOpen) return;
+ if (state.player.hp <= 0) return;
+
+ const TILE = state.TILE;
+ // gz приходит из рейкаста; если нет — берём Z игрока
+ const z = gz === undefined || gz === null ? Math.floor((state.player.z || 0) / TILE) : gz;
+
+ // Ближайший блок к точке касания
+ let gx = Math.floor(wx / TILE);
+ let gy = Math.floor(wy / TILE);
+ let b = getBlock(gx, gy, z);
+
+ // Если блока нет — пробуем тот же столбец, но сдвинутые на 1 (декоративные/плоские)
+ if (!b) {
+ for (const dy of [-1, 1, 0]) {
+ const cand = getBlock(gx, gy + dy, z);
+ if (cand) { gy += dy; b = cand; break; }
+ }
+ }
+
+ if (!b) return;
+
+ // ---------- ДОСТИЖИМОСТЬ ----------
+ if (!withinReach(gx, gy, z)) {
+ // Слишком далеко: подсказка, ничего не ломаем
+ showReachHint();
+ return;
+ }
+
+ // Пробуждение: клик по кровати когда спишь
+ if (state.player.sleeping && b.t === 'bed') {
+ state.player.sleeping = false;
+ return;
+ }
+ if (state.player.sleeping) return;
+
+ // Печь — открываем панель обжига
+ if (b.t === 'furnace' && mode() === 'mine') {
+ openFurnaceUI(gx, gy, z);
+ return;
+ }
+
+ // ---------- Урон мобам (только вблизи) ----------
+ if (mode() === 'mine') {
+ let handled = false;
+ if (state.isMultiplayer) {
+ for (const [id, sm] of state.serverMobs) {
+ if (sm.dead) continue;
+ if (!withinReachEntity(sm)) continue;
+ if (state.player.invuln <= 0) {
+ let dmg = 1;
+ for (const st of ['iron_sword', 'stone_sword', 'wood_sword']) {
+ if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
+ }
+ state.socket.emit('mob_hurt', { id, dmg });
+ playSound('attack');
+ handled = true;
+ }
+ break;
+ }
+ }
+ if (!handled) {
+ for (let i = state.mobs.length - 1; i >= 0; i--) {
+ const m = state.mobs[i];
+ if (!withinReachEntity(m)) continue;
+ let dmg = 1;
+ for (const st of ['iron_sword', 'stone_sword', 'wood_sword']) {
+ if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
+ }
+ m.hp -= dmg;
+ const kx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2);
+ const kz = ((state.player.z || 0) + (state.player.d || state.player.w) / 2) - ((m.z || 0) + (m.d || m.w) / 2);
+ m.vx += (kx / (Math.hypot(kx, kz) || 1)) * 400;
+ m.vz += (kz / (Math.hypot(kx, kz) || 1)) * 400;
+ m.vy -= 200;
+ playSound('attack');
+ if (m.hp <= 0) {
+ if (m.kind === 'chicken') playSound('hurt_chicken');
+ state.inv.meat += (m.kind === 'chicken' ? 1 : 2);
+ if (m.kind === 'skeleton') {
+ state.inv.arrow += 2 + Math.floor(Math.random() * 3);
+ if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
+ }
+ state.mobs.splice(i, 1);
+ rebuildHotbar();
+ }
+ handled = true;
+ break;
+ }
+ }
+ if (handled) return;
+ }
+
+ // ---------- Лук ----------
+ if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) {
+ const p = state.player;
+ const ox = p.x + p.w / 2, oy = p.y + p.h / 3, oz = (p.z || 0) + (p.d || p.w) / 2;
+ const tx = wx, ty = wy, tz = (z + 0.5) * TILE;
+ const dx = tx - ox, dy = ty - oy, dz = tz - oz;
+ const dist = Math.hypot(dx, dy, dz) || 1;
+ const speed = 550;
+ state.projectiles.push({
+ x: ox, y: oy, z: oz,
+ vx: dx / dist * speed, vy: dy / dist * speed, vz: dz / dist * speed,
+ dmg: 10, owner: 'player', life: 4,
+ });
+ state.inv.arrow--;
+ useTool('bow');
+ playSound('hit1');
+ rebuildHotbar();
+ return;
+ }
+
+ // ---------- Еда ----------
+ if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
+ const it = ITEMS[state.selected];
+ if (state.player.hp < 100 || state.player.hunger < 100) {
+ playSound('eat1');
+ state.player.hunger = Math.min(100, state.player.hunger + it.food);
+ state.player.hp = Math.min(100, state.player.hp + 15);
+ state.inv[state.selected]--;
+ rebuildHotbar();
+ }
+ return;
+ }
+
+ // ---------- Жарка на костре ----------
+ if (b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) {
+ playSound('fire');
+ state.inv.meat--;
+ state.inv.cooked = (state.inv.cooked || 0) + 1;
+ rebuildHotbar();
+ return;
+ }
+
+ // ---------- Сон ----------
+ if (b.t === 'bed' && isNight()) {
+ state.player.sleeping = true;
+ saveGame();
+ return;
+ }
+
+ // ---------- ДОБЫЧА ----------
+ if (mode() === 'mine') {
+ const def = BLOCKS[b.t];
+ if (def.fluid) return;
+ if (def.decor && def.growthStage === undefined && !def.harvestable) return;
+
+ if (b.t === 'tnt') { activateTNT3D(b, 3.2); return; }
+
+ const hardness = def.hardness || 1;
+ if (hardness === Infinity) return;
+
+ if (hardness === 0) {
+ const removed = removeBlock(gx, gy, z);
+ if (removed) {
+ handleBlockDrop(removed, state.inv);
+ sendBlockChange(gx, gy, z, removed.t, 'remove');
+ rebuildHotbar();
+ }
+ return;
+ }
+
+ if (state.mining.gx === gx && state.mining.gy === gy && (state.mining.gz || 0) === z) {
+ state.mining.progress += getMiningSpeed(b.t);
+ } else {
+ state.mining = { gx, gy, gz: z, progress: getMiningSpeed(b.t), hardness };
+ }
+
+ if (b.t === 'glass') playSound('glass1');
+ else if (b.t === 'sand') playSound('sand1');
+ else if (b.t === 'stone' || b.t.endsWith('_ore')) playSound('stone1');
+ else if (b.t === 'wood' || b.t === 'planks') playSound('wood1');
+ else playSound('cloth1');
+
+ if (state.mining.progress >= hardness) {
+ finishMining(gx, gy, z, b.t);
+ } else {
+ startHoldMining(gx, gy, z, b.t);
+ }
+ return;
+ }
+
+ // ---------- СТРОИТЕЛЬСТВО ----------
+ if (mode() === 'build') {
+ // посев на грядку
+ const seedToCrop = CROP_SEED_MAP[state.selected];
+ if (seedToCrop && state.inv[state.selected] > 0) {
+ if (b.t === 'farmland' && !getBlock(gx, gy - 1, z)) {
+ setBlock(gx, gy - 1, z, seedToCrop, true);
+ state.inv[state.selected]--;
+ rebuildHotbar();
+ return;
+ }
+ if (!b) {
+ const below = getBlock(gx, gy + 1, z);
+ if (below && below.t === 'farmland') {
+ setBlock(gx, gy, z, seedToCrop, true);
+ state.inv[state.selected]--;
+ rebuildHotbar();
+ return;
+ }
+ }
+ }
+
+ // цель — соседняя клетка по нормали грани
+ let tx2 = gx, ty2 = gy, tz2 = z;
+ if (face) {
+ if (Math.abs(face.x) >= Math.abs(face.y) && Math.abs(face.x) >= Math.abs(face.z)) tx2 += Math.sign(face.x);
+ else if (Math.abs(face.y) >= Math.abs(face.z)) ty2 += Math.sign(face.y);
+ else tz2 += Math.sign(face.z);
+ } else {
+ ty2 -= 1;
+ }
+ // по Y: в игре +y вниз, three +y вверх → нормаль -y = вниз = +gy
+ if (face && Math.abs(face.y) >= Math.abs(face.x) && Math.abs(face.y) >= Math.abs(face.z)) {
+ ty2 = gy - Math.sign(face.y);
+ }
+
+ if (getBlock(tx2, ty2, tz2)) return;
+ if (state.inv[state.selected] <= 0) return;
+ if (!BLOCKS[state.selected]) return;
+
+ // не ставим в себя
+ const p = state.player;
+ const bx = tx2 * TILE, by = ty2 * TILE, bz = tz2 * TILE;
+ const overlap = !(bx >= p.x + p.w || bx + TILE <= p.x ||
+ by >= p.y + p.h || by + TILE <= p.y ||
+ bz >= (p.z || 0) + (p.d || p.w) || bz + TILE <= (p.z || 0));
+ if (overlap) return;
+
+ setBlock(tx2, ty2, tz2, state.selected, true);
+ state.inv[state.selected]--;
+ sendBlockChange(tx2, ty2, tz2, state.selected, 'set');
+
+ if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build');
+ else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build');
+ else if (state.selected === 'glass') playSound('glass1');
+ else if (state.selected === 'sand') playSound('sand1');
+ else playSound('cloth1');
+
+ rebuildHotbar();
+ }
+}
+
+function finishMining(gx, gy, gz, type) {
+ const def = BLOCKS[type];
+ const minTool = def.minTool;
+ let drops = true;
+ if (minTool) {
+ const toolId = getBestTool(type);
+ if (toolId) {
+ const tool = TOOLS[toolId];
+ if (toolTierLessThan(tool.toolTier, minTool)) drops = false;
+ else useTool(toolId);
+ } else {
+ drops = false;
+ }
+ } else {
+ const toolId = getBestTool(type);
+ if (toolId) useTool(toolId);
+ }
+
+ const removed = removeBlock(gx, gy, gz);
+ if (removed) {
+ if (drops) {
+ handleBlockDrop(removed, state.inv);
+ } else if (def.toolType === 'pickaxe') {
+ state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
+ }
+ sendBlockChange(gx, gy, gz, removed.t, 'remove');
+ playSound('stone1');
+ rebuildHotbar();
+ }
+ stopMining();
+ state.mining = { gx: null, gy: null, gz: null, progress: 0, hardness: 0 };
+}
+
+function startHoldMining(gx, gy, gz, type) {
+ miningHeld = true;
+ if (miningInterval) clearInterval(miningInterval);
+ miningInterval = setInterval(() => {
+ if (!miningHeld) { clearInterval(miningInterval); miningInterval = null; return; }
+ const mg = state.mining;
+ if (mg.gx === null) { miningHeld = false; clearInterval(miningInterval); miningInterval = null; return; }
+ const mb = getBlock(mg.gx, mg.gy, mg.gz || 0);
+ if (!mb || mb.dead || mb.t !== type) {
+ miningHeld = false;
+ clearInterval(miningInterval);
+ miningInterval = null;
+ return;
+ }
+ if (!withinReach(mg.gx, mg.gy, mg.gz || 0)) {
+ // отошли — добыча прекращается
+ miningHeld = false;
+ clearInterval(miningInterval);
+ miningInterval = null;
+ state.mining = { gx: null, gy: null, gz: null, progress: 0, hardness: 0 };
+ return;
+ }
+ mg.progress += getMiningSpeed(mb.t) * 0.3;
+ if (mg.progress >= mg.hardness) {
+ finishMining(mg.gx, mg.gy, mg.gz || 0, mb.t);
+ }
+ }, 100);
+}
+
+// Подсказка «слишком далеко» — один элемент, не спамит
+let hintEl = null;
+let hintTimer = null;
+function showReachHint() {
+ if (!hintEl) {
+ hintEl = document.createElement('div');
+ hintEl.style.cssText = 'position:fixed;left:50%;top:18%;transform:translateX(-50%);' +
+ 'z-index:300;background:rgba(0,0,0,.72);color:#fff;padding:8px 14px;border-radius:8px;' +
+ 'font:600 14px system-ui;pointer-events:none;';
+ document.body.appendChild(hintEl);
+ }
+ hintEl.textContent = 'Слишком далеко — подойди ближе';
+ hintEl.style.display = 'block';
+ clearTimeout(hintTimer);
+ hintTimer = setTimeout(() => { if (hintEl) hintEl.style.display = 'none'; }, 1200);
+}
+
+// === render/hero-rig.js ===
+// ==================== ДАННЫЕ ГЕРОЯ (из платформёра grechka_game.html) ====================
+// COL/PARTS/PIVOT дословно оттуда: 107 прямоугольников, 5 частей (head/body/legs/tail/eye).
+// Ноль rig = ступни; высота рисунка ~3.07 юнита при PARTS-координатах.
+
+const HERO_COL = {
+ blue: { hex: 0x0232fe, d: 0.60 },
+ white: { hex: 0xffffff, d: 0.86 },
+ cyan: { hex: 0x00a1ff, d: 1.00 },
+ navy: { hex: 0x001a85, d: 1.12 },
+};
+
+const HERO_PARTS = {"head":[[-0.90035,2.93184,0.40909,0.13636,"blue"],[-1.17308,2.79547,0.13636,0.13636,"blue"],[-0.62762,2.79547,0.13636,0.13636,"blue"],[-0.96853,2.65911,0.27273,0.13636,"blue"],[-0.49126,2.65911,0.13636,0.13636,"blue"],[-1.17308,2.52275,0.13636,0.13636,"blue"],[-0.62762,2.52275,0.13636,0.13636,"blue"],[0.12238,2.52275,1.09091,0.13636,"blue"],[-0.83217,2.38638,0.81818,0.13636,"blue"],[1.00874,2.38638,0.68182,0.13636,"blue"],[-0.83217,2.25002,0.27273,0.13636,"blue"],[1.41783,2.25002,0.13636,0.13636,"blue"],[-1.03671,2.11365,0.13636,0.13636,"blue"],[1.28147,2.11365,0.13636,0.13636,"blue"],[0.12238,2.38638,1.09091,0.13636,"white"],[0.32692,2.25002,2.04545,0.13636,"white"],[-0.6958,2.11365,0.54545,0.13636,"white"],[-0.15035,2.11365,0.27273,0.13636,"white"],[0.66783,2.11365,1.09091,0.13636,"white"],[-0.3549,2.11365,0.13636,0.13636,"cyan"],[0.0542,2.11365,0.13636,0.13636,"cyan"]],"body":[[-1.17308,1.97729,0.13636,0.13636,"blue"],[1.1451,1.97729,0.13636,0.13636,"blue"],[-0.96853,1.84093,0.27273,0.13636,"blue"],[1.28147,1.77275,0.13636,0.27273,"blue"],[-0.90035,1.70456,0.13636,0.13636,"blue"],[-1.1049,1.5682,0.27273,0.13636,"blue"],[-0.42308,1.5682,0.27273,0.13636,"blue"],[0.53147,1.5682,0.27273,0.13636,"blue"],[1.07692,1.5682,0.27273,0.13636,"blue"],[1.41783,1.5682,0.13636,0.13636,"blue"],[-1.30944,1.43184,0.13636,0.13636,"blue"],[1.00874,1.43184,0.13636,0.13636,"blue"],[1.28147,1.43184,0.13636,0.13636,"blue"],[-1.17308,1.15911,0.13636,0.40909,"blue"],[1.1451,1.22729,0.13636,0.27273,"blue"],[-1.4458,1.02275,0.13636,0.13636,"blue"],[1.00874,1.02275,0.13636,0.13636,"blue"],[-1.4458,0.88638,0.40909,0.13636,"blue"],[-0.96853,0.88638,0.27273,0.13636,"blue"],[-0.08217,0.88638,0.68182,0.13636,"blue"],[0.87238,0.88638,0.13636,0.13636,"blue"],[-1.51399,0.75002,0.54545,0.13636,"blue"],[-0.90035,0.75002,0.13636,0.13636,"blue"],[0.66783,0.75002,0.27273,0.13636,"blue"],[1.00874,0.75002,0.13636,0.13636,"blue"],[-0.6958,1.97729,0.81818,0.13636,"white"],[-0.01399,1.97729,0.27273,0.13636,"white"],[0.8042,1.97729,0.54545,0.13636,"white"],[-0.6958,1.77275,0.27273,0.27273,"white"],[-0.21853,1.84093,0.13636,0.13636,"white"],[0.32692,1.84093,0.13636,0.13636,"white"],[0.94056,1.77275,0.54545,0.27273,"white"],[0.0542,1.63638,0.68182,0.27273,"white"],[-0.76399,1.5682,0.40909,0.13636,"white"],[0.8042,1.5682,0.27273,0.13636,"white"],[1.28147,1.5682,0.13636,0.13636,"white"],[-1.1049,1.43184,0.27273,0.13636,"white"],[-0.01399,1.43184,1.36364,0.13636,"white"],[-0.21853,1.29547,1.5,0.13636,"white"],[0.87238,1.29547,0.40909,0.13636,"white"],[-0.90035,1.15911,0.13636,0.13636,"white"],[-0.08217,1.15911,1.22727,0.13636,"white"],[0.73601,1.15911,0.13636,0.13636,"white"],[1.00874,1.15911,0.13636,0.13636,"white"],[-1.03671,1.02275,0.13636,0.13636,"white"],[-0.01399,1.02275,1.36364,0.13636,"white"],[-0.62762,0.88638,0.40909,0.13636,"white"],[0.53147,0.88638,0.54545,0.13636,"white"],[-0.15035,0.75002,1.36364,0.13636,"white"],[0.87238,0.75002,0.13636,0.13636,"white"],[-0.21853,1.97729,0.13636,0.13636,"cyan"],[0.32692,1.97729,0.40909,0.13636,"cyan"],[0.0542,1.84093,0.40909,0.13636,"cyan"],[-0.83217,1.43184,0.27273,0.13636,"cyan"],[0.8042,1.43184,0.27273,0.13636,"cyan"],[-1.03671,1.22729,0.13636,0.27273,"cyan"],[0.59965,1.22729,0.13636,0.27273,"cyan"],[-0.76399,1.15911,0.13636,0.13636,"cyan"],[0.87238,1.15911,0.13636,0.13636,"cyan"],[-0.83217,1.02275,0.27273,0.13636,"cyan"],[0.8042,1.02275,0.27273,0.13636,"cyan"]],"tail":[[-1.71853,0.61365,0.13636,0.13636,"blue"],[-1.03671,0.61365,0.13636,0.13636,"blue"],[-0.62762,0.61365,0.40909,0.13636,"blue"],[-1.65035,0.47729,0.27273,0.13636,"blue"],[-0.90035,0.47729,0.13636,0.13636,"blue"],[-1.4458,0.34093,0.40909,0.13636,"blue"],[-0.62762,0.34093,0.40909,0.13636,"blue"],[-0.90035,0.20456,0.95455,0.13636,"blue"],[-0.90035,0.61365,0.13636,0.13636,"white"]],"legs":[[0.3951,0.61365,0.27273,0.13636,"blue"],[0.87238,0.61365,0.13636,0.13636,"blue"],[-0.55944,0.47729,0.27273,0.13636,"blue"],[0.32692,0.34093,0.13636,0.40909,"blue"],[-0.21853,0.20456,0.13636,0.13636,"blue"],[0.0542,0.20456,0.13636,0.13636,"blue"],[-0.08217,0.0682,0.95455,0.13636,"blue"],[-0.3549,0.47729,0.13636,0.13636,"white"],[-0.08217,0.34093,0.68182,0.13636,"white"],[-0.3549,0.20456,0.13636,0.13636,"white"],[-0.08217,0.20456,0.13636,0.13636,"white"],[0.19056,0.20456,0.13636,0.13636,"white"],[-0.08217,0.61365,0.68182,0.13636,"cyan"],[-0.01399,0.47729,0.54545,0.13636,"cyan"]],"eye":[[-0.42308,1.77275,0.27273,0.27273,"navy"],[0.53147,1.77275,0.27273,0.27273,"navy"]]};
+
+const HERO_PIVOT = {"head":[0.0,2.5909],"body":[0.0,0.979],"legs":[-0.035,0.9091],"tail":[-0.7343,1.3287],"eye":[0.0524,2.1294]};
+
+const HERO_SCALE = 0.50;
+
+// === render/render3d.js ===
+// ==================== РЕНДЕР 3D — GrechkaCraft v3 ====================
+// Объёмный мир: у каждого блока есть gx/gy/gz. Рендерим кубы по всем трём
+// осям, отсекаем те, что не видны с камеры (соседи со всех шести сторон).
+
+
+
+
+
+
+
+
+
+
+let TD = null;
+
+function TDparseColor(c) {
+ const m = /rgba?\(([^)]+)\)/.exec(c);
+ if (!m) return { color: new THREE.Color(c), opacity: 1 };
+ const p = m[1].split(',').map(x => parseFloat(x));
+ return { color: new THREE.Color(p[0] / 255, p[1] / 255, p[2] / 255), opacity: p.length > 3 ? p[3] : 1 };
+}
+
+function TDblockMat(t) {
+ const def = BLOCKS[t] || {};
+ const { color, opacity } = TDparseColor(def.c || '#ff00ff');
+ const a = def.alpha !== undefined ? def.alpha : opacity;
+ return new THREE.MeshLambertMaterial({ color, transparent: a < 1, opacity: a });
+}
+
+function TDisFluid(t) { const d = BLOCKS[t]; return !!(d && d.fluid); }
+function TDisFlat(t) {
+ const d = BLOCKS[t];
+ if (!d) return false;
+ return !d.solid || !!d.decor || !!d.climbable;
+}
+
+// ---------- герой: тот же пиксельный рисунок, кубики ----------
+function TDbuildHeroRig() {
+ const group = new THREE.Group();
+ const P = {};
+ const MAT = {};
+ for (const k of Object.keys(HERO_COL)) MAT[k] = new THREE.MeshLambertMaterial({ color: HERO_COL[k].hex });
+ for (const name of Object.keys(HERO_PARTS)) {
+ const g = new THREE.Group();
+ const j = HERO_PIVOT[name];
+ g.position.set(j[0] * HERO_SCALE, j[1] * HERO_SCALE, 0);
+ const inner = new THREE.Group();
+ inner.position.set(-j[0] * HERO_SCALE, -j[1] * HERO_SCALE, 0);
+ g.add(inner);
+ group.add(g);
+ P[name] = g;
+ for (const r of HERO_PARTS[name]) {
+ const depth = HERO_COL[r[4]].d * HERO_SCALE;
+ const m = new THREE.Mesh(
+ new THREE.BoxGeometry(r[2] * HERO_SCALE, r[3] * HERO_SCALE, depth),
+ MAT[r[4]]
+ );
+ m.position.set((r[0] - 0.24) * HERO_SCALE, r[1] * HERO_SCALE, depth / 2);
+ m.castShadow = true;
+ m.receiveShadow = true;
+ inner.add(m);
+ }
+ }
+ return { group, P };
+}
+
+const TD_MOB_COLORS = {
+ zombie: 0x2ecc71, pig: 0xffb6c1, chicken: 0xecf0f1, creeper: 0x4caf50,
+ skeleton: 0xeceff1, scorpion: 0x5d4037, polar_bear: 0xeceff1,
+ slime: 0x4caf50, eagle: 0x5d4037,
+};
+
+function TDnameSprite(name) {
+ const cv = document.createElement('canvas');
+ cv.width = 256; cv.height = 48;
+ const g = cv.getContext('2d');
+ g.font = 'bold 26px system-ui';
+ g.textAlign = 'center';
+ g.fillStyle = 'rgba(0,0,0,0.45)';
+ g.fillRect(0, 0, 256, 48);
+ g.fillStyle = '#fff';
+ g.fillText(String(name || '?').slice(0, 14), 128, 33);
+ const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cv), depthTest: false }));
+ sp.scale.set(2.2, 0.41, 1);
+ return sp;
+}
+
+// ==================== INIT ====================
+function TDinit() {
+ const canvas = state.canvas;
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+ if (renderer.outputEncoding !== undefined && THREE.sRGBEncoding !== undefined) {
+ renderer.outputEncoding = THREE.sRGBEncoding;
+ }
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.BasicShadowMap;
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x8fc7f5);
+ scene.fog = new THREE.Fog(0x8fc7f5, 30, 90);
+ const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 300);
+
+ const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x6ab04c, 0.85);
+ const sun = new THREE.DirectionalLight(0xffffff, 1.5);
+ sun.castShadow = true;
+ sun.shadow.mapSize.set(512, 512);
+ const sc = sun.shadow.camera;
+ sc.left = -26; sc.right = 26; sc.top = 26; sc.bottom = -26;
+ sc.near = 1; sc.far = 140;
+ sun.shadow.normalBias = 0.05;
+ const amb = new THREE.AmbientLight(0xffffff, 0.25);
+ scene.add(hemi, sun, sun.target, amb);
+
+ const hero = TDbuildHeroRig();
+ scene.add(hero.group);
+
+ // Рабочая область: только блоки рядом с игроком (по всем осям)
+ const crack = new THREE.Mesh(
+ new THREE.BoxGeometry(1.004, 1.004, 1.004),
+ new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 })
+ );
+ crack.visible = false;
+ scene.add(crack);
+
+ const ghost = new THREE.Mesh(
+ new THREE.BoxGeometry(1.02, 1.02, 1.02),
+ new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.9 })
+ );
+ ghost.visible = false;
+ scene.add(ghost);
+
+ // куб подсветки выбранного блока
+ const outline = new THREE.LineSegments(
+ new THREE.EdgesGeometry(new THREE.BoxGeometry(1.03, 1.03, 1.03)),
+ new THREE.LineBasicMaterial({ color: 0xffffff })
+ );
+ outline.visible = false;
+ scene.add(outline);
+
+ const cloudMeshes = [];
+ const cloudMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.75 });
+ for (let i = 0; i < 16; i++) {
+ const m = new THREE.Mesh(new THREE.BoxGeometry(4, 0.8, 2.2), cloudMat);
+ scene.add(m);
+ cloudMeshes.push(m);
+ }
+
+ const projPool = [];
+ for (let i = 0; i < 24; i++) {
+ const m = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.1, 0.1), new THREE.MeshBasicMaterial({ color: 0xf1c40f }));
+ m.visible = false;
+ scene.add(m);
+ projPool.push(m);
+ }
+
+ const PMAX = 800;
+ const pGeo = new THREE.BufferGeometry();
+ const pPos = new Float32Array(PMAX * 3);
+ const pCol = new Float32Array(PMAX * 3);
+ pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
+ pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3));
+ const points = new THREE.Points(pGeo, new THREE.PointsMaterial({ size: 0.16, vertexColors: true, depthWrite: false }));
+ points.frustumCulled = false;
+ scene.add(points);
+
+ function makeFall(n, color, size) {
+ const g = new THREE.BufferGeometry();
+ const pos = new Float32Array(n * 3);
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
+ const pts = new THREE.Points(g, new THREE.PointsMaterial({ color, size, transparent: true, opacity: 0.7, depthWrite: false }));
+ pts.frustumCulled = false;
+ pts.visible = false;
+ scene.add(pts);
+ return { pts, pos, n };
+ }
+ const rain = makeFall(500, 0x74b9ff, 0.2);
+ const snow = makeFall(300, 0xffffff, 0.17);
+
+ const lightPool = [];
+ for (let i = 0; i < 6; i++) {
+ const L = new THREE.PointLight(0xff9a3c, 0, 16);
+ scene.add(L);
+ lightPool.push(L);
+ }
+ const flamePool = [];
+ for (let i = 0; i < 8; i++) {
+ const f = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.75, 6), new THREE.MeshBasicMaterial({ color: 0xff7a1a }));
+ f.visible = false;
+ scene.add(f);
+ flamePool.push(f);
+ }
+
+ const boatMesh = new THREE.Mesh(
+ new THREE.BoxGeometry(1.4, 0.5, 1.0),
+ new THREE.MeshLambertMaterial({ color: 0x8b4513 })
+ );
+ boatMesh.visible = false;
+ boatMesh.castShadow = true;
+ scene.add(boatMesh);
+
+ TD = {
+ renderer, scene, camera, hemi, sun, amb, hero,
+ crack, ghost, outline, cloudMeshes, projPool, points, pPos, pCol, PMAX,
+ rain, snow, lightPool, flamePool, boatMesh,
+ blockMeshes: [], lastVersion: -1, meshT: 0,
+ mobs: new Map(), players: new Map(),
+ yaw: 0.0, pitch: 0.42, dist: 10,
+ ptrs: new Map(), pinchD: 0,
+ lastTap: null, hovered: null, fireCache: null,
+ heroStep: 0, face: 1, frame: 0, lastT: 0,
+ colCache: new Map(),
+ };
+ TDbindInput(canvas);
+ TDresize();
+ window.addEventListener('resize', TDresize);
+}
+
+function TDresize() {
+ if (!TD) return;
+ const w = window.innerWidth, h = window.innerHeight;
+ state.W = w;
+ state.H = h;
+ TD.renderer.setPixelRatio(1);
+ TD.renderer.setSize(w, h, false);
+ TD.camera.aspect = w / h;
+ TD.camera.updateProjectionMatrix();
+}
+
+// ==================== БЛОКИ ====================
+// Полный пересбор мешей редкий; в обычном кадре — только обновление матриц.
+const VIEW_R = 14; // радиус прорисовки в тайлах по всем осям
+
+function isHiddenAt(gx, gy, gz) {
+ // спрятан ли блок: все 6 соседей непрозрачные?
+ return isOpaqueAt(gx + 1, gy, gz) && isOpaqueAt(gx - 1, gy, gz) &&
+ isOpaqueAt(gx, gy + 1, gz) && isOpaqueAt(gx, gy - 1, gz) &&
+ isOpaqueAt(gx, gy, gz + 1) && isOpaqueAt(gx, gy, gz - 1);
+}
+
+function isOpaqueAt(gx, gy, gz) {
+ const b = getBlock(gx, gy, gz);
+ if (!b) return false;
+ const def = BLOCKS[b.t];
+ if (!def) return false;
+ return !!def.solid && !def.fluid && !def.decor && !def.climbable && def.alpha === undefined;
+}
+
+function TDrebuildBlocks(px, py, pz) {
+ for (const e of TD.blockMeshes) {
+ TD.scene.remove(e.mesh);
+ e.mesh.geometry.dispose();
+ e.mesh.material.dispose();
+ }
+ TD.blockMeshes = [];
+ const byType = new Map();
+ for (const b of state.blocks) {
+ if (b.dead) continue;
+ if (!BLOCKS[b.t]) continue;
+ const dx = b.gx - px, dy = b.gy - py, dz = (b.gz || 0) - pz; // py — игровой gy
+ if (dx * dx + dy * dy + dz * dz > VIEW_R * VIEW_R) continue;
+ if (!TDisFluid(b.t) && !TDisFlat(b.t) && isHiddenAt(b.gx, b.gy, b.gz || 0)) continue;
+ if (!byType.has(b.t)) byType.set(b.t, []);
+ byType.get(b.t).push(b);
+ }
+ const boxGeo = new THREE.BoxGeometry(1, 1, 1);
+ const planeGeo = new THREE.PlaneGeometry(0.92, 0.92);
+ for (const [t, list] of byType) {
+ const fluid = TDisFluid(t);
+ const flat = !fluid && TDisFlat(t);
+ const mesh = new THREE.InstancedMesh(flat ? planeGeo : boxGeo, TDblockMat(t), list.length);
+ mesh.castShadow = !flat;
+ mesh.receiveShadow = true;
+ if (flat) mesh.material.side = THREE.DoubleSide;
+ const m = new THREE.Matrix4();
+ const q = new THREE.Quaternion();
+ const e = new THREE.Euler();
+ for (let i = 0; i < list.length; i++) {
+ const b = list[i];
+ if (flat) {
+ // плоские блоки (цветы) — поворачиваем к камере, стоят на земле
+ e.set(-Math.PI / 2, 0, 0);
+ q.setFromEuler(e);
+ m.compose(new THREE.Vector3(b.gx + 0.5, -b.gy + 0.05, (b.gz || 0) + 0.5), q, new THREE.Vector3(1, 1, 1));
+ } else if (fluid) {
+ m.makeScale(1, 0.82, 1);
+ m.setPosition(b.gx + 0.5, -b.gy - 0.09, (b.gz || 0) + 0.5);
+ } else {
+ m.identity();
+ m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5);
+ }
+ mesh.setMatrixAt(i, m);
+ }
+ mesh.instanceMatrix.needsUpdate = true;
+ mesh.userData.list = list;
+ TD.scene.add(mesh);
+ TD.blockMeshes.push({ mesh, type: t });
+ }
+}
+
+// ==================== PICK (рейкаст по 3D) ====================
+const TDray = new THREE.Raycaster();
+const TDndc = new THREE.Vector2();
+
+function TDpick(sx, sy) {
+ const r = state.canvas.getBoundingClientRect();
+ TDndc.x = ((sx - r.left) / r.width) * 2 - 1;
+ TDndc.y = -((sy - r.top) / r.height) * 2 + 1;
+ TDray.setFromCamera(TDndc, TD.camera);
+ const hits = TDray.intersectObjects(TD.blockMeshes.map(x => x.mesh), false);
+ if (!hits.length) return null;
+ const h = hits[0];
+ const b = h.object.userData.list[h.instanceId];
+ if (!b) return null;
+ // мировые игровые координаты точки удара
+ const gx = Math.floor(h.point.x);
+ const gy = -Math.floor(h.point.y);
+ const gz = Math.floor(h.point.z);
+ const wx = (h.point.x) * state.TILE;
+ const wy = (-h.point.y) * state.TILE;
+ return { gx, gy, gz, wx, wy, face: h.face ? h.face.normal : null };
+}
+
+// ==================== ВВОД ====================
+function TDbindInput(canvas) {
+ canvas.addEventListener('pointerdown', (e) => {
+ TD.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: false, t: performance.now() });
+ if (TD.ptrs.size === 2) {
+ const q = [...TD.ptrs.values()];
+ TD.pinchD = Math.hypot(q[0].x - q[1].x, q[0].y - q[1].y);
+ }
+ stopMining();
+ });
+
+ canvas.addEventListener('pointermove', (e) => {
+ const q = TD.ptrs.get(e.pointerId);
+ if (!q) return;
+ const dx = e.clientX - q.x, dy = e.clientY - q.y;
+ q.x = e.clientX; q.y = e.clientY;
+ if (Math.hypot(e.clientX - q.sx, e.clientY - q.sy) > 8) q.moved = true;
+ if (TD.ptrs.size === 2) {
+ const v = [...TD.ptrs.values()];
+ const d = Math.hypot(v[0].x - v[1].x, v[0].y - v[1].y);
+ if (TD.pinchD > 0 && d > 0) TD.dist = Math.min(34, Math.max(4, TD.dist * TD.pinchD / d));
+ TD.pinchD = d;
+ return;
+ }
+ if (q.moved) {
+ TD.yaw -= dx * 0.005;
+ TD.pitch = Math.min(1.45, Math.max(0.06, TD.pitch + dy * 0.005));
+ }
+ });
+
+ const up = (e) => {
+ const q = TD.ptrs.get(e.pointerId);
+ TD.ptrs.delete(e.pointerId);
+ TD.pinchD = 0;
+ if (q && !q.moved && performance.now() - q.t < 600) {
+ const hit = TDpick(e.clientX, e.clientY);
+ if (hit) {
+ TD.lastTap = hit;
+ handleTap(hit.wx, hit.wy, hit.gz, hit.face);
+ }
+ }
+ };
+ canvas.addEventListener('pointerup', up);
+ canvas.addEventListener('pointercancel', (e) => { TD.ptrs.delete(e.pointerId); TD.pinchD = 0; });
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ TD.dist = Math.min(34, Math.max(4, TD.dist * (1 + e.deltaY * 0.001)));
+ }, { passive: false, capture: true });
+}
+
+// ==================== СУЩНОСТИ ====================
+function TDmobMesh(m) {
+ let e = TD.mobs.get(m);
+ if (!e) {
+ const g = new THREE.Group();
+ const body = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 1, 1),
+ new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 })
+ );
+ body.castShadow = true;
+ g.add(body);
+ const eyeM = new THREE.MeshBasicMaterial({ color: 0x111111 });
+ const e1 = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.05), eyeM);
+ e1.position.set(-0.16, 0.22, 0.26);
+ const e2 = e1.clone();
+ e2.position.x = 0.16;
+ g.add(e1, e2);
+ TD.scene.add(g);
+ e = { g, body };
+ TD.mobs.set(m, e);
+ }
+ return e;
+}
+
+function TDplayerEntry(id, p) {
+ let e = TD.players.get(id);
+ if (!e) {
+ const rig = TDbuildHeroRig();
+ const label = TDnameSprite(p.name);
+ label.position.y = 2.2;
+ rig.group.add(label);
+ TD.scene.add(rig.group);
+ e = { rig, label };
+ TD.players.set(id, e);
+ }
+ return e;
+}
+
+// ==================== КАДР ====================
+function renderFrame3D(now) {
+ if (!TD) TDinit();
+ const p = state.player;
+ const TILE = state.TILE;
+ TD.frame++;
+ const dt = Math.min(0.05, Math.max(0.001, (now - (TD.lastT || now - 16)) / 1000));
+ TD.lastT = now;
+
+ const ptx = (p.x + (p.w || 34) / 2) / TILE;
+ const pgy = (p.y + (p.h || 34) / 2) / TILE; // игровый Y (вниз)
+ const py3 = -pgy; // three Y (вверх)
+ const ptz = ((p.z || 0) + (p.d || 34) / 2) / TILE;
+
+ // пересборка мешей: при изменении мира или раз в ~2 сек (для плавной подгрузки)
+ // пересборка не чаще раза в 700 мс и только при изменении мира
+ const ver = state.blocksVersion || 0;
+ if ((ver !== TD.lastVersion && now - TD.meshT > 700) || !TD.blockMeshes.length) {
+ TD.meshT = now;
+ TD.lastVersion = ver;
+ TDrebuildBlocks(ptx, pgy, ptz);
+ }
+
+ // небо/свет. Первые секунды после старта держим полдень, чтобы не было «рассвета»
+ const night = isNight();
+ const sky = skyColor(night, 'plains', state.weatherType, state.worldTime);
+ TD.scene.background.set(sky);
+ TD.scene.fog.color.copy(TD.scene.background);
+ TD.sun.intensity = night ? 0.14 : 1.5;
+ TD.hemi.intensity = night ? 0.3 : 0.85;
+ TD.amb.intensity = night ? 0.34 : 0.25;
+ if (state.lightningFlash > 0) TD.hemi.intensity += Math.min(2.2, state.lightningFlash * 2.4);
+ TD.sun.position.set(ptx + 20, py3 + 36, ptz + 20);
+ TD.sun.target.position.set(ptx, py3, ptz);
+ TD.sun.target.updateMatrixWorld();
+
+ // камера-орбита; yaw — тот же угол, что и для WASD
+ camDir.yaw = TD.yaw;
+ const cp = Math.cos(TD.pitch), sp2 = Math.sin(TD.pitch);
+ TD.camera.position.set(
+ ptx + TD.dist * Math.sin(TD.yaw) * cp,
+ py3 + TD.dist * sp2,
+ ptz + TD.dist * Math.cos(TD.yaw) * cp
+ );
+ TD.camera.lookAt(ptx, py3, ptz);
+
+ // герой: позиция в ногах коллайдера, поворот по направлению взгляда
+ TD.hero.group.position.set(ptx, -p.y / TILE, ptz);
+ if (p.facing !== undefined) {
+ const wantYaw = -p.facing;
+ TD.hero.group.rotation.y += (wantYaw - TD.hero.group.rotation.y) * Math.min(1, 9 * dt);
+ }
+ const moving = Math.hypot(p.vx || 0, p.vz || 0) > 20 && p.grounded;
+ if (moving) TD.heroStep += dt * 11;
+ TD.hero.P.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0;
+ TD.hero.P.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0;
+
+ // мобы
+ const allMobs = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
+ const seen = new Set();
+ for (const m of allMobs) {
+ if (m.dead) continue;
+ seen.add(m);
+ const e = TDmobMesh(m);
+ e.body.scale.set((m.w || 30) / TILE, (m.h || 30) / TILE, (m.d || m.w || 30) / TILE);
+ e.g.position.set(
+ (m.x + (m.w || 30) / 2) / TILE,
+ -(m.y + (m.h || 30) / 2) / TILE,
+ ((m.z || 0) + (m.d || m.w || 30) / 2) / TILE
+ );
+ }
+ for (const [m, e] of TD.mobs) {
+ if (!seen.has(m)) { TD.scene.remove(e.g); TD.mobs.delete(m); }
+ }
+
+ // игроки сети
+ const seenP = new Set();
+ if (state.otherPlayers) {
+ for (const [id, pl] of state.otherPlayers) {
+ seenP.add(id);
+ const e = TDplayerEntry(id, pl);
+ e.rig.group.position.set((pl.x + 17) / TILE, -pl.y / TILE, ((pl.z || 0) + 17) / TILE);
+ }
+ }
+ for (const [id, e] of TD.players) {
+ if (!seenP.has(id)) { TD.scene.remove(e.rig.group); TD.players.delete(id); }
+ }
+
+ // лодка
+ if (state.boat && state.boat.active) {
+ TD.boatMesh.visible = true;
+ TD.boatMesh.position.set(state.boat.x / TILE, -state.boat.y / TILE, (state.boat.z || 0) / TILE);
+ } else TD.boatMesh.visible = false;
+
+ // снаряды
+ const projs = state.projectiles || [];
+ for (let i = 0; i < TD.projPool.length; i++) {
+ const mesh = TD.projPool[i];
+ const pr = projs[i];
+ if (!pr) { mesh.visible = false; continue; }
+ mesh.visible = true;
+ mesh.material.color.set(pr.owner === 'mob' ? 0xc0392b : 0xf1c40f);
+ mesh.position.set(pr.x / TILE, -pr.y / TILE, (pr.z || 0) / TILE);
+ mesh.rotation.z = -Math.atan2(pr.vy, pr.vx);
+ }
+
+ // частицы
+ const parts = state.parts || [];
+ const n = Math.min(parts.length, TD.PMAX);
+ for (let i = 0; i < n; i++) {
+ const pt = parts[i];
+ TD.pPos[i * 3] = pt.x / TILE;
+ TD.pPos[i * 3 + 1] = -pt.y / TILE;
+ TD.pPos[i * 3 + 2] = (pt.z || 0) / TILE;
+ let cc = TD.colCache.get(pt.c);
+ if (!cc) { cc = new THREE.Color(pt.c || '#ffffff'); TD.colCache.set(pt.c, cc); }
+ TD.pCol[i * 3] = cc.r; TD.pCol[i * 3 + 1] = cc.g; TD.pCol[i * 3 + 2] = cc.b;
+ }
+ TD.points.geometry.setDrawRange(0, n);
+ TD.points.geometry.attributes.position.needsUpdate = true;
+ TD.points.geometry.attributes.color.needsUpdate = true;
+
+ // облака
+ const clouds = state.clouds || [];
+ for (let i = 0; i < TD.cloudMeshes.length; i++) {
+ const cm = TD.cloudMeshes[i];
+ const c = clouds.length ? clouds[i % clouds.length] : null;
+ if (!c) { cm.visible = false; continue; }
+ cm.visible = true;
+ cm.scale.set((c.w || 120) / TILE, 1, 1);
+ cm.position.set(c.x / TILE, -c.y / TILE, ptz - 12);
+ }
+
+ // огни
+ if (TD.frame % 20 === 0 || !TD.fireCache) {
+ const found = [];
+ for (const b of state.blocks) {
+ if (b.dead) continue;
+ const def = BLOCKS[b.t];
+ if (!def) continue;
+ const lit = def.lightRadius || b.t === 'campfire' ||
+ (b.t === 'furnace' && state.activeFurnaces && state.activeFurnaces.has(b.gx + ',' + b.gy + ',' + (b.gz || 0)));
+ if (!lit) continue;
+ const dx = b.gx - ptx, dy = -b.gy + pty, dz = (b.gz || 0) - ptz;
+ found.push({ t: b.t, gx: b.gx, gy: b.gy, gz: b.gz || 0, d: dx * dx + dy * dy + dz * dz });
+ }
+ found.sort((a, b) => a.d - b.d);
+ TD.fireCache = found.slice(0, 8);
+ }
+ for (const L of TD.lightPool) L.intensity = 0;
+ for (const f of TD.flamePool) f.visible = false;
+ let li = 0, fi = 0;
+ for (const src of (TD.fireCache || [])) {
+ if (li < TD.lightPool.length) {
+ const L = TD.lightPool[li++];
+ L.position.set(src.gx + 0.5, -src.gy + 0.5, src.gz + 0.5);
+ L.intensity = 1.6 + Math.sin(now / 130 + src.gx) * 0.35;
+ }
+ if ((src.t === 'campfire' || src.t === 'furnace') && fi < TD.flamePool.length) {
+ const f = TD.flamePool[fi++];
+ f.visible = true;
+ f.position.set(src.gx + 0.5, -src.gy + 0.78 + Math.sin(now / 90 + src.gy) * 0.06, src.gz + 0.5);
+ const sc2 = 0.9 + Math.sin(now / 70 + src.gx * 2) * 0.18;
+ f.scale.set(sc2, sc2 * (1 + Math.sin(now / 55) * 0.12), sc2);
+ }
+ }
+
+ // погода
+ if (state.isRaining) {
+ TD.rain.pts.visible = true;
+ for (let i = 0; i < TD.rain.n; i++) {
+ let y = TD.rain.pos[i * 3 + 1] - dt * 26;
+ let x = TD.rain.pos[i * 3];
+ if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; }
+ TD.rain.pos[i * 3] = x;
+ TD.rain.pos[i * 3 + 1] = y;
+ TD.rain.pos[i * 3 + 2] = ptz + 6;
+ }
+ TD.rain.pts.geometry.attributes.position.needsUpdate = true;
+ } else TD.rain.pts.visible = false;
+
+ if (state.weatherType === 'snow') {
+ TD.snow.pts.visible = true;
+ for (let i = 0; i < TD.snow.n; i++) {
+ let y = TD.snow.pos[i * 3 + 1] - dt * 2;
+ let x = TD.snow.pos[i * 3] + Math.sin(now / 900 + i) * dt * 1.5;
+ if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; }
+ TD.snow.pos[i * 3] = x;
+ TD.snow.pos[i * 3 + 1] = y;
+ TD.snow.pos[i * 3 + 2] = ptz + 6;
+ }
+ TD.snow.pts.geometry.attributes.position.needsUpdate = true;
+ } else TD.snow.pts.visible = false;
+
+ // подсветка блока под курсором / прогресс добычи
+ const mg = state.mining;
+ if (mg && mg.gx !== null && mg.hardness > 0) {
+ TD.crack.visible = true;
+ TD.crack.position.set(mg.gx + 0.5, -mg.gy - 0.5, (mg.gz || 0) + 0.5);
+ TD.crack.material.opacity = Math.min(0.55, (mg.progress / mg.hardness) * 0.55);
+ } else TD.crack.visible = false;
+
+ if (mode() === 'build' && TD.lastTap) {
+ TD.ghost.visible = true;
+ TD.ghost.position.set(TD.lastTap.gx + 0.5, -TD.lastTap.gy - 0.5, (TD.lastTap.gz || 0) + 0.5);
+ } else TD.ghost.visible = false;
+
+ if (state.W !== window.innerWidth || state.H !== window.innerHeight) TDresize();
+ TD.camera.aspect = window.innerWidth / window.innerHeight;
+ TD.camera.updateProjectionMatrix();
+
+ TD.renderer.render(TD.scene, TD.camera);
+}
+
+// === game/loop3d.js ===
+// ==================== ЦИКЛ ИГРЫ 3D — GrechkaCraft v3 ====================
+// Всё как в 2D-игре, но движение по трём осям: X (вперёд-назад по миру),
+// Y (вверх-вниз), Z (вбок). WASD работают относительно взгляда камеры.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+let last = performance.now();
+let prevJump = false;
+
+document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) last = performance.now();
+});
+
+// Направление взгляда камеры (задаётся рендером) — WASD относительно него
+const camDir = { yaw: 0, pitch: 0 };
+
+function loop(now) {
+ const rawDt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+ const PHYSICS_STEP = 0.016;
+ const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
+ const dt = rawDt / steps;
+
+ const player = state.player;
+ const inp = state.inp;
+ const mobs = state.mobs;
+ const projectiles = state.projectiles;
+ const boat = state.boat;
+
+ const jumpPressed = inp.j && !prevJump;
+ prevJump = inp.j;
+
+ // ---------- день/ночь ----------
+ if (!state.dayNightCycle) {
+ // время стоит
+ } else if (player.sleeping && isNight()) {
+ state.worldTime += dt * 8 / state.DAY_LEN;
+ player.hp = Math.min(100, player.hp + dt * 20);
+ if (!isNight()) player.sleeping = false;
+ } else {
+ state.worldTime += dt / state.DAY_LEN;
+ }
+ if (state.worldTime >= 1) state.worldTime -= 1;
+
+ // ---------- генерация мира ----------
+ ensureGenAroundCamera();
+
+ // ---------- кислород/утопление ----------
+ if (player.headInWater) {
+ player.o2 = Math.max(0, player.o2 - 6 * dt);
+ if (player.o2 === 0) player.hp -= calculateDamage(4 * dt);
+ } else {
+ player.o2 = Math.min(100, player.o2 + 10 * dt);
+ }
+
+ player.hunger = Math.max(0, player.hunger - dt * 0.2);
+
+ // ---------- температура по биому (теперь биом 2D) ----------
+ if (player.temperature === undefined) player.temperature = 15;
+ const BIOME_TEMP = { snow: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 };
+ const COLD_THRESHOLD = -5;
+ const HEAT_THRESHOLD = 35;
+ const pGX = Math.floor((player.x + player.w / 2) / state.TILE);
+ const pGZ = Math.floor((player.z + player.d / 2) / state.TILE);
+ const pGY = Math.floor((player.y + player.h / 2) / state.TILE);
+ const biome = getCachedBiome(pGX, pGZ);
+ const sGY = surfaceGyAt(pGX, pGZ);
+ const isUnderground = (pGY - sGY) > 2;
+ let targetTemp = BIOME_TEMP[biome] || 15;
+ if (isUnderground) targetTemp = 15;
+ if (isNight() && !isUnderground) targetTemp -= 10;
+ if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUnderground) targetTemp -= 5;
+ player.temperature += (targetTemp - player.temperature) * dt * 0.3;
+
+ // костры/факелы греют, вода в пустыне холодит — ищем в кубе вокруг
+ let nearHeat = false, nearCool = false;
+ for (let dx = -2; dx <= 2 && !nearHeat; dx++) {
+ for (let dy = -2; dy <= 2 && !nearHeat; dy++) {
+ for (let dz = -2; dz <= 2 && !nearHeat; dz++) {
+ const b = getBlock(pGX + dx, pGY + dy, pGZ + dz);
+ if (b && (b.t === 'campfire' || b.t === 'torch' || b.t === 'furnace')) nearHeat = true;
+ }
+ }
+ }
+ if (!nearHeat && biome === 'desert') {
+ for (let dx = -2; dx <= 2 && !nearCool; dx++) {
+ for (let dz = -2; dz <= 2 && !nearCool; dz++) {
+ const b = getBlock(pGX + dx, pGY, pGZ + dz);
+ if (b && b.t === 'water') nearCool = true;
+ }
+ }
+ }
+ if (nearHeat) {
+ player.temperature += (30 - player.temperature) * dt * 2;
+ if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt);
+ }
+ if (nearCool) {
+ player.temperature += (15 - player.temperature) * dt * 1.5;
+ if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) player.hp = Math.min(100, player.hp + 8 * dt);
+ }
+ if (isUnderground && !nearHeat && player.hp < 100 &&
+ player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) {
+ player.hp = Math.min(100, player.hp + 2 * dt);
+ }
+ const aboveBlk = getBlock(pGX, pGY + 1, pGZ);
+ if (aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass' && !isUnderground && !nearCool) {
+ player.temperature -= 5 * dt;
+ }
+ if (player.temperature < COLD_THRESHOLD) {
+ player.hp -= 1 * dt;
+ player.hunger -= 0.5 * dt;
+ }
+ if (player.slowTimer > 0) player.slowTimer -= dt;
+
+ // ---------- кактус: урон в кубе ----------
+ for (let dx = -1; dx <= 1; dx++) {
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dz = -1; dz <= 1; dz++) {
+ const cb = getBlock(pGX + dx, pGY + dy, pGZ + dz);
+ if (cb && BLOCKS[cb.t] && BLOCKS[cb.t].hurt && player.invuln <= 0) {
+ player.hp -= 2;
+ player.invuln = 0.5;
+ }
+ }
+ }
+ }
+
+ // ---------- лёд: скольжение ----------
+ const underBlk = getBlock(pGX, pGY - 1, pGZ);
+ if (underBlk && BLOCKS[underBlk.t] && BLOCKS[underBlk.t].slip && player.grounded) {
+ player.vx *= 0.98;
+ player.vz *= 0.98;
+ }
+
+ // ---------- ДВИЖЕНИЕ: WASD относительно камеры ----------
+ if (player.sleeping) {
+ player.vx = player.vy = player.vz = 0;
+ } else {
+ // локальные оси ввода
+ let fwd = 0, side = 0;
+ if (inp.up) fwd -= 1;
+ if (inp.r || inp.right) side += 1;
+ if (inp.l || inp.left) side -= 1;
+ if (inp.d || inp.back) fwd += 1;
+
+ // в мировых осях через угол камеры
+ const cy = Math.cos(camDir.yaw), sy = Math.sin(camDir.yaw);
+ let dx = side * cy - fwd * sy;
+ let dz = side * sy + fwd * cy;
+ const len = Math.hypot(dx, dz);
+ if (len > 0.0001) {
+ dx /= len; dz /= len;
+ player.vx = dx * state.MOVE;
+ player.vz = dz * state.MOVE;
+ player.facing = Math.atan2(dz, dx);
+ } else {
+ player.vx *= 0.82;
+ player.vz *= 0.82;
+ }
+ }
+
+ // шаги
+ if (player.grounded && !player.inWater && Math.hypot(player.vx, player.vz) > 50) {
+ if (now / 1000 - player.lastStepTime > 0.35) {
+ playSound('step');
+ player.lastStepTime = now / 1000;
+ }
+ }
+
+ // ---------- прыжок / плавание / лодка ----------
+ if (player.inBoat) {
+ player.vy = 0;
+ player.x = boat.x + 2;
+ player.y = boat.y - 4;
+ player.z = boat.z + 2;
+ player.grounded = true;
+ player.inWater = false;
+ if (jumpPressed) {
+ state.inv.boat = (state.inv.boat || 0) + 1;
+ player.inBoat = false;
+ boat.active = false;
+ player.y += state.TILE;
+ player.vy = -state.JUMP * 0.5;
+ playSound('splash');
+ }
+ } else if (player.inWater) {
+ player.vx *= 0.90;
+ player.vz *= 0.90;
+ player.vy *= 0.92;
+ if (!jumpPressed && !inp.j) {
+ player.vy += state.GRAV_WATER * dt;
+ } else if (jumpPressed) {
+ player.vy = Math.min(player.vy, -520);
+ } else if (inp.j) {
+ player.vy = Math.min(player.vy, -260);
+ }
+ } else {
+ if (jumpPressed && player.grounded && !player.sleeping) {
+ player.vy = -state.JUMP;
+ player.grounded = false;
+ player.fallStartY = player.y;
+ }
+ }
+
+ if (!player.inWater && !player.inBoat) {
+ player.vy += state.GRAV * dt;
+ }
+
+ // ---------- лодка: плывёт, тонет вне воды ----------
+ if (boat.active) {
+ boat.x += boat.vx * dt;
+ boat.y += boat.vy * dt;
+ boat.z += (boat.vz || 0) * dt;
+ const boatGX = Math.floor(boat.x / state.TILE);
+ const boatGY = Math.floor(boat.y / state.TILE);
+ const boatGZ = Math.floor(boat.z / state.TILE);
+ const below = getBlock(boatGX, boatGY + 1, boatGZ);
+ if (!below || (below.t !== 'water' && below.t !== 'swamp_water')) {
+ state.inv.boat = (state.inv.boat || 0) + 1;
+ player.inBoat = false;
+ boat.active = false;
+ player.y += state.TILE;
+ player.vy = -200;
+ playSound('splash');
+ }
+ }
+
+ if (player.inBoat && !boat.active) {
+ state.inv.boat = (state.inv.boat || 0) + 1;
+ player.inBoat = false;
+ player.y += state.TILE;
+ player.vy = -200;
+ playSound('splash');
+ }
+
+ // ---------- границы мира по Z ----------
+ const maxZ = WORLD_DEPTH * state.TILE;
+ if (player.z < -maxZ) { player.z = -maxZ; player.vz = 0; }
+ if (player.z > maxZ) { player.z = maxZ; player.vz = 0; }
+
+ // ---------- ФИЗИКА: X → Z → Y ----------
+ const sub = dt;
+ for (let step = 0; step < steps; step++) {
+ player.x += player.vx * sub;
+ player.z += player.vz * sub;
+ player.y += player.vy * sub;
+ resolveX(player, sub);
+ resolveZ(player, sub);
+ resolveY(player, sub);
+ }
+
+ // лестница: вертикальное перемещение
+ if (onLadder(player)) {
+ if (inp.j) player.vy = -200;
+ else if (inp.s || inp.down) player.vy = 100;
+ else player.vy = 0;
+ }
+
+ // урон от падения: дистанция от последнего касания земли
+ if (player.grounded) {
+ if (player.fallStartY !== undefined) {
+ const fallDist = player.fallStartY - player.y;
+ if (fallDist > state.TILE * 6) {
+ player.hp -= calculateDamage((fallDist / state.TILE - 6) * 2.5);
+ }
+ }
+ player.fallStartY = player.y;
+ } else if (player.vy > 0 && player.fallStartY === undefined) {
+ player.fallStartY = player.y;
+ }
+
+ // вода
+ updateWaterFlag3(player, dt);
+
+ sendPlayerPosition();
+ updateWeather(dt);
+ updateRain(dt);
+ player.invuln = Math.max(0, player.invuln - dt);
+
+ // ---------- мобы ----------
+ state.spawnT += dt;
+ if (state.spawnT > 6) {
+ state.spawnT = 0;
+ spawnMobs3D();
+ }
+ for (const m of mobs) {
+ mobAI(m, dt); // физика 3D внутри mob-ai3d
+ }
+ // снаряды
+ for (let i = projectiles.length - 1; i >= 0; i--) {
+ const pr = projectiles[i];
+ pr.x += pr.vx * dt;
+ pr.y += pr.vy * dt;
+ pr.life -= dt;
+ // столкновение с блоком (по Z игрока/снаряда)
+ const gx = Math.floor(pr.x / state.TILE);
+ const gy = Math.floor(pr.y / state.TILE);
+ const gz = Math.floor((pr.z || player.z) / state.TILE);
+ const b = getBlock(gx, gy, gz);
+ if (b && BLOCKS[b.t] && BLOCKS[b.t].solid) {
+ projectiles.splice(i, 1);
+ continue;
+ }
+ if (pr.life <= 0) projectiles.splice(i, 1);
+ }
+
+ // ---------- TNT ----------
+ for (const b of state.blocks) {
+ if (b.dead || b.t !== 'tnt' || !b.active) continue;
+ b.fuse -= dt;
+ if (b.fuse <= 0) {
+ explodeTNT3D(b, state);
+ }
+ }
+
+ // ---------- печь ----------
+ tickFurnaces(dt);
+ if (state.currentFurnaceKey && Math.random() < 0.1) renderFurnaceUI();
+
+ // ---------- смерть/возрождение ----------
+ if (player.hp <= 0 && state.deathEl && state.deathEl.style.display !== 'flex') {
+ player.hp = 0;
+ state.deathEl.style.display = 'flex';
+ } else if (player.hp > 0 && state.deathEl && state.deathEl.style.display === 'flex') {
+ state.deathEl.style.display = 'none';
+ }
+
+ // ---------- HUD ----------
+ if (Math.random() < 0.25) {
+ state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
+ state.foodEl.textContent = Math.ceil(player.hunger);
+ const o2El = document.getElementById('o2');
+ if (o2El) o2El.textContent = Math.ceil(player.o2);
+ const tempEl = document.getElementById('temp');
+ if (tempEl) {
+ const t = Math.round(player.temperature);
+ tempEl.textContent = t + '°C';
+ if (player.temperature < COLD_THRESHOLD) tempEl.style.color = '#74b9ff';
+ else if (player.temperature > HEAT_THRESHOLD) tempEl.style.color = '#e17055';
+ else tempEl.style.color = '#55efc4';
+ }
+ state.sxEl.textContent = pGX;
+ state.syEl.textContent = pGY;
+ state.todEl.textContent = isNight() ? 'Ночь' : 'День';
+ state.worldIdEl.textContent = state.worldId;
+ if (state.isMultiplayer) {
+ const st = document.getElementById('multiplayerStatus');
+ if (st) st.style.display = 'flex';
+ state.playerCountEl.textContent = state.otherPlayers.size + 1;
+ } else {
+ const st = document.getElementById('multiplayerStatus');
+ if (st) st.style.display = 'none';
+ }
+ }
+
+ // сон — DOM-оверлей
+ const sleepOv = document.getElementById('sleepOverlay');
+ if (sleepOv) sleepOv.style.display = player.sleeping ? 'flex' : 'none';
+
+ // ---------- РЕНДЕР ----------
+ renderFrame3D(now);
+
+ requestAnimationFrame(loop);
+}
+
+// ---------- спавн мобов в 3D ----------
+function spawnMobs3D() {
+ const p = state.player;
+ const pcx = p.x / state.TILE;
+ const pcy = p.y / state.TILE;
+ const pcz = (p.z || 0) / state.TILE;
+ const kinds = [Zombie, Pig, Chicken, Creeper, Skeleton, Scorpion, PolarBear, Slime, Eagle];
+ for (let i = 0; i < 2; i++) {
+ const ang = Math.random() * Math.PI * 2;
+ const dist = 14 + Math.random() * 12;
+ const mx = Math.floor(pcx + Math.cos(ang) * dist);
+ const mz = Math.floor(pcz + Math.sin(ang) * dist);
+ if (Math.abs(mz) > WORLD_DEPTH) continue;
+ const sy = surfaceGyAt(mx, mz) - 1;
+ if (sy < 1) continue;
+ const K = kinds[Math.floor(Math.random() * kinds.length)];
+ const m = new K(mx * state.TILE, sy * state.TILE, mz * state.TILE);
+ m.z = mz * state.TILE;
+ m.d = m.d || m.w;
+ m.vz = 0;
+ state.mobs.push(m);
+ }
+}
+
+// === ui/minimap.js ===
+// ==================== МИНИКАРТА ====================
+
+
+
+
+
+
+const minimapWrap = document.getElementById('minimapWrap');
+const minimapCanvas = document.getElementById('minimap');
+const minimapCtx = minimapCanvas.getContext('2d');
+
+// Цвета блоков для миникарты (по 1 пикселю на блок)
+const MINIMAP_COLORS = {
+ grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
+ gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
+ leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
+ copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
+ diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
+ campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
+ flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410'
+};
+
+function initMinimap() {
+ document.getElementById('mapToggle').onclick = () => {
+ playSound('click');
+ state.minimapOpen = !state.minimapOpen;
+ minimapWrap.style.display = state.minimapOpen ? 'block' : 'none';
+ };
+
+ const minimapClose = document.getElementById('minimapClose');
+ if (minimapClose) {
+ minimapClose.addEventListener('click', (e) => {
+ e.stopPropagation();
+ state.minimapOpen = false;
+ minimapWrap.style.display = 'none';
+ });
+ }
+}
+
+function renderMinimap() {
+ if (!state.minimapOpen) return;
+ const mW = minimapCanvas.width;
+ const mH = minimapCanvas.height;
+ const scale = 2; // пикселей на блок
+ const TILE = state.TILE;
+ const player = state.player;
+
+ // Область карты — центрирована на игроке
+ const pGX = Math.floor(player.x / TILE);
+ const pGY = Math.floor(player.y / TILE);
+ const viewW = Math.floor(mW / scale);
+ const viewH = Math.floor(mH / scale);
+ const startGX = pGX - Math.floor(viewW / 2);
+ const startGY = pGY - Math.floor(viewH / 2);
+
+ // Очищаем
+ minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
+ minimapCtx.fillRect(0, 0, mW, mH);
+
+ // Рисуем блоки
+ const imgData = minimapCtx.createImageData(mW, mH);
+ const data = imgData.data;
+
+ for (let dx = 0; dx < viewW; dx++) {
+ for (let dy = 0; dy < viewH; dy++) {
+ const gx = startGX + dx;
+ const gy = startGY + dy;
+ const b = getBlock(gx, gy, 0);
+ if (!b || b.dead || b.t === 'air') continue;
+
+ const color = MINIMAP_COLORS[b.t];
+ if (!color) continue;
+
+ // Парсим hex цвет
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const bl = parseInt(color.slice(5, 7), 16);
+
+ // Заполняем scale x scale пикселей
+ for (let sx = 0; sx < scale; sx++) {
+ for (let sy = 0; sy < scale; sy++) {
+ const px = dx * scale + sx;
+ const py = dy * scale + sy;
+ if (px >= mW || py >= mH) continue;
+ const idx = (py * mW + px) * 4;
+ data[idx] = r;
+ data[idx + 1] = g;
+ data[idx + 2] = bl;
+ data[idx + 3] = 255;
+ }
+ }
+ }
+ }
+
+ minimapCtx.putImageData(imgData, 0, 0);
+
+ // Игрок — белый пиксель по центру
+ minimapCtx.fillStyle = '#fff';
+ minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4);
+
+ // Другие игроки — жёлтые точки
+ for (const [sid, p] of state.otherPlayers) {
+ const dx = Math.floor(p.x / TILE) - startGX;
+ const dy = Math.floor(p.y / TILE) - startGY;
+ if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
+ minimapCtx.fillStyle = '#f1c40f';
+ minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
+ }
+ }
+
+ // Мобы — красные (враждебные) / зелёные (животные)
+ const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
+ for (const m of allMobsForMap) {
+ const dx = Math.floor(m.x / TILE) - startGX;
+ const dy = Math.floor(m.y / TILE) - startGY;
+ if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
+ const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
+ minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
+ minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
+ }
+ }
+}
+
+// === ui/market.js ===
+// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ====================
+
+
+
+let marketPanel, marketClose, marketContent;
+
+function initMarket() {
+ marketPanel = document.getElementById('marketPanel');
+ marketClose = document.getElementById('marketClose');
+ marketContent = document.getElementById('marketContent');
+
+ if (!marketPanel) return;
+
+ if (marketClose) marketClose.addEventListener('click', () => closeMarket());
+ document.addEventListener('toggleMarket', () => toggleMarket());
+}
+
+function toggleMarket() {
+ if (!marketPanel) return;
+ if (marketPanel.style.display === 'none' || !marketPanel.style.display) {
+ openMarket();
+ } else {
+ closeMarket();
+ }
+}
+
+function closeMarket() {
+ if (marketPanel) marketPanel.style.display = 'none';
+}
+
+function openMarket() {
+ if (marketPanel) {
+ marketPanel.style.display = 'block';
+ renderMarket();
+ }
+}
+
+function renderMarket() {
+ if (!marketContent) return;
+ if (!state.socket) {
+ marketContent.innerHTML = 'Подключение к серверу...
';
+ return;
+ }
+
+ state.socket.emit('market_list', {}, (orders) => {
+ if (!orders || !orders.length) {
+ marketContent.innerHTML = 'Предложений пока нет
';
+ const btn = document.getElementById('createOrderBtn');
+ if (btn) btn.addEventListener('click', showCreateOrder);
+ return;
+ }
+
+ let html = '';
+ html += orders.map(o => '' + (o.offer_item||'') + ' x' + (o.offer_qty||1) + ' ⇄ ' + (o.want_item||'') + ' x' + (o.want_qty||1) + '
' + (o.seller || 'Аноним') + '
').join('');
+ marketContent.innerHTML = html;
+ const btn = document.getElementById('createOrderBtn');
+ if (btn) btn.addEventListener('click', showCreateOrder);
+ });
+}
+
+function showCreateOrder() {
+ if (!marketContent) return;
+ const invKeys = Object.keys(state.inv || {});
+ const invOpts = invKeys.filter(k => (state.inv[k]||0) > 0).map(k => '').join('');
+
+ marketContent.innerHTML = '';
+
+ const cancelBtn = document.getElementById('cancelOrder');
+ if (cancelBtn) cancelBtn.addEventListener('click', () => renderMarket());
+ const submitBtn = document.getElementById('submitOrder');
+ if (submitBtn) submitBtn.addEventListener('click', () => {
+ const offerItem = document.getElementById('offerItem');
+ const wantItem = document.getElementById('wantItem');
+ if (!offerItem || !wantItem) return;
+ const offer = offerItem.value;
+ const want = wantItem.value.trim();
+ if (!offer || !want) { alert('Выберите предмет и укажите что хотите получить'); return; }
+ if (state.socket) {
+ state.socket.emit('market_create', { offer_item: offer, offer_qty: 1, want_item: want, want_qty: 1 });
+ playSound('click');
+ setTimeout(renderMarket, 500);
+ }
+ });
+}
+
+// === ui/settings.js ===
+// Настройки
+
+
+let settingsPanel = null;
+let dayNightToggle = null;
+
+// Weather button IDs in order
+const WEATHER_BTNS = ['weatherClear', 'weatherRain', 'weatherSnow', 'weatherThunder'];
+const WEATHER_ACTIVE_BG = '#3498db';
+const WEATHER_INACTIVE_BG = '#555';
+
+function setWeatherButtonActive(activeId) {
+ for (const id of WEATHER_BTNS) {
+ const btn = document.getElementById(id);
+ if (btn) {
+ btn.style.background = (id === activeId) ? WEATHER_ACTIVE_BG : WEATHER_INACTIVE_BG;
+ }
+ }
+}
+
+function initSettings() {
+ settingsPanel = document.getElementById('settingsPanel');
+ dayNightToggle = document.getElementById('dayNightToggle');
+
+ if (!settingsPanel) return;
+
+ const closeBtn = document.getElementById('settingsClose');
+ if (closeBtn) {
+ closeBtn.addEventListener('click', () => {
+ settingsPanel.style.display = 'none';
+ });
+ }
+
+ // Day/night cycle toggle
+ if (dayNightToggle) {
+ dayNightToggle.checked = state.dayNightCycle !== false;
+ dayNightToggle.addEventListener('change', () => {
+ state.dayNightCycle = dayNightToggle.checked;
+ });
+ }
+
+ // Time-of-day slider
+ const timeSlider = document.getElementById('timeSlider');
+ if (timeSlider) {
+ timeSlider.value = Math.round(state.worldTime * 100);
+ timeSlider.addEventListener('input', () => {
+ state.worldTime = parseInt(timeSlider.value) / 100;
+ if (state.dayNightCycle) {
+ state.dayNightCycle = false;
+ if (dayNightToggle) dayNightToggle.checked = false;
+ }
+ });
+ }
+
+ // ====== Weather controls (Ясно / Дождь / Снег / Гроза) ======
+ const weatherIntensitySlider = document.getElementById('weatherIntensity');
+
+ // Helper: apply a weather type
+ function applyWeather(type) {
+ state.weatherType = type;
+ state.weatherChangeInterval = Infinity; // Lock weather manually
+
+ switch (type) {
+ case 'clear':
+ state.isRaining = false;
+ state.rainIntensity = 0;
+ if (weatherIntensitySlider) weatherIntensitySlider.value = 0;
+ break;
+ case 'rain':
+ state.isRaining = true;
+ state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
+ break;
+ case 'snow':
+ state.isRaining = false; // snow uses its own rendering
+ state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
+ break;
+ case 'thunder':
+ state.isRaining = true; // thunder includes rain
+ state.rainIntensity = parseInt(weatherIntensitySlider?.value || 60) / 100;
+ if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
+ break;
+ }
+
+ setWeatherButtonActive('weather' + type.charAt(0).toUpperCase() + type.slice(1));
+ }
+
+ // Button handlers
+ for (const id of WEATHER_BTNS) {
+ const btn = document.getElementById(id);
+ if (btn) {
+ const weatherType = btn.dataset.weather || id.replace('weather', '').toLowerCase();
+ btn.addEventListener('click', () => applyWeather(weatherType));
+ }
+ }
+
+ // Intensity slider
+ if (weatherIntensitySlider) {
+ weatherIntensitySlider.addEventListener('input', () => {
+ const val = parseInt(weatherIntensitySlider.value) / 100;
+ if (state.weatherType !== 'clear') {
+ state.rainIntensity = val;
+ if (state.weatherType === 'thunder' || state.weatherType === 'rain') {
+ state.isRaining = true;
+ }
+ }
+ });
+ }
+
+ // Listen for toggleSettings custom event
+ document.addEventListener('toggleSettings', () => {
+ if (settingsPanel) {
+ // Sync all controls when panel opens
+ if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100);
+ if (dayNightToggle) dayNightToggle.checked = state.dayNightCycle !== false;
+ if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
+ // Highlight active weather button
+ const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear');
+ const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1);
+ setWeatherButtonActive(activeId);
+ settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
+ }
+ });
+}
+
+function isDayNightEnabled() {
+ return state.dayNightCycle !== false;
+}
+
+// === ui/respawn.js ===
+// ==================== RESPAWN ====================
+
+
+
+
+function initRespawn() {
+ document.getElementById('respawnBtn').onclick = async () => {
+ playSound('click'); // Звук клика по кнопке
+
+ console.log('=== RESPAWN CLICKED ===');
+ console.log('isMultiplayer:', state.isMultiplayer);
+ console.log('otherPlayers.size:', state.otherPlayers.size);
+ console.log('player.hp before respawn:', state.player.hp);
+
+ const player = state.player;
+ const deathEl = state.deathEl;
+
+ // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
+ if (state.isMultiplayer && state.otherPlayers.size > 0) {
+ console.log('Мультиплеер режим - возрождение в начальной точке');
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log('Возрождение в начальной точке, HP:', player.hp);
+ } else {
+ console.log('Одиночный режим - загружаем последнее сохранение');
+ // Одиночный режим - загружаем последнее сохранение
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
+ } else {
+ // Если сохранения нет, возрождаемся в начальной точке
+ player.hp = 100;
+ player.hunger = 100;
+ player.o2 = 100;
+ player.vx = player.vy = 0;
+ player.invuln = 0;
+ player.x = state.spawnPoint.x;
+ player.y = state.spawnPoint.y;
+ player.fallStartY = player.y;
+ console.log('Возрождение в начальной точке, HP:', player.hp);
+ }
+ }
+
+ console.log('player.hp after respawn logic:', player.hp);
+ console.log('Hiding death screen...');
+ deathEl.style.display = 'none';
+ console.log('=== RESPAWN END ===');
+ };
+}
+
+// === input/controls.js ===
+// ==================== УПРАВЛЕНИЕ ====================
+
+
+const inp = state.inp;
+
+function bindHold(el, key) {
+ const down = (e) => { e.preventDefault(); state.inp[key] = true; };
+ const up = (e) => { e.preventDefault(); state.inp[key] = false; };
+ el.addEventListener('pointerdown', down);
+ el.addEventListener('pointerup', up);
+ el.addEventListener('pointerleave', up);
+}
+
+function initControls() {
+ const leftBtn = document.getElementById('left');
+ const rightBtn = document.getElementById('right');
+ const jumpBtn = document.getElementById('jump');
+ const downBtn = document.getElementById('down');
+
+ if (leftBtn) bindHold(leftBtn, 'l');
+ if (rightBtn) bindHold(rightBtn, 'r');
+ if (jumpBtn) bindHold(jumpBtn, 'j');
+ if (downBtn) bindHold(downBtn, 's');
+
+ window.addEventListener('keydown', (e) => {
+ if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = true;
+ if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = true;
+ if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = true;
+ if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = true;
+ });
+ window.addEventListener('keyup', (e) => {
+ if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = false;
+ if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = false;
+ if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = false;
+ if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = false;
+ });
+}
+
+// === multiplayer/voice-chat.js ===
+// Голосовой чат
+
+
+
+
+
+let voiceSocket = null;
+let voiceStream = null;
+let audioCtx = null;
+let voiceProcessor = null;
+let voiceActive = false;
+
+// Кнопка микрофона
+const voiceBtn = document.createElement('div');
+voiceBtn.innerHTML = '🎤/';
+voiceBtn.title = 'Голосовой чат (выкл)';
+voiceBtn.style.cssText = 'position:absolute;top:10px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
+document.querySelector('.ui').appendChild(voiceBtn);
+
+// Индикатор говорящего
+const speakingIndicator = document.createElement('div');
+speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
+speakingIndicator.textContent = '🔊';
+document.querySelector('.ui').appendChild(speakingIndicator);
+let speakingTimeout = null;
+
+voiceBtn.onclick = async () => {
+ if (voiceActive) {
+ // Выключить
+ voiceActive = false;
+ voiceBtn.innerHTML = '🎤/';
+ voiceBtn.style.background = '#555';
+ if (voiceStream) {
+ voiceStream.getTracks().forEach(t => t.stop());
+ voiceStream = null;
+ }
+ if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
+ if (audioCtx) { audioCtx.close(); audioCtx = null; }
+ if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
+ return;
+ }
+
+ // Включить
+ try {
+ voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } });
+ audioCtx = new AudioContext({ sampleRate: 24000 });
+ const source = audioCtx.createMediaStreamSource(voiceStream);
+ voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
+
+ voiceProcessor.onaudioprocess = (e) => {
+ if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
+ const pcm = e.inputBuffer.getChannelData(0);
+ // Конвертируем float32 → int16 для экономии трафика
+ const int16 = new Int16Array(pcm.length);
+ for (let i = 0; i < pcm.length; i++) {
+ const s = Math.max(-1, Math.min(1, pcm[i]));
+ int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
+ }
+ voiceSocket.emit('voice_data', int16.buffer);
+ };
+
+ source.connect(voiceProcessor);
+ voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
+
+ // Подключаемся к голосовому серверу
+ voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
+ voiceSocket.on('connect', () => {
+ voiceSocket.emit('voice_join', { world_id: state.worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' });
+ });
+
+ voiceSocket.on('voice_in', (payload) => {
+ // Воспроизводим входящий голос
+ const { data, meta, volume: rawVolume } = payload;
+ const volume = rawVolume !== undefined ? rawVolume : 1.0;
+ if (!audioCtx || audioCtx.state === 'closed') return;
+
+ // Int16 → Float32 (volume applied via gain node, not in conversion)
+ const int16 = new Int16Array(data);
+ const float32 = new Float32Array(int16.length);
+ for (let i = 0; i < int16.length; i++) {
+ float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
+ }
+
+ const buf = audioCtx.createBuffer(1, float32.length, 24000);
+ buf.getChannelData(0).set(float32);
+ const src = audioCtx.createBufferSource();
+ src.buffer = buf;
+
+ const gain = audioCtx.createGain();
+ gain.gain.value = volume;
+ src.connect(gain).connect(audioCtx.destination);
+ src.start();
+
+ // Индикатор
+ speakingIndicator.style.display = 'block';
+ speakingIndicator.textContent = `🔊 ${meta.name}`;
+ clearTimeout(speakingTimeout);
+ speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
+ });
+
+ voiceActive = true;
+ voiceBtn.textContent = '🎤';
+ voiceBtn.style.background = '#2ecc71';
+ } catch (e) {
+ console.error('Voice error:', e);
+ voiceBtn.style.background = '#e74c3c';
+ }
+};
+
+function initVoice() {
+ // Voice position update hook — should be called from main loop
+ let voicePosT = 0;
+ return {
+ update(dt) {
+ voicePosT += dt;
+ if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
+ voicePosT = 0;
+ voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y });
+ }
+ }
+ };
+}
+
+// === ui/craft.js ===
+// Craft UI
+
+
+
+
+
+
+
+
+
+
+
+function canCraft(r) {
+ const inv = state.inv;
+ for (const res in r.cost) {
+ if ((inv[res] || 0) < r.cost[res]) return false;
+ }
+ return true;
+}
+
+function renderCraft() {
+ const inv = state.inv;
+ const recipesEl = state.recipesEl;
+ recipesEl.innerHTML = '';
+ for (const r of RECIPES) {
+ const row = document.createElement('div');
+ row.className = 'recipe';
+ const icon = document.createElement('div');
+ icon.className = 'ricon';
+ // Иконка результата — canvas sprite > emoji
+ if (tex[r.out]) {
+ icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
+ } else if (itemTex[r.out]) {
+ icon.style.backgroundImage = `url(${itemTex[r.out].toDataURL()})`;
+ } else if (TOOLS[r.out]) {
+ icon.textContent = TOOLS[r.out].icon || '🔧';
+ icon.style.fontSize = '24px';
+ icon.style.display = 'flex';
+ icon.style.alignItems = 'center';
+ icon.style.justifyContent = 'center';
+ } else if (ITEMS[r.out]) {
+ icon.textContent = ITEMS[r.out].icon || '📦';
+ icon.style.fontSize = '24px';
+ icon.style.display = 'flex';
+ icon.style.alignItems = 'center';
+ icon.style.justifyContent = 'center';
+ }
+ const info = document.createElement('div');
+ info.className = 'rinfo';
+ const nm = document.createElement('div');
+ nm.className = 'rname';
+ const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
+ nm.textContent = `${itemName} x${r.qty}`;
+ const cs = document.createElement('div');
+ cs.className = 'rcost';
+ cs.textContent = Object.keys(r.cost).map(x => {
+ const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
+ return `${cn}: ${(inv[x] || 0)}/${r.cost[x]}`;
+ }).join(' ');
+ info.appendChild(nm); info.appendChild(cs);
+ const btn = document.createElement('button');
+ btn.className = 'rcraft';
+ btn.textContent = 'Создать';
+ btn.disabled = !canCraft(r);
+ btn.onclick = () => {
+ if (!canCraft(r)) return;
+ playSound('click');
+ for (const res in r.cost) inv[res] -= r.cost[res];
+ inv[r.out] = (inv[r.out] || 0) + r.qty;
+ if (TOOLS[r.out]) addTool(r.out);
+ if (!state.recentItems.includes(r.out)) { state.recentItems.unshift(r.out); state.recentItems = state.recentItems.slice(0, 5); }
+ rebuildHotbar();
+ renderCraft();
+ renderInventory();
+ };
+ row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
+ recipesEl.appendChild(row);
+ }
+}
+
+let craftOpen = false;
+let inventoryOpen = false;
+
+function toggleCraft() {
+ playSound('click');
+ craftOpen = !craftOpen;
+ state.craftPanel.style.display = craftOpen ? 'block' : 'none';
+ if (craftOpen) {
+ renderCraft();
+ inventoryOpen = false;
+ state.inventoryPanel.style.display = 'none';
+ }
+}
+
+function closeCraft() {
+ playSound('click');
+ craftOpen = false;
+ state.craftPanel.style.display = 'none';
+}
+
+function toggleInventory() {
+ playSound('click');
+ inventoryOpen = true;
+ state.inventoryPanel.style.display = 'block';
+ renderInventory();
+ craftOpen = false;
+ state.craftPanel.style.display = 'none';
+}
+
+function closeInventory() {
+ playSound('click');
+ inventoryOpen = false;
+ state.inventoryPanel.style.display = 'none';
+}
+
+// === main3d.js ===
+// ==================== ENTRY POINT ====================
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// ==================== 1) CONFIG — parse URL ====================
+// urlParams defined in config.js
+state.SERVER_URL = SERVER_URL;
+state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot';
+state.TELEGRAM_APP_SHORT_NAME = 'minegrechka';
+
+// Set TG-related state
+if (isTgWebApp) {
+ state.myTgId = tgUser?.id || null;
+}
+
+state.heroImg = new Image(); state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
+
+// ==================== 2) START MENU ====================
+// Show the start menu before initializing the game.
+// The menu returns a Promise that resolves with the worldId.
+async function startGame() {
+ const worldId = await showStartMenu();
+
+ // Set worldId and playerName
+ state.worldId = worldId;
+ if (!state.playerName) {
+ state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
+ localStorage.setItem('minegrechka_playerName', state.playerName);
+ }
+ console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp);
+
+ // Validate TG initData if in TG context
+ if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) {
+ try {
+ const resp = await fetch(state.SERVER_URL + '/api/tg/auth', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ initData: Telegram.WebApp.initData })
+ });
+ const data = await resp.json();
+ if (data.ok && data.tg_id) {
+ console.log('[TG] Authenticated, tg_id:', data.tg_id);
+ state.myTgId = data.tg_id;
+ }
+ } catch (e) { console.warn('[TG] Auth failed:', e); }
+ }
+
+ // Update URL with world code
+ try {
+ const newUrl = new URL(window.location.href);
+ newUrl.searchParams.set('world', state.worldId);
+ if (typeof history !== 'undefined' && history.replaceState) {
+ history.replaceState(null, '', newUrl.toString());
+ }
+ } catch (e) {}
+
+ // ==================== 3) CANVAS — init from canvas module ====================
+ initCanvas();
+
+ // ==================== 4) STATE — set DOM refs ====================
+ state.hpEl = document.getElementById('hp');
+ state.foodEl = document.getElementById('food');
+ state.sxEl = document.getElementById('sx');
+ state.syEl = document.getElementById('sy');
+ state.todEl = document.getElementById('tod');
+ state.worldIdEl = document.getElementById('worldId');
+ state.playerCountEl = document.getElementById('playerCount');
+ state.deathEl = document.getElementById('death');
+ state.hotbarEl = document.getElementById('hotbar');
+ state.craftPanel = document.getElementById('craftPanel');
+ state.recipesEl = document.getElementById('recipes');
+ state.inventoryPanel = document.getElementById('inventoryPanel');
+ state.inventoryGrid = document.getElementById('inventoryGrid');
+ state.marketPanel = document.getElementById('marketPanel');
+ state.marketContent = document.getElementById('marketContent');
+ state.tradePanel = document.getElementById('tradePanel');
+ state.tradeContent = document.getElementById('tradeContent');
+ state.multiplayerStatus = document.getElementById('multiplayerStatus');
+
+ // ==================== 5) TEXTURES — initTextures() ====================
+ initTextures();
+ state.tex = tex;
+
+ // ==================== 6) CRAFT/INVENTORY BUTTONS ====================
+ const craftBtn = document.getElementById('craftBtn');
+ const invToggle = document.getElementById('invToggle');
+ const craftClose = document.getElementById('craftClose');
+ const inventoryClose = document.getElementById('inventoryClose');
+
+ if (craftBtn) craftBtn.onclick = () => toggleCraft();
+ if (craftClose) craftClose.onclick = () => closeCraft();
+ if (invToggle) invToggle.onclick = () => toggleInventory();
+ if (inventoryClose) inventoryClose.onclick = () => closeInventory();
+
+ // ==================== 7) CONTROLS — initControls ====================
+ initControls();
+
+ // ==================== 9) UI MODULES — init all UI ====================
+ initChat();
+ initFurnace();
+ initMinimap();
+ initSaveControls();
+ initMarket();
+ initRespawn();
+ initShare();
+ initSettings();
+ initModes();
+
+ // ==================== 10) SOCKET — initSocket ====================
+ initSocket();
+
+ // ==================== 11) VOICE — initVoice ====================
+ initVoice();
+
+ // ==================== 12) SAVE — loadGame, applySave ====================
+ // Ensure hotbar has items on first load
+ if (state.recentItems.length === 0) {
+ state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5);
+ }
+ // Make sure selected item is valid and in recentItems
+ if (!state.selected || !state.inv[state.selected] || state.inv[state.selected] <= 0) {
+ state.selected = state.recentItems[0] || 'dirt';
+ }
+ if (!state.recentItems.includes(state.selected)) {
+ state.recentItems.unshift(state.selected);
+ state.recentItems = state.recentItems.slice(0, 5);
+ }
+ rebuildHotbar();
+
+ // Keyboard hotbar switching (1/2/3)
+ document.addEventListener('keydown', (e) => {
+ if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return;
+ if (e.key >= '1' && e.key <= '3') {
+ const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3);
+ const idx = parseInt(e.key) - 1;
+ if (items[idx]) {
+ state.selected = items[idx];
+ rebuildHotbar();
+ }
+ }
+ });
+
+ // Scroll wheel hotbar cycling
+ document.addEventListener('wheel', (e) => {
+ if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return;
+ const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3);
+ if (items.length === 0) return;
+ let idx = items.indexOf(state.selected);
+ if (idx < 0) idx = 0;
+ if (e.deltaY > 0) idx = (idx + 1) % items.length;
+ else idx = (idx - 1 + items.length) % items.length;
+ state.selected = items[idx];
+ rebuildHotbar();
+ e.preventDefault();
+ }, { passive: false });
+
+ initDB().then(async () => {
+ const loadedSave = await loadGame();
+ if (loadedSave) {
+ await applySave(loadedSave);
+ console.log('Загружено сохранение, HP:', state.player.hp);
+
+ if (state.player.hp <= 0) {
+ console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.x = state.spawnPoint.x;
+ state.player.y = state.spawnPoint.y;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+ state.player.fallStartY = state.player.y;
+ }
+ } else {
+ console.log('Сохранение не найдено, начинаем новую игру');
+ // Fill recentItems from inventory
+ state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5);
+ rebuildHotbar();
+
+ state.player.hp = 100;
+ state.player.hunger = 100;
+ state.player.o2 = 100;
+ state.player.vx = state.player.vy = 0;
+ state.player.invuln = 0;
+
+ // старт — на поверхности
+ const startGX = 6, startGZ = 0;
+ state.player.x = startGX * state.TILE;
+ state.player.z = startGZ * state.TILE;
+ state.player.vx = state.player.vy = state.player.vz = 0;
+ for (let dx = -4; dx <= 4; dx++) {
+ for (let dz = -20; dz <= 20; dz++) genColumn(startGX + dx, startGZ + dz);
+ }
+ const surfaceY = surfaceGyAt(startGX, startGZ);
+ let safeGY = surfaceY - 1;
+ const aboveBlock = getBlock(startGX, surfaceY - 1, startGZ);
+ if (aboveBlock && (aboveBlock.t === 'water' || aboveBlock.t === 'swamp_water')) {
+ for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
+ const b = getBlock(startGX, gy, startGZ);
+ if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
+ safeGY = gy - 1;
+ break;
+ }
+ }
+ state.player.y = safeGY * state.TILE;
+ state.player.fallStartY = state.player.y;
+ state.spawnPoint.x = state.player.x;
+ state.spawnPoint.y = state.player.y;
+ state.spawnPoint.z = state.player.z;
+ console.log('Новая 3D игра: gx=', startGX, 'gz=', startGZ, 'surfaceY=', surfaceY, 'y=', state.player.y);
+ }
+
+ // Автосейв при скрытии страницы
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) {
+ saveGame();
+ }
+ });
+
+ // Автосейв перед закрытием страницы
+ window.addEventListener('beforeunload', () => {
+ saveGame();
+ });
+ }).catch(err => {
+ console.error('Ошибка инициализации:', err);
+ const startGX = 6, startGZ = 0;
+ genColumn(startGX, startGZ);
+ state.player.y = (surfaceGyAt(startGX, startGZ) - 1) * state.TILE;
+ state.player.fallStartY = state.player.y;
+ for (let gx = startGX - 4; gx <= startGX + 4; gx++) {
+ for (let gz = -20; gz <= 20; gz++) genColumn(gx, startGZ + gz);
+ }
+ });
+
+ // ==================== 13) LOOP — startLoop ====================
+ requestAnimationFrame(loop);
+}
+
+// Start the game (v3 — 3D)
+startGame().catch(err => {
+ console.error('Fatal error starting game:', err);
+});
+
+// Debug: give items
+window.debugGive = function(id, count) {
+ state.inv[id] = (state.inv[id] || 0) + (count || 1);
+ if (!state.recentItems.includes(id)) { state.recentItems.unshift(id); state.recentItems = state.recentItems.slice(0, 5); }
+ rebuildHotbar();
+ // Can't call renderInventory from here, but user can click inv
+ console.log('Gave', count || 1, id, '=>', state.inv[id]);
+ console.log('inv keys>0:', Object.keys(state.inv).filter(k => state.inv[k] > 0).join(', '));
+};
+window.debugInv = function() { return Object.keys(state.inv).filter(k => state.inv[k] > 0).map(k => k + ':' + state.inv[k]); };
+
+})();
diff --git a/index-v2.html b/index-v2.html
new file mode 100644
index 0000000..7a26e96
--- /dev/null
+++ b/index-v2.html
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
+
+
+GrechkaCraft
+
+
+
+
+
+
+
+
+
+
+
+
+
+
❤️ 100 🍗 100
+
🫁 100
+
🌡️ 15°C
+
📍 X:0 Y:0
+
🕒 День
+
🌐 default
+
⭐ Lv.1 | XP: 0/50
+
👥 0
+
+
+
⛏️
+
🔨
+
+
💬
+
📦
+
🗺️
+
+
+
+
+
+
+
+
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
💀 Вы погибли!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ РассветДеньЗакатНочь
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
💤 Спим...
Нажмите на кровать чтобы проснуться
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
index a0a81ad..a779040 100644
--- a/index.html
+++ b/index.html
@@ -5,9 +5,8 @@
GrechkaCraft: Multiplayer
-
-
+
@@ -18,7 +17,6 @@
❤️ 100 🍗 100
🫁 100
-
🌡️ 15°C
📍 X:0 Y:0
🕒 День
🌐 default
@@ -27,13 +25,17 @@
⛏️
- 💾
🔨
- 🔄
💬
📦
🗺️
+
+
@@ -93,8 +95,16 @@
+
+
-
+