feat(session): add explicit leave and immediate slot release
This commit is contained in:
@@ -13,15 +13,18 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
||||
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
|
||||
<span class="quality-pill" id="quality" data-state="unknown" role="status" aria-live="polite" aria-label="Отклик неизвестен"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span id="quality-text">—</span></span>
|
||||
<button class="icon-button" id="settings-toggle" type="button" aria-controls="settings-menu" aria-expanded="false" aria-label="Открыть настройки"><svg aria-hidden="true"><use href="/icons.svg#gear"></use></svg></button>
|
||||
</header>
|
||||
<aside class="settings-menu" id="settings-menu" hidden aria-label="Настройки">
|
||||
<strong>Настройки</strong>
|
||||
<span class="settings-details" id="details-text">Подключение ещё не готово.</span>
|
||||
<span class="settings-quality" id="quality-details">Отклик: неизвестен</span>
|
||||
<button class="settings-item" id="settings-mute" type="button" aria-pressed="false" hidden>Звук: вкл</button>
|
||||
<button class="settings-item" id="settings-motion" type="button" aria-pressed="false" hidden>Движение: вкл</button>
|
||||
<button class="settings-item" id="settings-help" type="button" hidden>Показать обучение</button>
|
||||
<a class="settings-item" id="settings-wifi" href="/provision">Настроить Wi‑Fi</a>
|
||||
<button class="settings-item settings-exit" id="settings-leave" type="button" hidden>Выйти из игры</button>
|
||||
</aside>
|
||||
|
||||
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
|
||||
@@ -86,6 +89,7 @@
|
||||
</main>
|
||||
|
||||
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
|
||||
<section class="modal" id="leave-confirmation" hidden role="dialog" aria-modal="true" aria-labelledby="leave-title"><div class="modal-card"><p class="eyebrow">Подтверждение</p><h2 id="leave-title">Выйти из игры?</h2><p id="leave-text"></p><div class="button-row"><button class="button primary" id="leave-accept" type="button">Выйти</button><button class="button" id="leave-cancel" type="button">Остаться</button></div></div></section>
|
||||
<script src="/player.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,6 +36,11 @@ button { color: inherit; }
|
||||
.brand { color: var(--ink); text-decoration: none; font-weight: 1000; font-size: clamp(1.1rem, 3vw, 1.45rem); letter-spacing: .14em; text-shadow: 3px 3px 0 #000; }
|
||||
.brand span { color: var(--aqua); }
|
||||
.connection-pill { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.quality-pill { display: flex; align-items: center; gap: 4px; min-width: 48px; color: var(--muted); font-size: .68rem; font-weight: 800; white-space: nowrap; }
|
||||
.quality-pill svg { width: 17px; height: 17px; }
|
||||
.quality-pill[data-state="good"] { color: var(--success); }
|
||||
.quality-pill[data-state="delayed"] { color: var(--warning); }
|
||||
.quality-pill[data-state="slow"], .quality-pill[data-state="offline"] { color: var(--error); }
|
||||
.signal { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 10px var(--warning); }
|
||||
.connection-pill[data-state="online"] .signal { background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||||
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
|
||||
@@ -43,9 +48,11 @@ button { color: inherit; }
|
||||
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
|
||||
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||
.settings-details { color: var(--muted); line-height: 1.45; }
|
||||
.settings-quality { color: var(--muted); line-height: 1.45; }
|
||||
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
|
||||
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
|
||||
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
|
||||
.settings-exit { border-color: var(--error); color: var(--error); }
|
||||
|
||||
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
|
||||
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
|
||||
@@ -290,6 +297,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
|
||||
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
|
||||
.connection-pill { font-size: .58rem; }
|
||||
.quality-pill { min-width: 40px; font-size: .6rem; }
|
||||
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
|
||||
|
||||
+161
-5
@@ -2,6 +2,7 @@
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "maze32.session.v1";
|
||||
const NAME_KEY = "maze32.player-name.v1";
|
||||
const MAX_VIEW_CHUNKS = 90;
|
||||
const MAX_VIEW_WIDTH = 32;
|
||||
const MAX_VIEW_HEIGHT = 32;
|
||||
@@ -16,6 +17,9 @@
|
||||
const AVATAR_FAST_REPEAT_MS = 170;
|
||||
const DIRECTIONAL_PAD_REPEAT_MS = 170;
|
||||
const LATENCY_SAMPLE_LIMIT = 256;
|
||||
const QUALITY_SAMPLE_LIMIT = 5;
|
||||
const HEARTBEAT_INTERVAL_MS = 5000;
|
||||
const HEARTBEAT_STALE_MS = 15000;
|
||||
const ONBOARDING_STEPS = Object.freeze([
|
||||
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
|
||||
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
|
||||
@@ -249,6 +253,50 @@
|
||||
pointerSnapshot() { return this.pointerSamples.slice(); }
|
||||
}
|
||||
|
||||
class ConnectionQuality {
|
||||
constructor(now) {
|
||||
this.now = typeof now === "function" ? now : () => root.performance.now();
|
||||
this.pending = null;
|
||||
this.samples = [];
|
||||
this.latest = null;
|
||||
this.level = "unknown";
|
||||
}
|
||||
|
||||
begin(requestId) {
|
||||
if (this.pending || !requestId) return false;
|
||||
this.pending = {requestId, sent: this.now()};
|
||||
return true;
|
||||
}
|
||||
|
||||
acknowledge(requestId, handlingMs) {
|
||||
if (!this.pending || this.now() - this.pending.sent > HEARTBEAT_STALE_MS || this.pending.requestId !== requestId || !Number.isInteger(handlingMs) || handlingMs < 0 || handlingMs > 1000) return false;
|
||||
const totalMs = Math.max(0, this.now() - this.pending.sent);
|
||||
this.pending = null;
|
||||
this.samples.push(totalMs);
|
||||
if (this.samples.length > QUALITY_SAMPLE_LIMIT) this.samples.shift();
|
||||
const ordered = this.samples.slice().sort((left, right) => left - right);
|
||||
const smoothedMs = ordered[Math.floor(ordered.length / 2)];
|
||||
this.latest = {totalMs: smoothedMs, communicationMs: Math.max(0, smoothedMs - handlingMs), handlingMs, received: this.now()};
|
||||
if (this.level === "good") this.level = smoothedMs > 175 ? "delayed" : "good";
|
||||
else if (this.level === "slow") this.level = smoothedMs <= 450 ? "delayed" : "slow";
|
||||
else if (smoothedMs <= 150) this.level = "good";
|
||||
else this.level = smoothedMs <= 500 ? "delayed" : "slow";
|
||||
return true;
|
||||
}
|
||||
|
||||
reset() { this.pending = null; this.samples = []; this.latest = null; this.level = "unknown"; }
|
||||
|
||||
expirePending() {
|
||||
if (this.pending && this.now() - this.pending.sent > HEARTBEAT_STALE_MS) this.pending = null;
|
||||
}
|
||||
|
||||
status() {
|
||||
if (!this.latest) return "unknown";
|
||||
if (this.now() - this.latest.received > HEARTBEAT_STALE_MS) return "stale";
|
||||
return this.level;
|
||||
}
|
||||
}
|
||||
|
||||
function createEnvelope(type, sequence, requestId, payload) {
|
||||
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
||||
}
|
||||
@@ -355,7 +403,7 @@
|
||||
|
||||
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, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, 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}};
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, heartbeatTimer: null, leaveRequest: null, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null, breakthroughTimer: null, travelTimer: null}, hedge: {openedKeys: new Set()}, moveDirections: new Map(), touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), quality: new ConnectionQuality(() => 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) {
|
||||
@@ -423,6 +471,7 @@
|
||||
byId("observer-screen").hidden = name !== "observer";
|
||||
documentObject.body.classList.toggle("match-active", name === "match");
|
||||
["settings-mute", "settings-motion", "settings-help"].forEach(id => { byId(id).hidden = name !== "match"; });
|
||||
byId("settings-leave").hidden = state.session == null;
|
||||
if (name !== "match") stopBreakthroughTravel();
|
||||
if (name !== "match") closeSettings(false);
|
||||
}
|
||||
@@ -444,6 +493,40 @@
|
||||
if (firstAction) firstAction.focus();
|
||||
}
|
||||
|
||||
function openLeaveConfirmation() {
|
||||
closeSettings(false);
|
||||
const phase = state.snapshot ? state.snapshot.phase : state.lobby && state.lobby.phase;
|
||||
const endsMatch = state.session && state.session.role === "player" &&
|
||||
["countdown", "active", "finish_pending", "paused"].includes(phase);
|
||||
byId("leave-text").textContent = endsMatch
|
||||
? "Текущая гонка закончится для всех, а ваш слот сразу освободится."
|
||||
: "Освободим только ваше место в игре.";
|
||||
byId("leave-confirmation").hidden = false;
|
||||
byId("leave-accept").focus();
|
||||
}
|
||||
|
||||
function closeLeaveConfirmation() {
|
||||
byId("leave-confirmation").hidden = true;
|
||||
byId("settings-toggle").focus();
|
||||
}
|
||||
|
||||
function finishLeave() {
|
||||
windowObject.localStorage.removeItem(TOKEN_KEY);
|
||||
state.session = null;
|
||||
state.lobby = null;
|
||||
state.snapshot = null;
|
||||
state.leaveRequest = null;
|
||||
state.pending.clear();
|
||||
resetPlayerControls();
|
||||
clearEffects();
|
||||
stopHeartbeat();
|
||||
byId("maze").replaceChildren();
|
||||
byId("map-placeholder").hidden = false;
|
||||
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
|
||||
closeLeaveConfirmation();
|
||||
setScreen("welcome");
|
||||
}
|
||||
|
||||
function renderWelcomeHeadline(headline) {
|
||||
const title = byId("welcome-title");
|
||||
const highlight = documentObject.createElement("span");
|
||||
@@ -457,9 +540,51 @@
|
||||
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");
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function renderQuality() {
|
||||
const quality = state.quality.latest;
|
||||
const status = !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN ? "offline" : state.quality.status();
|
||||
const indicator = byId("quality");
|
||||
indicator.dataset.state = status;
|
||||
if (!quality || status === "unknown" || status === "stale" || status === "offline") {
|
||||
const label = status === "offline" ? "нет связи" : status === "stale" ? "устарело" : "неизвестно";
|
||||
byId("quality-text").textContent = status === "offline" ? "нет" : "—";
|
||||
indicator.setAttribute("aria-label", `Отклик: ${label}`);
|
||||
byId("quality-details").textContent = `Отклик: ${label}`;
|
||||
return;
|
||||
}
|
||||
const total = Math.round(quality.totalMs);
|
||||
const communication = Math.round(quality.communicationMs);
|
||||
const handling = Math.round(quality.handlingMs);
|
||||
byId("quality-text").textContent = `${total} мс`;
|
||||
indicator.setAttribute("aria-label", `Отклик ${total} мс. Связь ${communication} мс. ESP ${handling} мс.`);
|
||||
byId("quality-details").textContent = `Отклик: ${total} мс · Связь: ${communication} мс · ESP: ${handling} мс. Связь — остаток транспорта, не только Wi‑Fi.`;
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = null;
|
||||
state.quality.reset();
|
||||
renderQuality();
|
||||
}
|
||||
|
||||
function scheduleHeartbeat(delay) {
|
||||
windowObject.clearTimeout(state.heartbeatTimer);
|
||||
state.heartbeatTimer = windowObject.setTimeout(() => {
|
||||
state.quality.expirePending();
|
||||
if (documentObject.hidden || !state.session || state.quality.pending || !state.socket || state.socket.readyState !== windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
send("heartbeat", {}, null);
|
||||
scheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
renderWelcomeHeadline(randomLoginHeadline());
|
||||
byId("player-name").value = windowObject.localStorage.getItem(NAME_KEY) || "";
|
||||
|
||||
function beginFeedback() {
|
||||
if (state.feedbackReady) return;
|
||||
@@ -505,6 +630,7 @@
|
||||
}
|
||||
const sequence = ++state.clientSequence;
|
||||
const requestId = `${type}-${sequence}`;
|
||||
if (type === "heartbeat" && !state.quality.begin(requestId)) return null;
|
||||
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); }
|
||||
@@ -852,7 +978,9 @@
|
||||
clearPending(message.requestId);
|
||||
if (message.type === "session") {
|
||||
state.session = message.payload; windowObject.localStorage.setItem(TOKEN_KEY, message.payload.token);
|
||||
if (message.payload.role === "player") windowObject.localStorage.setItem(NAME_KEY, byId("player-name").value.slice(0, 32));
|
||||
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
|
||||
scheduleHeartbeat(0);
|
||||
if (state.lobby) renderLobby();
|
||||
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||
@@ -861,7 +989,16 @@
|
||||
} 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 === "ack") {
|
||||
if (message.payload && message.payload.command === "leave" && message.requestId === state.leaveRequest) {
|
||||
finishLeave();
|
||||
return;
|
||||
}
|
||||
if (message.payload && message.payload.command === "heartbeat") {
|
||||
if (state.quality.acknowledge(message.requestId, message.payload.handlingMs)) renderQuality();
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -879,7 +1016,7 @@
|
||||
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.onclose = () => { state.moveFlow.disconnect(); stopHeartbeat(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
|
||||
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
|
||||
}
|
||||
|
||||
@@ -986,6 +1123,11 @@
|
||||
|
||||
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
|
||||
documentObject.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && !byId("leave-confirmation").hidden) {
|
||||
event.preventDefault();
|
||||
closeLeaveConfirmation();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && !byId("settings-menu").hidden) {
|
||||
event.preventDefault();
|
||||
closeSettings(true);
|
||||
@@ -1022,7 +1164,14 @@
|
||||
};
|
||||
windowObject.addEventListener("resize", refreshPlayerFocalPoint);
|
||||
windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint);
|
||||
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); });
|
||||
documentObject.addEventListener("visibilitychange", () => {
|
||||
if (documentObject.hidden) {
|
||||
resetPlayerControls();
|
||||
stopHeartbeat();
|
||||
} else if (state.session && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN) {
|
||||
scheduleHeartbeat(0);
|
||||
}
|
||||
});
|
||||
documentObject.querySelectorAll("[data-direction]").forEach(button => {
|
||||
button.addEventListener("pointerdown", event => {
|
||||
if (button.disabled) return;
|
||||
@@ -1056,6 +1205,13 @@
|
||||
byId("settings-motion").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||
byId("settings-help").addEventListener("click", () => { closeSettings(false); showOnboarding(0); });
|
||||
byId("settings-wifi").addEventListener("click", () => closeSettings(false));
|
||||
byId("settings-leave").addEventListener("click", openLeaveConfirmation);
|
||||
byId("leave-cancel").addEventListener("click", closeLeaveConfirmation);
|
||||
byId("leave-accept").addEventListener("click", () => {
|
||||
const requestId = send("leave", {}, byId("leave-accept"));
|
||||
if (requestId) state.leaveRequest = requestId;
|
||||
else showNotice("Не удалось отправить выход. Связь продолжается.", "error");
|
||||
});
|
||||
byId("onboarding-next").addEventListener("click", () => {
|
||||
const next = nextOnboardingStep(state.onboardingStep);
|
||||
if (next == null) hideOnboarding();
|
||||
@@ -1096,7 +1252,7 @@
|
||||
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, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, clampObserverZoom, observerFitScale, boot});
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_ADJECTIVES, NAME_NOUNS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, QUALITY_SAMPLE_LIMIT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_STALE_MS, 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, ConnectionQuality, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, playerFocalPoint, playerFocalMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, hedgeEdgeKey, hedgeKeyForMove, hedgeVariant, projectedHedgeEdges, 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); });
|
||||
|
||||
Reference in New Issue
Block a user