feat(ui): consolidate secondary controls in settings menu
This commit is contained in:
@@ -2960,7 +2960,7 @@ without automatically opening the optional player help overlay.
|
||||
|
||||
## Milestone 027 — Playful breakthrough movement and prominent hedge gaps
|
||||
|
||||
**Status:** `IN_PROGRESS`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -3126,3 +3126,291 @@ passage on supported mobile and desktop screens.
|
||||
2,031,616 / 2,031,616 byte LittleFS image.
|
||||
- Mobile, desktop, observer, reduced-motion, and physical-device visual
|
||||
acceptance remain required before this milestone can be marked `COMPLETE`.
|
||||
|
||||
## Milestone 028 — Compact settings menu
|
||||
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
Reduce persistent browser-interface clutter on every supported viewport by
|
||||
consolidating secondary connection and player-preference controls into one
|
||||
compact settings menu opened by a gear button beside the Wi-Fi connection
|
||||
indicator.
|
||||
|
||||
### Scope
|
||||
|
||||
- Do not start implementation until every preceding milestone is `COMPLETE`.
|
||||
Milestone 027 is `COMPLETE` when this plan is created; this milestone remains
|
||||
`NOT_STARTED` because implementation was not requested.
|
||||
- Keep the Wi-Fi connection indicator visible and place one persistent
|
||||
gear-icon menu button next to it on welcome, lobby, active-player, observer,
|
||||
pause, and result screens where the corresponding secondary actions are
|
||||
available.
|
||||
- Remove the separate persistent buttons for connection details, sound,
|
||||
reduced motion, and help. Expose those actions as clearly labelled Russian
|
||||
items in the gear-button popover.
|
||||
- Move the Wi-Fi settings action out of the connection-details panel and into
|
||||
the same popover. Do not duplicate the Wi-Fi settings action elsewhere in
|
||||
the normal connected application flow.
|
||||
- Preserve the existing behavior and current state of connection details,
|
||||
sound, reduced motion, the optional help overlay, and Wi-Fi settings. The
|
||||
sound and reduced-motion items must visibly and accessibly communicate their
|
||||
current on/off state.
|
||||
- Keep primary gameplay actions outside the settings menu, including movement,
|
||||
avatar drag, key drop, lobby participation, match start, observer navigation,
|
||||
and result actions.
|
||||
- Make the popover usable with touch, pointer, and keyboard. Give its trigger
|
||||
an accessible Russian name and exposed open/closed state; provide predictable
|
||||
focus order; close it after an applicable navigation action, on outside
|
||||
activation, and with Escape; and restore focus to the trigger when
|
||||
appropriate.
|
||||
- Prevent the popover from covering essential controls or escaping the
|
||||
viewport. Anchor it to the top-right status area and allow it to reposition
|
||||
or constrain its height on narrow portrait, compact landscape, desktop, and
|
||||
safe-area-inset layouts.
|
||||
- Preserve Russian player-facing copy, offline operation, observer read-only
|
||||
behavior, server-authoritative game state, supported browser layouts, and
|
||||
firmware and LittleFS budgets.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Updated compact-settings-menu behavior and responsive-layout contracts in
|
||||
`docs/UI_STYLE.md` and any affected supporting UI documentation.
|
||||
- One reusable gear-triggered settings popover with connection details, sound,
|
||||
reduced motion, help, and Wi-Fi settings actions shown only when applicable.
|
||||
- Removal of the superseded standalone secondary-control buttons and of the
|
||||
duplicated Wi-Fi settings entry from the connection-details panel.
|
||||
- Deterministic browser tests for visibility, state reflection, action routing,
|
||||
keyboard and pointer dismissal, focus behavior, responsive placement, and
|
||||
absence of duplicate controls.
|
||||
- Browser, host-regression, compressed-web-asset, LittleFS, and representative
|
||||
mobile and desktop visual-verification evidence.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. Every supported screen and viewport shows at most one persistent secondary
|
||||
settings control: a gear button beside the visible Wi-Fi connection
|
||||
indicator. The previous standalone connection-details, sound,
|
||||
reduced-motion, and help buttons are absent.
|
||||
2. Opening the gear button displays the applicable connection details, sound,
|
||||
reduced motion, help, and Wi-Fi settings actions with clear Russian labels.
|
||||
No primary gameplay or lobby action is moved into the menu.
|
||||
3. Connection details show the same live information as before, and their
|
||||
former standalone round button is not rendered.
|
||||
4. Sound and reduced-motion actions retain their existing behavior and reflect
|
||||
their current on/off state visually and through accessible state. Changing
|
||||
either setting updates the menu item without creating another persistent
|
||||
control.
|
||||
5. Help opens the existing optional help overlay without changing its content,
|
||||
navigation, close behavior, or the direct countdown-to-maze transition.
|
||||
6. Wi-Fi settings remain reachable from the menu through the existing
|
||||
configuration flow, and the action is not duplicated in the
|
||||
connection-details panel or elsewhere in the normal connected application
|
||||
flow.
|
||||
7. The trigger and popover work with touch, pointer, and keyboard. The trigger
|
||||
has an accessible Russian name and accurate expanded state; focus order is
|
||||
predictable; Escape and outside activation dismiss the popover; and focus
|
||||
returns safely without trapping the player.
|
||||
8. Opening, using, and closing the popover does not issue gameplay commands,
|
||||
change authoritative game state, enable observer input, or interfere with
|
||||
keyboard, swipe, directional-pad, avatar-drag, key-drop, pause, or result
|
||||
controls.
|
||||
9. At all supported mobile, compact-landscape, tablet, and desktop sizes, the
|
||||
closed layout is more compact than the current standalone-control layout.
|
||||
The open popover stays within safe areas and the viewport, remains readable,
|
||||
introduces no page or map overflow, and does not permanently cover
|
||||
essential controls.
|
||||
10. Automated tests cover screen applicability, absence of standalone and
|
||||
duplicate controls, state reflection, every routed action, dismissal,
|
||||
focus restoration, supported responsive layouts, and regression behavior.
|
||||
11. All host and browser tests pass; compressed-web-asset validation passes;
|
||||
the payload remains within the 1.5 MiB web budget and LittleFS partition;
|
||||
and firmware remains within the factory partition.
|
||||
12. Physical-device verification confirms that the menu and all five
|
||||
secondary actions work in the served application, and the project owner
|
||||
confirms on representative mobile and desktop browsers that the interface
|
||||
is less distracting and materially more compact.
|
||||
|
||||
### Evidence
|
||||
|
||||
- On 2026-09-13, planning-time inspection of the active game at
|
||||
`http://192.168.1.50/` identified the separate round connection-details
|
||||
button beside `ESP на связи` and the standalone sound, reduced-motion, and
|
||||
help controls in the active-player layout.
|
||||
- On 2026-09-13, the project owner chose a gear icon beside the connection
|
||||
indicator, required all referenced secondary controls and Wi-Fi settings to
|
||||
move into its popover without duplicating the Wi-Fi action, and required the
|
||||
compact layout on all screen sizes.
|
||||
- On 2026-09-13, implementation began with one header gear button, a bounded
|
||||
safe-area-aware settings popover, and the removal of the superseded details,
|
||||
sound, reduced-motion, and help buttons. The popover retains live connection
|
||||
information and Wi-Fi navigation on every screen, while player preferences
|
||||
and optional help appear only during an active player match.
|
||||
- On 2026-09-13, `node --test test/web/test_player_app.js
|
||||
test/web/test_hedge_svg.js` passed all 31 tests. The compact-settings test
|
||||
covers the sole trigger, removed standalone controls, state labels,
|
||||
player-screen applicability, Escape and outside dismissal, and safe-area
|
||||
constrained placement.
|
||||
- On 2026-09-13, `bash scripts/run_host_tests.sh` passed all host and bundled
|
||||
browser checks. `python3 scripts/verify_web_assets.py` passed with a
|
||||
deterministic 31,420 / 1,572,864 byte compressed payload, and
|
||||
`/home/sasa/.platformio/penv/bin/pio run --target buildfs` succeeded with a
|
||||
2,031,616 / 2,031,616 byte LittleFS image.
|
||||
- Mobile, desktop, and physical-device visual verification of all settings
|
||||
actions were pending until the project-owner check below.
|
||||
- On 2026-09-13, the project owner checked the UI in every available mode and
|
||||
confirmed that the settings menu is present and works as expected. This
|
||||
supplies the required visual and physical-device acceptance evidence.
|
||||
|
||||
## Milestone 029 — Low-overhead live connection quality
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
|
||||
### Objective
|
||||
|
||||
Show a compact, continuously understandable indication of the browser's live
|
||||
round-trip response to the ESP on every application screen, with an honest
|
||||
breakdown between measured ESP handling time and the remaining communication
|
||||
delay, without reducing gameplay responsiveness or consuming unbounded device
|
||||
resources.
|
||||
|
||||
### Scope
|
||||
|
||||
- Do not start implementation until every preceding milestone is `COMPLETE`.
|
||||
Milestone 028 is `COMPLETE` when this plan is created; this milestone remains
|
||||
`NOT_STARTED` because implementation was not requested.
|
||||
- Reuse the existing versioned WebSocket heartbeat request and acknowledgement
|
||||
path. Correlate each probe with its unique request ID and use the browser's
|
||||
monotonic clock to measure complete application round-trip time.
|
||||
- Measure heartbeat handling duration only with the ESP's monotonic clock,
|
||||
from bounded request handling to acknowledgement preparation, and return
|
||||
that duration in the matching acknowledgement. Never subtract an ESP
|
||||
timestamp from a browser timestamp or require clock synchronization.
|
||||
- Derive the communication remainder as the non-negative difference between
|
||||
browser-measured round-trip time and ESP-measured handling time. Label it
|
||||
`Связь`, not pure `Wi-Fi`, because it can also include TCP/WebSocket
|
||||
transport, task scheduling, and outbound queueing. Label the measured device
|
||||
interval `ESP` and the full round trip `Отклик`.
|
||||
- Keep one compact persistent quality indicator beside the existing connection
|
||||
status and settings trigger. Show a connection-quality symbol and rounded
|
||||
total milliseconds without expanding the header or displacing primary
|
||||
content.
|
||||
- Make the `Связь` and `ESP` values available in the existing connection
|
||||
details reached through the settings menu and in the indicator's accessible
|
||||
Russian description. Do not expose developer diagnostics or misleading
|
||||
claims about which person or device is at fault.
|
||||
- Show the indicator on welcome, lobby, active-player, observer, countdown,
|
||||
pause, and result screens. Before the first valid sample, while a sample is
|
||||
stale, and while disconnected, show explicit compact unknown or offline
|
||||
states instead of retaining an old number as if it were current.
|
||||
- Define documented quality thresholds from representative physical-device
|
||||
measurements. Use stable good, delayed, and slow states with smoothing and
|
||||
hysteresis so normal jitter does not make the indicator flicker. State must
|
||||
not rely on color alone.
|
||||
- Keep probing strictly bounded and lower priority than gameplay: at most one
|
||||
heartbeat may be outstanding per browser, normal cadence may not exceed one
|
||||
probe every five seconds, no retry burst is allowed, and a probe or its
|
||||
response must be skipped or dropped rather than delay a move, projection,
|
||||
pause, resume, or result message.
|
||||
- Suspend probes while the WebSocket is closed and while the document is
|
||||
hidden. Clear pending measurements on disconnect, session reset, and socket
|
||||
replacement, then take one fresh sample after a successful connection before
|
||||
resuming the bounded cadence.
|
||||
- Keep a small fixed-size sample window in the browser only. Do not persist or
|
||||
transmit latency history, player names, session tokens, network names, or
|
||||
hidden game state as part of this feature.
|
||||
- Preserve observer read-only behavior, server-authoritative gameplay,
|
||||
existing connection-loss messaging, offline operation, Russian
|
||||
player-facing copy, responsive layouts, and firmware and LittleFS budgets.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Updated measurement, protocol, quality-state, copy, and responsive-placement
|
||||
contracts in `ARCHITECTURE.md`, `docs/UI_STYLE.md`, `docs/PLAYER_UI.md`, and
|
||||
the Milestone 016 latency procedure where applicable.
|
||||
- A bounded heartbeat round-trip sampler and ESP handling-duration field with
|
||||
documented units, range validation, correlation, timeout, and stale-sample
|
||||
behavior.
|
||||
- A compact always-applicable connection-quality indicator showing total
|
||||
milliseconds, plus `Связь` and `ESP` details integrated with the existing
|
||||
settings and connection-details interface.
|
||||
- Deterministic protocol, gateway, browser, accessibility, responsive-layout,
|
||||
scheduling, smoothing, timeout, reconnect, and backpressure tests.
|
||||
- Before-and-after maximum-session latency, queue, traffic, heap, web-asset,
|
||||
firmware, LittleFS, and physical-device visual-verification evidence.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. On every supported application screen, the header contains one compact
|
||||
quality symbol and a rounded current total such as `85 мс` beside the
|
||||
existing connection status and gear trigger, without moving or obscuring
|
||||
primary game, lobby, observer, pause, or result controls.
|
||||
2. Each displayed total is calculated from the browser monotonic send and
|
||||
matching-heartbeat-acknowledgement times. Responses with a missing,
|
||||
duplicate, expired, malformed, or mismatched request ID cannot update the
|
||||
indicator.
|
||||
3. The matching heartbeat acknowledgement carries a bounded ESP handling
|
||||
duration measured solely with the ESP monotonic clock. The browser validates
|
||||
the field and derives `Связь = max(0, Отклик - ESP)` without comparing clocks
|
||||
from different devices.
|
||||
4. Connection details show the same sample as three Russian-labelled values:
|
||||
`Отклик`, `Связь`, and `ESP`. Supporting text explains that `Связь` is a
|
||||
transport remainder and cannot isolate radio-only Wi-Fi delay or assign
|
||||
blame with certainty.
|
||||
5. Before the first sample, after the documented stale timeout, and while
|
||||
disconnected, the indicator uses distinct unknown, stale, or offline text
|
||||
and shape states. It never presents an old millisecond value as current.
|
||||
6. Good, delayed, and slow thresholds, smoothing window, hysteresis, rounding,
|
||||
sample timeout, and stale timeout are fixed in canonical documentation and
|
||||
covered at every boundary by deterministic tests. Meaning remains clear in
|
||||
grayscale and without relying on animation or color alone.
|
||||
7. Each browser has at most one heartbeat in flight, sends no more than one
|
||||
normal probe per five seconds, keeps only the documented fixed number of
|
||||
recent samples, and produces no immediate retry loop after timeout,
|
||||
malformed input, disconnect, background suspension, or reconnect.
|
||||
8. Heartbeat work is constant and bounded on the ESP, causes no broadcast or
|
||||
authoritative-state mutation, and cannot occupy queue capacity needed by
|
||||
gameplay or lifecycle messages. Under backpressure its acknowledgement is
|
||||
discarded before any move, view, pause, resume, or result message.
|
||||
9. Pointer feedback, keyboard, swipe, directional-pad, avatar-drag, key-drop,
|
||||
rendering, and observer navigation remain responsive while sampling. The
|
||||
Milestone 017 controlled two-player and two-player/four-observer latency
|
||||
budgets continue to pass, with no increased dropped gameplay messages or
|
||||
queue high-water regression attributable to probes.
|
||||
10. Visibility changes, socket replacement, disconnect, reconnection, session
|
||||
reset, screen changes, and result transitions leave no orphan timer,
|
||||
duplicate probe, stale accessible label, or retained pending correlation.
|
||||
11. The indicator and its details remain readable and within the viewport on
|
||||
supported narrow portrait, compact-landscape, tablet, and desktop layouts,
|
||||
including safe-area insets and all player and observer states.
|
||||
12. Automated protocol, gateway, browser, host, and web-asset tests pass. The
|
||||
compressed payload remains within the 1.5 MiB web budget and LittleFS
|
||||
partition, and firmware remains within the factory partition.
|
||||
13. Physical-device testing with two players and four observers confirms the
|
||||
displayed total and split track controlled fast and delayed conditions,
|
||||
reconnect states recover correctly, and continuous sampling causes no
|
||||
perceptible input delay, animation stutter, disconnect, or gameplay
|
||||
regression.
|
||||
14. The project owner confirms on representative mobile and desktop browsers
|
||||
that the indicator is compact, understandable in every application state,
|
||||
and useful for recognizing a delayed ESP response without distracting from
|
||||
play, or explicitly approves a documented evidence deviation.
|
||||
|
||||
### Evidence
|
||||
|
||||
- On 2026-09-13, planning inspection confirmed that the protocol already
|
||||
supports a correlated `heartbeat` acknowledgement and that the browser uses
|
||||
monotonic timing with a bounded 256-sample diagnostic tracker for accepted
|
||||
movement acknowledgements and painted projections.
|
||||
- On 2026-09-13, the project owner requested a compact icon with a millisecond
|
||||
value in every application state and asked to separate communication delay
|
||||
from ESP processing when this can be done without complicating or slowing
|
||||
gameplay.
|
||||
- Planning analysis established that a single round trip cannot measure pure
|
||||
radio-only Wi-Fi latency. This milestone therefore specifies a directly
|
||||
measured ESP handling interval and an honestly labelled communication
|
||||
remainder, while keeping the displayed headline value equal to the complete
|
||||
browser-to-ESP application round trip.
|
||||
- Implementation and verification have not started, as requested by the
|
||||
project owner.
|
||||
|
||||
Binary file not shown.
@@ -56,6 +56,16 @@ a maze boundary, and is recalculated on resize and rotation.
|
||||
|
||||
## Feedback and message rail
|
||||
|
||||
## Compact settings
|
||||
|
||||
The persistent gear button beside the connection indicator opens one compact
|
||||
popover. It shows live connection details and Wi-Fi settings on every screen.
|
||||
During an active player match it additionally provides sound, reduced-motion,
|
||||
and optional-help controls with visible Russian on/off labels. Opening or
|
||||
closing this presentation-only menu never sends a command or changes server
|
||||
state. Escape and outside activation dismiss it; player actions also close it
|
||||
before opening their existing destination.
|
||||
|
||||
The browser stores the direction for each outstanding move request. A matching
|
||||
server `blocked` rejection may include only that player's approved breakthrough
|
||||
stage, which bends the already rendered bush wall for at most 340 ms without
|
||||
|
||||
+6
-2
@@ -100,8 +100,12 @@ are not MVP requirements.
|
||||
- The player identity is represented by a distinct character shape, not `P1`
|
||||
or `P2`. Object, blocked-move, pause, and result feedback must pair a shape
|
||||
or icon with its color.
|
||||
- Connection details and the Wi-Fi settings link belong in a secondary panel,
|
||||
not in the persistent status row.
|
||||
- A single gear button beside the persistent Wi-Fi connection indicator opens
|
||||
the secondary settings popover. It contains connection details and Wi-Fi
|
||||
settings on every screen, plus the player-only sound, reduced-motion, and
|
||||
help actions during an active player match. The popover is constrained to
|
||||
safe areas, closes on outside activation or Escape, and never contains
|
||||
primary game, lobby, observer, or result actions.
|
||||
- Countdown, pause, and result states appear as modal overlays above the map.
|
||||
- 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
|
||||
|
||||
@@ -140,7 +140,7 @@ test("countdown completion leaves help closed until the player requests it", ()
|
||||
assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/);
|
||||
assert.match(activeView, /else hideModal\(\);/);
|
||||
assert.doesNotMatch(activeView, /showOnboarding\(0\)/);
|
||||
assert.match(script, /byId\("onboarding-replay"\)\.addEventListener\("click", \(\) => showOnboarding\(0\)\)/);
|
||||
assert.match(script, /byId\("settings-help"\)\.addEventListener\("click", \(\) => \{ closeSettings\(false\); showOnboarding\(0\); \}\)/);
|
||||
assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/);
|
||||
assert.match(script, /if \(next == null\) hideOnboarding\(\)/);
|
||||
});
|
||||
@@ -162,7 +162,7 @@ test("avatar control resets safely and retains accessible alternatives", () => {
|
||||
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
|
||||
assert.match(html, /id="avatar-control"/);
|
||||
assert.match(html, /aria-label="Тянуть героя для движения"/);
|
||||
assert.match(script, /onboarding-replay/);
|
||||
assert.match(script, /settings-help/);
|
||||
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
|
||||
});
|
||||
|
||||
@@ -359,7 +359,7 @@ test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
assert.match(css, /\.dpad \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
|
||||
assert.match(html, /id="avatar-control"/);
|
||||
assert.match(html, /id="onboarding"/);
|
||||
assert.match(html, /id="mute-toggle"/);
|
||||
assert.match(html, /id="settings-mute"/);
|
||||
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
|
||||
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
|
||||
assert.match(css, /\.avatar-handle \{[^}]*background: transparent/);
|
||||
@@ -370,6 +370,28 @@ test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
assert.match(icons, /id="maze"/);
|
||||
});
|
||||
|
||||
test("one compact settings popover replaces standalone secondary controls", () => {
|
||||
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, /id="settings-toggle"[^>]*aria-controls="settings-menu"[^>]*aria-expanded="false"[^>]*aria-label="Открыть настройки"/);
|
||||
for (const identifier of ["settings-menu", "details-text", "settings-mute", "settings-motion", "settings-help", "settings-wifi"]) {
|
||||
assert.match(html, new RegExp(`id="${identifier}"`));
|
||||
}
|
||||
assert.doesNotMatch(html, /id="details-toggle"|id="details-panel"|id="mute-toggle"|id="motion-toggle"|id="onboarding-replay"/);
|
||||
assert.doesNotMatch(html, /access-controls|access-button/);
|
||||
assert.match(css, /\.settings-menu \{[^}]*max-height: calc\(100dvh/);
|
||||
assert.match(css, /\.settings-item\[aria-pressed="true"\]/);
|
||||
assert.match(script, /function openSettings\(\)/);
|
||||
assert.match(script, /function closeSettings\(restoreFocus\)/);
|
||||
assert.match(script, /event\.key === "Escape" && !byId\("settings-menu"\)\.hidden/);
|
||||
assert.match(script, /!menu\.hidden && !menu\.contains\(event\.target\) && !trigger\.contains\(event\.target\)/);
|
||||
assert.match(script, /\["settings-mute", "settings-motion", "settings-help"\]\.forEach\(id => \{ byId\(id\)\.hidden = name !== "match"; \}\)/);
|
||||
assert.match(script, /button\.textContent = muted \? "Звук: выкл" : "Звук: вкл"/);
|
||||
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
|
||||
});
|
||||
|
||||
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
|
||||
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
|
||||
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
|
||||
<symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol>
|
||||
<symbol id="gear" viewBox="0 0 24 24"><path d="M10 2h4l.7 3 2.6 1.1 2.7-1.5 2 3.5-2.2 2 .1 3 2.1 1.9-2 3.5-2.7-1.5-2.6 1.1-.7 3h-4l-.7-3-2.6-1.1-2.7 1.5-2-3.5 2.1-1.9.1-3-2.2-2 2-3.5 2.7 1.5L9.3 5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
|
||||
<symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>
|
||||
<symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
|
||||
<symbol id="sound" viewBox="0 0 24 24"><path d="M4 10h4l5-4v12l-5-4H4zM17 9c2 2 2 4 0 6M20 6c4 4 4 8 0 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.8 KiB |
+9
-7
@@ -13,9 +13,16 @@
|
||||
<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>
|
||||
<button class="icon-button" id="details-toggle" type="button" aria-controls="details-panel" aria-expanded="false"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span class="sr-only">Открыть сведения о соединении</span></button>
|
||||
<button class="icon-button" id="settings-toggle" type="button" aria-controls="settings-menu" aria-expanded="false" aria-label="Открыть настройки"><svg aria-hidden="true"><use href="/icons.svg#gear"></use></svg></button>
|
||||
</header>
|
||||
<aside class="details-panel" id="details-panel" hidden><strong>Связь с коробочкой</strong><span id="details-text">Подключение ещё не готово.</span><a href="/provision">Настроить Wi‑Fi</a></aside>
|
||||
<aside class="settings-menu" id="settings-menu" hidden aria-label="Настройки">
|
||||
<strong>Настройки</strong>
|
||||
<span class="settings-details" id="details-text">Подключение ещё не готово.</span>
|
||||
<button class="settings-item" id="settings-mute" type="button" aria-pressed="false" hidden>Звук: вкл</button>
|
||||
<button class="settings-item" id="settings-motion" type="button" aria-pressed="false" hidden>Движение: вкл</button>
|
||||
<button class="settings-item" id="settings-help" type="button" hidden>Показать обучение</button>
|
||||
<a class="settings-item" id="settings-wifi" href="/provision">Настроить Wi‑Fi</a>
|
||||
</aside>
|
||||
|
||||
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
|
||||
<div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div>
|
||||
@@ -59,11 +66,6 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="access-controls" aria-label="Настройки игры">
|
||||
<button class="access-button" id="mute-toggle" type="button" aria-label="Выключить звук"><svg aria-hidden="true"><use href="/icons.svg#sound"></use></svg></button>
|
||||
<button class="access-button" id="motion-toggle" type="button" aria-label="Уменьшить движение"><svg aria-hidden="true"><use href="/icons.svg#motion"></use></svg></button>
|
||||
<button class="access-button" id="onboarding-replay" type="button" aria-label="Показать обучение"><svg aria-hidden="true"><use href="/icons.svg#help"></use></svg></button>
|
||||
</div>
|
||||
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх" disabled>▲</button><button type="button" data-direction="west" aria-label="Двигаться влево" disabled>◀</button><span class="dpad-center" aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо" disabled>▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз" disabled>▼</button></nav>
|
||||
<section class="onboarding" id="onboarding" hidden aria-label="Обучение игре">
|
||||
<div class="onboarding-card" id="onboarding-card"><svg class="onboarding-icon" aria-hidden="true"><use id="onboarding-icon-use" href="/icons.svg#hand"></use></svg><h2 id="onboarding-title">Потяни кружок</h2><p id="onboarding-text">Кружок ведёт героя по лабиринту.</p><div class="onboarding-dots" id="onboarding-dots" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><button id="onboarding-next" type="button" aria-label="Следующий шаг обучения">Дальше</button><button id="onboarding-skip" type="button" aria-label="Пропустить обучение">Пропустить</button></div>
|
||||
|
||||
+6
-8
@@ -41,9 +41,11 @@ button { color: inherit; }
|
||||
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
|
||||
.icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; }
|
||||
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
|
||||
.details-panel { position: absolute; z-index: 30; top: 60px; right: max(12px, env(safe-area-inset-right)); display: grid; gap: 6px; width: min(300px, calc(100% - 24px)); padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||
.details-panel span { color: var(--muted); }
|
||||
.details-panel a { color: var(--aqua); }
|
||||
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
|
||||
.settings-details { color: var(--muted); line-height: 1.45; }
|
||||
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
|
||||
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
|
||||
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
|
||||
|
||||
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
|
||||
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
|
||||
@@ -183,10 +185,6 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
|
||||
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
|
||||
.child-controls > * { pointer-events: auto; }
|
||||
.access-controls { position: absolute; z-index: 14; top: 92px; right: 8px; display: grid; gap: 6px; }
|
||||
.access-button { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--aqua); border-radius: 50%; background: #102a30e8; color: var(--aqua); box-shadow: 3px 3px 0 #020506; }
|
||||
.access-button svg { width: 24px; height: 24px; }
|
||||
.access-button[aria-pressed="true"] { color: var(--warning); border-color: var(--warning); }
|
||||
.onboarding { position: absolute; z-index: 30; inset: 0; display: grid; place-items: center; padding: 18px; background: #08050bd9; }
|
||||
.onboarding-card { width: min(340px, 92%); padding: 20px; border: 3px solid var(--aqua); background: var(--panel-deep); box-shadow: 7px 7px 0 #020103; text-align: center; }
|
||||
.onboarding-icon { width: 104px; height: 104px; color: var(--aqua); animation: guide-hand 1.2s ease-in-out infinite; }
|
||||
@@ -288,7 +286,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.map-shell { inset: 82px 0 0; }
|
||||
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
|
||||
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
|
||||
.access-controls { top: 88px; right: 8px; }
|
||||
.settings-menu { top: max(52px, calc(env(safe-area-inset-top) + 52px)); }
|
||||
.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; }
|
||||
.connection-pill { font-size: .58rem; }
|
||||
|
||||
+42
-10
@@ -422,7 +422,26 @@
|
||||
byId("match-screen").hidden = name !== "match";
|
||||
byId("observer-screen").hidden = name !== "observer";
|
||||
documentObject.body.classList.toggle("match-active", name === "match");
|
||||
["settings-mute", "settings-motion", "settings-help"].forEach(id => { byId(id).hidden = name !== "match"; });
|
||||
if (name !== "match") stopBreakthroughTravel();
|
||||
if (name !== "match") closeSettings(false);
|
||||
}
|
||||
|
||||
function closeSettings(restoreFocus) {
|
||||
const menu = byId("settings-menu");
|
||||
if (menu.hidden) return;
|
||||
menu.hidden = true;
|
||||
const trigger = byId("settings-toggle");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
if (restoreFocus) trigger.focus();
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
const menu = byId("settings-menu");
|
||||
menu.hidden = false;
|
||||
byId("settings-toggle").setAttribute("aria-expanded", "true");
|
||||
const firstAction = menu.querySelector(".settings-item:not([hidden])");
|
||||
if (firstAction) firstAction.focus();
|
||||
}
|
||||
|
||||
function renderWelcomeHeadline(headline) {
|
||||
@@ -951,23 +970,27 @@
|
||||
|
||||
function setMuted(muted) {
|
||||
state.muted = muted;
|
||||
const button = byId("mute-toggle");
|
||||
const button = byId("settings-mute");
|
||||
button.setAttribute("aria-pressed", String(muted));
|
||||
button.setAttribute("aria-label", muted ? "Включить звук" : "Выключить звук");
|
||||
button.title = muted ? "Включить звук" : "Выключить звук";
|
||||
button.querySelector("use").setAttribute("href", muted ? "/icons.svg#sound" : "/icons.svg#sound");
|
||||
button.textContent = muted ? "Звук: выкл" : "Звук: вкл";
|
||||
}
|
||||
|
||||
function setReducedMotion(reduced) {
|
||||
state.reducedMotion = reduced;
|
||||
if (reduced) stopBreakthroughTravel();
|
||||
documentObject.documentElement.dataset.reducedMotion = String(reduced);
|
||||
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
|
||||
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
|
||||
const button = byId("settings-motion");
|
||||
button.setAttribute("aria-pressed", String(reduced));
|
||||
button.textContent = reduced ? "Движение: выкл" : "Движение: вкл";
|
||||
}
|
||||
|
||||
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
|
||||
documentObject.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && !byId("settings-menu").hidden) {
|
||||
event.preventDefault();
|
||||
closeSettings(true);
|
||||
return;
|
||||
}
|
||||
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
|
||||
beginFeedback();
|
||||
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
|
||||
@@ -1025,9 +1048,14 @@
|
||||
});
|
||||
});
|
||||
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
|
||||
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
|
||||
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||
byId("onboarding-replay").addEventListener("click", () => showOnboarding(0));
|
||||
byId("settings-toggle").addEventListener("click", () => {
|
||||
if (byId("settings-menu").hidden) openSettings();
|
||||
else closeSettings(false);
|
||||
});
|
||||
byId("settings-mute").addEventListener("click", () => setMuted(!state.muted));
|
||||
byId("settings-motion").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
|
||||
byId("settings-help").addEventListener("click", () => { closeSettings(false); showOnboarding(0); });
|
||||
byId("settings-wifi").addEventListener("click", () => closeSettings(false));
|
||||
byId("onboarding-next").addEventListener("click", () => {
|
||||
const next = nextOnboardingStep(state.onboardingStep);
|
||||
if (next == null) hideOnboarding();
|
||||
@@ -1052,7 +1080,11 @@
|
||||
const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } };
|
||||
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
|
||||
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
|
||||
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
|
||||
documentObject.addEventListener("pointerdown", event => {
|
||||
const menu = byId("settings-menu");
|
||||
const trigger = byId("settings-toggle");
|
||||
if (!menu.hidden && !menu.contains(event.target) && !trigger.contains(event.target)) closeSettings(false);
|
||||
});
|
||||
byId("modal-action").addEventListener("click", () => {
|
||||
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
|
||||
send("restart", {}, byId("modal-action"));
|
||||
|
||||
Reference in New Issue
Block a user