feat(player): add mobile directional pad controls

This commit is contained in:
2026-09-10 21:23:38 +03:00
parent 5a806fb5b8
commit 8214e7f7ed
10 changed files with 405 additions and 47 deletions
+7 -4
View File
@@ -315,10 +315,13 @@ The browser application has four presentation modes:
3. player match view; 3. player match view;
4. observer full-map view. 4. observer full-map view.
Player controls support keyboard movement, touch swipes, and an explicit Player controls support keyboard movement, avatar drag, touch swipes, an
drop-key action. The UI renders only server-provided visible state. The observer explicit four-button directional pad, and an explicit drop-key action. Every
view supports fitting, panning, or zooming the complete `32 x 32` map without movement input uses the same bounded latest-intent path and sends only a
changing server authority. cardinal intention; the browser does not predict a movement result. The UI
renders only server-provided visible state. The observer view supports fitting,
panning, or zooming the complete `32 x 32` map without changing server
authority.
`docs/UI_STYLE.md` is the canonical browser presentation contract. The player `docs/UI_STYLE.md` is the canonical browser presentation contract. The player
layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires
+5 -3
View File
@@ -228,9 +228,11 @@ 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.
- Computers support keyboard movement and on-screen arcade controls. Tablets - Computers and tablets support keyboard, swipes, and on-screen arcade
support swipes and on-screen controls. Phones use directional swipes without controls. Phones show an always-visible four-button directional pad as the
on-screen directional buttons. primary touch control while retaining avatar drag and directional swipes as
optional alternatives. The pad sends only existing orthogonal movement
intentions through the authoritative server path.
- An always-visible `OK` control with a key icon drops the carried key without - An always-visible `OK` control with a key icon drops the carried key without
confirmation and exposes the Russian accessible label `Сбросить ключ`. confirmation and exposes the Russian accessible label `Сбросить ключ`.
- Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially - Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially
+254 -7
View File
@@ -1561,7 +1561,7 @@ controlled measurement criteria:
## Milestone 017 — Avatar-centered drag steering and latency remediation ## Milestone 017 — Avatar-centered drag steering and latency remediation
**Status:** `IN_PROGRESS` **Status:** `COMPLETE`
### Objective ### Objective
@@ -1678,12 +1678,6 @@ map moves below it; displacement controls speed; approximately 100 ms is ideal,
2,097,152 application bytes. `/home/sasa/.platformio/penv/bin/pio run -e 2,097,152 application bytes. `/home/sasa/.platformio/penv/bin/pio run -e
esp32-c6-devkitm-1 --target buildfs` passed and reported a fixed LittleFS esp32-c6-devkitm-1 --target buildfs` passed and reported a fixed LittleFS
image of 2,031,616 of 2,031,616 bytes. image of 2,031,616 of 2,031,616 bytes.
- `docs/M017_LATENCY_REPORT.md` records the M016 baseline, the browser backlog
remediation, the required isolated-LAN deployment setup, and the controlled
before-and-after retest procedure. The physical mobile usability observation,
isolated-network 200-sample runs, and maximum-session diagnostics are still
required for acceptance criteria 6, 7, 9, and 10. Milestone 017 remains
`IN_PROGRESS`; Milestone 018 has not been started.
- On 2026-09-09, an edge-projection correction translated the received maze so - On 2026-09-09, an edge-projection correction translated the received maze so
the authoritative `self` cell remains below the centered avatar. Drag the authoritative `self` cell remains below the centered avatar. Drag
displacement now starts at pointer-down, preventing a touch near the displacement now starts at pointer-down, preventing a touch near the
@@ -1693,3 +1687,256 @@ map moves below it; displacement controls speed; approximately 100 ms is ideal,
25,346 of 1,572,864 allowed compressed bytes). Firmware and filesystem 25,346 of 1,572,864 allowed compressed bytes). Firmware and filesystem
rebuilds passed with 58,868 of 327,680 RAM bytes, 1,027,966 of 2,097,152 rebuilds passed with 58,868 of 327,680 RAM bytes, 1,027,966 of 2,097,152
application bytes, and a 2,031,616-byte LittleFS image. application bytes, and a 2,031,616-byte LittleFS image.
## Milestone 018 — Mobile directional controls and viewport refinement
**Status:** `IN_PROGRESS`
### Objective
Add a familiar, always-visible four-direction control cluster to the mobile
player view and refine the compact layout so a player can move without covering
the centered avatar or the most useful part of the maze with their finger.
### Scope
- Before implementation, reconcile `MVP.md`, `ARCHITECTURE.md`, and
`docs/UI_STYLE.md` with the project owner's decision to show directional
buttons during normal phone play. This explicitly supersedes the current
phone-specific rule that hides the directional-button cluster.
- Present four separate up, down, left, and right buttons as a conventional
cross-shaped directional pad in the lower-right control zone. Keep the
center of the cross inactive so diagonal input cannot be implied.
- Give every direction a large, distinct touch target, Russian accessible
label, and visible normal, pressed, disabled, and pending states. Prevent
browser scrolling, text selection, double-tap zoom, and stuck input during
normal button use.
- Send exactly one of the four existing orthogonal movement intentions from
each button interaction. Reuse the bounded latest-intent flow and server
cooldown; the browser must not predict movement or create a second movement
authority path.
- Retain avatar drag, directional swipe, and keyboard movement as optional
equivalent inputs. Directional buttons become the obvious default touch
control without removing the alternatives completed in earlier milestones.
- Rebalance the compact player HUD, maze viewport, key-drop action, secondary
controls, and transient notices so the map remains dominant and the player,
nearby maze cells, and important feedback remain visible while a thumb rests
on the directional pad.
- Keep the directional pad and key-drop action in separate lower-screen zones,
respect display safe areas and handed reach, and avoid overlap between
movement, drop-key, mute, reduced-motion, onboarding, or connection controls.
- Eliminate page-level scrolling and accidental overflow in supported portrait
and landscape player layouts, including the live approximately `412 x 915`
phone viewport as well as the canonical `412 x 916` validation viewport.
- Preserve Russian player-facing copy, observer read-only behavior,
hidden-state protection, local assets, reduced-motion behavior, and the
existing firmware and LittleFS budgets.
### Deliverables
- Updated mobile-control and responsive-layout contracts in `MVP.md`,
`ARCHITECTURE.md`, and `docs/UI_STYLE.md`.
- Responsive four-button directional pad integrated with the existing bounded
movement-intent path.
- Refined compact HUD, maze, key-drop, utility-control, and notice layout for
portrait and landscape mobile play.
- Russian accessible names and complete visual interaction states for all four
direction buttons.
- Deterministic browser tests and representative mobile-browser visual and
touch verification evidence.
### Acceptance criteria
1. At `412 x 915` and `412 x 916` CSS pixels in portrait orientation, all four
directional buttons, the key-drop action, essential player status, and the
maze are visible without page-level horizontal or vertical scrolling.
2. The directional buttons form an immediately recognizable cross with a
non-interactive center. Each visible button has a touch target of at least
`48 x 48` CSS pixels, remains inside the display safe area, and does not
overlap another control at any supported viewport.
3. A thumb operating any directional button does not cover the centered player
avatar. The current cell and enough adjacent maze context to understand the
next orthogonal move remain visible during button use.
4. Pressing or holding up, down, left, or right produces only the matching
existing cardinal movement intention through the bounded latest-intent
flow. Release, cancellation, pause, disconnect, blur, hidden-document
events, and lost pointer capture stop or clear button-generated intent.
5. Button input obeys the authoritative movement cooldown, never predicts a
successful move, and remains behaviorally equivalent to avatar drag, swipe,
and keyboard input for accepted and rejected movement.
6. Normal, pressed, disabled, and pending states are visually distinct without
relying on color alone. The buttons expose the Russian accessible labels
`Двигаться вверх`, `Двигаться вниз`, `Двигаться влево`, and
`Двигаться вправо`.
7. Button use does not cause page scrolling, text selection, double-tap zoom,
a context menu, diagonal commands, duplicate command streams, or movement
that continues after release.
8. The refined HUD and overlays keep the map as the dominant screen region;
key-drop, mute, reduced-motion, onboarding, connection state, errors, and
transient notices remain reachable and do not obscure one another in
portrait or landscape orientation.
9. Browser tests cover all four directions, tap and hold behavior, pointer and
touch cancellation, reset paths, cooldown and latest-intent bounds,
coexistence with drag/swipe/keyboard input, accessibility labels, safe-area
layout, control overlap, and zero page overflow at supported viewports.
10. 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 player projection or observer-authority contract changes.
11. The project owner confirms on a representative mobile browser that the
four buttons are comfortable and familiar, the central play area stays
readable while moving, every essential control fits on screen, and normal
play no longer requires dragging the avatar, or explicitly approves a
documented evidence deviation.
### Evidence
Implementation and automated validation are in progress. The following
planning-time observation motivates the milestone:
- On 2026-09-10, the project owner reported that dragging the centered player
is unfamiliar and uncomfortable because the finger hides useful maze space,
and requested visible up, down, left, and right buttons plus a better-fitting
mobile layout. Read-only inspection of the live player page at approximately
`412 x 915` CSS pixels found a `96 x 96` centered avatar drag target, four
directional buttons present but hidden, and a document height of 923 CSS
pixels, slightly exceeding the 915 CSS-pixel viewport. Implementation and
completion verification are deferred until Milestone 017 is `COMPLETE`.
- On 2026-09-10, `MVP.md`, `ARCHITECTURE.md`, and `docs/UI_STYLE.md` were
reconciled to make the four-button lower-right directional pad the primary
phone control while preserving avatar drag, swipe, and keyboard alternatives.
The inactive centre prevents diagonal input. Each button is 62 by 62 CSS
pixels on compact phones (and at least 52 by 52 CSS pixels in compact
landscape), has Russian accessible text, and uses pointer capture plus a
170 ms bounded hold repeat through the existing latest-intent flow. The
compact application viewport is explicitly clipped to `100dvh` to prevent
page scrolling; the lower-left key action, lower-right pad, top-right utility
controls, and raised notices occupy separate zones.
- On 2026-09-10, `node --test test/web/test_player_app.js` passed all 20
browser tests. `bash scripts/run_host_tests.sh` passed all host tests and
deterministic web-asset verification
(`ecf9429fd873e27df67774616ec68684df19d88cce6913d53b0764b53e8e95cd`,
25,939 of 1,572,864 allowed compressed bytes). `/home/sasa/.platformio/penv/bin/pio
run -e esp32-c6-devkitm-1` passed with 58,868 of 327,680 RAM bytes and
1,027,966 of 2,097,152 application bytes. The matching `--target buildfs`
command passed and produced the fixed 2,031,616-byte LittleFS image.
- A representative physical mobile-browser touch/layout confirmation remains
required for acceptance criterion 11. Milestone 018 remains `IN_PROGRESS`;
Milestone 019 has not been started.
## Milestone 019 — Reference-derived SVG icon system
**Status:** `NOT_STARTED`
### Objective
Replace the current player and gameplay pictograms with a coherent local SVG
set derived from the project owner's supplied visual reference, and establish
yellow and blue as the two unmistakable player identities across players,
matching keys, and matching exits.
### Scope
- Before changing application markup, styling, or rendering, manually recreate
the supplied raster-reference silhouettes as clean, editable SVG artwork.
Do not embed, trace at runtime, or ship the raster reference itself.
- Create SVG symbols for the six supplied concepts: running player, key,
closed arched exit, vision crystal, opened arched exit with outward arrow,
and blocked-movement spikes.
- Treat the reference as visual direction rather than a source of product
requirements. Map its artwork only to existing maze32 gameplay states; the
spikes communicate a rejected move or wall collision and must not introduce
a trap, damage, or any other new mechanic.
- Review and approve the standalone SVG set before using it to replace icons in
the browser application. Keep source paths understandable, view boxes
consistent, silhouettes legible at gameplay sizes, and all colors controlled
through project-owned CSS or explicit documented SVG tokens.
- Reconcile `MVP.md`, `ARCHITECTURE.md`, `docs/UI_STYLE.md`, and supporting UI
documentation before implementation. Replace the current blue/pink identity
contract with Player 1 yellow and Player 2 blue across every player, key,
exit, status, map, onboarding, result, and observer presentation.
- Give each player's matching key and exit the same yellow or blue ownership
identity as its owner. Preserve different silhouettes and state treatments
for players, keys, closed exits, and opened exits so meaning never depends on
color alone.
- Replace the applicable current pictograms in the player HUD, maze, key-drop
control, onboarding, transient feedback, results, and observer view with the
approved SVG symbols. Retain or restyle icons not represented by the supplied
sample only as needed to keep the complete icon family visually coherent.
- Preserve Russian accessible labels, icon-first comprehension, reduced-motion
behavior, server authority, hidden-state rules, offline operation, and the
existing compressed-asset and Flash budgets.
- Verify that yellow remains distinguishable from warning states and blue from
pause states by pairing semantic colors with shape, outline, animation, or
labels, as appropriate.
### Deliverables
- A project-owned, editable SVG source set for the player, key, closed exit,
vision crystal, opened exit, and blocked-movement symbols.
- A documented mapping from each reference-derived SVG symbol and yellow/blue
variant to existing maze32 entities and UI states.
- Updated color and icon contracts in `MVP.md`, `ARCHITECTURE.md`,
`docs/UI_STYLE.md`, and affected supporting UI documentation.
- Replacement icons in player, observer, onboarding, HUD, feedback, and result
presentations without embedded raster images or external dependencies.
- Deterministic SVG validation, browser tests, asset-budget checks, and
representative mobile and desktop visual-verification evidence.
### Acceptance criteria
1. Before application icon replacement begins, the six reference-derived SVG
sources exist as editable vector paths, pass XML validation, have consistent
documented view boxes, contain no embedded raster data, and are approved by
the project owner against the supplied visual reference.
2. The running figure, key, closed arched exit, faceted vision crystal, opened
arched exit with outward arrow, and spikes remain recognizable at every
rendered application size without relying on accompanying text.
3. Player 1 is yellow and Player 2 is blue in every player and observer view.
Each player's key and exit carry the matching ownership color consistently,
including ground, inventory, closed, opened, onboarding, and result states.
4. Player, key, closed exit, and opened exit remain distinguishable by both
silhouette and state treatment in monochrome inspection; color is never the
only way to identify object type, ownership, or exit state.
5. The spikes appear only as blocked-movement or wall-collision feedback. No
trap object, damage rule, hidden-state field, protocol message, or server
state is added.
6. Yellow player identity cannot be confused with warnings, and blue player
identity cannot be confused with pause state: each conflicting semantic role
has an additional icon, outline, pattern, motion, or Russian label.
7. All applicable current player, key, exit, vision-artifact, and
blocked-movement pictograms are replaced across the maze, HUD, key-drop
action, onboarding, transient feedback, results, and observer presentation;
no obsolete duplicate icon remains visible in a reachable state.
8. SVG symbols render without clipping, distortion, unexpected inherited
color, or loss of contrast at the canonical `412 x 916` player viewport,
compact landscape, representative tablet and desktop player viewports, and
observer viewports of `1024 x 768` or larger.
9. Player-facing accessible labels remain in Russian, the icon-first onboarding
remains understandable, reduced motion preserves all state information, and
the application loads every icon locally without an internet dependency.
10. Automated checks validate SVG structure, required symbol identifiers,
yellow/blue ownership mapping, all closed/open and carried/ground variants,
absence of embedded raster content, blocked-feedback semantics, and the
removal of obsolete reachable icons.
11. All host and browser tests pass, the compressed web payload remains within
the 1.5 MiB budget and LittleFS partition, and firmware remains within the
factory partition without changing authoritative game or projection data.
12. The project owner confirms on representative mobile and desktop browsers
that the new icons match the supplied examples closely enough, yellow and
blue ownership is immediately understandable, keys and doors are assigned
to the correct player, and every icon is clear at normal gameplay size, or
explicitly approves a documented evidence deviation.
### Evidence
Completion evidence is not yet available because SVG authoring and application
replacement have not started. The following planning-time observation motivates
the milestone but does not satisfy its acceptance criteria:
- On 2026-09-10, the project owner supplied a raster visual reference containing
a yellow running figure, yellow key, red closed arched door, cyan faceted
crystal, green opened arched door with an outward arrow, and red spikes. The
owner requested that local SVG artwork be created from these examples before
icon replacement, and selected yellow and blue as the two player colors.
Implementation and completion verification are deferred until Milestone 018
is `COMPLETE`.
Binary file not shown.
+5 -1
View File
@@ -20,7 +20,11 @@ layouts hide the direction pad and retain swipes; tablet and desktop layouts
show the direction pad and also accept swipes. The persistent `OK` control show the direction pad and also accept swipes. The persistent `OK` control
sends `drop_key` and exposes the accessible label `Сбросить ключ`. sends `drop_key` and exposes the accessible label `Сбросить ключ`.
The centered player avatar is also the touch movement handle. Its transparent On phones, an always-visible four-button directional pad is the primary touch
control. Its inactive centre prevents diagonal input; each button sends one of
the same four authoritative movement intentions and a held button repeats no
faster than every 170 ms. The centered player avatar is also an optional touch
movement handle. Its transparent
target is 96 by 96 CSS pixels while the visible avatar can deflect at most 28 target is 96 by 96 CSS pixels while the visible avatar can deflect at most 28
CSS pixels. A 12-pixel dead zone and diagonal hysteresis resolve only cardinal CSS pixels. A 12-pixel dead zone and diagonal hysteresis resolve only cardinal
intentions. Drag displacement selects a monotonically bounded local cadence intentions. Drag displacement selects a monotonically bounded local cadence
+7 -3
View File
@@ -64,7 +64,10 @@ are not MVP requirements.
- Visible keyboard-focus outlines are not required for the MVP. - Visible keyboard-focus outlines are not required for the MVP.
- On computers, movement works from the keyboard and from an on-screen - On computers, movement works from the keyboard and from an on-screen
directional control cluster fixed in the lower-right corner. directional control cluster fixed in the lower-right corner.
- On touch devices, the centered player avatar is the movement handle. Its - On touch devices, an always-visible four-button directional pad in the lower
right is the primary movement control. Its centre is inactive, each button
is at least 48 by 48 CSS pixels, and it sends only one cardinal intention.
The centered player avatar remains an optional movement handle. Its
transparent touch target is at least 96 by 96 CSS pixels and its visible transparent touch target is at least 96 by 96 CSS pixels and its visible
character remains unobscured. During a drag, it deflects no more than 28 CSS character remains unobscured. During a drag, it deflects no more than 28 CSS
pixels from center and returns exactly to center on release, cancellation, pixels from center and returns exactly to center on release, cancellation,
@@ -72,8 +75,9 @@ are not MVP requirements.
- The avatar drag has a 12 CSS-pixel dead zone and directional hysteresis. Its - The avatar drag has a 12 CSS-pixel dead zone and directional hysteresis. Its
bounded repeat cadence changes monotonically from 360 ms at a small bounded repeat cadence changes monotonically from 360 ms at a small
intentional displacement to 170 ms at the target edge; it never exceeds the intentional displacement to 170 ms at the target edge; it never exceeds the
server's 150 ms movement cooldown. Touch swipes remain supported. Phones do server's 150 ms movement cooldown. The pad's hold repeat is 170 ms. Touch
not show a directional-button cluster. swipes remain supported. The pad, avatar drag, swipe, and keyboard inputs
share one bounded latest-intent path.
- The always-visible key-drop control is labelled `OK`, includes a key icon, - The always-visible key-drop control is labelled `OK`, includes a key icon,
and is positioned closer to the upper edge of the screen than the and is positioned closer to the upper edge of the screen than the
directional controls. Dropping does not require confirmation. directional controls. Dropping does not require confirmation.
+43 -4
View File
@@ -63,6 +63,22 @@ test("latest movement intent coalesces while one command is in flight", () => {
assert.equal(flow.latest, null); assert.equal(flow.latest, null);
}); });
test("directional pad repeat remains slower than the authoritative cooldown", () => {
assert.ok(player.DIRECTIONAL_PAD_REPEAT_MS > 150);
const dispatched = [];
const flow = new player.LatestIntentFlow((direction, element) => {
dispatched.push({direction, element});
return "move-1";
});
const button = {id: "north"};
flow.request("north", button);
flow.request("east", {id: "east"});
assert.deepEqual(flow.latest, {direction: "east", element: {id: "east"}});
flow.release();
assert.equal(flow.latest, null);
assert.deepEqual(dispatched, [{direction: "north", element: button}]);
});
test("latency correlation is bounded and only completes matching authoritative views", () => { test("latency correlation is bounded and only completes matching authoritative views", () => {
let clock = 0; let clock = 0;
const tracker = new player.LatencyTracker(() => clock); const tracker = new player.LatencyTracker(() => clock);
@@ -115,9 +131,9 @@ test("avatar control resets safely and retains accessible alternatives", () => {
assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/); assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/);
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", resetAvatarControl\)/); assert.match(script, /addEventListener\("blur", resetPlayerControls\)/);
assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/); assert.match(script, /connectionState !== "online"\) resetPlayerControls\(\)/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/); 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/);
assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setMuted\(!state\.muted\)/);
@@ -128,6 +144,29 @@ 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("mobile directional pad is explicit, bounded, and resettable", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
for (const label of ["Двигаться вверх", "Двигаться вниз", "Двигаться влево", "Двигаться вправо"]) {
assert.match(html, new RegExp(`aria-label="${label}"`));
}
assert.match(html, /class="dpad-center" aria-hidden="true"/);
assert.match(html, /data-direction="north"[^>]*disabled/);
assert.match(css, /\.dpad \{[^}]*grid-template-rows: repeat\(3, 58px\)/);
assert.match(css, /\.dpad button:disabled/);
assert.match(css, /\.dpad button\.active/);
assert.match(css, /\.dpad button\.pending/);
assert.match(css, /\.dpad \{[^}]*touch-action: none/);
assert.match(css, /\.dpad button \{[^}]*touch-action: none/);
assert.match(css, /grid-template-columns: repeat\(3, 62px\)/);
assert.match(script, /function resetDirectionalPad\(\)/);
assert.match(script, /addEventListener\("pointercancel", stopDirectionalPad\)/);
assert.match(script, /addEventListener\("lostpointercapture", stopDirectionalPad\)/);
assert.match(script, /windowObject\.addEventListener\("blur", resetPlayerControls\)/);
assert.match(script, /state\.moveFlow\.request\(direction, element\)/);
});
test("frontend name suggestions randomize both words and remain bounded", () => { test("frontend name suggestions randomize both words and remain bounded", () => {
const picks = [3, 4]; const picks = [3, 4];
const suggestion = player.randomSuggestedName([], () => picks.shift()); const suggestion = player.randomSuggestedName([], () => picks.shift());
@@ -217,7 +256,7 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(css, /@media \(max-width: 720px\)/); assert.match(css, /@media \(max-width: 720px\)/);
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/); assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
assert.match(css, /height: calc\(100dvh - 78px\)/); assert.match(css, /height: calc\(100dvh - 78px\)/);
assert.match(css, /\.dpad \{ display: none; \}/); assert.match(css, /\.dpad \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
assert.match(html, /id="avatar-control"/); assert.match(html, /id="avatar-control"/);
assert.match(html, /id="onboarding"/); assert.match(html, /id="onboarding"/);
assert.match(html, /id="mute-toggle"/); assert.match(html, /id="mute-toggle"/);
+1 -1
View File
@@ -63,7 +63,7 @@
<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="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> <button class="access-button" id="onboarding-replay" type="button" aria-label="Показать обучение"><svg aria-hidden="true"><use href="/icons.svg#help"></use></svg></button>
</div> </div>
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav> <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="Обучение игре"> <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> <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>
</section> </section>
+14 -7
View File
@@ -174,11 +174,14 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.drop-key strong { font-size: 1.2rem; } .drop-key strong { font-size: 1.2rem; }
.drop-key span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; } .drop-key span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; }
.drop-key:disabled { opacity: .48; } .drop-key:disabled { opacity: .48; }
.dpad { position: fixed; z-index: 11; right: max(24px, env(safe-area-inset-right)); bottom: max(28px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(2, 58px); gap: 5px; } .dpad { position: fixed; z-index: 16; right: max(18px, env(safe-area-inset-right)); bottom: max(18px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: repeat(3, 58px); gap: 5px; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button { border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; } .dpad button { min-width: 58px; min-height: 58px; border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; }
.dpad button.active, .dpad button:active { transform: translate(3px, 3px); border-style: dashed; box-shadow: 1px 1px 0 #020506; }
.dpad button:disabled { border-color: var(--edge); background: #171027c9; color: var(--muted); opacity: .55; cursor: not-allowed; }
.dpad button.pending { outline: 3px solid var(--warning); outline-offset: -5px; }
.dpad button:first-child { grid-column: 2; } .dpad button:first-child { grid-column: 2; }
.dpad button:nth-child(2) { grid-column: 1; grid-row: 2; } .dpad button:nth-child(2) { grid-column: 1; grid-row: 2; }
.dpad span { grid-column: 2; grid-row: 2; display: grid; place-items: center; color: #4b3a60; } .dpad-center { grid-column: 2; grid-row: 2; display: grid; place-items: center; border: 2px solid #3b2951; background: #0d0915d9; color: #5a4174; pointer-events: none; }
.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; }
@@ -227,7 +230,8 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@media (max-width: 720px) { @media (max-width: 720px) {
:root { --cell: 46px; } :root { --cell: 46px; }
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); } html, body { height: 100%; overflow: hidden; }
#app { height: 100dvh; min-height: 0; overflow: hidden; padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); }
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; } .welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
.hero-art { display: none; } .hero-art { display: none; }
.welcome-panel { padding: 22px 18px; } .welcome-panel { padding: 22px 18px; }
@@ -240,7 +244,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.player-card { margin-top: 8px; padding: 8px; } .player-card { margin-top: 8px; padding: 8px; }
.roster-panel { grid-row: auto; } .roster-panel { grid-row: auto; }
.section-heading { align-items: start; flex-direction: column; } .section-heading { align-items: start; flex-direction: column; }
.match-screen { height: calc(100dvh - 70px); padding-top: 82px; } .match-screen { height: calc(100dvh - 70px); min-height: 0; padding-top: 82px; }
.player-status { grid-template-columns: 1.25fr repeat(3, .75fr); inset: 6px 0 auto; } .player-status { grid-template-columns: 1.25fr repeat(3, .75fr); inset: 6px 0 auto; }
.identity, .stat { min-width: 0; min-height: 72px; padding: 5px; flex-direction: column; justify-content: center; text-align: center; gap: 1px; } .identity, .stat { min-width: 0; min-height: 72px; padding: 5px; flex-direction: column; justify-content: center; text-align: center; gap: 1px; }
.identity .avatar { flex-basis: 34px; width: 34px; height: 34px; font-size: 1.4rem; } .identity .avatar { flex-basis: 34px; width: 34px; height: 34px; font-size: 1.4rem; }
@@ -252,7 +256,9 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; } .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; } .drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
.access-controls { top: 88px; right: 8px; } .access-controls { top: 88px; right: 8px; }
.dpad { display: none; } .dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
.notice { bottom: max(218px, calc(env(safe-area-inset-bottom) + 206px)); }
.connection-pill { font-size: .58rem; } .connection-pill { 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; }
} }
@@ -269,10 +275,11 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.identity, .stat { min-height: 55px; } .identity, .stat { min-height: 55px; }
.map-shell { inset: 64px 0 0; } .map-shell { inset: 64px 0 0; }
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; } .drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(8px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 52px); grid-template-rows: repeat(3, 52px); }
.dpad button { min-width: 52px; min-height: 52px; }
} }
@media (hover: none) and (pointer: coarse) and (max-height: 720px) { @media (hover: none) and (pointer: coarse) and (max-height: 720px) {
.dpad { display: none; }
.drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); } .drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); }
} }
+69 -17
View File
@@ -14,6 +14,7 @@
const AVATAR_DIRECTION_HYSTERESIS = .18; const AVATAR_DIRECTION_HYSTERESIS = .18;
const AVATAR_SLOW_REPEAT_MS = 360; const AVATAR_SLOW_REPEAT_MS = 360;
const AVATAR_FAST_REPEAT_MS = 170; const AVATAR_FAST_REPEAT_MS = 170;
const DIRECTIONAL_PAD_REPEAT_MS = 170;
const LATENCY_SAMPLE_LIMIT = 256; const LATENCY_SAMPLE_LIMIT = 256;
const ONBOARDING_STEPS = Object.freeze([ const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}), Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
@@ -147,13 +148,13 @@
this.latest = null; this.latest = null;
} }
request(direction) { request(direction, element) {
if (!direction) return null; if (!direction) return null;
if (this.inFlight) { if (this.inFlight) {
this.latest = direction; this.latest = {direction, element};
return null; return null;
} }
const requestId = this.dispatch(direction); const requestId = this.dispatch(direction, element);
if (requestId) this.inFlight = requestId; if (requestId) this.inFlight = requestId;
return requestId; return requestId;
} }
@@ -161,9 +162,9 @@
complete(requestId) { complete(requestId) {
if (!requestId || requestId !== this.inFlight) return null; if (!requestId || requestId !== this.inFlight) return null;
this.inFlight = null; this.inFlight = null;
const direction = this.latest; const latest = this.latest;
this.latest = null; this.latest = null;
return direction ? this.request(direction) : null; return latest ? this.request(latest.direction, latest.element) : null;
} }
release() { this.latest = null; } release() { this.latest = null; }
@@ -300,8 +301,8 @@
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}, 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, 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 => sendMoveNow(direction)); state.moveFlow = new LatestIntentFlow((direction, element) => sendMoveNow(direction, element));
function showNotice(text, kind) { function showNotice(text, kind) {
const element = byId("notice"); const element = byId("notice");
@@ -329,7 +330,7 @@
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") resetAvatarControl(); if (connectionState !== "online") resetPlayerControls();
} }
renderWelcomeHeadline(randomLoginHeadline()); renderWelcomeHeadline(randomLoginHeadline());
@@ -502,6 +503,7 @@
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).symbol}`; byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).symbol}`;
byId("inventory-stat").dataset.filled = String(snapshot.inventory != null); byId("inventory-stat").dataset.filled = String(snapshot.inventory != null);
byId("drop-key").disabled = snapshot.inventory == null; byId("drop-key").disabled = snapshot.inventory == null;
setDirectionalPadEnabled(snapshot.phase === "active" && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN);
byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет"; byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет";
byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0); byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0);
const hint = HINTS[snapshot.hint] ? snapshot.hint : "none"; const hint = HINTS[snapshot.hint] ? snapshot.hint : "none";
@@ -512,7 +514,7 @@
if (Number.isInteger(snapshot.stateSequence)) { if (Number.isInteger(snapshot.stateSequence)) {
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence)); windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
} }
if (snapshot.phase === "paused") { resetAvatarControl(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); } if (snapshot.phase === "paused") { resetPlayerControls(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result); else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else { else {
hideModal(); hideModal();
@@ -615,7 +617,7 @@
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат."; text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
} }
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false); showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
resetAvatarControl(); resetPlayerControls();
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби"; byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
byId("modal-action").hidden = false; byId("modal-action").hidden = false;
} }
@@ -651,7 +653,7 @@
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false); } else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
} 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(); resetAvatarControl(); 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.moveFlow.complete(message.requestId); feedback(true);
} else if (message.type === "error") { } else if (message.type === "error") {
state.moveFlow.complete(message.requestId); state.moveFlow.complete(message.requestId);
@@ -675,8 +677,35 @@
if (direction) state.moveFlow.request(direction, element); if (direction) state.moveFlow.request(direction, element);
} }
function sendMoveNow(direction) { function sendMoveNow(direction, element) {
return send("move", {direction}, byId("avatar-control")); return send("move", {direction}, element || byId("avatar-control"));
}
function setDirectionalPadEnabled(enabled) {
documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.disabled = !enabled;
});
if (!enabled) resetDirectionalPad();
}
function resetDirectionalPad() {
windowObject.clearTimeout(state.dpad.repeatTimer);
state.dpad.pointerId = null;
state.dpad.direction = null;
state.dpad.repeatTimer = null;
documentObject.querySelectorAll("[data-direction]").forEach(button => button.classList.remove("active"));
state.moveFlow.release();
}
function repeatDirectionalPadMove() {
if (!state.dpad.direction || state.dpad.pointerId == null) return;
move(state.dpad.direction, documentObject.querySelector(`[data-direction="${state.dpad.direction}"]`));
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
}
function resetPlayerControls() {
resetAvatarControl();
resetDirectionalPad();
} }
function resetAvatarControl() { function resetAvatarControl() {
@@ -771,9 +800,32 @@
byId("avatar-control").addEventListener("pointerup", stopAvatarControl); byId("avatar-control").addEventListener("pointerup", stopAvatarControl);
byId("avatar-control").addEventListener("pointercancel", stopAvatarControl); byId("avatar-control").addEventListener("pointercancel", stopAvatarControl);
byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl); byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl);
windowObject.addEventListener("blur", resetAvatarControl); windowObject.addEventListener("blur", resetPlayerControls);
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetAvatarControl(); }); documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); });
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button))); documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.addEventListener("pointerdown", event => {
if (button.disabled) return;
event.preventDefault();
beginFeedback();
resetDirectionalPad();
state.dpad.pointerId = event.pointerId;
state.dpad.direction = button.dataset.direction;
button.classList.add("active");
if (button.setPointerCapture) button.setPointerCapture(event.pointerId);
move(state.dpad.direction, button);
state.dpad.repeatTimer = windowObject.setTimeout(repeatDirectionalPadMove, DIRECTIONAL_PAD_REPEAT_MS);
});
const stopDirectionalPad = event => {
if (event.pointerId === state.dpad.pointerId) resetDirectionalPad();
};
button.addEventListener("pointerup", stopDirectionalPad);
button.addEventListener("pointercancel", stopDirectionalPad);
button.addEventListener("lostpointercapture", stopDirectionalPad);
button.addEventListener("contextmenu", event => event.preventDefault());
button.addEventListener("click", event => {
if (event.detail === 0 && !button.disabled) move(button.dataset.direction, button);
});
});
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key"))); byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted)); byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion)); byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
@@ -814,7 +866,7 @@
return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()}; return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
} }
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, DIRECTIONAL_PAD_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, playerMapOffset, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
if (typeof module !== "undefined" && module.exports) module.exports = api; if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api; root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); }); if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });