// ==================== ВЗАИМОДЕЙСТВИЕ 3D (мышь/тап) — GrechkaCraft v3 ==================== // Ключевое отличие от 2D: блок добывается/ставится ТОЛЬКО если игрок рядом // (в пределах REACH). Касание экрана вдали — лишь поворот камеры. import { state } from '../core/state.js'; import { BLOCKS } from '../data/blocks.js'; import { ITEMS } from '../data/items.js'; import { TOOLS } from '../data/tools.js'; import { getBestTool, useTool, toolTierLessThan, getMiningSpeed } from '../data/tools.js'; import { getBlock, setBlock, removeBlock, isSolid, isOpaque, hasBlock } from '../world/world-storage3d.js'; import { playSound } from '../audio/sound-engine.js'; import { rebuildHotbar, addToRecentItems } from '../ui/hotbar.js'; import { mode } from '../game/modes.js'; import { openFurnaceUI } from '../ui/furnace.js'; import { saveGame } from '../game/save.js'; import { activateTNT3D } from '../world/tnt3d.js'; import { sendBlockChange } from '../multiplayer/socket-helpers.js'; import { isNight } from '../entities/mob-ai3d.js'; // ---------- ДИСТАНЦИЯ ДОБЫЧИ (как в майнкрафте) ---------- export const REACH = 5.2; // тайлов от центра игрока до центра блока export 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; } export 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' }; // Начисление опыта. В 3D XP не считался вообще: элементы #xplevel/#xpbar // были в HTML, но без обработчика, а player.xp нигде не рос. Теперь опыт // даётся за добычу блока, и порог на уровень — XP_PER_LEVEL * level. export function addXP(amount) { const pr = state.player; if (!pr) return; pr.xp = (pr.xp || 0) + (amount || 0); const per = 50; pr.level = pr.level || 1; while (pr.xp >= per * pr.level) { pr.xp -= per * pr.level; pr.level++; } } function handleBlockDrop(removed, inv) { // Опыт за блок: 1 за обычный, 3 за руду. addXP(/ore|gold_iron|diamond/i.test(removed.t) ? 3 : 1); 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); } } export const mouse = { x: null, y: null }; let miningHeld = false; let miningInterval = null; export function stopMining() { miningHeld = false; if (miningInterval) { clearInterval(miningInterval); miningInterval = null; } } // Основное действие. ВСЕ координаты — ТАЙЛОВЫЕ (gx, gy, gz), face — нормаль. // Раньше wx/wy приходили в ПИКСЕЛЯХ (и делились на TILE), а gz — уже // тайловой. Смешение единиц сдвигало цель на 1 тайл: клик попадал в // соседний блок, и печь «не открывалась» (клик уходил в dirt рядом). // Рейкаст в render3d отдаёт block.gx/gy/gz из userData.list — тоже тайловые. export function handleTap(gx, gy, gz, face) { state.tapDbg = 'start'; if (state.craftOpen) { state.tapDbg = 'craft-open'; return; } if (state.player.hp <= 0) { state.tapDbg = 'dead'; return; } const TILE = state.TILE; // Защита от вызова в пикселях (старый путь из тач-кнопки). if (Math.abs(gx) > 512 || Math.abs(gy) > 512) { gx = Math.floor(gx / TILE); gy = Math.floor(gy / TILE); } let z = gz === undefined || gz === null ? Math.floor(((state.player.z || 0) + state.player.d / 2) / TILE) : (Math.abs(gz) > 512 ? Math.floor(gz / TILE) : gz); let b = getBlock(gx, gy, z); // Рейкаст бьёт по грани: блока по этой оси может не быть, тогда ищем // ближайший непустой сосед по X/Y/Z — иначе клик «попадал» в пустоту. if (!b) { for (const d of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) { const cand = getBlock(gx + d[0], gy + d[1], z + d[2]); if (cand) { gx += d[0]; gy += d[1]; z += d[2]; b = cand; break; } } } // Если блока нет — пробуем тот же столбец, но сдвинутые на 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) { state.tapDbg = 'no-block'; return; } // ---------- ДОСТИЖИМОСТЬ ---------- if (!withinReach(gx, gy, z)) { // Слишком далеко: подсказка, ничего не ломаем state.tapDbg = 'far:' + gx + ',' + gy + ',' + z; showReachHint(); return; } state.tapDbg = 'ok:' + (b ? b.t : '?') + ':' + gx + ',' + gy + ',' + z; // Пробуждение: клик по кровати когда спишь 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'); // Лут по виду моба. Раньше ВСЕ давали 2 мяса, поэтому зомби и белый // медведь роняли сырое мясо, а скелет — стрелы, но не кости. const LOOT = { chicken: { chicken_meat: 1, feather: 1 + Math.floor(Math.random() * 2) }, pig: { meat: 2 + Math.floor(Math.random() * 2) }, cow: { meat: 2 + Math.floor(Math.random() * 2) }, sheep: { meat: 1 + Math.floor(Math.random() * 2), bone: 1 }, zombie: { meat: 1 }, skeleton: { bone: 1 + Math.floor(Math.random() * 2), arrow: 2 + Math.floor(Math.random() * 3) }, creeper: { gunpowder: 1 + Math.floor(Math.random() * 2) }, slime: { slime_ball: 1 + Math.floor(Math.random() * 2) }, scorpion: { scorpion_stinger: 1 + Math.floor(Math.random() * 2), meat: 1 }, polarbear: { polar_fur: 1, meat: 3 + Math.floor(Math.random() * 2) }, eagle: { eagle_feather: 1 + Math.floor(Math.random() * 2), feather: 1 } }; const drop = LOOT[m.kind] || { meat: 1 }; for (const item in drop) { const n = drop[item]; if (n > 0) state.inv[item] = (state.inv[item] || 0) + n; } if (m.kind === 'skeleton' && 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); } // Диаг-хук: доступен только по ?diag=1, в обычной игре не создаётся if (/[?&]diag=1/.test(location.search)) { // setBlock/removeBlock нужны для браузерных проверок добычи и установки: // без них тест не может подготовить блок перед игроком. window.__m3d = { REACH, REACH_SQ, withinReach, withinReachEntity, handleTap, stopMining, getBlock, setBlock, removeBlock, isSolid, isOpaque, hasBlock }; } // Кнопка копки на джойстике: удержание = добыча блока под прицелом. // TDtarget и startHoldMining живут в render3d/mouse-handler соответственно, // но ЭТОТ модуль их не видит: обращение к TDtarget давало // "TDtarget is not defined" и кнопка копки не работала вообще. Рендер // публикует цель через TDaim(), а удержание запускается handleTap по // координатам цели — того же пути, что и обычный клик мышью. function TDaim() { return (typeof window !== 'undefined' && window.__td && window.__td.target) || null; } window.addEventListener('v2-mine', (e) => { const down = !!(e.detail && e.detail.down); if (!down) { stopMining(); return; } const t = TDaim(); if (!t) return; const blk = getBlock(t.gx, t.gy, t.gz); if (!blk) return; // Оба действия через handleTap: он сам различает добычу и установку // по режиму и запускает удержание через setInterval. handleTap(t.gx, t.gy, t.gz, t.face); });