feat(ui): implement responsive player browser experience
This commit is contained in:
+365
@@ -0,0 +1,365 @@
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "maze32.session.v1";
|
||||
const MAX_VIEW_CHUNKS = 700;
|
||||
const MAX_VIEW_CELLS = 8192;
|
||||
const MAX_VIEW_OBJECTS = 30;
|
||||
const KEY_DIRECTIONS = Object.freeze({
|
||||
ArrowUp: "north", KeyW: "north",
|
||||
ArrowRight: "east", KeyD: "east",
|
||||
ArrowDown: "south", KeyS: "south",
|
||||
ArrowLeft: "west", KeyA: "west"
|
||||
});
|
||||
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"});
|
||||
const HINT_ANGLES = Object.freeze({none: 0, n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315});
|
||||
const PHASES = Object.freeze({countdown: "Обратный отсчёт", waiting: "Ждём игроков", active: "Гонка идёт", finish_pending: "Фотофиниш", paused: "Пауза", finished: "Финиш"});
|
||||
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду."});
|
||||
|
||||
function directionFromKey(code) {
|
||||
return KEY_DIRECTIONS[code] || null;
|
||||
}
|
||||
|
||||
function directionFromSwipe(startX, startY, endX, endY, minimumDistance) {
|
||||
const dx = endX - startX;
|
||||
const dy = endY - startY;
|
||||
const threshold = minimumDistance == null ? 28 : minimumDistance;
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < threshold) {
|
||||
return null;
|
||||
}
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
return dx > 0 ? "east" : "west";
|
||||
}
|
||||
return dy > 0 ? "south" : "north";
|
||||
}
|
||||
|
||||
function createEnvelope(type, sequence, requestId, payload) {
|
||||
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
||||
}
|
||||
|
||||
function snapshotIsValid(snapshot) {
|
||||
if (!Number.isInteger(snapshot.width) || snapshot.width < 1 || !Number.isInteger(snapshot.height) || snapshot.height < 1 || snapshot.width * snapshot.height > MAX_VIEW_CELLS) {
|
||||
return false;
|
||||
}
|
||||
const cellLimit = snapshot.width * snapshot.height;
|
||||
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 2 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
|
||||
}
|
||||
|
||||
class SnapshotAssembler {
|
||||
constructor() {
|
||||
this.pending = null;
|
||||
}
|
||||
|
||||
accept(message) {
|
||||
if (!message || message.v !== 1 || message.type !== "view" || !Number.isInteger(message.seq)) {
|
||||
return null;
|
||||
}
|
||||
const payload = message.payload;
|
||||
if (!payload || !Number.isInteger(payload.chunk) || !Number.isInteger(payload.chunks) || payload.chunks < 1 || payload.chunks > MAX_VIEW_CHUNKS || payload.chunk < 0 || payload.chunk >= payload.chunks || !Array.isArray(payload.cells) || payload.cells.length > 12 || !Array.isArray(payload.objects) || payload.objects.length > 6) {
|
||||
return null;
|
||||
}
|
||||
if (!this.pending || this.pending.seq !== message.seq || this.pending.count !== payload.chunks) {
|
||||
this.pending = {seq: message.seq, count: payload.chunks, parts: new Array(payload.chunks)};
|
||||
}
|
||||
this.pending.parts[payload.chunk] = payload;
|
||||
if (this.pending.parts.filter(Boolean).length !== this.pending.count) {
|
||||
return null;
|
||||
}
|
||||
const first = this.pending.parts[0];
|
||||
const snapshot = Object.assign({}, first, {
|
||||
cells: this.pending.parts.flatMap(part => part.cells),
|
||||
objects: this.pending.parts.flatMap(part => part.objects)
|
||||
});
|
||||
if (snapshot.cells.length > MAX_VIEW_CELLS || snapshot.objects.length > MAX_VIEW_OBJECTS || !snapshotIsValid(snapshot)) {
|
||||
this.pending = null;
|
||||
return null;
|
||||
}
|
||||
delete snapshot.chunk;
|
||||
delete snapshot.chunks;
|
||||
this.pending = null;
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
function projectedCellIndices(snapshot) {
|
||||
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
|
||||
}
|
||||
|
||||
function boot(documentObject, windowObject) {
|
||||
const byId = id => documentObject.getElementById(id);
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false};
|
||||
|
||||
function showNotice(text, kind) {
|
||||
const element = byId("notice");
|
||||
element.textContent = text;
|
||||
element.className = `notice visible ${kind || ""}`;
|
||||
windowObject.clearTimeout(state.noticeTimer);
|
||||
state.noticeTimer = windowObject.setTimeout(() => element.classList.remove("visible"), 2600);
|
||||
}
|
||||
|
||||
function setScreen(name) {
|
||||
byId("welcome-screen").hidden = name !== "welcome";
|
||||
byId("lobby-screen").hidden = name !== "lobby";
|
||||
byId("match-screen").hidden = name !== "match";
|
||||
}
|
||||
|
||||
function setConnection(connectionState, text) {
|
||||
byId("connection").dataset.state = connectionState;
|
||||
byId("connection-text").textContent = text;
|
||||
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
|
||||
}
|
||||
|
||||
function beginFeedback() {
|
||||
if (state.feedbackReady) return;
|
||||
state.feedbackReady = true;
|
||||
const AudioContext = windowObject.AudioContext || windowObject.webkitAudioContext;
|
||||
if (AudioContext) {
|
||||
try { state.audio = new AudioContext(); } catch (_) { state.audio = null; }
|
||||
}
|
||||
}
|
||||
|
||||
function feedback(success) {
|
||||
if (windowObject.navigator && typeof windowObject.navigator.vibrate === "function") {
|
||||
windowObject.navigator.vibrate(success ? 18 : [24, 35, 24]);
|
||||
}
|
||||
if (!state.audio || state.audio.state === "suspended") return;
|
||||
const oscillator = state.audio.createOscillator();
|
||||
const gain = state.audio.createGain();
|
||||
oscillator.frequency.value = success ? 560 : 150;
|
||||
oscillator.type = "square";
|
||||
gain.gain.setValueAtTime(.035, state.audio.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(.001, state.audio.currentTime + .06);
|
||||
oscillator.connect(gain).connect(state.audio.destination);
|
||||
oscillator.start(); oscillator.stop(state.audio.currentTime + .065);
|
||||
}
|
||||
|
||||
function setPending(requestId, element) {
|
||||
if (!requestId || !element) return;
|
||||
state.pending.set(requestId, element);
|
||||
element.classList.add("pending");
|
||||
}
|
||||
|
||||
function clearPending(requestId) {
|
||||
const element = state.pending.get(requestId);
|
||||
if (element) element.classList.remove("pending");
|
||||
state.pending.delete(requestId);
|
||||
}
|
||||
|
||||
function send(type, payload, element) {
|
||||
if (!state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
|
||||
showNotice("Нет связи с ESP. Команда осталась дома.", "error");
|
||||
return null;
|
||||
}
|
||||
const sequence = ++state.clientSequence;
|
||||
const requestId = `${type}-${sequence}`;
|
||||
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
||||
setPending(requestId, element);
|
||||
return requestId;
|
||||
}
|
||||
|
||||
function playerName() {
|
||||
if (!state.session || !state.lobby || state.session.role !== "player") return "Неизвестный герой";
|
||||
const player = state.lobby.players.find(item => item.slot === state.session.slot);
|
||||
return player && player.name ? player.name : `Игрок ${state.session.slot}`;
|
||||
}
|
||||
|
||||
function renderLobby() {
|
||||
if (!state.lobby) return;
|
||||
state.lobby.players.forEach(player => {
|
||||
const card = byId(`player-${player.slot}`);
|
||||
card.querySelector(".player-name").textContent = player.occupied ? player.name : "Свободно";
|
||||
card.querySelector(".player-state").textContent = player.connected ? "На связи" : player.occupied ? "Отошёл искать Wi‑Fi" : "Слот грустит";
|
||||
card.classList.toggle("online", !!player.connected);
|
||||
});
|
||||
byId("observer-count").textContent = state.lobby.observers;
|
||||
byId("lobby-phase").textContent = PHASES[state.lobby.phase] || "Сервер размышляет";
|
||||
const owner = state.session && state.session.role === "player" && state.session.slot === 1;
|
||||
byId("start-match").disabled = !(owner && state.lobby.ready && state.lobby.phase === "waiting");
|
||||
byId("launch-help").textContent = owner ? (state.lobby.ready ? "Оба героя здесь. Можно будить лабиринт." : "Ждём второго героя. Первый пока разминает стрелки.") : "Старт доступен Игроку 1, когда оба слота на месте.";
|
||||
if (state.session && state.lobby.phase !== "active") setScreen("lobby");
|
||||
}
|
||||
|
||||
function icon(name, className) {
|
||||
const svg = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const use = documentObject.createElementNS("http://www.w3.org/2000/svg", "use");
|
||||
use.setAttribute("href", `/icons.svg#${name}`);
|
||||
svg.appendChild(use);
|
||||
const wrapper = documentObject.createElement("span");
|
||||
wrapper.className = className;
|
||||
wrapper.appendChild(svg);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function addObject(cell, object) {
|
||||
const [kind, owner, variant] = object;
|
||||
let marker;
|
||||
if (kind === "key") {
|
||||
marker = icon("key", "object object-key"); marker.dataset.owner = `P${owner + 1}`;
|
||||
} else if (kind === "exit") {
|
||||
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = `P${owner + 1}`; marker.dataset.open = String(variant);
|
||||
} else if (kind === "compass") {
|
||||
marker = icon("arrow", "object object-compass");
|
||||
} else if (kind === "vision") {
|
||||
marker = icon("eye", "object object-vision");
|
||||
} else {
|
||||
marker = documentObject.createElement("span"); marker.className = "object object-landmark"; marker.textContent = ["♣", "✿", "♠", "⌂"][variant % 4] || "♣";
|
||||
}
|
||||
cell.appendChild(marker);
|
||||
}
|
||||
|
||||
function addPawn(cell, label, opponent) {
|
||||
const pawn = documentObject.createElement("span");
|
||||
pawn.className = opponent ? "pawn pawn-opponent" : "pawn pawn-self";
|
||||
const text = documentObject.createElement("span"); text.textContent = label;
|
||||
pawn.appendChild(text); cell.appendChild(pawn);
|
||||
}
|
||||
|
||||
function renderMap(snapshot) {
|
||||
const maze = byId("maze");
|
||||
maze.replaceChildren();
|
||||
if (!Number.isInteger(snapshot.width) || snapshot.width < 1 || !Number.isInteger(snapshot.height) || snapshot.height < 1 || !snapshot.cells.length) {
|
||||
byId("map-placeholder").hidden = false;
|
||||
return;
|
||||
}
|
||||
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1]}));
|
||||
const minX = Math.min(...coordinates.map(cell => cell.x)); const maxX = Math.max(...coordinates.map(cell => cell.x));
|
||||
const minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
|
||||
maze.style.gridTemplateColumns = `repeat(${maxX - minX + 1}, var(--cell))`;
|
||||
maze.style.gridTemplateRows = `repeat(${maxY - minY + 1}, var(--cell))`;
|
||||
const objects = new Map();
|
||||
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
||||
coordinates.forEach(data => {
|
||||
const cell = documentObject.createElement("span");
|
||||
cell.className = "cell";
|
||||
cell.style.gridColumn = String(data.x - minX + 1); cell.style.gridRow = String(data.y - minY + 1);
|
||||
if (!(data.passages & 1)) cell.classList.add("wall-n");
|
||||
if (!(data.passages & 2)) cell.classList.add("wall-e");
|
||||
if (!(data.passages & 4)) cell.classList.add("wall-s");
|
||||
if (!(data.passages & 8)) cell.classList.add("wall-w");
|
||||
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
|
||||
if (data.index === snapshot.self) addPawn(cell, `P${state.session ? state.session.slot : "?"}`, false);
|
||||
if (data.index === snapshot.opponent) addPawn(cell, "СОП", true);
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
byId("map-placeholder").hidden = true;
|
||||
}
|
||||
|
||||
function renderSnapshot(snapshot) {
|
||||
state.snapshot = snapshot;
|
||||
setScreen("match");
|
||||
const slot = state.session && state.session.slot ? state.session.slot : 1;
|
||||
byId("self-slot").textContent = `P${slot}`;
|
||||
byId("self-name").textContent = playerName();
|
||||
byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`;
|
||||
byId("match-screen").classList.toggle("player-two-mode", slot === 2);
|
||||
byId("match-state").textContent = PHASES[snapshot.phase] || "Гонка идёт";
|
||||
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ P${snapshot.inventory + 1}`;
|
||||
byId("drop-key").disabled = snapshot.inventory == null;
|
||||
byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет";
|
||||
byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0);
|
||||
const hint = HINTS[snapshot.hint] ? snapshot.hint : "none";
|
||||
byId("compass").textContent = HINTS[hint];
|
||||
byId("compass-stat").dataset.direction = hint;
|
||||
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
||||
renderMap(snapshot);
|
||||
if (snapshot.phase === "paused") showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false);
|
||||
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||
else hideModal();
|
||||
}
|
||||
|
||||
function showModal(title, text, kicker, countdown) {
|
||||
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
|
||||
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
|
||||
}
|
||||
|
||||
function hideModal() { byId("modal").hidden = true; byId("countdown").hidden = true; }
|
||||
|
||||
function renderResult(result) {
|
||||
const slot = state.session && state.session.slot;
|
||||
let title = "Ничья!"; let text = "Фотофиниш пожал плечами: оба выбрались почти одновременно.";
|
||||
if (result === "player1" || result === "player2") {
|
||||
const winner = result === "player1" ? 1 : 2;
|
||||
title = winner === slot ? "Победа!" : "Победил соперник";
|
||||
text = winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.";
|
||||
}
|
||||
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
|
||||
byId("modal-action").hidden = false;
|
||||
}
|
||||
|
||||
function showCountdown(payload) {
|
||||
showModal("Приготовились!", "Один сервер — один честный момент старта.", "СИНХРОННЫЙ СТАРТ", true);
|
||||
const steps = payload && Array.isArray(payload.steps) ? payload.steps : [3, 2, 1];
|
||||
let index = 0; byId("countdown").textContent = steps[index];
|
||||
const timer = windowObject.setInterval(() => { index++; if (index < steps.length) byId("countdown").textContent = steps[index]; else windowObject.clearInterval(timer); }, 1000);
|
||||
}
|
||||
|
||||
function localError(code) { return ERRORS[code] || "Сервер отклонил команду. Подробности остались у него."; }
|
||||
|
||||
function handleMessage(message) {
|
||||
if (!message || message.v !== 1 || !Number.isInteger(message.seq)) return;
|
||||
if (message.type === "view") {
|
||||
if (message.seq < state.viewSequence) return;
|
||||
state.viewSequence = Math.max(state.viewSequence, message.seq);
|
||||
const snapshot = state.assembler.accept(message);
|
||||
if (snapshot) renderSnapshot(snapshot);
|
||||
return;
|
||||
}
|
||||
if (message.seq <= state.serverSequence) return;
|
||||
state.serverSequence = message.seq;
|
||||
clearPending(message.requestId);
|
||||
if (message.type === "session") {
|
||||
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
|
||||
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
|
||||
if (state.lobby) renderLobby();
|
||||
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||
} else if (message.type === "name_suggestion") { byId("player-name").value = message.payload.name;
|
||||
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||
} else if (message.type === "active") { setScreen("match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750);
|
||||
} else if (message.type === "pause") { showModal("Пауза", "Второй герой переподключается. Ноги пока не считаются.", "ЖДЁМ СВЯЗЬ", false);
|
||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||
} else if (message.type === "ack") { feedback(true);
|
||||
} else if (message.type === "error") {
|
||||
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
|
||||
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
|
||||
showNotice(localError(message.payload.code), "error"); feedback(false);
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
windowObject.clearTimeout(state.reconnectTimer); state.clientSequence = 0; state.serverSequence = 0; state.viewSequence = -1; state.assembler = new SnapshotAssembler();
|
||||
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
|
||||
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
|
||||
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
|
||||
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Сервер прислал нечитаемый пакет.", "error"); return; } handleMessage(message); };
|
||||
state.socket.onclose = () => { setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error");
|
||||
}
|
||||
|
||||
function move(direction, element) {
|
||||
if (direction) send("move", {direction}, element || documentObject.querySelector(`[data-direction="${direction}"]`));
|
||||
}
|
||||
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
|
||||
documentObject.addEventListener("keydown", event => {
|
||||
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
|
||||
beginFeedback();
|
||||
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
|
||||
});
|
||||
byId("map-shell").addEventListener("touchstart", event => { const touch = event.changedTouches[0]; state.touchStart = {x: touch.clientX, y: touch.clientY}; }, {passive: true});
|
||||
byId("map-shell").addEventListener("touchend", event => { if (!state.touchStart) return; const touch = event.changedTouches[0]; move(directionFromSwipe(state.touchStart.x, state.touchStart.y, touch.clientX, touch.clientY), null); state.touchStart = null; }, {passive: true});
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button)));
|
||||
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
|
||||
byId("suggest-name").addEventListener("click", () => send("suggest_name", {}, byId("suggest-name")));
|
||||
byId("join-player").addEventListener("click", () => send("join", {role: "player", name: byId("player-name").value}, byId("join-player")));
|
||||
byId("join-observer").addEventListener("click", () => send("join", {role: "observer"}, byId("join-observer")));
|
||||
byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match")));
|
||||
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
|
||||
byId("modal-action").addEventListener("click", () => { hideModal(); setScreen("lobby"); });
|
||||
connect();
|
||||
return {state, handleMessage, move};
|
||||
}
|
||||
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, boot});
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
root.Maze32Player = api;
|
||||
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this);
|
||||
Reference in New Issue
Block a user