diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f06c20f..be53f82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -335,6 +335,12 @@ at least `1024 x 768` CSS pixels and presents a large-screen requirement message below that threshold. Responsive layout changes presentation only; they never change projection content, command validation, or server authority. +The browser may correlate a server rejection only with its matching outstanding +movement intention for presentation feedback. A `blocked` rejection shakes the +already visible projection in that attempted direction, while `rate_limited` +briefly darkens and blurs the maze only. Neither effect changes or derives game +state, and both use bounded, resettable local timers. + All application assets are local and precompressed. Browser startup and play must not depend on internet access, third-party CSS, remote fonts, icons, analytics, or content services. The complete compressed web payload is limited diff --git a/MVP.md b/MVP.md index 40a0ccb..3f8f371 100644 --- a/MVP.md +++ b/MVP.md @@ -230,6 +230,9 @@ Browser controls must support: - The player map consumes all space not required by fixed status and control panels. The player view supports a minimum `412 x 916` CSS-pixel viewport without page scrolling. +- Textual system notices use one Russian live message rail above the top bar. + A connection-loss notice remains there until recovery; movement blocked by a + wall and rate limiting use maze-only visual feedback and never show text. - Computers and tablets support keyboard, swipes, and on-screen arcade controls. Phones show an always-visible four-button directional pad as the primary touch control while retaining avatar drag and directional swipes as diff --git a/PLANS.md b/PLANS.md index 52b3b7a..e543eb3 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1822,7 +1822,7 @@ planning-time observation motivates the milestone: ## Milestone 019 — Reference-derived SVG icon system -**Status:** `IN_PROGRESS` +**Status:** `COMPLETE` ### Objective @@ -1965,6 +1965,161 @@ the project-owner visual confirmation remains outstanding: local `player` and `spikes` symbols. It did not create or alter a player session, so full active-match visual confirmation remains for the project owner. -- Milestone 019 remains `IN_PROGRESS` pending the project owner's - representative mobile and desktop confirmation that the new icon shapes, - yellow/blue ownership, keys, and exits are clear at normal gameplay size. +- On 2026-09-10, the project owner confirmed after representative player + testing that the new icons are satisfactory. This closes the remaining + visual-confirmation criterion; Milestone 019 is `COMPLETE`. + +## Milestone 020 — Playful visual feedback and top message rail + +**Status:** `IN_PROGRESS` + +### Objective + +Replace distracting gameplay warning text with immediate visual feedback and +move the remaining player-facing notices into a playful top-of-screen message +rail that keeps children engaged without obscuring the maze or controls. + +### Scope + +- Before implementation, reconcile `MVP.md`, `ARCHITECTURE.md`, + `docs/UI_STYLE.md`, and supporting player-UI documentation with the new + feedback hierarchy and copy rules. +- When the server rejects a movement intention because of a wall or maze + boundary, shake the visible maze briefly in the attempted cardinal direction. + Do not show a toast, tooltip, spike notice, or any textual warning for the + blocked move. +- Keep blocked-movement feedback local and presentation-only. It must not move + the authoritative player position, reveal a hidden wall, infer unseen maze + state, or change the existing `blocked` protocol response. +- When movement is rejected with `rate_limited`, briefly darken and blur only + the maze image so the player understands that input is arriving too quickly. + Do not display the current server-speed warning or any replacement text. +- Bound, coalesce, and safely clear shake and blur feedback. Repeated blocked or + rate-limited responses may restart or extend one current effect only within a + documented maximum duration; they must not create animation queues, timers, + DOM nodes, or a permanently blurred maze. +- Move connection loss, reconnection, session, lobby, protocol, and other + messages that still require text to a dedicated rail at the top of the + screen, above the application top bar and the player status panel. Respect + display safe areas and do not cover essential status or controls. +- Rewrite player-facing messages as short, varied, child-friendly Russian + jokes or playful reactions. Remove visible engineering language such as + `WebSocket`, protocol packets, request limits, and internal server details; + retain technical detail only in diagnostics or the secondary connection + panel where appropriate. +- Define priority, deduplication, replacement, persistence, and dismissal rules + for the top message rail. Connection loss remains visible until recovery; + lower-priority messages are bounded and cannot flood or indefinitely delay a + more important connection state. +- Keep the rail legible but lively through icon, color, and restrained motion. + Avoid rapid flashing, excessive movement, or overlapping announcements, and + preserve Russian live-region semantics for assistive technology. +- In reduced-motion mode, replace maze shaking with a single non-animated + directional border pulse or equivalent static wall-impact cue. The + rate-limit darkening and blur may remain because they do not imply movement, + but their transitions must be effectively immediate. +- Preserve optional sound and haptic feedback, authoritative server behavior, + observer read-only behavior, hidden-state protection, offline assets, and the + existing firmware and LittleFS budgets. + +### Deliverables + +- Updated feedback, message-placement, reduced-motion, and humorous-copy + contracts in the canonical and supporting UI documentation. +- Direction-aware maze-shake feedback for blocked movement with no visible + blocked-move text. +- Bounded maze darkening and blur for `rate_limited` movement with no visible + rate-limit text. +- Responsive top message rail with safe-area placement, bounded prioritization, + deduplication, persistent connection state, and playful Russian copy. +- Deterministic browser tests plus representative mobile and desktop visual, + motion, reduced-motion, and accessibility verification evidence. + +### Acceptance criteria + +1. A `blocked` response causes one brief shake of the maze in the attempted + direction without moving the authoritative map position. No blocked-move + text, toast, tooltip, spike notice, or generic error notification becomes + visible. +2. The blocked effect uses only the direction of the matching outstanding + movement intention and the server rejection. It does not inspect, retain, + infer, or expose hidden maze geometry. +3. A `rate_limited` movement response darkens and blurs only the maze region for + a documented bounded duration. The HUD, direction pad, key action, top bar, + and message rail remain readable, and no rate-limit warning text appears. +4. Repeated `blocked` or `rate_limited` responses coalesce into at most one + active effect of each type, use a bounded number of timers and DOM nodes, + and restore the exact normal maze presentation after the documented maximum + duration, disconnect, pause, result, screen change, or application reset. +5. With reduced motion enabled, blocked movement produces a single static or + non-animated directional border cue instead of shaking. Rate-limit feedback + remains understandable without animation, sound, vibration, or text. +6. Every remaining player-facing transient or persistent system message appears + in the top message rail above the application top bar and player status + panel. No ordinary notice remains anchored near the bottom of the maze or + behind mobile controls. +7. At `412 x 915`, `412 x 916`, compact landscape, representative tablet and + desktop player viewports, and observer viewports of `1024 x 768` or larger, + the message rail respects safe areas, does not create page overflow, and + does not cover essential status, map controls, dialogs, or observer tools. +8. Visible messages are concise Russian, child-friendly, non-offensive, and + playful. Reachable normal-operation copy contains no `WebSocket`, packet, + protocol, queue, rate-limit, request-processing, or similar engineering + terminology. +9. Connection loss produces a persistent high-priority playful message until + reconnection. Recovery replaces it with a short positive message; repeated + reconnect attempts do not stack duplicates or hide the current connection + state. +10. The rail maintains a documented bounded message count and priority order, + deduplicates repeated events, exposes appropriate Russian live-region + semantics, and never rapidly flashes or animates indefinitely. +11. Browser tests cover directional wall shake, absence of blocked text, + rate-limit blur and absence of rate-limit text, effect coalescing and reset + paths, reduced motion, top placement, safe areas, message priority and + deduplication, connection persistence, playful-copy constraints, supported + viewports, and observer presentation. +12. All host tests pass, compressed web assets remain within the 1.5 MiB budget + and LittleFS partition, firmware remains within the factory partition, and + no protocol, game-authority, projection, or hidden-state contract changes. +13. The project owner confirms on a representative mobile browser that wall + collisions and excessive input are understandable without warning text, + the top messages do not obstruct play, and the humorous copy keeps the + experience lively without becoming repetitive or distracting, or explicitly + approves a documented evidence deviation. + +### Evidence + +The following planning-time observation motivated the milestone: + +- On 2026-09-10, the project owner requested removal of the visible + `Там стена. Очень убедительная.` blocked-movement warning in favor of shaking + the maze, replacement of the server-speed warning with temporary maze + darkening and blur, and relocation of connection and other necessary messages + above the top panel. The owner also required playful, non-engineering Russian + copy intended to retain children's attention. Static inspection found the + current blocked and `rate_limited` responses feeding the shared notice path, + while `.notice` is positioned near the bottom of the viewport. Implementation + and completion verification are deferred to this milestone. +- On 2026-09-10, the feedback and rail contracts were reconciled in `MVP.md`, + `ARCHITECTURE.md`, `docs/UI_STYLE.md`, and `docs/PLAYER_UI.md`. The browser + records each outstanding move direction, uses it only after the matching + `blocked` response to restart one 340 ms directional maze effect, and uses + one restartable 520 ms maze-only `rate_limited` effect. Both timers are + cleared by control reset, disconnect, pause, result, and reset paths. The + map projection offset is held in CSS variables during the effect, preserving + the centered authoritative player position at board edges. +- `node --test test/web/test_player_app.js` passed: 22 tests, 0 failures. The + focused deterministic checks include the message rail placement and live + semantics, direction correlation, no blocked/rate-limit text, maze-only + blur, and reduced-motion behavior. +- `bash scripts/run_host_tests.sh` passed all portable host suites, the same + 22 browser tests, and the deterministic web-asset check: + `web asset reproducibility: PASS (..., 27068/1572864 bytes)`. +- `/home/sasa/.platformio/penv/bin/pio run` passed for `esp32-c6-devkitm-1`: + RAM `58868/327680` bytes and factory flash `1027966/2097152` bytes. + `/home/sasa/.platformio/penv/bin/pio run -t buildfs` passed and produced the + configured `2031616/2031616` byte LittleFS image. +- Milestone 020 remains `IN_PROGRESS`: representative mobile/desktop visual, + reduced-motion, accessibility, and project-owner confirmation evidence for + acceptance criteria 7 and 13 has not been recorded. Milestone 021 has not + been started. diff --git a/data/index.html.gz b/data/index.html.gz index 3e5d75f..4c8682e 100644 Binary files a/data/index.html.gz and b/data/index.html.gz differ diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index b8de562..12eefc2 100644 --- a/docs/PLAYER_UI.md +++ b/docs/PLAYER_UI.md @@ -22,9 +22,9 @@ exit silhouettes remain distinct, and Player 2 uses a dashed secondary treatment so ownership does not depend on color alone. The closed and opened arched exits show the existing locked and opened exit -states. The crystal represents the existing vision artifact. Spikes appear only -with local blocked-movement feedback and do not add a trap, damage, protocol, -or server-state feature. +states. The crystal represents the existing vision artifact. The spike symbol +is kept as a local catalog asset but is not shown for blocked movement; it does +not add a trap, damage, protocol, or server-state feature. ## Input contract @@ -51,6 +51,21 @@ For every projection, the browser translates only the received cells so the authoritative `self` cell stays under the centered avatar; this also keeps the avatar off a wall when the visible projection reaches a maze boundary. +## Feedback and message rail + +The browser stores the direction for each outstanding move request. Only after +a matching server `blocked` rejection does it shake the already rendered maze +in the attempted direction for at most 340 ms; it shows no blocked-move text or spike +notice. A matching `rate_limited` rejection darkens and blurs only the maze for +at most 520 ms. Each effect has one restartable timer and is cleared on pause, +result, reset, disconnect, and control reset. Reduced-motion mode uses a static +maze border cue for blocked movement instead of a shake. + +All remaining Russian system messages use one `role=status` live message rail +above the application top bar. One connection-loss message has highest priority +and remains until a successful recovery; other messages deduplicate, replace +lower-priority content, and dismiss after 2.6 seconds. + Commands use the version-one protocol envelope and contain intention data only: ```json diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md index a6238c0..ebae897 100644 --- a/docs/UI_STYLE.md +++ b/docs/UI_STYLE.md @@ -44,8 +44,9 @@ marker. The gameplay icon family is locally authored SVG artwork derived from the approved running-player, key, arched-exit, crystal, opened-exit, and spike -reference silhouettes. The spike icon communicates only blocked movement or a -wall collision; it never represents a trap or a new gameplay mechanic. +reference silhouettes. The spike icon remains a catalog asset only; blocked +movement uses the maze feedback defined below and never represents a trap or a +new gameplay mechanic. WCAG AA contrast certification and dedicated color-vision-deficiency palettes are not MVP requirements. @@ -102,8 +103,10 @@ are not MVP requirements. - Connection details and the Wi-Fi settings link belong in a secondary panel, not in the persistent status row. - Countdown, pause, and result states appear as modal overlays above the map. -- Errors and rejected actions appear as contextual tooltips or notices near - the relevant control or status area. +- Textual system notices use one live message rail above the top bar and player + status area. It shows one message at a time: connection loss has highest + priority and persists until recovery; all other messages replace lower + priority content, deduplicate by event, and dismiss after 2.6 seconds. - The lobby uses separate blocks for players, observers, and match actions. - Network provisioning uses the same colors, type, controls, and humor as the game application. @@ -121,8 +124,12 @@ are not MVP requirements. - Player zoom and pan controls are not part of the MVP. - Use different colors plus labels, outlines, shapes, or icons to distinguish player ownership and object types. -- Give immediate visual feedback when movement is rejected by a wall without - revealing hidden state. +- A server `blocked` rejection shakes the visible maze opposite the attempted + cardinal movement for at most 340 ms, without text or spike notice. A + `rate_limited` rejection darkens and blurs only the maze for at most 520 ms, + also without text. Repeated feedback restarts one bounded effect of each type. +- Reduced-motion mode replaces the blocked shake with one static directional + border cue; rate-limit feedback remains immediate and non-animated. ## 8. Responsive behavior diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index ae82dbe..ce8ab71 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -132,7 +132,7 @@ test("avatar control resets safely and retains accessible alternatives", () => { assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/); assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/); assert.match(script, /addEventListener\("blur", resetPlayerControls\)/); - assert.match(script, /connectionState !== "online"\) resetPlayerControls\(\)/); + assert.match(script, /connectionState === "offline"\) \{ resetPlayerControls\(\);/); assert.match(script, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/); assert.match(script, /state\.avatar\.startX = event\.clientX/); assert.match(script, /event\.clientX - state\.avatar\.startX/); @@ -144,6 +144,23 @@ test("avatar control resets safely and retains accessible alternatives", () => { assert.match(html, /id="onboarding-dots"[\s\S]*?<\/span><\/span><\/span><\/span><\/span><\/span><\/span><\/span>/); }); +test("playful feedback uses bounded maze-only effects and a top message rail", () => { + const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); + const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8"); + const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); + assert.match(html, /
\s*
@@ -80,7 +81,6 @@
-
Сервер решает всё. Мы отвечаем за драматичное мерцание.
diff --git a/web/player.css b/web/player.css index 6ab03f1..c3f37ae 100644 --- a/web/player.css +++ b/web/player.css @@ -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); } } diff --git a/web/player.js b/web/player.js index 1f9b60c..42e3566 100644 --- a/web/player.js +++ b/web/player.js @@ -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) {