feat(player): reposition mobile player focal point

This commit is contained in:
2026-09-10 22:26:55 +03:00
parent 2501345740
commit 8949891a6b
8 changed files with 201 additions and 12 deletions
+6
View File
@@ -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 briefly darkens and blurs the maze only. Neither effect changes or derives game
state, and both use bounded, resettable local timers. 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 All application assets are local and precompressed. Browser startup and play
must not depend on internet access, third-party CSS, remote fonts, icons, must not depend on internet access, third-party CSS, remote fonts, icons,
analytics, or content services. The complete compressed web payload is limited analytics, or content services. The complete compressed web payload is limited
+4
View File
@@ -230,6 +230,10 @@ 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.
- 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. - 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 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. wall and rate limiting use maze-only visual feedback and never show text.
+149 -5
View File
@@ -1971,7 +1971,7 @@ the project-owner visual confirmation remains outstanding:
## Milestone 020 — Playful visual feedback and top message rail ## Milestone 020 — Playful visual feedback and top message rail
**Status:** `IN_PROGRESS` **Status:** `COMPLETE`
### Objective ### Objective
@@ -2119,7 +2119,151 @@ The following planning-time observation motivated the milestone:
RAM `58868/327680` bytes and factory flash `1027966/2097152` bytes. RAM `58868/327680` bytes and factory flash `1027966/2097152` bytes.
`/home/sasa/.platformio/penv/bin/pio run -t buildfs` passed and produced the `/home/sasa/.platformio/penv/bin/pio run -t buildfs` passed and produced the
configured `2031616/2031616` byte LittleFS image. configured `2031616/2031616` byte LittleFS image.
- Milestone 020 remains `IN_PROGRESS`: representative mobile/desktop visual, - On 2026-09-10, the project owner completed a live UI check and confirmed that
reduced-motion, accessibility, and project-owner confirmation evidence for the feedback, message rail, and animations work as expected. This satisfies
acceptance criteria 7 and 13 has not been recorded. Milestone 021 has not the remaining visual-confirmation criterion; Milestone 020 is `COMPLETE`.
been started.
## 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.
+5 -2
View File
@@ -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 The deflection is local input feedback only. The maze redraws only after a
server-authoritative projection and the browser never predicts a cell change. server-authoritative projection and the browser never predicts a cell change.
For every projection, the browser translates only the received cells so the For every projection, the browser translates only the received cells so the
authoritative `self` cell stays under the centered avatar; this also keeps the authoritative `self` cell stays under the responsive avatar focal point. On a
avatar off a wall when the visible projection reaches a maze boundary. 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 ## Feedback and message rail
+5 -2
View File
@@ -113,8 +113,11 @@ are not MVP requirements.
## 7. Player map presentation ## 7. Player map presentation
- Keep the player near the center of the map viewport except where maze - Keep the portrait player horizontally centered at approximately 30% of the
boundaries prevent it. 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 - Render unknown space as a solid background. Never infer, retain, animate, or
decorate maze geometry outside the server-provided visible projection. decorate maze geometry outside the server-provided visible projection.
- Compute cell size from the current server projection and available map area. - Compute cell size from the current server projection and available map area.
+9
View File
@@ -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}); 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", () => { test("onboarding has bounded, replayable steps", () => {
assert.equal(player.ONBOARDING_STEPS.length, 8); assert.equal(player.ONBOARDING_STEPS.length, 8);
assert.equal(player.nextOnboardingStep(0), 1); 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, /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, /windowObject\.addEventListener\("resize", refreshPlayerFocalPoint\)/);
assert.match(script, /windowObject\.addEventListener\("orientationchange", refreshPlayerFocalPoint\)/);
assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setMuted\(!state\.muted\)/);
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(html, /id="avatar-control"/); assert.match(html, /id="avatar-control"/);
+1 -1
View File
@@ -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-south { border-bottom-color: var(--error); }
[data-reduced-motion="true"] .wall-bump.impact-west { border-left-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-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); } .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); } .player-two-mode .avatar-handle { color: var(--p2); }
+22 -2
View File
@@ -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) { function nextOnboardingStep(currentStep) {
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1; return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
} }
@@ -495,12 +507,15 @@
const rows = maxY - minY + 1; const rows = maxY - minY + 1;
const mapShell = byId("map-shell"); const mapShell = byId("map-shell");
const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows); 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.setProperty("--cell", `${cellSize}px`);
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`; maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`; maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
maze.style.setProperty("--map-offset-x", `${offset.x}px`); maze.style.setProperty("--map-offset-x", `${offset.x}px`);
maze.style.setProperty("--map-offset-y", `${offset.y}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(); const objects = new Map();
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); }); snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
coordinates.forEach(data => { coordinates.forEach(data => {
@@ -837,6 +852,11 @@
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", resetPlayerControls); 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.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetPlayerControls(); });
documentObject.querySelectorAll("[data-direction]").forEach(button => { documentObject.querySelectorAll("[data-direction]").forEach(button => {
button.addEventListener("pointerdown", event => { button.addEventListener("pointerdown", event => {
@@ -902,7 +922,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, 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; 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); });