grechka-game/srcv3/input/mouse-handler3d.js

415 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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