feat(session): add explicit leave and immediate slot release

This commit is contained in:
2026-09-13 22:53:18 +03:00
parent 44433aee9c
commit b6d484dbc9
16 changed files with 526 additions and 13 deletions
+161 -5
View File
@@ -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); });