feat(observer): add observer mode and disconnect recovery
This commit is contained in:
+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