docs(plans): add playful visual feedback milestone
This commit is contained in:
+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