From 8949891a6b1dd5f8acce34ee2b1ebf2f7d36c183 Mon Sep 17 00:00:00 2001 From: sasa Date: Thu, 10 Sep 2026 22:26:55 +0300 Subject: [PATCH] feat(player): reposition mobile player focal point --- ARCHITECTURE.md | 6 ++ MVP.md | 4 + PLANS.md | 154 ++++++++++++++++++++++++++++++++++-- docs/PLAYER_UI.md | 7 +- docs/UI_STYLE.md | 7 +- test/web/test_player_app.js | 9 +++ web/player.css | 2 +- web/player.js | 24 +++++- 8 files changed, 201 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be53f82..c2f8268 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -341,6 +341,12 @@ already visible projection in that attempted direction, while `rate_limited` briefly darkens and blurs the maze only. Neither effect changes or derives game state, and both use bounded, resettable local timers. +Player projections are translated only within their received cells so the +authoritative `self` cell aligns with the responsive local focal point. That +point is centered horizontally and 30% down the usable portrait map region; +compact landscape uses a left-biased unobstructed region. It changes no server +projection, visibility, or hidden-state handling. + All application assets are local and precompressed. Browser startup and play must not depend on internet access, third-party CSS, remote fonts, icons, analytics, or content services. The complete compressed web payload is limited diff --git a/MVP.md b/MVP.md index 3f8f371..0521e58 100644 --- a/MVP.md +++ b/MVP.md @@ -230,6 +230,10 @@ 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. +- On portrait phones, the authoritative player focal point is horizontally + centered at approximately 30% of the usable map height, above the operating + hand zone. Compact landscape uses a stable left-biased focal point in the + largest unobstructed map area. - Textual system notices use one Russian live message rail above the top bar. A connection-loss notice remains there until recovery; movement blocked by a wall and rate limiting use maze-only visual feedback and never show text. diff --git a/PLANS.md b/PLANS.md index e543eb3..d2994a8 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1971,7 +1971,7 @@ the project-owner visual confirmation remains outstanding: ## Milestone 020 — Playful visual feedback and top message rail -**Status:** `IN_PROGRESS` +**Status:** `COMPLETE` ### Objective @@ -2119,7 +2119,151 @@ The following planning-time observation motivated the milestone: RAM `58868/327680` bytes and factory flash `1027966/2097152` bytes. `/home/sasa/.platformio/penv/bin/pio run -t buildfs` passed and produced the configured `2031616/2031616` byte LittleFS image. -- Milestone 020 remains `IN_PROGRESS`: representative mobile/desktop visual, - reduced-motion, accessibility, and project-owner confirmation evidence for - acceptance criteria 7 and 13 has not been recorded. Milestone 021 has not - been started. +- On 2026-09-10, the project owner completed a live UI check and confirmed that + the feedback, message rail, and animations work as expected. This satisfies + the remaining visual-confirmation criterion; Milestone 020 is `COMPLETE`. + +## Milestone 021 — Mobile player focal-point repositioning + +**Status:** `IN_PROGRESS` + +### Objective + +Move the player avatar away from the geometric center of the mobile maze into +a responsive, unobstructed focal position so hands operating the lower controls +do not hide the player or the most useful nearby maze context. + +### Scope + +- Before implementation, reconcile `MVP.md`, `ARCHITECTURE.md`, + `docs/UI_STYLE.md`, and supporting player-UI documentation with the new + mobile player-anchor contract. This supersedes the current requirement to + keep the player at the exact center of the map viewport. +- Define a responsive safe play area that excludes the fixed player-status + panel, Milestone 020 top message rail, directional pad, key-drop action, + utility controls, safe-area insets, and other persistent overlays. +- In portrait mobile layouts, place the authoritative player focal point above + the maze's geometric center while retaining useful visible context on every + side. At the canonical compact viewport, target approximately 30% of the map + region's height from its top rather than 50%. +- In compact landscape layouts, choose the player focal point from the largest + unobstructed map region and bias it away from the directional-pad hand zone. + Do not apply the portrait offset blindly when it would reduce usable context. +- Translate only the server-provided projection so its authoritative `self` + cell appears beneath the responsive focal point. Do not request, synthesize, + retain, or infer extra maze cells to fill space created by the offset. +- Keep the visible player icon aligned exactly with the authoritative `self` + cell after every projection, resize, orientation change, safe-area change, + and projection-edge case. +- Preserve the four-button directional pad as the primary phone input. Avatar + drag remains an optional compatibility input, but normal mobile play must not + require touching or covering the player icon. +- Keep the optional avatar-drag deflection relative to the new focal point and + return it there on release, cancellation, pause, disconnect, blur, hidden + document, lost pointer capture, and application reset. +- Integrate the new focal point with Milestone 020 maze shake and blur effects. + Temporary feedback transforms must compose with the projection offset and + must not cause the player or map to jump when an effect begins or ends. +- Preserve observer full-map positioning, desktop behavior where appropriate, + reduced-motion behavior, authoritative server ownership, hidden-state + protection, offline assets, and existing memory and Flash budgets. + +### Deliverables + +- Updated player-anchor, responsive-layout, input, and projection-presentation + contracts in canonical and supporting UI documentation. +- A bounded responsive focal-point calculation for portrait and compact + landscape mobile layouts. +- Player and server-projection rendering aligned to the new focal point, + including edge projections and orientation changes. +- Compatibility with the directional pad, optional avatar drag, top message + rail, blocked-movement shake, and rate-limit blur. +- Deterministic layout tests plus representative mobile portrait and landscape + visual and touch verification evidence. + +### Acceptance criteria + +1. At `412 x 915` and `412 x 916` CSS pixels in portrait orientation, the + resting player center is horizontally centered and positioned at + approximately 30% of the usable map region's height from its top, with a + documented tolerance of no more than five percentage points. It is not at + the map's 50% geometric center. +2. The player focal point lies completely outside the directional pad, + key-drop, utility-control, top-message, and status-panel exclusion zones, + including safe-area insets. The visible player icon and its outline do not + overlap any persistent control. +3. While a representative thumb operates every directional-pad button, the + player icon and its current cell remain visible and enough adjacent + server-provided maze context remains unobstructed to understand the next + orthogonal move. +4. Compact landscape orientation computes a stable focal point within the + largest unobstructed map region and away from the directional-pad hand zone. + Rotating between portrait and landscape does not leave the player beneath a + control or outside the visible map shell. +5. The rendered player stays aligned with the authoritative `self` cell to + within one CSS pixel after movement projections, viewport resize, + orientation changes, dynamic safe-area changes, and projections touching + each maze edge or corner. +6. Repositioning translates only cells contained in the current authoritative + projection. The browser does not expose, retain, infer, decorate, or fill + hidden maze geometry, and no protocol or server projection changes. +7. Direction-pad, swipe, keyboard, and optional avatar-drag inputs continue to + send the same bounded cardinal intentions. Avatar drag deflects relative to + the new focal point and every existing reset path returns it exactly there. +8. Milestone 020 directional shake and rate-limit blur compose with the focal + offset without changing the underlying authoritative position, accumulating + transforms, exposing hidden cells, or producing a jump when feedback clears. +9. The player layout requires no horizontal or vertical page scrolling at the + supported mobile viewports. The maze remains the dominant visual region and + the shifted projection does not create a distracting inactive void. +10. Browser tests cover focal-point calculation, exclusion-zone avoidance, + portrait and landscape placement, safe areas, resize and rotation, all map + edges and corners, one-pixel player/self alignment, input compatibility, + drag reset paths, Milestone 020 transform composition, zero overflow, and + absence of hidden-state expansion. +11. All host tests pass, compressed web assets remain within the 1.5 MiB budget + and LittleFS partition, firmware remains within the factory partition, and + observer layout and authority remain unchanged. +12. The project owner confirms on a representative mobile browser that the new + player position is comfortable, the operating hand no longer hides the + player or critical nearby maze context during directional-button play, and + portrait and landscape layouts remain easy to understand, or explicitly + approves a documented evidence deviation. + +### Evidence + +The following planning-time observation motivated the milestone: + +- On 2026-09-10, the project owner reported that the exactly centered mobile + player is uncomfortable because fingers hide the maze and the player. A + read-only inspection of the open player page at `412 x 915` CSS pixels found + the `96 x 96` avatar target centered at `(206, 525.5)` and the map shell also + centered at `(206, 525.5)`, confirming exact geometric overlap. The player + requested a planning milestone only; implementation and completion + verification were deferred until Milestone 020 was `COMPLETE`. +- On 2026-09-10, `MVP.md`, `ARCHITECTURE.md`, `docs/UI_STYLE.md`, and + `docs/PLAYER_UI.md` were reconciled to define a 30%-height portrait focal + point and a compact-landscape left-biased unobstructed focal point. The + browser now composes the received-projection translation with that focal + offset, places the optional avatar drag target at the same point, and + recalculates both after resize and orientation change. It continues to render + only received projection cells; the Milestone 020 feedback transform uses + these composed map-offset variables unchanged. +- `node --test test/web/test_player_app.js` passed: 23 tests, 0 failures. + The added deterministic focal-point checks cover portrait and compact + landscape placement plus projection-offset composition at a projection edge. +- `bash scripts/run_host_tests.sh` passed all portable host suites, all 23 + browser tests, and the deterministic asset check: + `web asset reproducibility: PASS (..., 27367/1572864 bytes)`. +- `/home/sasa/.platformio/penv/bin/pio run` passed for `esp32-c6-devkitm-1`: + RAM `58868/327680` bytes and factory flash `1027966/2097152` bytes. + `/home/sasa/.platformio/penv/bin/pio run -t buildfs` passed and produced the + configured `2031616/2031616` byte LittleFS image. +- Milestone 021 remains `IN_PROGRESS`: the project-owner representative mobile + portrait and compact-landscape touch confirmation required by acceptance + criterion 12 has not been recorded. Milestone 022 has not been started. +- On 2026-09-10, a project-owner live UI check found that the original 40% + map-region target still appeared centered on the full phone screen because + the map begins below the status area. The owner requested a more elevated + player position, so the active milestone target and implementation were + adjusted to 30% of map height; mobile confirmation remains required. diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index 12eefc2..bcf83d7 100644 --- a/docs/PLAYER_UI.md +++ b/docs/PLAYER_UI.md @@ -48,8 +48,11 @@ drag changes into one latest intention; release discards that unsent intention. The deflection is local input feedback only. The maze redraws only after a server-authoritative projection and the browser never predicts a cell change. For every projection, the browser translates only the received cells so the -authoritative `self` cell stays under the centered avatar; this also keeps the -avatar off a wall when the visible projection reaches a maze boundary. +authoritative `self` cell stays under the responsive avatar focal point. On a +portrait phone this is horizontally centered at 30% of the map height; compact +landscape uses a stable left-biased unobstructed point away from the directional +pad. This also keeps the avatar off a wall when the visible projection reaches +a maze boundary, and is recalculated on resize and rotation. ## Feedback and message rail diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md index ebae897..f40dc5b 100644 --- a/docs/UI_STYLE.md +++ b/docs/UI_STYLE.md @@ -113,8 +113,11 @@ are not MVP requirements. ## 7. Player map presentation -- Keep the player near the center of the map viewport except where maze - boundaries prevent it. +- Keep the portrait player horizontally centered at approximately 30% of the + usable map height, above the directional-pad hand zone. In compact landscape, + use the stable left-biased focal point in the largest unobstructed map area. + Recalculate it after resize and rotation without moving the authoritative + player away from its received `self` cell. - Render unknown space as a solid background. Never infer, retain, animate, or decorate maze geometry outside the server-provided visible projection. - Compute cell size from the current server projection and available map area. diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index ce8ab71..a372ad8 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -118,6 +118,13 @@ test("authoritative self cell remains below the centered avatar at map edges", ( assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0}); }); +test("responsive player focal point stays above phone controls and composes with map offsets", () => { + assert.deepEqual(player.playerFocalPoint(400, 800), {x: 200, y: 240}); + assert.deepEqual(player.playerFocalPoint(900, 400), {x: 378, y: 180}); + assert.deepEqual(player.playerFocalMapOffset(24, 7, 1, 1, 5, 5, 46, 400, 800), {x: 0, y: -160}); + assert.deepEqual(player.playerFocalMapOffset(0, 7, 0, 0, 7, 7, 46, 400, 800), {x: 138, y: -22}); +}); + test("onboarding has bounded, replayable steps", () => { assert.equal(player.ONBOARDING_STEPS.length, 8); assert.equal(player.nextOnboardingStep(0), 1); @@ -136,6 +143,8 @@ test("avatar control resets safely and retains accessible alternatives", () => { 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, /windowObject\.addEventListener\("resize", refreshPlayerFocalPoint\)/); + assert.match(script, /windowObject\.addEventListener\("orientationchange", refreshPlayerFocalPoint\)/); assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); assert.match(html, /id="avatar-control"/); diff --git a/web/player.css b/web/player.css index c3f37ae..e884fbf 100644 --- a/web/player.css +++ b/web/player.css @@ -153,7 +153,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; } [data-reduced-motion="true"] .wall-bump.impact-south { border-bottom-color: var(--error); } [data-reduced-motion="true"] .wall-bump.impact-west { border-left-color: var(--error); } -.avatar-control { position: absolute; z-index: 8; left: 50%; top: 50%; width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; } +.avatar-control { position: absolute; z-index: 8; left: var(--player-focal-x, 50%); top: var(--player-focal-y, 50%); width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; } .avatar-control:active, .avatar-control.active { cursor: grabbing; } .avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: #0b0712; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); } .player-two-mode .avatar-handle { color: var(--p2); } diff --git a/web/player.js b/web/player.js index 42e3566..c1fa171 100644 --- a/web/player.js +++ b/web/player.js @@ -190,6 +190,18 @@ }; } + function playerFocalPoint(stageWidth, stageHeight) { + if (![stageWidth, stageHeight].every(value => Number.isFinite(value) && value > 0)) return {x: 0, y: 0}; + if (stageWidth > stageHeight) return {x: Math.round(stageWidth * .42), y: Math.round(stageHeight * .45)}; + return {x: Math.round(stageWidth / 2), y: Math.round(stageHeight * .3)}; + } + + function playerFocalMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize, stageWidth, stageHeight) { + const centered = playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize); + const focal = playerFocalPoint(stageWidth, stageHeight); + return {x: centered.x + focal.x - stageWidth / 2, y: centered.y + focal.y - stageHeight / 2}; + } + function nextOnboardingStep(currentStep) { return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1; } @@ -495,12 +507,15 @@ const rows = maxY - minY + 1; const mapShell = byId("map-shell"); const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows); - const offset = playerMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize); + const focal = playerFocalPoint(mapShell.clientWidth, mapShell.clientHeight); + const offset = playerFocalMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize, mapShell.clientWidth, mapShell.clientHeight); maze.style.setProperty("--cell", `${cellSize}px`); maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`; maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`; maze.style.setProperty("--map-offset-x", `${offset.x}px`); maze.style.setProperty("--map-offset-y", `${offset.y}px`); + mapShell.style.setProperty("--player-focal-x", `${focal.x}px`); + mapShell.style.setProperty("--player-focal-y", `${focal.y}px`); const objects = new Map(); snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); }); coordinates.forEach(data => { @@ -837,6 +852,11 @@ byId("avatar-control").addEventListener("pointercancel", stopAvatarControl); byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl); windowObject.addEventListener("blur", resetPlayerControls); + const refreshPlayerFocalPoint = () => { + if (state.snapshot && state.snapshot.role === "player") windowObject.requestAnimationFrame(() => renderMap(state.snapshot)); + }; + windowObject.addEventListener("resize", refreshPlayerFocalPoint); + windowObject.addEventListener("orientationchange", refreshPlayerFocalPoint); documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); }); documentObject.querySelectorAll("[data-direction]").forEach(button => { button.addEventListener("pointerdown", event => { @@ -902,7 +922,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, DIRECTIONAL_PAD_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_ADJECTIVES, NAME_NOUNS, 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, playerFocalPoint, playerFocalMapOffset, 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); });