// ==================== ДЖОЙСТИК 3D — GrechkaCraft v3 ==================== // Аналоговый джойстик: движение во все стороны по горизонтали (X и Z), // камера вращается вторым пальцем по экрану. Кнопки прыжка и копки рядом. import { state } from '../core/state.js'; let joyEl = null, knobEl = null, jumpEl = null, mineEl = null; let joyId = null; // активный pointer на джойстике let jcx = 0, jcy = 0, jrad = 56; // центр джойстика и рабочий радиус в px let touchSeated = false; // джойстик сейчас показан let hideT = 0; // таймер скрытия function setKnob(dx, dy) { if (!knobEl) return; knobEl.style.transform = `translate(${dx}px, ${dy}px)`; } function resetAxes() { state.inp.ax = 0; state.inp.ay = 0; setKnob(0, 0); if (joyEl) joyEl.classList.remove('act'); } export function initJoystick() { joyEl = document.getElementById('v2joy'); knobEl = joyEl ? joyEl.querySelector('.knob') : null; jumpEl = document.getElementById('v2jump'); mineEl = document.getElementById('v2mine'); if (!joyEl) return; const rectCenter = () => { const r = joyEl.getBoundingClientRect(); jcx = r.left + r.width / 2; jcy = r.top + r.height / 2; jrad = r.width / 2 - 12; }; joyEl.addEventListener('pointerdown', (e) => { e.preventDefault(); rectCenter(); joyId = e.pointerId; joyEl.setPointerCapture(joyId); joyEl.classList.add('act'); update(e); }); joyEl.addEventListener('pointermove', (e) => { if (e.pointerId !== joyId) return; e.preventDefault(); update(e); }); const end = (e) => { if (e.pointerId !== joyId) return; joyId = null; resetAxes(); }; joyEl.addEventListener('pointerup', end); joyEl.addEventListener('pointercancel', end); function update(e) { let dx = e.clientX - jcx; let dy = e.clientY - jcy; const len = Math.hypot(dx, dy); if (len > jrad) { // не даём уйти за круг dx = (dx / len) * jrad; dy = (dy / len) * jrad; } setKnob(dx, dy); // мёртвая зона — чтобы лёгкое касание не дёргало персонажа const nx = dx / jrad, ny = dy / jrad; const mag = Math.hypot(nx, ny); const DEAD = 0.18; if (mag < DEAD) { state.inp.ax = 0; state.inp.ay = 0; } else { // нормализуем так, чтобы край джойстика давал полную скорость const k = (mag - DEAD) / (1 - DEAD) / mag; state.inp.ax = nx * k; state.inp.ay = ny * k; } } // прыжок if (jumpEl) { jumpEl.addEventListener('pointerdown', (e) => { e.preventDefault(); state.inp.j = true; }); const up = (e) => { e.preventDefault(); state.inp.j = false; }; jumpEl.addEventListener('pointerup', up); jumpEl.addEventListener('pointercancel', up); jumpEl.addEventListener('pointerleave', up); } // копка: добыча/установка — удержание if (mineEl) { mineEl.addEventListener('pointerdown', (e) => { e.preventDefault(); state.inp.mineHeld = true; window.dispatchEvent(new CustomEvent('v2-mine', { detail: { down: true } })); }); const up = (e) => { e.preventDefault(); state.inp.mineHeld = false; window.dispatchEvent(new CustomEvent('v2-mine', { detail: { down: false } })); }; mineEl.addEventListener('pointerup', up); mineEl.addEventListener('pointercancel', up); mineEl.addEventListener('pointerleave', up); } // держим джойстик в нуле, если палец ушёл за экран window.addEventListener('pointerup', (e) => { if (joyId !== null && e.pointerId === joyId) { joyId = null; resetAxes(); } }); window.addEventListener('blur', resetAxes); // ---------- джойстик: только если палец в левой нижней четверти экрана ---------- // Показываем при касании, прячем через 0.6 с после отпускания — чтобы // не перекрывать хотбар с избранными блоками. const ZONE = () => ({ w: window.innerWidth * 0.5, h: window.innerHeight * 0.5 }); const inZone = (x, y) => { const z = ZONE(); return x < z.w && y > window.innerHeight - z.h; }; const wake = () => { clearTimeout(hideT); // Кнопки прыжка и копки видны ВСЕГДА на тач-устройствах: без прыжка // и добычи играть невозможно, и требование контекстного показа // касалось только джойстика. [jumpEl, mineEl].forEach(e => e && e.classList.add('on')); if (touchSeated) return; touchSeated = true; if (joyEl) joyEl.classList.add('on'); }; const sleepSoon = () => { clearTimeout(hideT); hideT = setTimeout(() => { if (joyId === null) { touchSeated = false; // Прячем ТОЛЬКО джойстик. Раньше класс 'on' снимался со всех // элементов или не снимался вовсе: в первом случае исчезали // кнопки прыжка и копки, во втором джойстик висел поверх // хотбара всё время. if (joyEl) joyEl.classList.remove('on'); [jumpEl, mineEl].forEach(e => e && e.classList.add('on')); } }, 600); }; // Первый контакт с зоной — сажаем джойстик под палец window.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; if (!inZone(e.clientX, e.clientY)) return; wake(); if (joyId !== null) return; // если палец попал в кнопки — не мешаем им // contains() бросает исключение, если e.target — не Node (например, // window при синтетическом событии). Проверяем instanceof. const isNode = e.target && typeof e.target === 'object' && 'nodeType' in e.target; if (isNode && ((jumpEl && jumpEl.contains(e.target)) || (mineEl && mineEl.contains(e.target)))) return; // ищем ближайшую точку джойстика к пальцу const r = joyEl.getBoundingClientRect(); const cxr = r.left + r.width / 2, cyr = r.top + r.height / 2; const far = Math.hypot(e.clientX - cxr, e.clientY - cyr) > r.width * 0.75; if (far) { jcx = e.clientX; jcy = e.clientY; } // Запоминаем палец СРАЗУ: без этого joyId оставался null, update() // не считал смещение, и inp.ax/inp.ay всегда были 0 — джойстик // показывался, но игрок не двигался. Обработчик самого джойстика // тоже ставит joyEl.classList.add('act'). if (joyId === null) { joyId = e.pointerId; joyEl.classList.add('act'); update(e); } }, { passive: true, capture: true }); window.addEventListener('pointerup', sleepSoon, { passive: true }); window.addEventListener('pointercancel', sleepSoon, { passive: true }); window.addEventListener('blur', () => { touchSeated = false; }); // десктоп: всё скрыто, клавиатура if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) { [joyEl, jumpEl, mineEl].forEach(e => e && e.classList.remove('on')); } // Автопоказ на старте УБРАН: джойстик должен появляться только по // касанию левой нижней зоны, а не висеть поверх хотбара сразу. sleepSoon(); }