501 lines
19 KiB
JavaScript
501 lines
19 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, 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);
|
||
});
|