fix(player): align edge projections with avatar drag

This commit is contained in:
2026-09-10 01:02:43 +03:00
parent 1396bad76d
commit 5a806fb5b8
5 changed files with 48 additions and 4 deletions
+9
View File
@@ -1684,3 +1684,12 @@ map moves below it; displacement controls speed; approximately 100 ms is ideal,
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
transparent target edge from selecting an unintended direction. `bash
scripts/run_host_tests.sh` passed with 18 browser tests and deterministic
web-asset verification (`907d0323bd48120dd51034014df6bb21de375631adf9559af88d16a52f9958cf`,
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.
+6
View File
@@ -24,6 +24,12 @@ intention. Release removes the unsent intention. Consequently a slow socket
cannot accumulate a client-side movement backlog. No browser code predicts a
maze position, collision result, inventory, visibility, timer, or outcome.
Each received projection is translated so its authoritative `self` cell is
directly below the fixed avatar. This replaces rectangle-centering, which could
put the fixed avatar over a wall when a visible projection reached a maze edge.
Drag displacement is measured from the pointer-down position rather than from
the edge of the transparent target, preventing an unintended move on touch.
## Required deployment-network remediation and retest
Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both
+3
View File
@@ -29,6 +29,9 @@ cooldown. The browser keeps one sent move in flight and coalesces all later
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.
Commands use the version-one protocol envelope and contain intention data only:
+9
View File
@@ -95,6 +95,13 @@ test("player map cells enlarge small projections without exceeding a readable bo
assert.ok(fourByFour <= 72);
});
test("authoritative self cell remains below the centered avatar at map edges", () => {
assert.deepEqual(player.playerMapOffset(0, 7, 0, 0, 7, 7, 46), {x: 138, y: 138});
assert.deepEqual(player.playerMapOffset(48, 7, 0, 0, 7, 7, 46), {x: -138, y: -138});
assert.deepEqual(player.playerMapOffset(24, 7, 1, 1, 5, 5, 46), {x: 0, y: 0});
assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0});
});
test("onboarding has bounded, replayable steps", () => {
assert.equal(player.ONBOARDING_STEPS.length, 8);
assert.equal(player.nextOnboardingStep(0), 1);
@@ -111,6 +118,8 @@ test("avatar control resets safely and retains accessible alternatives", () => {
assert.match(script, /addEventListener\("blur", resetAvatarControl\)/);
assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/);
assert.match(script, /state\.avatar\.startX = event\.clientX/);
assert.match(script, /event\.clientX - state\.avatar\.startX/);
assert.match(script, /setMuted\(!state\.muted\)/);
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(html, /id="avatar-control"/);
+21 -4
View File
@@ -179,6 +179,16 @@
return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72));
}
function playerMapOffset(selfIndex, width, minX, minY, columns, rows, cellSize) {
if (![selfIndex, width, minX, minY, columns, rows, cellSize].every(Number.isFinite) || !Number.isInteger(selfIndex) || selfIndex < 0 || width < 1 || columns < 1 || rows < 1 || cellSize < 1) return {x: 0, y: 0};
const selfX = selfIndex % width - minX;
const selfY = Math.floor(selfIndex / width) - minY;
return {
x: (columns / 2 - selfX - .5) * cellSize,
y: (rows / 2 - selfY - .5) * cellSize
};
}
function nextOnboardingStep(currentStep) {
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
}
@@ -290,7 +300,7 @@
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, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, startX: null, startY: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction));
function showNotice(text, kind) {
@@ -448,9 +458,12 @@
const columns = maxX - minX + 1;
const rows = maxY - minY + 1;
const mapShell = byId("map-shell");
maze.style.setProperty("--cell", `${playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows)}px`);
const cellSize = playerCellSize(mapShell.clientWidth, mapShell.clientHeight, columns, rows);
const offset = playerMapOffset(snapshot.self, snapshot.width, minX, minY, columns, rows, cellSize);
maze.style.setProperty("--cell", `${cellSize}px`);
maze.style.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
maze.style.gridTemplateRows = `repeat(${rows}, var(--cell))`;
maze.style.transform = `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.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 => {
@@ -669,6 +682,8 @@
function resetAvatarControl() {
windowObject.clearTimeout(state.avatar.repeatTimer);
state.avatar.pointerId = null;
state.avatar.startX = null;
state.avatar.startY = null;
state.avatar.direction = null;
state.avatar.repeatTimer = null;
state.avatar.repeatMs = null;
@@ -695,7 +710,7 @@
const control = byId("avatar-control");
const rect = control.getBoundingClientRect();
const radius = Math.max(AVATAR_DEAD_ZONE + 1, Math.min(rect.width, rect.height) / 2);
const vector = avatarVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.avatar.direction);
const vector = avatarVector(event.clientX - state.avatar.startX, event.clientY - state.avatar.startY, radius, state.avatar.direction);
const handle = byId("avatar-handle");
handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`;
const directionChanged = vector.direction !== state.avatar.direction;
@@ -741,6 +756,8 @@
event.preventDefault();
beginFeedback();
state.avatar.pointerId = event.pointerId;
state.avatar.startX = event.clientX;
state.avatar.startY = event.clientY;
byId("avatar-control").classList.add("active");
if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId);
updateAvatarControl(event);
@@ -797,7 +814,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, 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, 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); });