feat(observer): add observer mode and disconnect recovery
This commit is contained in:
@@ -57,6 +57,18 @@
|
||||
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
|
||||
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav>
|
||||
</section>
|
||||
|
||||
<section class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
|
||||
<div class="observer-toolbar">
|
||||
<div><p class="eyebrow">Режим наблюдателя · только чтение</p><h1 id="observer-title">Весь куст целиком</h1></div>
|
||||
<div class="observer-players" aria-live="polite"><span class="player-one">P1 <strong id="observer-p1">готов</strong></span><span class="player-two">P2 <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
|
||||
<div class="zoom-controls" aria-label="Масштаб карты"><button id="zoom-out" type="button" aria-label="Уменьшить масштаб">−</button><button id="zoom-reset" type="button">Вся карта</button><button id="zoom-in" type="button" aria-label="Увеличить масштаб">+</button></div>
|
||||
</div>
|
||||
<div class="observer-requirement" id="observer-requirement" role="status"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><strong>Нужен экран побольше</strong><span>Зрительный зал открывается от 1024 × 768 пикселей.</span></div>
|
||||
<div class="observer-stage" id="observer-stage" tabindex="0" aria-label="Полная карта лабиринта. Масштабируйте колёсиком или кнопками, перетаскивайте мышью.">
|
||||
<div class="observer-maze" id="observer-maze" role="img" aria-label="Полная карта лабиринта"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -158,6 +158,39 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.modal-card h2 { font-size: clamp(1.5rem, 7vw, 2.7rem); }
|
||||
.countdown { color: var(--warning); font-size: clamp(5rem, 24vw, 10rem); font-weight: 1000; line-height: 1; text-shadow: 7px 7px 0 #000; animation: count .65s steps(2, end) infinite; }
|
||||
|
||||
.observer-screen { height: calc(100dvh - 78px); min-height: 678px; overflow: hidden; padding-top: 92px; }
|
||||
.observer-toolbar { position: absolute; z-index: 8; inset: 8px 0 auto; min-height: 76px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 18px; padding: 9px 12px; border: 1px solid var(--edge); background: #171027eb; }
|
||||
.observer-toolbar h1 { font-size: clamp(1.15rem, 2.8vw, 2.1rem); }
|
||||
.observer-toolbar .eyebrow { margin-bottom: 3px; }
|
||||
.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; }
|
||||
.observer-players span { padding: 7px; border: 1px solid #51386e; }
|
||||
.observer-players .player-one { border-color: var(--p1); }
|
||||
.observer-players .player-two { border-color: var(--p2); }
|
||||
.zoom-controls { display: flex; gap: 5px; }
|
||||
.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; }
|
||||
.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; }
|
||||
.observer-stage.dragging { cursor: grabbing; }
|
||||
.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; }
|
||||
.observer-maze .cell { width: 8px; height: 8px; border-width: 1px; }
|
||||
.observer-maze .cell::after { inset: 2px; }
|
||||
.observer-maze .object { inset: 5%; }
|
||||
.observer-maze .object-key::after { display: none; }
|
||||
.observer-maze .object-landmark { font-size: 6px; }
|
||||
.observer-maze .pawn { inset: -30%; z-index: 5; font-size: 5px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
|
||||
.observer-maze .pawn-player-one { color: var(--p1); }
|
||||
.observer-maze .pawn-player-two { color: var(--p2); transform: rotate(45deg); }
|
||||
.observer-maze .pawn-player-two span { transform: rotate(-45deg); }
|
||||
.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); }
|
||||
.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); }
|
||||
.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; }
|
||||
|
||||
@media (max-width: 1023px), (max-height: 767px) {
|
||||
.observer-toolbar { grid-template-columns: 1fr; }
|
||||
.observer-toolbar > :not(:first-child) { display: none; }
|
||||
.observer-stage { display: none; }
|
||||
.observer-requirement { display: grid; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:root { --cell: 20px; }
|
||||
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); }
|
||||
|
||||
+91
-6
@@ -5,6 +5,7 @@
|
||||
const MAX_VIEW_CHUNKS = 700;
|
||||
const MAX_VIEW_CELLS = 8192;
|
||||
const MAX_VIEW_OBJECTS = 30;
|
||||
const OBSERVER_CELL_SIZE = 8;
|
||||
const KEY_DIRECTIONS = Object.freeze({
|
||||
ArrowUp: "north", KeyW: "north",
|
||||
ArrowRight: "east", KeyD: "east",
|
||||
@@ -85,9 +86,18 @@
|
||||
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
|
||||
}
|
||||
|
||||
function clampObserverZoom(value) {
|
||||
return Math.min(6, Math.max(.25, value));
|
||||
}
|
||||
|
||||
function observerFitScale(stageWidth, stageHeight, columns, rows) {
|
||||
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 1;
|
||||
return clampObserverZoom(Math.min(stageWidth / (columns * OBSERVER_CELL_SIZE), stageHeight / (rows * OBSERVER_CELL_SIZE)) * .96);
|
||||
}
|
||||
|
||||
function boot(documentObject, windowObject) {
|
||||
const byId = id => documentObject.getElementById(id);
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false};
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
|
||||
function showNotice(text, kind) {
|
||||
const element = byId("notice");
|
||||
@@ -101,6 +111,7 @@
|
||||
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 setConnection(connectionState, text) {
|
||||
@@ -245,6 +256,10 @@
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -267,6 +282,66 @@
|
||||
else hideModal();
|
||||
}
|
||||
|
||||
function applyObserverTransform() {
|
||||
const transform = state.observer;
|
||||
byId("observer-maze").style.transform = `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`;
|
||||
}
|
||||
|
||||
function resetObserverView(snapshot) {
|
||||
const stage = byId("observer-stage");
|
||||
state.observer.scale = observerFitScale(stage.clientWidth, stage.clientHeight, snapshot.width, snapshot.height);
|
||||
state.observer.x = 0;
|
||||
state.observer.y = 0;
|
||||
applyObserverTransform();
|
||||
}
|
||||
|
||||
function renderObserverSnapshot(snapshot) {
|
||||
if (!snapshot.cells.length && state.snapshot && state.snapshot.role === "observer" && state.snapshot.width === snapshot.width && state.snapshot.height === snapshot.height) {
|
||||
snapshot.cells = state.snapshot.cells;
|
||||
}
|
||||
state.snapshot = snapshot;
|
||||
setScreen("observer");
|
||||
const maze = byId("observer-maze");
|
||||
maze.replaceChildren();
|
||||
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
|
||||
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
|
||||
const objects = new Map();
|
||||
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
||||
const players = Array.isArray(snapshot.players) ? snapshot.players : [];
|
||||
snapshot.cells.forEach(data => {
|
||||
const index = data[0]; const passages = data[1];
|
||||
const cell = documentObject.createElement("span");
|
||||
cell.className = "cell";
|
||||
cell.style.gridColumn = String(index % snapshot.width + 1);
|
||||
cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1);
|
||||
if (!(passages & 1)) cell.classList.add("wall-n");
|
||||
if (!(passages & 2)) cell.classList.add("wall-e");
|
||||
if (!(passages & 4)) cell.classList.add("wall-s");
|
||||
if (!(passages & 8)) cell.classList.add("wall-w");
|
||||
(objects.get(index) || []).forEach(object => addObject(cell, object));
|
||||
players.forEach((player, playerIndex) => {
|
||||
if (player.cell === index && !player.exited) {
|
||||
const pawn = documentObject.createElement("span");
|
||||
pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`;
|
||||
const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`;
|
||||
pawn.appendChild(label); cell.appendChild(pawn);
|
||||
}
|
||||
});
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
players.forEach((player, index) => {
|
||||
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
||||
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
|
||||
});
|
||||
byId("observer-phase").textContent = PHASES[snapshot.phase] || "Сервер размышляет";
|
||||
const dimensions = `${snapshot.width}x${snapshot.height}`;
|
||||
if (!state.observer.initialized || state.observer.dimensions !== dimensions) {
|
||||
resetObserverView(snapshot); state.observer.initialized = true; state.observer.dimensions = dimensions;
|
||||
} else applyObserverTransform();
|
||||
if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||
else if (snapshot.phase !== "paused") hideModal();
|
||||
}
|
||||
|
||||
function showModal(title, text, kicker, countdown) {
|
||||
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
|
||||
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
|
||||
@@ -279,8 +354,8 @@
|
||||
let title = "Ничья!"; let text = "Фотофиниш пожал плечами: оба выбрались почти одновременно.";
|
||||
if (result === "player1" || result === "player2") {
|
||||
const winner = result === "player1" ? 1 : 2;
|
||||
title = winner === slot ? "Победа!" : "Победил соперник";
|
||||
text = winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.";
|
||||
title = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`;
|
||||
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
|
||||
}
|
||||
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
|
||||
byId("modal-action").hidden = false;
|
||||
@@ -314,8 +389,9 @@
|
||||
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||
} else if (message.type === "name_suggestion") { byId("player-name").value = message.payload.name;
|
||||
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||
} else if (message.type === "active") { setScreen("match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750);
|
||||
} else if (message.type === "pause") { showModal("Пауза", "Второй герой переподключается. Ноги пока не считаются.", "ЖДЁМ СВЯЗЬ", false);
|
||||
} 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 === "ack") { feedback(true);
|
||||
} else if (message.type === "error") {
|
||||
@@ -352,13 +428,22 @@
|
||||
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", () => { hideModal(); setScreen("lobby"); });
|
||||
connect();
|
||||
return {state, handleMessage, move};
|
||||
}
|
||||
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, boot});
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, 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); });
|
||||
|
||||
Reference in New Issue
Block a user