415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
// ==================== ВЗАИМОДЕЙСТВИЕ 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);
|
||
}
|