// ==================== ВЗАИМОДЕЙСТВИЕ 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 } 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' }; 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); } } export const mouse = { x: null, y: null }; let miningHeld = false; let miningInterval = null; export function stopMining() { miningHeld = false; if (miningInterval) { clearInterval(miningInterval); miningInterval = null; } } // Основное действие. coords — результат рейкаста; face — нормаль грани. export 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); }