feat(player): add mobile directional pad controls
This commit is contained in:
+7
-4
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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); });
|
||||||
|
|||||||
Reference in New Issue
Block a user