docs(plans): add playful visual feedback milestone
This commit is contained in:
+2
-2
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user