193 lines
7.9 KiB
JavaScript
193 lines
7.9 KiB
JavaScript
// ==================== ДЖОЙСТИК 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();
|
||
}
|