docs(plans): add playful visual feedback milestone

This commit is contained in:
2026-09-10 22:08:02 +03:00
parent abb3ff27b7
commit 2501345740
10 changed files with 294 additions and 38 deletions
+2 -2
View File
@@ -9,6 +9,7 @@
</head>
<body>
<main id="app">
<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<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>
@@ -53,7 +54,7 @@
<div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div>
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><svg class="avatar-player-icon"><use href="/icons.svg#player"></use></svg><svg class="avatar-direction-icon"><use href="/icons.svg#arrow"></use></svg></span></button>
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"><svg aria-hidden="true"><use href="/icons.svg#spikes"></use></svg><span class="wall-bump-text"></span></div>
<div class="wall-bump" id="wall-bump" aria-hidden="true"><svg hidden><use href="/icons.svg#spikes"></use></svg></div>
</div>
<div class="child-controls" aria-label="Игровые действия">
<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>
@@ -80,7 +81,6 @@
<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>
<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>
+22 -9
View File
@@ -111,7 +111,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.map-shell:focus-visible { border-color: var(--aqua); }
.unknown-message { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; }
.unknown-message svg { width: 54px; height: 54px; color: var(--aqua); animation: radar 1.8s linear infinite; }
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(-50%, -50%); }
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); }
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; border: 4px solid transparent; }
.cell.wall-n { border-top-color: var(--plant); box-shadow: inset 0 3px 0 var(--plant-dark); }
.cell.wall-e { border-right-color: var(--plant); }
@@ -138,9 +138,20 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.pawn-opponent { color: var(--p2); }
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
.pawn-owner-two { border-style: dashed; }
.wall-bump { position: absolute; left: 50%; bottom: 18px; z-index: 6; display: flex; align-items: center; gap: 6px; min-height: 28px; transform: translateX(-50%); padding: 6px 10px; color: var(--error); background: #260b12dd; opacity: 0; }
.wall-bump.show { animation: bump .8s steps(3, end); }
.wall-bump svg { width: 27px; height: 27px; flex: 0 0 27px; }
.wall-bump { position: absolute; inset: 0; z-index: 6; border: 0 solid transparent; pointer-events: none; }
.wall-bump.show { border-color: var(--error); }
.maze.impact-north { animation: impact-north .28s steps(3, end); }
.maze.impact-east { animation: impact-east .28s steps(3, end); }
.maze.impact-south { animation: impact-south .28s steps(3, end); }
.maze.impact-west { animation: impact-west .28s steps(3, end); }
.maze.rate-limited { filter: brightness(.48) blur(2px); transition: filter .12s linear; }
[data-reduced-motion="true"] .maze[class*="impact-"] { animation: none; }
[data-reduced-motion="true"] .maze.rate-limited { transition: none; }
[data-reduced-motion="true"] .wall-bump.show { border-width: 4px; }
[data-reduced-motion="true"] .wall-bump.impact-north { border-top-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-east { border-right-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-south { border-bottom-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-west { border-left-color: var(--error); }
.avatar-control { position: absolute; z-index: 8; left: 50%; top: 50%; width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
.avatar-control:active, .avatar-control.active { cursor: grabbing; }
@@ -191,10 +202,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.dpad button:nth-child(4) { grid-column: 3; grid-row: 2; }
.dpad button:last-child { grid-column: 2; grid-row: 3; }
.notice { position: fixed; z-index: 40; left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); max-width: calc(100% - 24px); transform: translate(-50%, 130%); padding: 10px 14px; border-left: 4px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; opacity: 0; transition: .18s steps(3, end); pointer-events: none; }
.notice.visible { opacity: 1; transform: translate(-50%, 0); }
.notice.error { border-color: var(--error); }
.notice.success { border-color: var(--success); }
.message-rail { position: fixed; top: max(4px, env(safe-area-inset-top)); z-index: 45; left: 50%; width: min(620px, calc(100% - 20px)); transform: translateX(-50%); padding: 8px 14px; border: 2px solid var(--warning); background: #191020f2; color: var(--ink); box-shadow: 4px 4px 0 #020104; font-size: .76rem; text-align: center; pointer-events: none; }
.message-rail.error { border-color: var(--error); }
.message-rail.success { border-color: var(--success); }
.modal { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 18px; background: #050308dc; backdrop-filter: blur(5px); }
.modal-card { width: min(440px, 100%); padding: 30px; border: 3px solid var(--aqua); background: #130b22; text-align: center; box-shadow: 9px 9px 0 #020103; animation: modal-in .28s steps(4, end); }
.modal-card > svg { width: 54px; height: 54px; color: var(--aqua); }
@@ -264,7 +274,6 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.access-controls { top: 88px; right: 8px; }
.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; }
.notice { bottom: max(218px, calc(env(safe-area-inset-bottom) + 206px)); }
.connection-pill { font-size: .58rem; }
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@@ -297,6 +306,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@keyframes artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
@keyframes pawn-in { from { transform: scale(.6); } }
@keyframes impact-north { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) - 10px)); } }
@keyframes impact-east { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) + 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } }
@keyframes impact-west { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) - 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes bump { 0%, 100% { opacity: 0; } 20%, 75% { opacity: 1; transform: translateX(-50%) translateX(4px); } 45% { transform: translateX(-50%) translateX(-4px); } }
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
@keyframes count { 50% { transform: scale(1.08); } }
+53 -13
View File
@@ -48,7 +48,7 @@
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"});
const HINT_ANGLES = Object.freeze({none: 0, n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315});
const PHASES = Object.freeze({countdown: "Обратный отсчёт", waiting: "Ждём игроков", active: "Гонка идёт", finish_pending: "Фотофиниш", paused: "Пауза", finished: "Финиш"});
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду.", no_suggestion: "Все смешные имена уже разобрали."});
const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Команда потеряла слова по дороге.", protocol_error: "Лабиринт не понял этот знак.", request_rejected: "Лабиринт пока не принимает эту затею.", no_suggestion: "Все смешные имена уже разобрали."});
function randomIndex(limit, randomSource) {
if (!Number.isInteger(limit) || limit < 1) return 0;
@@ -301,15 +301,50 @@
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(), 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, noticeTimer: null, rail: {priority: 0, key: null, persistent: false}, effects: {impactTimer: null, rateTimer: null}, 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}};
state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
function showNotice(text, kind) {
function showNotice(text, kind, priority, persistent, key) {
const element = byId("notice");
const nextPriority = priority == null ? 1 : priority;
if (state.rail.persistent && !persistent && nextPriority < state.rail.priority) return;
if (state.rail.key === key && element.textContent === text) return;
element.textContent = text;
element.className = `notice visible ${kind || ""}`;
element.className = `message-rail ${kind || ""}`;
element.hidden = false;
windowObject.clearTimeout(state.noticeTimer);
state.noticeTimer = windowObject.setTimeout(() => element.classList.remove("visible"), 2600);
state.rail = {priority: nextPriority, key: key || null, persistent: !!persistent};
if (!persistent) state.noticeTimer = windowObject.setTimeout(() => { element.hidden = true; state.rail = {priority: 0, key: null, persistent: false}; }, 2600);
}
function clearEffects() {
windowObject.clearTimeout(state.effects.impactTimer);
windowObject.clearTimeout(state.effects.rateTimer);
state.effects.impactTimer = null;
state.effects.rateTimer = null;
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west");
}
function showImpact(direction) {
if (!direction) return;
const maze = byId("maze");
const bump = byId("wall-bump");
windowObject.clearTimeout(state.effects.impactTimer);
maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
void maze.offsetWidth;
maze.classList.add(`impact-${direction}`);
bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
bump.classList.add(`impact-${direction}`);
bump.classList.add("show");
state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); }, 340);
}
function showRateLimit() {
const maze = byId("maze");
windowObject.clearTimeout(state.effects.rateTimer);
maze.classList.add("rate-limited");
state.effects.rateTimer = windowObject.setTimeout(() => maze.classList.remove("rate-limited"), 520);
}
function setScreen(name) {
@@ -330,7 +365,8 @@
byId("connection").dataset.state = connectionState;
byId("connection-text").textContent = text;
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
if (connectionState !== "online") resetPlayerControls();
if (connectionState === "offline") { resetPlayerControls(); state.moveDirections.clear(); showNotice("Связь ушла гулять. Ждём её здесь.", "error", 3, true, "offline"); }
else if (state.rail.persistent) showNotice("Связь вернулась! Кусты снова в игре.", "success", 3, false, "online");
}
renderWelcomeHeadline(randomLoginHeadline());
@@ -381,7 +417,7 @@
const requestId = `${type}-${sequence}`;
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
setPending(requestId, element);
if (type === "move") state.latency.sent(requestId);
if (type === "move") { state.latency.sent(requestId); state.moveDirections.set(requestId, payload.direction); }
return requestId;
}
@@ -463,7 +499,8 @@
maze.style.setProperty("--cell", `${cellSize}px`);
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
maze.style.transform = `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`;
maze.style.setProperty("--map-offset-x", `${offset.x}px`);
maze.style.setProperty("--map-offset-y", `${offset.y}px`);
const objects = new Map();
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
coordinates.forEach(data => {
@@ -625,7 +662,7 @@
const timer = windowObject.setInterval(() => { index++; if (index < steps.length) byId("countdown").textContent = steps[index]; else windowObject.clearInterval(timer); }, 1000);
}
function localError(code) { return ERRORS[code] || "Сервер отклонил команду. Подробности остались у него."; }
function localError(code) { return ERRORS[code] || "Лабиринт пока не принимает эту затею."; }
function handleMessage(message) {
if (!message || message.v !== 1 || !Number.isInteger(message.seq)) return;
@@ -650,11 +687,14 @@
} 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.moveFlow.complete(message.requestId); feedback(true);
} 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 === "error") {
const direction = state.moveDirections.get(message.requestId);
state.moveDirections.delete(message.requestId);
state.moveFlow.complete(message.requestId);
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.querySelector(".wall-bump-text").textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
if (message.payload.code === "blocked") { showImpact(direction); feedback(false); return; }
if (message.payload.code === "rate_limited") { showRateLimit(); feedback(false); return; }
showNotice(localError(message.payload.code), "error"); feedback(false);
}
}
@@ -664,9 +704,9 @@
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
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.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.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error");
state.socket.onerror = () => showNotice("Связь чихнула. Пробуем снова…", "error");
}
function move(direction, element) {