From 25013457402b782197453cbe9dacb9cb2ea5694f Mon Sep 17 00:00:00 2001 From: sasa Date: Thu, 10 Sep 2026 22:08:02 +0300 Subject: [PATCH] docs(plans): add playful visual feedback milestone --- ARCHITECTURE.md | 6 ++ MVP.md | 3 + PLANS.md | 163 +++++++++++++++++++++++++++++++++++- data/index.html.gz | Bin 3408 -> 3378 bytes docs/PLAYER_UI.md | 21 ++++- docs/UI_STYLE.md | 19 +++-- test/web/test_player_app.js | 19 ++++- web/index.html | 4 +- web/player.css | 31 +++++-- web/player.js | 66 ++++++++++++--- 10 files changed, 294 insertions(+), 38 deletions(-) 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 3e5d75f19e9d722c2da9417c95a200323ada8338..4c8682ebef8179fc101aac8c7022aae0a9fdf75b 100644 GIT binary patch literal 3378 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 I%5W?I09zw`xc~qF literal 3408 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 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) {