docs(plans): add playful visual feedback milestone
This commit is contained in:
@@ -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
|
below that threshold. Responsive layout changes presentation only; they never
|
||||||
change projection content, command validation, or server authority.
|
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
|
All application assets are local and precompressed. Browser startup and play
|
||||||
must not depend on internet access, third-party CSS, remote fonts, icons,
|
must not depend on internet access, third-party CSS, remote fonts, icons,
|
||||||
analytics, or content services. The complete compressed web payload is limited
|
analytics, or content services. The complete compressed web payload is limited
|
||||||
|
|||||||
@@ -230,6 +230,9 @@ Browser controls must support:
|
|||||||
- The player map consumes all space not required by fixed status and control
|
- 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
|
panels. The player view supports a minimum `412 x 916` CSS-pixel viewport
|
||||||
without page scrolling.
|
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
|
- Computers and tablets support keyboard, swipes, and on-screen arcade
|
||||||
controls. Phones show an always-visible four-button directional pad as the
|
controls. Phones show an always-visible four-button directional pad as the
|
||||||
primary touch control while retaining avatar drag and directional swipes as
|
primary touch control while retaining avatar drag and directional swipes as
|
||||||
|
|||||||
@@ -1822,7 +1822,7 @@ planning-time observation motivates the milestone:
|
|||||||
|
|
||||||
## Milestone 019 — Reference-derived SVG icon system
|
## Milestone 019 — Reference-derived SVG icon system
|
||||||
|
|
||||||
**Status:** `IN_PROGRESS`
|
**Status:** `COMPLETE`
|
||||||
|
|
||||||
### Objective
|
### 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
|
local `player` and `spikes` symbols. It did not create or alter a player
|
||||||
session, so full active-match visual confirmation remains for the project
|
session, so full active-match visual confirmation remains for the project
|
||||||
owner.
|
owner.
|
||||||
- Milestone 019 remains `IN_PROGRESS` pending the project owner's
|
- On 2026-09-10, the project owner confirmed after representative player
|
||||||
representative mobile and desktop confirmation that the new icon shapes,
|
testing that the new icons are satisfactory. This closes the remaining
|
||||||
yellow/blue ownership, keys, and exits are clear at normal gameplay size.
|
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
@@ -22,9 +22,9 @@ exit silhouettes remain distinct, and Player 2 uses a dashed secondary
|
|||||||
treatment so ownership does not depend on color alone.
|
treatment so ownership does not depend on color alone.
|
||||||
|
|
||||||
The closed and opened arched exits show the existing locked and opened exit
|
The closed and opened arched exits show the existing locked and opened exit
|
||||||
states. The crystal represents the existing vision artifact. Spikes appear only
|
states. The crystal represents the existing vision artifact. The spike symbol
|
||||||
with local blocked-movement feedback and do not add a trap, damage, protocol,
|
is kept as a local catalog asset but is not shown for blocked movement; it does
|
||||||
or server-state feature.
|
not add a trap, damage, protocol, or server-state feature.
|
||||||
|
|
||||||
## Input contract
|
## 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
|
authoritative `self` cell stays under the centered avatar; this also keeps the
|
||||||
avatar off a wall when the visible projection reaches a maze boundary.
|
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:
|
Commands use the version-one protocol envelope and contain intention data only:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+13
-6
@@ -44,8 +44,9 @@ marker.
|
|||||||
|
|
||||||
The gameplay icon family is locally authored SVG artwork derived from the
|
The gameplay icon family is locally authored SVG artwork derived from the
|
||||||
approved running-player, key, arched-exit, crystal, opened-exit, and spike
|
approved running-player, key, arched-exit, crystal, opened-exit, and spike
|
||||||
reference silhouettes. The spike icon communicates only blocked movement or a
|
reference silhouettes. The spike icon remains a catalog asset only; blocked
|
||||||
wall collision; it never represents a trap or a new gameplay mechanic.
|
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
|
WCAG AA contrast certification and dedicated color-vision-deficiency palettes
|
||||||
are not MVP requirements.
|
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,
|
- Connection details and the Wi-Fi settings link belong in a secondary panel,
|
||||||
not in the persistent status row.
|
not in the persistent status row.
|
||||||
- Countdown, pause, and result states appear as modal overlays above the map.
|
- Countdown, pause, and result states appear as modal overlays above the map.
|
||||||
- Errors and rejected actions appear as contextual tooltips or notices near
|
- Textual system notices use one live message rail above the top bar and player
|
||||||
the relevant control or status area.
|
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.
|
- The lobby uses separate blocks for players, observers, and match actions.
|
||||||
- Network provisioning uses the same colors, type, controls, and humor as the
|
- Network provisioning uses the same colors, type, controls, and humor as the
|
||||||
game application.
|
game application.
|
||||||
@@ -121,8 +124,12 @@ are not MVP requirements.
|
|||||||
- Player zoom and pan controls are not part of the MVP.
|
- Player zoom and pan controls are not part of the MVP.
|
||||||
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
- Use different colors plus labels, outlines, shapes, or icons to distinguish
|
||||||
player ownership and object types.
|
player ownership and object types.
|
||||||
- Give immediate visual feedback when movement is rejected by a wall without
|
- A server `blocked` rejection shakes the visible maze opposite the attempted
|
||||||
revealing hidden state.
|
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
|
## 8. Responsive behavior
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ test("avatar control resets safely and retains accessible alternatives", () => {
|
|||||||
assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/);
|
assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/);
|
||||||
assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/);
|
assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/);
|
||||||
assert.match(script, /addEventListener\("blur", resetPlayerControls\)/);
|
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, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/);
|
||||||
assert.match(script, /state\.avatar\.startX = event\.clientX/);
|
assert.match(script, /state\.avatar\.startX = event\.clientX/);
|
||||||
assert.match(script, /event\.clientX - state\.avatar\.startX/);
|
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>/);
|
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", () => {
|
test("mobile directional pad is explicit, bounded, and resettable", () => {
|
||||||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||||
|
|||||||
+2
-2
@@ -9,6 +9,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="app">
|
<main id="app">
|
||||||
|
<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden></div>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
|
<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>
|
<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="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>
|
<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>
|
<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>
|
||||||
<div class="child-controls" aria-label="Игровые действия">
|
<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>
|
<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 class="observer-maze" id="observer-maze" role="img" aria-label="Полная карта лабиринта"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</div>
|
|
||||||
</main>
|
</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>
|
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modal-title"><div class="modal-card"><svg id="modal-icon" aria-hidden="true"><use href="/icons.svg#hourglass"></use></svg><p class="eyebrow" id="modal-kicker">Серверное объявление</p><h2 id="modal-title">Приготовились!</h2><div class="countdown" id="countdown" hidden>3</div><p id="modal-text">Один сервер — один честный момент.</p><button class="button primary" id="modal-action" type="button" hidden>Вернуться в лобби</button></div></div>
|
||||||
|
|||||||
+22
-9
@@ -111,7 +111,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
|||||||
.map-shell:focus-visible { border-color: var(--aqua); }
|
.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 { 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; }
|
.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 { 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-n { border-top-color: var(--plant); box-shadow: inset 0 3px 0 var(--plant-dark); }
|
||||||
.cell.wall-e { border-right-color: var(--plant); }
|
.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); }
|
.pawn-opponent { color: var(--p2); }
|
||||||
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
|
.match-screen.player-two-mode .pawn-opponent { color: var(--p1); }
|
||||||
.pawn-owner-two { border-style: dashed; }
|
.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 { position: absolute; inset: 0; z-index: 6; border: 0 solid transparent; pointer-events: none; }
|
||||||
.wall-bump.show { animation: bump .8s steps(3, end); }
|
.wall-bump.show { border-color: var(--error); }
|
||||||
.wall-bump svg { width: 27px; height: 27px; flex: 0 0 27px; }
|
.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 { 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; }
|
.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:nth-child(4) { grid-column: 3; grid-row: 2; }
|
||||||
.dpad button:last-child { grid-column: 2; grid-row: 3; }
|
.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; }
|
.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; }
|
||||||
.notice.visible { opacity: 1; transform: translate(-50%, 0); }
|
.message-rail.error { border-color: var(--error); }
|
||||||
.notice.error { border-color: var(--error); }
|
.message-rail.success { border-color: var(--success); }
|
||||||
.notice.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 { 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 { 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); }
|
.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; }
|
.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 { 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; }
|
.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 { font-size: .58rem; }
|
||||||
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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 artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
|
||||||
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
|
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
|
||||||
@keyframes pawn-in { from { transform: scale(.6); } }
|
@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 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 modal-in { from { opacity: 0; transform: scale(.82); } }
|
||||||
@keyframes count { 50% { transform: scale(1.08); } }
|
@keyframes count { 50% { transform: scale(1.08); } }
|
||||||
|
|||||||
+53
-13
@@ -48,7 +48,7 @@
|
|||||||
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"});
|
const 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 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 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) {
|
function randomIndex(limit, randomSource) {
|
||||||
if (!Number.isInteger(limit) || limit < 1) return 0;
|
if (!Number.isInteger(limit) || limit < 1) return 0;
|
||||||
@@ -301,15 +301,50 @@
|
|||||||
|
|
||||||
function boot(documentObject, windowObject) {
|
function boot(documentObject, windowObject) {
|
||||||
const byId = id => documentObject.getElementById(id);
|
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));
|
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 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.textContent = text;
|
||||||
element.className = `notice visible ${kind || ""}`;
|
element.className = `message-rail ${kind || ""}`;
|
||||||
|
element.hidden = false;
|
||||||
windowObject.clearTimeout(state.noticeTimer);
|
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) {
|
function setScreen(name) {
|
||||||
@@ -330,7 +365,8 @@
|
|||||||
byId("connection").dataset.state = connectionState;
|
byId("connection").dataset.state = connectionState;
|
||||||
byId("connection-text").textContent = text;
|
byId("connection-text").textContent = text;
|
||||||
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
|
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());
|
renderWelcomeHeadline(randomLoginHeadline());
|
||||||
@@ -381,7 +417,7 @@
|
|||||||
const requestId = `${type}-${sequence}`;
|
const requestId = `${type}-${sequence}`;
|
||||||
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
||||||
setPending(requestId, element);
|
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;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +499,8 @@
|
|||||||
maze.style.setProperty("--cell", `${cellSize}px`);
|
maze.style.setProperty("--cell", `${cellSize}px`);
|
||||||
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
|
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
|
||||||
maze.style.gridTemplateRows = `repeat(${rows}, 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();
|
const objects = new Map();
|
||||||
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
|
||||||
coordinates.forEach(data => {
|
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);
|
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) {
|
function handleMessage(message) {
|
||||||
if (!message || message.v !== 1 || !Number.isInteger(message.seq)) return;
|
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 === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
} 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 === "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") {
|
} else if (message.type === "error") {
|
||||||
|
const direction = state.moveDirections.get(message.requestId);
|
||||||
|
state.moveDirections.delete(message.requestId);
|
||||||
state.moveFlow.complete(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 === "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);
|
showNotice(localError(message.payload.code), "error"); feedback(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,9 +704,9 @@
|
|||||||
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
|
const scheme = windowObject.location.protocol === "https:" ? "wss" : "ws";
|
||||||
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/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.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.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) {
|
function move(direction, element) {
|
||||||
|
|||||||
Reference in New Issue
Block a user