From 8214e7f7ed53c921af20e0b171880ff99dfd0f49 Mon Sep 17 00:00:00 2001 From: sasa Date: Thu, 10 Sep 2026 21:23:38 +0300 Subject: [PATCH] feat(player): add mobile directional pad controls --- ARCHITECTURE.md | 11 +- MVP.md | 8 +- PLANS.md | 261 +++++++++++++++++++++++++++++++++++- data/index.html.gz | Bin 3347 -> 3359 bytes docs/PLAYER_UI.md | 6 +- docs/UI_STYLE.md | 10 +- test/web/test_player_app.js | 47 ++++++- web/index.html | 2 +- web/player.css | 21 ++- web/player.js | 86 +++++++++--- 10 files changed, 405 insertions(+), 47 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64ee639..2f56457 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -315,10 +315,13 @@ The browser application has four presentation modes: 3. player match view; 4. observer full-map view. -Player controls support keyboard movement, touch swipes, and an explicit -drop-key action. 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. +Player controls support keyboard movement, avatar drag, touch swipes, an +explicit four-button directional pad, and an explicit drop-key action. Every +movement input uses the same bounded latest-intent path and sends only a +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 layout supports a minimum `412 x 916` CSS-pixel viewport. Observer mode requires diff --git a/MVP.md b/MVP.md index ab23df2..487e515 100644 --- a/MVP.md +++ b/MVP.md @@ -228,9 +228,11 @@ Browser controls must support: - 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 without page scrolling. -- Computers support keyboard movement and on-screen arcade controls. Tablets - support swipes and on-screen controls. Phones use directional swipes without - on-screen directional buttons. +- Computers and tablets support keyboard, swipes, and on-screen arcade + controls. Phones show an always-visible four-button directional pad as the + 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 confirmation and exposes the Russian accessible label `Сбросить ключ`. - Observer mode requires at least a `1024 x 768` CSS-pixel viewport, initially diff --git a/PLANS.md b/PLANS.md index d454189..132b474 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1561,7 +1561,7 @@ controlled measurement criteria: ## Milestone 017 — Avatar-centered drag steering and latency remediation -**Status:** `IN_PROGRESS` +**Status:** `COMPLETE` ### 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 esp32-c6-devkitm-1 --target buildfs` passed and reported a fixed LittleFS 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 the authoritative `self` cell remains below the centered avatar. Drag 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 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. + +## 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`. diff --git a/data/index.html.gz b/data/index.html.gz index e03bc46184c4f9090c687e7a8a8baf2a99806850..a984561f26dff4e0d59d9a4327f2b5c3293a218d 100644 GIT binary patch delta 932 zcmV;V16%x)8lM`liV1%$m_NK2RmivNH(M8Bbd$YMG3X`qy?h~-dQ3*t`^Z-vkPz27n5;vuN!&PK-ufuH0&sRAs zSBu+q)9OinjV*t<1$mM&WIBIR8Ehinvx&{zdp^bYUHzj0Ijxq3{|D%=2)Ikdr$0j; zOYwet>^~>P*H+06-BP`BPSTg_Q{y>%k*qG>QLdsDTUg zeolwC@3&YjIXP&7WZ9eKN{?H!9KBJ!zu2(c;_T&$50rmiHjt+sNJ?~eJNMA{`%RVT z&s2#%!G+>Otxq%xQ9~<0Ub$_lcA>8hiSxP>RmNo$}W$A4y56uP9#2_uBEL#*wDqAWd^9 zEVeey96f&{ucYrqh#JAyiF5+^j-qvU@iX?{m+bT3eo=hw3=QD?tXEz+WJMWYQXXq& zd$4{!&w2QR^mRamXqab3pT>I`7ep9x4rz8P#gmi?(z$(bjL95R9N(BCZHwL#kK~JL zT<9X%g&m$I&*qI8K$MdyrJ*KP<;JQuH}mC8qsf2eLHlDVfzsbINrS?74U&D5zM;Np zK%*mrHYE~Zj7$(V4CGVDMQ@T(4=6qE$S)t{tBp-S_XC*~<}>-17V!Hv%8CV6%C~t+ z5Xzq_=}GZzhr51iOPZiUs+tt(Zkll_Tj&L5w%LrrvRXg-!=URDJz5CcN-ov5Saqyb zBn^LcYp2G-vg2=1Vrm_lOrwk10$DvuK9Ya=L@th5^%Gf<7QWRRdXep~dnv`7U8>Ku z?y1^spzk9K*f;W%u>$)jKY^U4cXo_X8m_(^ECRd|k!ps`l>bxh z4aN&%;32*kFW`FaXHYyjgNTKJm49;iEkqoEEmlN;Cr0=215EklC0LI>SN$InK8{1b GDgXfSf6Fld delta 920 zcmV;J184l78j~8ZiV1%J%NL^x=XU)b>mrP9vIXh`Ed=86X1)bs_w@z)jieC#W>Tcq zMLAx)zon`Wb2l?59@7c#aG^DCaPXNEl)AIrgUcRW-;%v4x@<~$jT=U7rmmx@6lxw17TekG|7PI{*tXNDAHWef7Vz2psxsdFra@&%09-8C&WPVQ8 z*@i^)Js1J%)+b<-qY@Zm2>}j%poo}pC0t7ThO5ZTUWdJuXRmU0trmalrqz@68tZWj z5+q}~bRMKK*hGJzXA_$_^n8l%yZXz2IjxpW{|D%=h_p+^i$6m?O7VAl>^~>P*H6g~ z-BLYoPPUijgWdqSjHlV>4=njkzM&%0>p~;j>%k*qCW*&ssAUT@eNJn)@3&Yj$v9|% zWZ4_xO0Qb89K9L6zu2(c;_T&$50qXSkf$9;YIAm5_RxR#`%M+)&s0%9!G+>Ot=}^W zQ9~<0I=OAhb)l~g8Rp6J+MuN60}+a-@`CEQOj2jLwoT7F-VEtR(vR|5wj|wenxeL$ zRL@Q)5Puy)+U*N3Dn)1EPWda9kEE8=*ATDe`|0>n<2uuCiKaOe7F(2NuAPxr(sv(3 zao}q~Ist!t*U-AV_!;}}OZNG1zbL*whDL6F)~l-=vZ9RlD33L>HdqIr=Y0A>O6yk$ zgLzi;X}p(lI)owTkY=}1JV_ZHoudcGn9MQ7)r~3Aw&-o|NWO5!g)Wj^*x_mNY~Gjw zL^&x^8fs!kZmeo^Q(n$Anp_^VF_sc2{XMh!FMNNWAXy~o8|vExG&(Y2Q)=eN$mn3< zPdlBKamw_;hVdm7uo*0mr}*qrTXmZo~qpj`aZINeIq~p zDzJ$1Q^sj}XQ%jP;)0mrG4ZZ5-%IJH;pz*(BETyVsb<(rc|6tCV7wp(9^wn`0 { 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", () => { let clock = 0; 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\("pointercancel", stopAvatarControl\)/); assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/); - assert.match(script, /addEventListener\("blur", resetAvatarControl\)/); - assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/); - assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/); + assert.match(script, /addEventListener\("blur", resetPlayerControls\)/); + assert.match(script, /connectionState !== "online"\) resetPlayerControls\(\)/); + assert.match(script, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/); assert.match(script, /state\.avatar\.startX = event\.clientX/); assert.match(script, /event\.clientX - state\.avatar\.startX/); 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>/); }); +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", () => { const picks = [3, 4]; 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 \(min-width: 721px\) and \(max-width: 980px\)/); 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="onboarding"/); assert.match(html, /id="mute-toggle"/); diff --git a/web/index.html b/web/index.html index 64d0032..f49f2b3 100644 --- a/web/index.html +++ b/web/index.html @@ -63,7 +63,7 @@ - + diff --git a/web/player.css b/web/player.css index cf41933..233530f 100644 --- a/web/player.css +++ b/web/player.css @@ -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 span { grid-column: 1 / -1; font-size: .56rem; text-transform: uppercase; } .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 button { border: 2px solid var(--aqua); background: #102a30df; color: var(--aqua); box-shadow: 4px 4px 0 #020506; cursor: pointer; } +.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 { 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: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: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) { :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; } .hero-art { display: none; } .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; } .roster-panel { grid-row: auto; } .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; } .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; } @@ -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; } .drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; } .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 #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; } .map-shell { inset: 64px 0 0; } .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) { - .dpad { display: none; } .drop-key { top: auto; bottom: max(14px, env(safe-area-inset-bottom)); } } diff --git a/web/player.js b/web/player.js index 428e3a2..d43fa12 100644 --- a/web/player.js +++ b/web/player.js @@ -14,6 +14,7 @@ const AVATAR_DIRECTION_HYSTERESIS = .18; const AVATAR_SLOW_REPEAT_MS = 360; const AVATAR_FAST_REPEAT_MS = 170; + const DIRECTIONAL_PAD_REPEAT_MS = 170; const LATENCY_SAMPLE_LIMIT = 256; const ONBOARDING_STEPS = Object.freeze([ Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}), @@ -147,13 +148,13 @@ this.latest = null; } - request(direction) { + request(direction, element) { if (!direction) return null; if (this.inFlight) { - this.latest = direction; + this.latest = {direction, element}; return null; } - const requestId = this.dispatch(direction); + const requestId = this.dispatch(direction, element); if (requestId) this.inFlight = requestId; return requestId; } @@ -161,9 +162,9 @@ complete(requestId) { if (!requestId || requestId !== this.inFlight) return null; this.inFlight = null; - const direction = this.latest; + const latest = this.latest; this.latest = null; - return direction ? this.request(direction) : null; + return latest ? this.request(latest.direction, latest.element) : null; } release() { this.latest = null; } @@ -300,8 +301,8 @@ 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(), 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}}; - state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction)); + 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, element) => sendMoveNow(direction, element)); function showNotice(text, kind) { const element = byId("notice"); @@ -329,7 +330,7 @@ byId("connection").dataset.state = connectionState; byId("connection-text").textContent = text; byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически."; - if (connectionState !== "online") resetAvatarControl(); + if (connectionState !== "online") resetPlayerControls(); } renderWelcomeHeadline(randomLoginHeadline()); @@ -502,6 +503,7 @@ 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; + setDirectionalPadEnabled(snapshot.phase === "active" && state.socket && state.socket.readyState === windowObject.WebSocket.OPEN); byId("vision").textContent = snapshot.visionMoves ? `${snapshot.visionMoves} ход.` : "Нет"; byId("vision-stat").classList.toggle("vision-active", snapshot.visionMoves > 0); const hint = HINTS[snapshot.hint] ? snapshot.hint : "none"; @@ -512,7 +514,7 @@ if (Number.isInteger(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 { hideModal(); @@ -615,7 +617,7 @@ text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат."; } showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false); - resetAvatarControl(); + resetPlayerControls(); byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби"; byId("modal-action").hidden = false; } @@ -651,7 +653,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(); 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 === "error") { state.moveFlow.complete(message.requestId); @@ -675,8 +677,35 @@ if (direction) state.moveFlow.request(direction, element); } - function sendMoveNow(direction) { - return send("move", {direction}, byId("avatar-control")); + function sendMoveNow(direction, element) { + 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() { @@ -771,9 +800,32 @@ byId("avatar-control").addEventListener("pointerup", stopAvatarControl); byId("avatar-control").addEventListener("pointercancel", stopAvatarControl); byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl); - windowObject.addEventListener("blur", resetAvatarControl); - documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetAvatarControl(); }); - documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button))); + windowObject.addEventListener("blur", resetPlayerControls); + documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); }); + 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("mute-toggle").addEventListener("click", () => setMuted(!state.muted)); 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()}; } - 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; root.Maze32Player = api; if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });