(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 JOYSTICK_DEAD_ZONE = 20; const JOYSTICK_HYSTERESIS = .18; const JOYSTICK_SLOW_REPEAT_MS = 360; const JOYSTICK_FAST_REPEAT_MS = 170; const ONBOARDING_STEPS = Object.freeze([ Object.freeze({icon: "hand", title: "Потяни кружок", text: "Кружок ведёт героя по лабиринту."}), Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}), Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}), Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}), Object.freeze({icon: "eye", title: "Глаз расширяет карту", text: "Возьми глаз — увидишь дальше."}), Object.freeze({icon: "arrow", title: "Стрелка знает путь", text: "Компас показывает сторону выхода."}), Object.freeze({icon: "exit", 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: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", 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 joystickDirection(dx, dy, previousDirection) { const horizontal = Math.abs(dx); const vertical = Math.abs(dy); if (Math.max(horizontal, vertical) < JOYSTICK_DEAD_ZONE) return null; const previousWasHorizontal = previousDirection === "east" || previousDirection === "west"; const previousWasVertical = previousDirection === "north" || previousDirection === "south"; if (previousWasHorizontal && horizontal >= vertical * (1 - JOYSTICK_HYSTERESIS)) return dx >= 0 ? "east" : "west"; if (previousWasVertical && vertical >= horizontal * (1 - JOYSTICK_HYSTERESIS)) return dy >= 0 ? "south" : "north"; return horizontal >= vertical ? (dx >= 0 ? "east" : "west") : (dy >= 0 ? "south" : "north"); } function joystickRepeatMs(distance, radius) { if (!Number.isFinite(distance) || !Number.isFinite(radius) || distance <= JOYSTICK_DEAD_ZONE || radius <= JOYSTICK_DEAD_ZONE) return null; const strength = clamp((distance - JOYSTICK_DEAD_ZONE) / (radius - JOYSTICK_DEAD_ZONE), 0, 1); return Math.round(JOYSTICK_SLOW_REPEAT_MS - (JOYSTICK_SLOW_REPEAT_MS - JOYSTICK_FAST_REPEAT_MS) * strength); } function joystickVector(dx, dy, radius, previousDirection) { const distance = Math.hypot(dx, dy); const boundedDistance = Math.min(distance, radius); const ratio = distance > 0 ? boundedDistance / distance : 0; return { x: dx * ratio, y: dy * ratio, distance: boundedDistance, direction: joystickDirection(dx, dy, previousDirection), repeatMs: joystickRepeatMs(boundedDistance, radius) }; } 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 nextOnboardingStep(currentStep) { return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1; } 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, { 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, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, joystick: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}}; 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"; 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 !== "online") resetJoystick(); } 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); 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 = owner === 1 ? "two" : "one"; } else if (kind === "exit") { marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = owner === 1 ? "two" : "one"; 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 characterForSlot(slot) { return slot === 2 ? {symbol: "◆", name: "Мышонок"} : {symbol: "●", name: "Лисёнок"}; } 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"); maze.style.setProperty("--cell", `${playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows)}px`); maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`; maze.style.gridTemplateRows = `repeat(${rows}, 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)); const slot = state.session ? state.session.slot : 1; if (data.index === snapshot.self) addPawn(cell, characterForSlot(slot).symbol, false); if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, 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").textContent = character.symbol; byId("self-slot").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).symbol}`; byId("inventory-stat").dataset.filled = String(snapshot.inventory != null); 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") { resetJoystick(); 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 = documentObject.createElement("span"); pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`; const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`; pawn.appendChild(label); 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) { 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); resetJoystick(); 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(); resetJoystick(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success"); } 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}"]`)); } function resetJoystick() { windowObject.clearTimeout(state.joystick.repeatTimer); state.joystick.pointerId = null; state.joystick.direction = null; state.joystick.repeatTimer = null; state.joystick.repeatMs = null; const joystick = byId("joystick"); const handle = byId("joystick-handle"); if (joystick) joystick.classList.remove("active"); if (handle) handle.style.transform = "translate(-50%, -50%)"; } function repeatJoystickMove() { const joystick = state.joystick; if (!joystick.direction || joystick.pointerId == null || !joystick.repeatMs) return; move(joystick.direction, byId("joystick")); joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, joystick.repeatMs); } function updateJoystick(event) { const joystick = byId("joystick"); const rect = joystick.getBoundingClientRect(); const radius = Math.max(1, Math.min(rect.width, rect.height) / 2 - 33); const vector = joystickVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.joystick.direction); byId("joystick-handle").style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`; const directionChanged = vector.direction !== state.joystick.direction; state.joystick.direction = vector.direction; state.joystick.repeatMs = vector.repeatMs; windowObject.clearTimeout(state.joystick.repeatTimer); state.joystick.repeatTimer = null; if (vector.direction) { if (directionChanged) move(vector.direction, joystick); state.joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, vector.repeatMs); } } 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 => { 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}); byId("joystick").addEventListener("pointerdown", event => { event.preventDefault(); beginFeedback(); state.joystick.pointerId = event.pointerId; byId("joystick").classList.add("active"); if (byId("joystick").setPointerCapture) byId("joystick").setPointerCapture(event.pointerId); updateJoystick(event); }); byId("joystick").addEventListener("pointermove", event => { if (event.pointerId === state.joystick.pointerId) updateJoystick(event); }); const stopJoystick = event => { if (event.pointerId === state.joystick.pointerId) resetJoystick(); }; byId("joystick").addEventListener("pointerup", stopJoystick); byId("joystick").addEventListener("pointercancel", stopJoystick); windowObject.addEventListener("blur", resetJoystick); documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetJoystick(); }); 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("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, resetJoystick}; } const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, JOYSTICK_DEAD_ZONE, JOYSTICK_HYSTERESIS, JOYSTICK_SLOW_REPEAT_MS, JOYSTICK_FAST_REPEAT_MS, directionFromKey, directionFromSwipe, joystickDirection, joystickRepeatMs, joystickVector, playerCellSize, 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);