feat(session): add explicit leave and immediate slot release
This commit is contained in:
+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