feat(player): reposition mobile player focal point
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
|
||||
+5
-2
@@ -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.
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
+1
-1
@@ -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); }
|
||||
|
||||
+22
-2
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user