943 lines
56 KiB
JavaScript
943 lines
56 KiB
JavaScript
(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 playerFocalPoint(stageWidth, stageHeight) {
|
||
if (![stageWidth, stageHeight].every(value => Number.isFinite(value) && value > 0)) return {x: 0, y: 0};
|
||
if (stageWidth > stageHeight) return {x: Math.round(stageWidth * .42), y: Math.round(stageHeight * .45)};
|
||
return {x: Math.round(stageWidth / 2), y: Math.round(stageHeight * .3)};
|
||
}
|
||
|
||
function playerFocalMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize, stageWidth, stageHeight) {
|
||
const centered = playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize);
|
||
const focal = playerFocalPoint(stageWidth, stageHeight);
|
||
return {x: centered.x + focal.x - stageWidth / 2, y: centered.y + focal.y - stageHeight / 2};
|
||
}
|
||
|
||
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 === 3 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 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");
|
||
documentObject.querySelectorAll(".cell.bush-impact").forEach(cell => cell.classList.remove("bush-impact", "bush-stage-1", "bush-stage-2", "bush-stage-3", "bush-stage-4"));
|
||
}
|
||
|
||
function showImpact(direction, hits) {
|
||
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");
|
||
const stage = Math.min(4, Math.max(1, Number.isInteger(hits) ? hits : 1));
|
||
const target = state.snapshot && documentObject.querySelector(`.cell[data-cell-index="${state.snapshot.self}"]`);
|
||
if (target && Number.isInteger(hits) && hits > 0) target.classList.add("bush-impact", `bush-stage-${stage}`);
|
||
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"); if (target) target.classList.remove("bush-impact", `bush-stage-${stage}`); }, 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], opened: cell[2]}));
|
||
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 focal = playerFocalPoint(mapShell.clientWidth, mapShell.clientHeight);
|
||
const offset = playerFocalMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize, mapShell.clientWidth, mapShell.clientHeight);
|
||
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`);
|
||
mapShell.style.setProperty("--player-focal-x", `${focal.x}px`);
|
||
mapShell.style.setProperty("--player-focal-y", `${focal.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.dataset.cellIndex = String(data.index);
|
||
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");
|
||
if (data.opened & 1) cell.classList.add("trampled-n");
|
||
if (data.opened & 2) cell.classList.add("trampled-e");
|
||
if (data.opened & 4) cell.classList.add("trampled-s");
|
||
if (data.opened & 8) cell.classList.add("trampled-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 opened = data[2];
|
||
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");
|
||
if (opened & 1) cell.classList.add("trampled-n");
|
||
if (opened & 2) cell.classList.add("trampled-e");
|
||
if (opened & 4) cell.classList.add("trampled-s");
|
||
if (opened & 8) cell.classList.add("trampled-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, message.payload.breakthroughHits); 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);
|
||
const refreshPlayerFocalPoint = () => {
|
||
if (state.snapshot && state.snapshot.role === "player") windowObject.requestAnimationFrame(() => renderMap(state.snapshot));
|
||
};
|
||
windowObject.addEventListener("resize", refreshPlayerFocalPoint);
|
||
windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint);
|
||
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_ADJECTIVES, NAME_NOUNS, 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, playerFocalPoint, playerFocalMapOffset, 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);
|