docs(plans): add playful visual feedback milestone

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