Files
maze32/web/player.js
T

910 lines
53 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.
(function (root) {
"use strict";
const TOKEN_KEY = "maze32.session.v1";
const MAX_VIEW_CHUNKS = 90;
const MAX_VIEW_WIDTH = 32;
const MAX_VIEW_HEIGHT = 32;
const MAX_VIEW_CELLS = 1024;
const MAX_VIEW_OBJECTS = 12;
const OBSERVER_CELL_SIZE = 8;
const AVATAR_TOUCH_TARGET_SIZE = 96;
const AVATAR_MAX_DEFLECTION = 28;
const AVATAR_DEAD_ZONE = 12;
const AVATAR_DIRECTION_HYSTERESIS = .18;
const AVATAR_SLOW_REPEAT_MS = 360;
const AVATAR_FAST_REPEAT_MS = 170;
const DIRECTIONAL_PAD_REPEAT_MS = 170;
const LATENCY_SAMPLE_LIMIT = 256;
const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
Object.freeze({icon: "spikes", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}),
Object.freeze({icon: "crystal", title: "Кристалл расширяет карту", text: "Возьми кристалл — увидишь дальше."}),
Object.freeze({icon: "arrow", title: "Стрелка знает путь", text: "Компас показывает сторону выхода."}),
Object.freeze({icon: "exit-closed", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
Object.freeze({icon: "friends", title: "Встретил друга", text: "Другой герой может отдать найденный ключ."})
]);
const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]);
const NAME_NOUNS = Object.freeze(["Барсук", "Крот", "Енот", "Кабан", "Кактус", "Жук", "Тостер", "Тюлень", "Гриб", "Ежик", "Бублик", "Пингвин"]);
const NAME_COMBINATION_COUNT = NAME_ADJECTIVES.length * NAME_NOUNS.length;
const LOGIN_HEADLINES = Object.freeze([
Object.freeze({first: "Кусты уже", highlight: "потеряли выход"}),
Object.freeze({first: "Лабиринт снова", highlight: "подстригли криво"}),
Object.freeze({first: "Пиксели дружно", highlight: "ушли в кусты"}),
Object.freeze({first: "Карта молчит", highlight: "но смотрит косо"}),
Object.freeze({first: "Выход рядом", highlight: "в другой вселенной"}),
Object.freeze({first: "Крот сказал", highlight: "что это срез"}),
Object.freeze({first: "Тупик машет", highlight: "обеими стенами"}),
Object.freeze({first: "Компас уверен", highlight: "почти наверняка"})
]);
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: "Сейчас эту команду выполнять нельзя.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Команда потеряла слова по дороге.", protocol_error: "Лабиринт не понял этот знак.", request_rejected: "Лабиринт пока не принимает эту затею.", no_suggestion: "Все смешные имена уже разобрали."});
function randomIndex(limit, randomSource) {
if (!Number.isInteger(limit) || limit < 1) return 0;
if (typeof randomSource === "function") {
const value = randomSource(limit);
return Number.isInteger(value) && value >= 0 && value < limit ? value : 0;
}
if (root.crypto && typeof root.crypto.getRandomValues === "function") {
const maximum = Math.floor(0x100000000 / limit) * limit;
const values = new Uint32Array(1);
for (let attempt = 0; attempt < 4; attempt++) {
root.crypto.getRandomValues(values);
if (values[0] < maximum) return values[0] % limit;
}
return values[0] % limit;
}
return Math.floor(Math.random() * limit);
}
function randomSuggestedName(occupiedNames, randomSource) {
const occupied = new Set(Array.isArray(occupiedNames) ? occupiedNames : []);
for (let attempt = 0; attempt < NAME_COMBINATION_COUNT; attempt++) {
const adjective = NAME_ADJECTIVES[randomIndex(NAME_ADJECTIVES.length, randomSource)];
const noun = NAME_NOUNS[randomIndex(NAME_NOUNS.length, randomSource)];
const name = `${adjective}-${noun}`;
if (!occupied.has(name)) return name;
}
for (let combination = 0; combination < NAME_COMBINATION_COUNT; combination++) {
const adjective = NAME_ADJECTIVES[Math.floor(combination / NAME_NOUNS.length)];
const noun = NAME_NOUNS[combination % NAME_NOUNS.length];
const name = `${adjective}-${noun}`;
if (!occupied.has(name)) return name;
}
return null;
}
function randomLoginHeadline(randomSource) {
return LOGIN_HEADLINES[randomIndex(LOGIN_HEADLINES.length, randomSource)];
}
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 clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function avatarDirection(dx, dy, previousDirection) {
const horizontal = Math.abs(dx);
const vertical = Math.abs(dy);
if (Math.max(horizontal, vertical) < AVATAR_DEAD_ZONE) return null;
const previousWasHorizontal = previousDirection === "east" || previousDirection === "west";
const previousWasVertical = previousDirection === "north" || previousDirection === "south";
if (previousWasHorizontal && horizontal >= vertical * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dx >= 0 ? "east" : "west";
if (previousWasVertical && vertical >= horizontal * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dy >= 0 ? "south" : "north";
return horizontal >= vertical ? (dx >= 0 ? "east" : "west") : (dy >= 0 ? "south" : "north");
}
function avatarRepeatMs(distance, targetRadius) {
if (!Number.isFinite(distance) || !Number.isFinite(targetRadius) || distance <= AVATAR_DEAD_ZONE || targetRadius <= AVATAR_DEAD_ZONE) return null;
const strength = clamp((distance - AVATAR_DEAD_ZONE) / (targetRadius - AVATAR_DEAD_ZONE), 0, 1);
return Math.round(AVATAR_SLOW_REPEAT_MS - (AVATAR_SLOW_REPEAT_MS - AVATAR_FAST_REPEAT_MS) * strength);
}
function avatarVector(dx, dy, targetRadius, previousDirection) {
const distance = Math.hypot(dx, dy);
const inputDistance = Math.min(distance, targetRadius);
const deflection = Math.min(inputDistance, AVATAR_MAX_DEFLECTION);
const ratio = distance > 0 ? deflection / distance : 0;
return {
x: dx * ratio,
y: dy * ratio,
distance: deflection,
direction: avatarDirection(dx, dy, previousDirection),
repeatMs: avatarRepeatMs(inputDistance, targetRadius)
};
}
class LatestIntentFlow {
constructor(dispatch) {
this.dispatch = dispatch;
this.inFlight = null;
this.latest = null;
}
request(direction, element) {
if (!direction) return null;
if (this.inFlight) {
this.latest = {direction, element};
return null;
}
const requestId = this.dispatch(direction, element);
if (requestId) this.inFlight = requestId;
return requestId;
}
complete(requestId) {
if (!requestId || requestId !== this.inFlight) return null;
this.inFlight = null;
const latest = this.latest;
this.latest = null;
return latest ? this.request(latest.direction, latest.element) : null;
}
release() { this.latest = null; }
disconnect() {
this.inFlight = null;
this.latest = null;
}
}
function playerCellSize(stageWidth, stageHeight, columns, rows) {
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 46;
return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72));
}
function playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize) {
if (![selfIndex, width, minX, minY, columns, rows, cellSize].every(Number.isFinite) || !Number.isInteger(selfIndex) || selfIndex < 0 || width < 1 || columns < 1 || rows < 1 || cellSize < 1) return {x: 0, y: 0};
const selfX = selfIndex % width - minX;
const selfY = Math.floor(selfIndex / width) - minY;
return {
x: (columns / 2 - selfX - .5) * cellSize,
y: (rows / 2 - selfY - .5) * cellSize
};
}
function nextOnboardingStep(currentStep) {
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
}
class LatencyTracker {
constructor(now) {
this.now = typeof now === "function" ? now : () => root.performance.now();
this.pending = new Map();
this.samples = [];
this.pointerSamples = [];
}
sent(requestId) {
if (!requestId) return;
this.pending.set(requestId, {sent: this.now(), acknowledged: null, stateSequence: null});
if (this.pending.size > LATENCY_SAMPLE_LIMIT) this.pending.delete(this.pending.keys().next().value);
}
acknowledged(requestId, stateSequence) {
const sample = this.pending.get(requestId);
if (!sample) return;
sample.acknowledged = this.now();
sample.stateSequence = Number.isInteger(stateSequence) ? stateSequence : null;
}
painted(stateSequence) {
for (const [requestId, sample] of this.pending) {
if (sample.acknowledged == null || sample.stateSequence !== stateSequence) continue;
const painted = this.now();
this.samples.push({requestId, commandToAckMs: sample.acknowledged - sample.sent,
commandToPaintMs: painted - sample.sent, ackToPaintMs: painted - sample.acknowledged});
if (this.samples.length > LATENCY_SAMPLE_LIMIT) this.samples.shift();
this.pending.delete(requestId);
}
}
snapshot() { return this.samples.map(sample => Object.assign({}, sample)); }
pointerPainted(startedAt) {
if (!Number.isFinite(startedAt)) return;
this.pointerSamples.push(this.now() - startedAt);
if (this.pointerSamples.length > LATENCY_SAMPLE_LIMIT) this.pointerSamples.shift();
}
pointerSnapshot() { return this.pointerSamples.slice(); }
}
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 || snapshot.width > MAX_VIEW_WIDTH || !Number.isInteger(snapshot.height) || snapshot.height < 1 || snapshot.height > MAX_VIEW_HEIGHT || 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, {
stateSequence: message.seq,
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 clampObserverZoom(value) {
return Math.min(6, Math.max(.25, value));
}
function observerFitScale(stageWidth, stageHeight, columns, rows) {
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 1;
return clampObserverZoom(Math.min(stageWidth / (columns * OBSERVER_CELL_SIZE), stageHeight / (rows * OBSERVER_CELL_SIZE)) * .96);
}
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, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, dpad: {pointerId: null, direction: null, repeatTimer: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
function showNotice(text, kind, priority, persistent, key) {
const element = byId("notice");
const nextPriority = priority == null ? 1 : priority;
if (state.rail.persistent && !persistent && nextPriority < state.rail.priority) return;
if (state.rail.key === key && element.textContent === text) return;
element.textContent = text;
element.className = `message-rail ${kind || ""}`;
element.hidden = false;
windowObject.clearTimeout(state.noticeTimer);
state.rail = {priority: nextPriority, key: key || null, persistent: !!persistent};
if (!persistent) state.noticeTimer = windowObject.setTimeout(() => { element.hidden = true; state.rail = {priority: 0, key: null, persistent: false}; }, 2600);
}
function clearEffects() {
windowObject.clearTimeout(state.effects.impactTimer);
windowObject.clearTimeout(state.effects.rateTimer);
state.effects.impactTimer = null;
state.effects.rateTimer = null;
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west");
}
function showImpact(direction) {
if (!direction) return;
const maze = byId("maze");
const bump = byId("wall-bump");
windowObject.clearTimeout(state.effects.impactTimer);
maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
void maze.offsetWidth;
maze.classList.add(`impact-${direction}`);
bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
bump.classList.add(`impact-${direction}`);
bump.classList.add("show");
state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); }, 340);
}
function showRateLimit() {
const maze = byId("maze");
windowObject.clearTimeout(state.effects.rateTimer);
maze.classList.add("rate-limited");
state.effects.rateTimer = windowObject.setTimeout(() => maze.classList.remove("rate-limited"), 520);
}
function setScreen(name) {
byId("welcome-screen").hidden = name !== "welcome";
byId("lobby-screen").hidden = name !== "lobby";
byId("match-screen").hidden = name !== "match";
byId("observer-screen").hidden = name !== "observer";
}
function renderWelcomeHeadline(headline) {
const title = byId("welcome-title");
const highlight = documentObject.createElement("span");
highlight.textContent = headline.highlight;
title.replaceChildren(documentObject.createTextNode(headline.first), documentObject.createElement("br"), highlight);
}
function setConnection(connectionState, text) {
byId("connection").dataset.state = connectionState;
byId("connection-text").textContent = text;
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); }
else if (state.rail.persistent) showNotice("Связь вернулась! Кусты снова в игре.", "success", 3, false, "online");
}
renderWelcomeHeadline(randomLoginHeadline());
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 (state.muted) return;
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);
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
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;
const ownerVariant = owner === 1 ? "two" : "one";
if (kind === "key") {
marker = icon("key", "object object-key"); marker.dataset.owner = ownerVariant;
} else if (kind === "exit") {
marker = icon(variant ? "exit-open" : "exit-closed", "object object-exit"); marker.dataset.owner = ownerVariant; marker.dataset.open = String(variant);
} else if (kind === "compass") {
marker = icon("arrow", "object object-compass");
} else if (kind === "vision") {
marker = icon("crystal", "object object-vision");
} else {
marker = documentObject.createElement("span"); marker.className = "object object-landmark"; marker.textContent = ["♣", "✿", "♠", "⌂"][variant % 4] || "♣";
}
cell.appendChild(marker);
}
function addPawn(cell, slot, opponent) {
const pawn = icon("player", `pawn ${opponent ? "pawn-opponent" : "pawn-self"} pawn-owner-${slot === 2 ? "two" : "one"}`);
cell.appendChild(pawn);
}
function characterForSlot(slot) {
return slot === 2 ? {name: "Синий герой", keyName: "синего игрока", owner: "two"} : {name: "Жёлтый герой", keyName: "жёлтого игрока", owner: "one"};
}
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));
const columns = maxX - minX + 1;
const rows = maxY - minY + 1;
const mapShell = byId("map-shell");
const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows);
const offset = playerMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize);
maze.style.setProperty("--cell", `${cellSize}px`);
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
maze.style.setProperty("--map-offset-x", `${offset.x}px`);
maze.style.setProperty("--map-offset-y", `${offset.y}px`);
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));
const slot = state.session ? state.session.slot : 1;
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
maze.appendChild(cell);
});
byId("map-placeholder").hidden = true;
}
function renderSnapshot(snapshot) {
if (snapshot.role === "observer") {
renderObserverSnapshot(snapshot);
return;
}
state.snapshot = snapshot;
setScreen("match");
const slot = state.session && state.session.slot ? state.session.slot : 1;
const character = characterForSlot(slot);
byId("self-slot").setAttribute("aria-label", character.name);
byId("avatar-control").setAttribute("aria-label", `Тянуть героя ${character.name} для движения`);
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 ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).keyName}`;
byId("inventory-stat").dataset.filled = String(snapshot.inventory != null);
byId("drop-key").disabled = snapshot.inventory == null;
setDirectionalPadEnabled(snapshot.phase === "active" && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN);
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 (Number.isInteger(snapshot.stateSequence)) {
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
}
if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else {
hideModal();
if (!state.onboardingSeen) showOnboarding(0);
}
}
function applyObserverTransform() {
const transform = state.observer;
byId("observer-maze").style.transform = `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`;
}
function resetObserverView(snapshot) {
const stage = byId("observer-stage");
state.observer.scale = observerFitScale(stage.clientWidth, stage.clientHeight, snapshot.width, snapshot.height);
state.observer.x = 0;
state.observer.y = 0;
applyObserverTransform();
}
function renderObserverSnapshot(snapshot) {
if (!snapshot.cells.length && state.snapshot && state.snapshot.role === "observer" && state.snapshot.width === snapshot.width && state.snapshot.height === snapshot.height) {
snapshot.cells = state.snapshot.cells;
}
state.snapshot = snapshot;
setScreen("observer");
const maze = byId("observer-maze");
maze.replaceChildren();
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
const objects = new Map();
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
const players = Array.isArray(snapshot.players) ? snapshot.players : [];
snapshot.cells.forEach(data => {
const index = data[0]; const passages = data[1];
const cell = documentObject.createElement("span");
cell.className = "cell";
cell.style.gridColumn = String(index % snapshot.width + 1);
cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1);
if (!(passages & 1)) cell.classList.add("wall-n");
if (!(passages & 2)) cell.classList.add("wall-e");
if (!(passages & 4)) cell.classList.add("wall-s");
if (!(passages & 8)) cell.classList.add("wall-w");
(objects.get(index) || []).forEach(object => addObject(cell, object));
players.forEach((player, playerIndex) => {
if (player.cell === index && !player.exited) {
const pawn = icon("player", `pawn pawn-player-${playerIndex === 0 ? "one" : "two"} pawn-owner-${playerIndex === 0 ? "one" : "two"}`);
cell.appendChild(pawn);
}
});
maze.appendChild(cell);
});
players.forEach((player, index) => {
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
});
byId("observer-phase").textContent = PHASES[snapshot.phase] || "Сервер размышляет";
const dimensions = `${snapshot.width}x${snapshot.height}`;
if (!state.observer.initialized || state.observer.dimensions !== dimensions) {
resetObserverView(snapshot); state.observer.initialized = true; state.observer.dimensions = dimensions;
} else applyObserverTransform();
if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else if (snapshot.phase !== "paused") hideModal();
}
function showModal(title, text, kicker, countdown, iconName) {
byId("modal-icon").querySelector("use").setAttribute("href", `/icons.svg#${iconName || "hourglass"}`);
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 renderOnboardingStep(step) {
const scene = ONBOARDING_STEPS[step] || ONBOARDING_STEPS[0];
byId("onboarding-icon-use").setAttribute("href", `/icons.svg#${scene.icon}`);
byId("onboarding-title").textContent = scene.title;
byId("onboarding-text").textContent = scene.text;
documentObject.querySelectorAll("#onboarding-dots span").forEach((dot, index) => dot.classList.toggle("active", index === step));
byId("onboarding-card").dataset.step = String(step);
byId("onboarding-next").textContent = step === ONBOARDING_STEPS.length - 1 ? "В лабиринт" : "Дальше";
}
function showOnboarding(step) {
state.onboardingSeen = true;
state.onboardingStep = clamp(step, 0, ONBOARDING_STEPS.length - 1);
renderOnboardingStep(state.onboardingStep);
byId("onboarding").hidden = false;
}
function hideOnboarding() { byId("onboarding").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 = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`;
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
}
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false, "exit-open");
resetPlayerControls();
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
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 === "countdown") { showCountdown(message.payload);
} else if (message.type === "active") { setScreen(state.session && state.session.role === "observer" ? "observer" : "match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750);
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
} else if (message.type === "match_result") { renderResult(message.payload.result);
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetPlayerControls(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveDirections.delete(message.requestId); state.moveFlow.complete(message.requestId); feedback(true);
} else if (message.type === "error") {
const direction = state.moveDirections.get(message.requestId);
state.moveDirections.delete(message.requestId);
state.moveFlow.complete(message.requestId);
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
if (message.payload.code === "blocked") { showImpact(direction); feedback(false); return; }
if (message.payload.code === "rate_limited") { showRateLimit(); feedback(false); return; }
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 = () => { state.moveFlow.disconnect(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
}
function move(direction, element) {
if (direction) state.moveFlow.request(direction, element);
}
function sendMoveNow(direction, element) {
return send("move", {direction}, element || byId("avatar-control"));
}
function setDirectionalPadEnabled(enabled) {
documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.disabled = !enabled;
});
if (!enabled) resetDirectionalPad();
}
function resetDirectionalPad() {
windowObject.clearTimeout(state.dpad.repeatTimer);
state.dpad.pointerId = null;
state.dpad.direction = null;
state.dpad.repeatTimer = null;
documentObject.querySelectorAll("[data-direction]").forEach(button => button.classList.remove("active"));
state.moveFlow.release();
}
function repeatDirectionalPadMove() {
if (!state.dpad.direction || state.dpad.pointerId == null) return;
move(state.dpad.direction, documentObject.querySelector(`[data-direction="${state.dpad.direction}"]`));
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
}
function resetPlayerControls() {
resetAvatarControl();
resetDirectionalPad();
}
function resetAvatarControl() {
windowObject.clearTimeout(state.avatar.repeatTimer);
state.avatar.pointerId = null;
state.avatar.startX = null;
state.avatar.startY = null;
state.avatar.direction = null;
state.avatar.repeatTimer = null;
state.avatar.repeatMs = null;
state.moveFlow.release();
const control = byId("avatar-control");
const handle = byId("avatar-handle");
if (control) {
control.classList.remove("active");
control.removeAttribute("data-direction");
control.dataset.speed = "stop";
}
if (handle) handle.style.transform = "translate(-50%, -50%)";
}
function repeatAvatarMove() {
const avatar = state.avatar;
if (!avatar.direction || avatar.pointerId == null || !avatar.repeatMs) return;
move(avatar.direction);
avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, avatar.repeatMs);
}
function updateAvatarControl(event) {
const pointerStartedAt = windowObject.performance.now();
const control = byId("avatar-control");
const rect = control.getBoundingClientRect();
const radius = Math.max(AVATAR_DEAD_ZONE + 1, Math.min(rect.width, rect.height) / 2);
const vector = avatarVector(event.clientX - state.avatar.startX, event.clientY - state.avatar.startY, radius, state.avatar.direction);
const handle = byId("avatar-handle");
handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`;
const directionChanged = vector.direction !== state.avatar.direction;
state.avatar.direction = vector.direction;
state.avatar.repeatMs = vector.repeatMs;
control.dataset.speed = vector.repeatMs == null ? "stop" : vector.repeatMs <= 220 ? "fast" : "slow";
if (vector.direction) control.dataset.direction = vector.direction;
else control.removeAttribute("data-direction");
windowObject.clearTimeout(state.avatar.repeatTimer);
state.avatar.repeatTimer = null;
if (vector.direction) {
if (directionChanged) move(vector.direction);
state.avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, vector.repeatMs);
}
windowObject.requestAnimationFrame(() => state.latency.pointerPainted(pointerStartedAt));
}
function setMuted(muted) {
state.muted = muted;
const button = byId("mute-toggle");
button.setAttribute("aria-pressed", String(muted));
button.setAttribute("aria-label", muted ? "Включить звук" : "Выключить звук");
button.title = muted ? "Включить звук" : "Выключить звук";
button.querySelector("use").setAttribute("href", muted ? "/icons.svg#sound" : "/icons.svg#sound");
}
function setReducedMotion(reduced) {
state.reducedMotion = reduced;
documentObject.documentElement.dataset.reducedMotion = String(reduced);
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
}
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 => { if (event.target.closest("#avatar-control")) return; const touch = event.changedTouches[0]; state.touchStart = {x: touch.clientX, y: touch.clientY}; }, {passive: true});
byId("map-shell").addEventListener("touchend", event => { if (event.target.closest("#avatar-control") || !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});
byId("avatar-control").addEventListener("pointerdown", event => {
event.preventDefault();
beginFeedback();
state.avatar.pointerId = event.pointerId;
state.avatar.startX = event.clientX;
state.avatar.startY = event.clientY;
byId("avatar-control").classList.add("active");
if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId);
updateAvatarControl(event);
});
byId("avatar-control").addEventListener("pointermove", event => {
if (event.pointerId === state.avatar.pointerId) updateAvatarControl(event);
});
const stopAvatarControl = event => {
if (event.pointerId === state.avatar.pointerId) resetAvatarControl();
};
byId("avatar-control").addEventListener("pointerup", stopAvatarControl);
byId("avatar-control").addEventListener("pointercancel", stopAvatarControl);
byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl);
windowObject.addEventListener("blur", resetPlayerControls);
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); });
documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.addEventListener("pointerdown", event => {
if (button.disabled) return;
event.preventDefault();
beginFeedback();
resetDirectionalPad();
state.dpad.pointerId = event.pointerId;
state.dpad.direction = button.dataset.direction;
button.classList.add("active");
if (button.setPointerCapture) button.setPointerCapture(event.pointerId);
move(state.dpad.direction, button);
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
});
const stopDirectionalPad = event => {
if (event.pointerId === state.dpad.pointerId) resetDirectionalPad();
};
button.addEventListener("pointerup", stopDirectionalPad);
button.addEventListener("pointercancel", stopDirectionalPad);
button.addEventListener("lostpointercapture", stopDirectionalPad);
button.addEventListener("contextmenu", event => event.preventDefault());
button.addEventListener("click", event => {
if (event.detail === 0 && !button.disabled) move(button.dataset.direction, button);
});
});
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
byId("onboarding-replay").addEventListener("click", () => showOnboarding(0));
byId("onboarding-next").addEventListener("click", () => {
const next = nextOnboardingStep(state.onboardingStep);
if (next == null) hideOnboarding();
else { state.onboardingStep = next; renderOnboardingStep(next); }
});
byId("onboarding-skip").addEventListener("click", hideOnboarding);
byId("suggest-name").addEventListener("click", () => {
const occupiedNames = state.lobby && Array.isArray(state.lobby.players) ? state.lobby.players.filter(player => player.occupied && typeof player.name === "string").map(player => player.name) : [];
const suggestion = randomSuggestedName(occupiedNames);
if (suggestion) byId("player-name").value = suggestion;
else showNotice(localError("no_suggestion"), "error");
});
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("zoom-in").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale * 1.25); applyObserverTransform(); });
byId("zoom-out").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale / 1.25); applyObserverTransform(); });
byId("zoom-reset").addEventListener("click", () => { if (state.snapshot) resetObserverView(state.snapshot); });
byId("observer-stage").addEventListener("wheel", event => { event.preventDefault(); state.observer.scale = clampObserverZoom(state.observer.scale * (event.deltaY < 0 ? 1.12 : .89)); applyObserverTransform(); }, {passive: false});
byId("observer-stage").addEventListener("pointerdown", event => { state.observer.dragging = {id: event.pointerId, x: event.clientX, y: event.clientY, originX: state.observer.x, originY: state.observer.y}; byId("observer-stage").classList.add("dragging"); if (byId("observer-stage").setPointerCapture) byId("observer-stage").setPointerCapture(event.pointerId); });
byId("observer-stage").addEventListener("pointermove", event => { const drag = state.observer.dragging; if (!drag || drag.id !== event.pointerId) return; state.observer.x = drag.originX + event.clientX - drag.x; state.observer.y = drag.originY + event.clientY - drag.y; applyObserverTransform(); });
const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } };
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
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", () => {
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
send("restart", {}, byId("modal-action"));
} else {
hideModal(); setScreen("lobby");
}
});
connect();
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
}
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, 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);