feat(player-ui): add pre-reader controls and onboarding

This commit is contained in:
2026-09-09 20:56:38 +03:00
parent f043b63732
commit 3827ba7ec7
9 changed files with 424 additions and 52 deletions
+40 -13
View File
@@ -1267,7 +1267,7 @@ confirmation remains pending.
## Milestone 015 — Pre-reader child-friendly game experience
**Status:** `NOT_STARTED`
**Status:** `IN_PROGRESS`
### Objective
@@ -1363,15 +1363,42 @@ rules.
### Evidence
Completion evidence is not yet available because implementation has not
started. A baseline inspection of the live game at a `412 x 915` viewport on
2026-09-08 found that the visible maze occupied only `80 x 140` pixels inside a
`381 x 729` play area, status labels were approximately `8--10.56` pixels,
status icons were `23 x 23` pixels, the player was identified primarily as
`P1`, and no directional touch controls were visible in the game area. These
observations define the principal redesign targets. The planned joystick
replaces directional-arrow buttons rather than adding them. Expected completion
evidence includes before-and-after viewport captures, deterministic browser
tests, `bash scripts/run_host_tests.sh`, a reproducible `buildfs` size report,
and the facilitated child-usability confirmation or an explicitly approved
deviation.
The implementation and automated verification are complete; the required
representative pre-reader observation or owner-approved evidence deviation
remains pending.
- The player screen now uses circle and diamond character shapes instead of
`P1`/`P2`, icon-and-shape ownership markers for keys and exits, dynamic
projection sizing from 32 to 72 CSS pixels per cell, a 180 CSS-pixel
joystick (172 on phones), and an icon-only visible mute, reduced-motion, and
onboarding-replay control group. The mobile directional-button cluster stays
hidden while keyboard and swipe input remain available.
- The joystick clamps its handle to the circular base, has a 20-pixel dead
zone and 18% axis hysteresis, emits only cardinal movement intentions, and
schedules repeats from 360 to 170 ms. It resets on pointer release or
cancellation, pause, disconnect, browser blur, and hidden-document events;
the fastest local repeat remains above the 150 ms authoritative cooldown.
- First active player projection now opens an eight-step, icon-first Russian
walkthrough for drag, release, walls, key, vision, compass, exit, and the
other player. It can be replayed or skipped locally. The new
`docs/PRE_READER_USABILITY.md` records the final privacy-safe observation
protocol without collecting child-identifying data.
- On 2026-09-09, `bash scripts/run_host_tests.sh` passed every host suite,
including 15 browser tests. The focused browser coverage verifies joystick
cardinal direction and bounded travel, dead zone, monotonic repeat rate,
hysteresis, dynamic mobile cell sizing, onboarding bounds, reset paths,
mute and reduced-motion controls, local assets, Russian UI, and existing
protocol/observer constraints. Web asset reproducibility reported SHA-256
`13f95418169fb38d277d86f3324a4dbc3fa8b5e5e2e9fb7bde9439aa2f7b75e4`
using 23,950 of 1,572,864 allowed compressed bytes.
- On 2026-09-09, `pio run -e esp32-c6-devkitm-1` passed with 58,868 of
327,680 RAM bytes and 1,027,934 of 2,097,152 application bytes. Its
`firmware.bin` SHA-256 was
`87cfffaaa9b119794707c9caedc3da7f3f015d540fc1149d74e7cc0a1ecd2841`.
`pio run -e esp32-c6-devkitm-1 --target buildfs` passed; the fixed LittleFS
image used 2,031,616 of 2,031,616 bytes and SHA-256
`ea6813e603d883a1f6b79a9f9efbd20e7d02f010052210716a89d7e863716415`.
- Acceptance criterion 11 still requires the facilitated representative
pre-reader observation from `docs/PRE_READER_USABILITY.md`, or a
project-owner-approved documented deviation. Therefore Milestone 015 remains
`IN_PROGRESS`; Milestone 016 has not been started.
Binary file not shown.
+38
View File
@@ -0,0 +1,38 @@
# Pre-reader usability observation guide
## Purpose
Use this short, consent-based observation to verify the player interface with
a child who does not rely on reading. It is an acceptance activity for
Milestone 015, not a source of analytics. Do not record names, images, audio,
contact details, or any other identifying information.
## Setup
- Use the player screen at a `412 x 916` CSS-pixel viewport with sound enabled.
- Start a normal two-player match with the observer screen hidden.
- An adult may read a prompt once but must not point to the answer or operate
the child's device.
## Prompts
Ask the child to demonstrate each task. Mark it as completed only when the
child succeeds without reading the Russian support text.
| Task | Expected non-reading cue |
| --- | --- |
| Move the hero | circular joystick and character movement |
| Find and pick up a key | key shape and pickup feedback |
| Recognize farther vision | eye symbol and map expansion |
| Follow the compass | arrow direction and compass icon |
| Find the exit | outlined exit shape and owner character shape |
| Recognize a wall | wall shape plus rejection feedback |
| Recognize the result | result icon/overlay plus local feedback |
## Record
Record only the date, viewport, browser, whether Russian labels were read,
and the seven task outcomes. Summarize unclear cues and any adult assistance.
If no representative child observation is feasible, the project owner may
approve a documented deviation instead; include the reason and the remaining
risk in the active milestone evidence.
+38 -9
View File
@@ -49,11 +49,13 @@ are not MVP requirements.
- Use frequent but purposeful animation for screen transitions, movement,
pickups, game events, and humorous feedback.
- Pending controls blink until their command is resolved.
- A reduced-motion mode is not required for the MVP.
- A visible reduced-motion toggle is required in player mode. It must suppress
non-essential animation without changing gameplay feedback or authority.
- Sound and vibration are enabled by default. They begin only after the first
user interaction when required by the browser, and are used only when the
browser and device support them.
- Dedicated sound and vibration toggles are not required for the MVP.
- A visible mute toggle is required in player mode. It disables local sound and
vibration feedback only; it must never change protocol behavior.
## 5. Shared controls
@@ -62,10 +64,14 @@ are not MVP requirements.
- Visible keyboard-focus outlines are not required for the MVP.
- On computers, movement works from the keyboard and from an on-screen
directional control cluster fixed in the lower-right corner.
- On tablets, an on-screen directional control cluster is fixed in the
lower-right corner for right-handed use. Touch swipes remain supported.
- On phones, movement uses directional swipes and no on-screen directional
buttons are shown.
- On touch devices, movement uses an always-visible circular joystick at least
160 by 160 CSS pixels. Its drag stays within the control, returns to center
on release, cancellation, pause, disconnect, and browser blur, and sends
only cardinal movement intentions.
- The joystick has a dead zone and directional hysteresis. Its locally
scheduled repeat interval is bounded and never faster than the server's
movement cooldown. Touch swipes remain supported. Phones do not show a
directional-button cluster.
- 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
directional controls. Dropping does not require confirmation.
@@ -78,6 +84,9 @@ are not MVP requirements.
- A fixed status panel occupies the top of the screen.
- The persistent player status shows the player name, carried key when one is
held, active vision effect, and match state.
- 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.
- Countdown, pause, and result states appear as modal overlays above the map.
@@ -93,9 +102,10 @@ are not MVP requirements.
boundaries prevent it.
- Render unknown space as a solid background. Never infer, retain, animate, or
decorate maze geometry outside the server-provided visible projection.
- Use a nominal cell size of 20 CSS pixels on phones and 40 CSS pixels on
tablets and computers. Minor adjustment is allowed to fit browser chrome and
safe areas without making the map unusable.
- Compute cell size from the current server projection and available map area.
At the minimum supported phone viewport a common 7 by 7 projection must use
at least 46 CSS-pixel cells, while smaller projections may grow to 72 pixels.
Do not alter, retain, or infer hidden maze cells to fill spare space.
- Player zoom and pan controls are not part of the MVP.
- Use different colors plus labels, outlines, shapes, or icons to distinguish
player ownership and object types.
@@ -108,6 +118,9 @@ are not MVP requirements.
- Support both portrait and landscape orientations. Suggest landscape when it
materially improves play, but do not block portrait play.
- Respect display safe areas and keep essential status and controls visible.
- At `412 x 916`, the map is the dominant visual region; controls overlay it
instead of creating an empty lower region. Status remains icon-first and
readable without requiring reading as the normal interaction path.
- Scale icons and controls up on tablets and computers while preserving map
priority and fixed panel placement.
- Validate the MVP against the current stable releases of Chrome, Safari,
@@ -143,3 +156,19 @@ are not MVP requirements.
message, and semantic color role manually and through focused browser tests
where practical.
- Golden-image or screenshot-regression tests are not required for the MVP.
## 12. Pre-reader onboarding and usability
- On a player's first active projection, show an eight-step, wordless-first
walkthrough for joystick drag and release, walls, key, vision, compass,
exit, and meeting the other player. Each step uses a large icon; short
Russian text is secondary support for an adult.
- The walkthrough provides a replay button and an explicit Russian skip button
for adults. It is browser-local UI only and never changes server state.
- A child must be able to distinguish their character, the other character,
key, vision, compass, exit, blocked movement, and results through at least
two channels such as shape/icon, position, motion, outline, color, sound, or
haptic feedback.
- Before release, record a representative pre-reader observation using
`docs/PRE_READER_USABILITY.md`, or record a project-owner-approved deviation
with its reason and impact.
+56
View File
@@ -22,6 +22,56 @@ test("keyboard and swipe controls produce equivalent directions", () => {
assert.equal(player.directionFromSwipe(0, 0, 12, 4), null);
});
test("joystick produces only cardinal, bounded movement intentions", () => {
assert.equal(player.joystickDirection(10, 10, null), null);
assert.equal(player.joystickDirection(44, 8, null), "east");
assert.equal(player.joystickDirection(-8, -44, null), "north");
assert.equal(player.joystickDirection(42, 38, "east"), "east");
assert.equal(player.joystickDirection(38, 42, "north"), "south");
const vector = player.joystickVector(300, 0, 57, null);
assert.equal(vector.direction, "east");
assert.equal(vector.x, 57);
assert.equal(vector.y, 0);
});
test("joystick repeat interval is monotonic and remains slower than cooldown", () => {
assert.equal(player.joystickRepeatMs(player.JOYSTICK_DEAD_ZONE, 60), null);
const near = player.joystickRepeatMs(25, 60);
const far = player.joystickRepeatMs(60, 60);
assert.ok(near > far);
assert.ok(far >= player.JOYSTICK_FAST_REPEAT_MS);
assert.ok(player.JOYSTICK_FAST_REPEAT_MS > 150);
});
test("player map cells enlarge small projections without exceeding a readable bound", () => {
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
const fourByFour = player.playerCellSize(396, 730, 4, 4);
assert.ok(sevenBySeven >= 46);
assert.ok(fourByFour > sevenBySeven);
assert.ok(fourByFour <= 72);
});
test("onboarding has bounded, replayable steps", () => {
assert.equal(player.ONBOARDING_STEPS.length, 8);
assert.equal(player.nextOnboardingStep(0), 1);
assert.equal(player.nextOnboardingStep(6), 7);
assert.equal(player.nextOnboardingStep(7), null);
});
test("child controls reset safely and retain accessible alternatives", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /addEventListener\("pointerup", stopJoystick\)/);
assert.match(script, /addEventListener\("pointercancel", stopJoystick\)/);
assert.match(script, /addEventListener\("blur", resetJoystick\)/);
assert.match(script, /connectionState !== "online"\) resetJoystick\(\)/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetJoystick\(\)/);
assert.match(script, /setMuted\(!state\.muted\)/);
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(script, /onboarding-replay/);
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("frontend name suggestions randomize both words and remain bounded", () => {
const picks = [3, 4];
const suggestion = player.randomSuggestedName([], () => picks.shift());
@@ -112,6 +162,12 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
assert.match(css, /height: calc\(100dvh - 78px\)/);
assert.match(css, /\.dpad \{ display: none; \}/);
assert.match(html, /id="joystick"/);
assert.match(html, /id="onboarding"/);
assert.match(html, /id="mute-toggle"/);
assert.match(css, /\.joystick \{[^}]*width: 180px; height: 180px/);
assert.match(script, /resetJoystick/);
assert.match(script, /playerCellSize/);
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
assert.match(icons, /id="key"/);
assert.match(icons, /id="maze"/);
+6
View File
@@ -5,5 +5,11 @@
<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="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>
<symbol id="motion" viewBox="0 0 24 24"><path d="M4 12h6m4 0h6M8 7l5 5-5 5M16 7l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M9.5 9a2.7 2.7 0 1 1 4.2 2.2c-1.4.9-1.7 1.4-1.7 2.8M12 17h.01" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="hand" viewBox="0 0 24 24"><path d="M8 12V5a1.5 1.5 0 0 1 3 0v5V3.5a1.5 1.5 0 0 1 3 0V10V5a1.5 1.5 0 0 1 3 0v6V8a1.5 1.5 0 0 1 3 0v6c0 4-3 7-7 7-3 0-5-2-6-5l-1-3a1.5 1.5 0 0 1 2-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></symbol>
<symbol id="exit" viewBox="0 0 24 24"><path d="M4 3h12v18H4zM16 8h4v13H8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><path d="m11 12 5-4m-5 4 5 4m-5-4H2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="friends" viewBox="0 0 24 24"><circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="17" cy="10" r="3" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M3 21c0-4 2-7 5-7s5 3 5 7m1-1c.5-3 1.7-5 4-5 2 0 3 2 3 5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>
<symbol id="maze" viewBox="0 0 160 112"><path d="M8 8h144v96H8V8Zm20 0v24H8m44-24v20h28v28H52v24H28v24m52-48h28V32h24v48h20M80 80v24m28-48v48" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="square"/><circle cx="20" cy="92" r="7" fill="currentColor"/><path d="m135 89 9 7-9 7" fill="none" stroke="currentColor" stroke-width="6"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+13 -2
View File
@@ -44,7 +44,7 @@
<section class="screen match-screen" id="match-screen" hidden aria-label="Игровой экран">
<div class="player-status" id="player-status">
<div class="identity" id="identity"><span class="avatar" id="self-slot">P?</span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
<div class="identity" id="identity"><span class="avatar character-avatar" id="self-slot" aria-hidden="true">●</span><span><strong id="self-name">Неизвестный герой</strong><small id="match-state">Лабиринт думает</small></span></div>
<div class="stat" id="inventory-stat"><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><span><small>Ключ</small><strong id="inventory">Пусто</strong></span></div>
<div class="stat" id="vision-stat"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><span><small>Суперзрение</small><strong id="vision">Нет</strong></span></div>
<div class="stat compass-stat" id="compass-stat"><svg id="compass-arrow" aria-hidden="true"><use href="/icons.svg#arrow"></use></svg><span><small>Компас</small><strong id="compass">Молчит</strong></span></div>
@@ -54,8 +54,19 @@
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
</div>
<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 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 class="joystick" id="joystick" role="application" aria-label="Джойстик движения"><span class="joystick-ring" aria-hidden="true"></span><span class="joystick-handle" id="joystick-handle" aria-hidden="true"><svg><use href="/icons.svg#arrow"></use></svg></span></div>
</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="Двигаться вверх">▲</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>
<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>
</section>
</section>
<section class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
+45 -14
View File
@@ -118,10 +118,11 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
.object svg { width: 100%; height: 100%; }
.object-key { color: var(--warning); }
.object-key::after { content: attr(data-owner); position: absolute; right: -5px; bottom: -6px; padding: 1px 3px; background: #090711; border: 1px solid currentColor; font-size: 8px; font-weight: 900; }
.object-key::after { content: "●"; position: absolute; right: -5px; bottom: -6px; padding: 1px 3px; background: #090711; border: 1px solid currentColor; font-size: 8px; font-weight: 900; }
.object-key[data-owner="two"]::after { content: "◆"; }
.object-exit { inset: 8%; border: 3px double currentColor; background: #0b0911; }
.object-exit[data-owner="P1"] { color: var(--p1); }
.object-exit[data-owner="P2"] { color: var(--p2); }
.object-exit[data-owner="one"] { color: var(--p1); }
.object-exit[data-owner="two"] { color: var(--p2); transform: rotate(45deg); }
.object-exit[data-open="1"] { background: #72e48c35; animation: exit-open .8s steps(2, end) infinite; }
.object-compass { color: var(--aqua); border: 2px dashed currentColor; border-radius: 50%; }
.object-landmark { color: var(--plant); font-size: 1.15rem; text-shadow: 2px 2px #000; }
@@ -134,6 +135,31 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.pawn-opponent span { transform: rotate(-45deg); }
.wall-bump { position: absolute; left: 50%; bottom: 18px; z-index: 6; min-height: 28px; transform: translateX(-50%); padding: 6px 10px; color: var(--error); background: #260b12dd; opacity: 0; }
.wall-bump.show { animation: bump .8s steps(3, end); }
.wall-bump::before { content: "▌"; display: inline-block; margin-right: 6px; font-size: 1.35em; font-weight: 1000; }
.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; }
.joystick { position: relative; width: 180px; height: 180px; border: 5px solid var(--aqua); border-radius: 50%; background: #102a30dd; box-shadow: inset 0 0 0 12px #43ead214, 5px 5px 0 #020506; touch-action: none; user-select: none; }
.joystick-ring { position: absolute; inset: 18px; border: 2px dashed #43ead266; border-radius: 50%; }
.joystick-handle { position: absolute; left: 50%; top: 50%; width: 66px; height: 66px; display: grid; place-items: center; border: 3px solid var(--ink); border-radius: 50%; background: var(--aqua); color: #06211f; box-shadow: 4px 4px 0 #020506; transform: translate(-50%, -50%); transition: transform .08s linear; }
.joystick-handle svg { width: 38px; height: 38px; }
.joystick.active .joystick-handle { transition: none; }
.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; }
.onboarding-card h2 { margin: 12px 0 6px; font-size: 1.35rem; }
.onboarding-card p { min-height: 42px; margin: 0; color: var(--muted); }
.onboarding-dots { display: flex; justify-content: center; gap: 8px; margin: 10px; }
.onboarding-dots span { width: 10px; height: 10px; border: 2px solid var(--aqua); border-radius: 50%; }
.onboarding-dots .active { background: var(--aqua); }
#onboarding-next { min-width: 108px; min-height: 52px; border: 2px solid var(--aqua); background: var(--aqua); color: #05201c; }
#onboarding-skip { display: block; margin: 12px auto 0; border: 0; background: transparent; color: var(--muted); text-decoration: underline; }
[data-reduced-motion="true"] * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } }
.drop-key { position: fixed; z-index: 12; right: max(22px, env(safe-area-inset-right)); top: max(166px, calc(env(safe-area-inset-top) + 150px)); width: 78px; height: 78px; display: grid; grid-template-columns: 26px 1fr; place-content: center; align-items: center; border: 3px solid var(--warning); background: #392c0d; color: var(--warning); box-shadow: 5px 5px 0 #050301; cursor: pointer; }
.drop-key svg { width: 25px; height: 25px; }
@@ -192,7 +218,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
}
@media (max-width: 720px) {
:root { --cell: 20px; }
:root { --cell: 46px; }
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); }
.welcome-screen { grid-template-columns: 1fr; padding: 28px 2px; }
.hero-art { display: none; }
@@ -206,16 +232,19 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.player-card { margin-top: 8px; padding: 8px; }
.roster-panel { grid-row: auto; }
.section-heading { align-items: start; flex-direction: column; }
.match-screen { height: calc(100dvh - 70px); padding-top: 116px; }
.player-status { grid-template-columns: 1.4fr repeat(3, 1fr); inset: 6px 0 auto; }
.identity, .stat { min-width: 0; min-height: 104px; padding: 7px; flex-direction: column; justify-content: center; text-align: center; gap: 2px; }
.identity .avatar { flex-basis: 38px; width: 38px; height: 38px; }
.identity strong { max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .66rem; }
.identity small, .stat small { font-size: .5rem; }
.stat strong { font-size: .58rem; }
.stat svg { width: 23px; height: 23px; flex-basis: 23px; }
.map-shell { inset: 116px 0 0; }
.drop-key { top: auto; right: max(14px, env(safe-area-inset-right)); bottom: max(18px, env(safe-area-inset-bottom)); width: 72px; height: 72px; }
.match-screen { height: calc(100dvh - 70px); padding-top: 82px; }
.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 .avatar { flex-basis: 34px; width: 34px; height: 34px; font-size: 1.4rem; }
.identity strong { max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .62rem; }
.identity small, .stat small { font-size: .48rem; }
.stat strong { font-size: .54rem; }
.stat svg { width: 27px; height: 27px; flex-basis: 27px; }
.map-shell { inset: 82px 0 0; }
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
.joystick { width: 172px; height: 172px; }
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
.access-controls { top: 88px; right: 8px; }
.dpad { display: none; }
.connection-pill { font-size: .58rem; }
.connection-pill #connection-text { max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -233,6 +262,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.identity, .stat { min-height: 55px; }
.map-shell { inset: 64px 0 0; }
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
.joystick { width: 150px; height: 150px; }
}
@media (hover: none) and (pointer: coarse) and (max-height: 720px) {
@@ -251,3 +281,4 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@keyframes bump { 0%, 100% { opacity: 0; } 20%, 75% { opacity: 1; transform: translateX(-50%) translateX(4px); } 45% { transform: translateX(-50%) translateX(-4px); } }
@keyframes modal-in { from { opacity: 0; transform: scale(.82); } }
@keyframes count { 50% { transform: scale(1.08); } }
@keyframes guide-hand { 50% { transform: translate(18px, -12px); } }
+188 -14
View File
@@ -8,6 +8,20 @@
const MAX_VIEW_CELLS = 1024;
const MAX_VIEW_OBJECTS = 12;
const OBSERVER_CELL_SIZE = 8;
const JOYSTICK_DEAD_ZONE = 20;
const JOYSTICK_HYSTERESIS = .18;
const JOYSTICK_SLOW_REPEAT_MS = 360;
const JOYSTICK_FAST_REPEAT_MS = 170;
const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни кружок", text: "Кружок ведёт героя по лабиринту."}),
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}),
Object.freeze({icon: "eye", title: "Глаз расширяет карту", text: "Возьми глаз — увидишь дальше."}),
Object.freeze({icon: "arrow", title: "Стрелка знает путь", text: "Компас показывает сторону выхода."}),
Object.freeze({icon: "exit", title: "Выходи с ключом", text: "Коснись своего выхода с ключом."}),
Object.freeze({icon: "friends", title: "Встретил друга", text: "Другой герой может отдать найденный ключ."})
]);
const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]);
const NAME_NOUNS = Object.freeze(["Барсук", "Крот", "Енот", "Кабан", "Кактус", "Жук", "Тостер", "Тюлень", "Гриб", "Ежик", "Бублик", "Пингвин"]);
const NAME_COMBINATION_COUNT = NAME_ADJECTIVES.length * NAME_NOUNS.length;
@@ -88,6 +102,49 @@
return dy > 0 ? "south" : "north";
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function joystickDirection(dx, dy, previousDirection) {
const horizontal = Math.abs(dx);
const vertical = Math.abs(dy);
if (Math.max(horizontal, vertical) < JOYSTICK_DEAD_ZONE) return null;
const previousWasHorizontal = previousDirection === "east" || previousDirection === "west";
const previousWasVertical = previousDirection === "north" || previousDirection === "south";
if (previousWasHorizontal && horizontal >= vertical * (1 - JOYSTICK_HYSTERESIS)) return dx >= 0 ? "east" : "west";
if (previousWasVertical && vertical >= horizontal * (1 - JOYSTICK_HYSTERESIS)) return dy >= 0 ? "south" : "north";
return horizontal >= vertical ? (dx >= 0 ? "east" : "west") : (dy >= 0 ? "south" : "north");
}
function joystickRepeatMs(distance, radius) {
if (!Number.isFinite(distance) || !Number.isFinite(radius) || distance <= JOYSTICK_DEAD_ZONE || radius <= JOYSTICK_DEAD_ZONE) return null;
const strength = clamp((distance - JOYSTICK_DEAD_ZONE) / (radius - JOYSTICK_DEAD_ZONE), 0, 1);
return Math.round(JOYSTICK_SLOW_REPEAT_MS - (JOYSTICK_SLOW_REPEAT_MS - JOYSTICK_FAST_REPEAT_MS) * strength);
}
function joystickVector(dx, dy, radius, previousDirection) {
const distance = Math.hypot(dx, dy);
const boundedDistance = Math.min(distance, radius);
const ratio = distance > 0 ? boundedDistance / distance : 0;
return {
x: dx * ratio,
y: dy * ratio,
distance: boundedDistance,
direction: joystickDirection(dx, dy, previousDirection),
repeatMs: joystickRepeatMs(boundedDistance, radius)
};
}
function playerCellSize(stageWidth, stageHeight, columns, rows) {
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 46;
return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72));
}
function nextOnboardingStep(currentStep) {
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
}
function createEnvelope(type, sequence, requestId, payload) {
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
}
@@ -151,7 +208,7 @@
function boot(documentObject, windowObject) {
const byId = id => documentObject.getElementById(id);
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false, 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(), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, joystick: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
function showNotice(text, kind) {
const element = byId("notice");
@@ -179,6 +236,7 @@
byId("connection").dataset.state = connectionState;
byId("connection-text").textContent = text;
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
if (connectionState !== "online") resetJoystick();
}
renderWelcomeHeadline(randomLoginHeadline());
@@ -193,6 +251,7 @@
}
function feedback(success) {
if (state.muted) return;
if (windowObject.navigator && typeof windowObject.navigator.vibrate === "function") {
windowObject.navigator.vibrate(success ? 18 : [24, 35, 24]);
}
@@ -268,9 +327,9 @@
const [kind, owner, variant] = object;
let marker;
if (kind === "key") {
marker = icon("key", "object object-key"); marker.dataset.owner = `P${owner + 1}`;
marker = icon("key", "object object-key"); marker.dataset.owner = owner === 1 ? "two" : "one";
} else if (kind === "exit") {
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = `P${owner + 1}`; marker.dataset.open = String(variant);
marker = documentObject.createElement("span"); marker.className = "object object-exit"; marker.dataset.owner = owner === 1 ? "two" : "one"; marker.dataset.open = String(variant);
} else if (kind === "compass") {
marker = icon("arrow", "object object-compass");
} else if (kind === "vision") {
@@ -288,6 +347,10 @@
pawn.appendChild(text); cell.appendChild(pawn);
}
function characterForSlot(slot) {
return slot === 2 ? {symbol: "◆", name: "Мышонок"} : {symbol: "●", name: "Лисёнок"};
}
function renderMap(snapshot) {
const maze = byId("maze");
maze.replaceChildren();
@@ -298,8 +361,12 @@
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1]}));
const minX = Math.min(...coordinates.map(cell => cell.x)); const maxX = Math.max(...coordinates.map(cell => cell.x));
const minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
maze.style.gridTemplateColumns = `repeat(${maxX - minX + 1}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${maxY - minY + 1}, var(--cell))`;
const columns = maxX - minX + 1;
const rows = maxY - minY + 1;
const mapShell = byId("map-shell");
maze.style.setProperty("--cell", `${playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows)}px`);
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
const objects = new Map();
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
coordinates.forEach(data => {
@@ -311,8 +378,9 @@
if (!(data.passages & 4)) cell.classList.add("wall-s");
if (!(data.passages & 8)) cell.classList.add("wall-w");
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
if (data.index === snapshot.self) addPawn(cell, `P${state.session ? state.session.slot : "?"}`, false);
if (data.index === snapshot.opponent) addPawn(cell, "СОП", true);
const slot = state.session ? state.session.slot : 1;
if (data.index === snapshot.self) addPawn(cell, characterForSlot(slot).symbol, false);
if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, true);
maze.appendChild(cell);
});
byId("map-placeholder").hidden = true;
@@ -326,12 +394,15 @@
state.snapshot = snapshot;
setScreen("match");
const slot = state.session && state.session.slot ? state.session.slot : 1;
byId("self-slot").textContent = `P${slot}`;
const character = characterForSlot(slot);
byId("self-slot").textContent = character.symbol;
byId("self-slot").setAttribute("aria-label", character.name);
byId("self-name").textContent = playerName();
byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`;
byId("match-screen").classList.toggle("player-two-mode", slot === 2);
byId("match-state").textContent = PHASES[snapshot.phase] || "Гонка идёт";
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ P${snapshot.inventory + 1}`;
byId("inventory").textContent = snapshot.inventory == null ? "Пусто" : `Ключ ${characterForSlot(snapshot.inventory + 1).symbol}`;
byId("inventory-stat").dataset.filled = String(snapshot.inventory != null);
byId("drop-key").disabled = snapshot.inventory == null;
byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет";
byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0);
@@ -340,9 +411,12 @@
byId("compass-stat").dataset.direction = hint;
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
renderMap(snapshot);
if (snapshot.phase === "paused") showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false);
if (snapshot.phase === "paused") { resetJoystick(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else hideModal();
else {
hideModal();
if (!state.onboardingSeen) showOnboarding(0);
}
}
function applyObserverTransform() {
@@ -412,6 +486,25 @@
function hideModal() { byId("modal").hidden = true; byId("countdown").hidden = true; }
function renderOnboardingStep(step) {
const scene = ONBOARDING_STEPS[step] || ONBOARDING_STEPS[0];
byId("onboarding-icon-use").setAttribute("href", `/icons.svg#${scene.icon}`);
byId("onboarding-title").textContent = scene.title;
byId("onboarding-text").textContent = scene.text;
documentObject.querySelectorAll("#onboarding-dots span").forEach((dot, index) => dot.classList.toggle("active", index === step));
byId("onboarding-card").dataset.step = String(step);
byId("onboarding-next").textContent = step === ONBOARDING_STEPS.length - 1 ? "В лабиринт" : "Дальше";
}
function showOnboarding(step) {
state.onboardingSeen = true;
state.onboardingStep = clamp(step, 0, ONBOARDING_STEPS.length - 1);
renderOnboardingStep(state.onboardingStep);
byId("onboarding").hidden = false;
}
function hideOnboarding() { byId("onboarding").hidden = true; }
function renderResult(result) {
const slot = state.session && state.session.slot;
let title = "Ничья!"; let text = "Фотофиниш пожал плечами: оба выбрались почти одновременно.";
@@ -421,6 +514,7 @@
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
}
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
resetJoystick();
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
byId("modal-action").hidden = false;
}
@@ -456,7 +550,7 @@
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
} else if (message.type === "match_result") { renderResult(message.payload.result);
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetJoystick(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
} else if (message.type === "ack") { feedback(true);
} else if (message.type === "error") {
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
@@ -478,6 +572,59 @@
function move(direction, element) {
if (direction) send("move", {direction}, element || documentObject.querySelector(`[data-direction="${direction}"]`));
}
function resetJoystick() {
windowObject.clearTimeout(state.joystick.repeatTimer);
state.joystick.pointerId = null;
state.joystick.direction = null;
state.joystick.repeatTimer = null;
state.joystick.repeatMs = null;
const joystick = byId("joystick");
const handle = byId("joystick-handle");
if (joystick) joystick.classList.remove("active");
if (handle) handle.style.transform = "translate(-50%, -50%)";
}
function repeatJoystickMove() {
const joystick = state.joystick;
if (!joystick.direction || joystick.pointerId == null || !joystick.repeatMs) return;
move(joystick.direction, byId("joystick"));
joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, joystick.repeatMs);
}
function updateJoystick(event) {
const joystick = byId("joystick");
const rect = joystick.getBoundingClientRect();
const radius = Math.max(1, Math.min(rect.width, rect.height) / 2 - 33);
const vector = joystickVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.joystick.direction);
byId("joystick-handle").style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`;
const directionChanged = vector.direction !== state.joystick.direction;
state.joystick.direction = vector.direction;
state.joystick.repeatMs = vector.repeatMs;
windowObject.clearTimeout(state.joystick.repeatTimer);
state.joystick.repeatTimer = null;
if (vector.direction) {
if (directionChanged) move(vector.direction, joystick);
state.joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, vector.repeatMs);
}
}
function setMuted(muted) {
state.muted = muted;
const button = byId("mute-toggle");
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");
}
function setReducedMotion(reduced) {
state.reducedMotion = reduced;
documentObject.documentElement.dataset.reducedMotion = String(reduced);
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
}
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
documentObject.addEventListener("keydown", event => {
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
@@ -486,8 +633,35 @@
});
byId("map-shell").addEventListener("touchstart", event => { const touch = event.changedTouches[0]; state.touchStart = {x: touch.clientX, y: touch.clientY}; }, {passive: true});
byId("map-shell").addEventListener("touchend", event => { if (!state.touchStart) return; const touch = event.changedTouches[0]; move(directionFromSwipe(state.touchStart.x, state.touchStart.y, touch.clientX, touch.clientY), null); state.touchStart = null; }, {passive: true});
byId("joystick").addEventListener("pointerdown", event => {
event.preventDefault();
beginFeedback();
state.joystick.pointerId = event.pointerId;
byId("joystick").classList.add("active");
if (byId("joystick").setPointerCapture) byId("joystick").setPointerCapture(event.pointerId);
updateJoystick(event);
});
byId("joystick").addEventListener("pointermove", event => {
if (event.pointerId === state.joystick.pointerId) updateJoystick(event);
});
const stopJoystick = event => {
if (event.pointerId === state.joystick.pointerId) resetJoystick();
};
byId("joystick").addEventListener("pointerup", stopJoystick);
byId("joystick").addEventListener("pointercancel", stopJoystick);
windowObject.addEventListener("blur", resetJoystick);
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetJoystick(); });
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button)));
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("onboarding-next").addEventListener("click", () => {
const next = nextOnboardingStep(state.onboardingStep);
if (next == null) hideOnboarding();
else { state.onboardingStep = next; renderOnboardingStep(next); }
});
byId("onboarding-skip").addEventListener("click", hideOnboarding);
byId("suggest-name").addEventListener("click", () => {
const occupiedNames = state.lobby && Array.isArray(state.lobby.players) ? state.lobby.players.filter(player => player.occupied && typeof player.name === "string").map(player => player.name) : [];
const suggestion = randomSuggestedName(occupiedNames);
@@ -515,10 +689,10 @@
}
});
connect();
return {state, handleMessage, move};
return {state, handleMessage, move, resetJoystick};
}
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, directionFromKey, directionFromSwipe, 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, JOYSTICK_DEAD_ZONE, JOYSTICK_HYSTERESIS, JOYSTICK_SLOW_REPEAT_MS, JOYSTICK_FAST_REPEAT_MS, directionFromKey, directionFromSwipe, joystickDirection, joystickRepeatMs, joystickVector, playerCellSize, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });