fix(player): align edge projections with avatar drag
This commit is contained in:
@@ -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
|
isolated-network 200-sample runs, and maximum-session diagnostics are still
|
||||||
required for acceptance criteria 6, 7, 9, and 10. Milestone 017 remains
|
required for acceptance criteria 6, 7, 9, and 10. Milestone 017 remains
|
||||||
`IN_PROGRESS`; Milestone 018 has not been started.
|
`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.
|
||||||
|
|||||||
@@ -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
|
cannot accumulate a client-side movement backlog. No browser code predicts a
|
||||||
maze position, collision result, inventory, visibility, timer, or outcome.
|
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
|
## Required deployment-network remediation and retest
|
||||||
|
|
||||||
Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both
|
Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both
|
||||||
|
|||||||
@@ -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.
|
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
|
||||||
|
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:
|
Commands use the version-one protocol envelope and contain intention data only:
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ test("player map cells enlarge small projections without exceeding a readable bo
|
|||||||
assert.ok(fourByFour <= 72);
|
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", () => {
|
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);
|
||||||
@@ -111,6 +118,8 @@ test("avatar control resets safely and retains accessible alternatives", () => {
|
|||||||
assert.match(script, /addEventListener\("blur", resetAvatarControl\)/);
|
assert.match(script, /addEventListener\("blur", resetAvatarControl\)/);
|
||||||
assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/);
|
assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/);
|
||||||
assert.match(script, /snapshot\.phase === "paused"\) \{ 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, /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"/);
|
||||||
|
|||||||
+21
-4
@@ -179,6 +179,16 @@
|
|||||||
return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72));
|
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) {
|
function nextOnboardingStep(currentStep) {
|
||||||
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
|
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
|
||||||
}
|
}
|
||||||
@@ -290,7 +300,7 @@
|
|||||||
|
|
||||||
function boot(documentObject, windowObject) {
|
function boot(documentObject, windowObject) {
|
||||||
const byId = id => documentObject.getElementById(id);
|
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));
|
state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction));
|
||||||
|
|
||||||
function showNotice(text, kind) {
|
function showNotice(text, kind) {
|
||||||
@@ -448,9 +458,12 @@
|
|||||||
const columns = maxX - minX + 1;
|
const columns = maxX - minX + 1;
|
||||||
const rows = maxY - minY + 1;
|
const rows = maxY - minY + 1;
|
||||||
const mapShell = byId("map-shell");
|
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.gridTemplateColumns = `repeat(${columns}, var(--cell))`;
|
||||||
maze.style.gridTemplateRows = `repeat(${rows}, 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();
|
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 => {
|
||||||
@@ -669,6 +682,8 @@
|
|||||||
function resetAvatarControl() {
|
function resetAvatarControl() {
|
||||||
windowObject.clearTimeout(state.avatar.repeatTimer);
|
windowObject.clearTimeout(state.avatar.repeatTimer);
|
||||||
state.avatar.pointerId = null;
|
state.avatar.pointerId = null;
|
||||||
|
state.avatar.startX = null;
|
||||||
|
state.avatar.startY = null;
|
||||||
state.avatar.direction = null;
|
state.avatar.direction = null;
|
||||||
state.avatar.repeatTimer = null;
|
state.avatar.repeatTimer = null;
|
||||||
state.avatar.repeatMs = null;
|
state.avatar.repeatMs = null;
|
||||||
@@ -695,7 +710,7 @@
|
|||||||
const control = byId("avatar-control");
|
const control = byId("avatar-control");
|
||||||
const rect = control.getBoundingClientRect();
|
const rect = control.getBoundingClientRect();
|
||||||
const radius = Math.max(AVATAR_DEAD_ZONE + 1, Math.min(rect.width, rect.height) / 2);
|
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");
|
const handle = byId("avatar-handle");
|
||||||
handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`;
|
handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`;
|
||||||
const directionChanged = vector.direction !== state.avatar.direction;
|
const directionChanged = vector.direction !== state.avatar.direction;
|
||||||
@@ -741,6 +756,8 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
beginFeedback();
|
beginFeedback();
|
||||||
state.avatar.pointerId = event.pointerId;
|
state.avatar.pointerId = event.pointerId;
|
||||||
|
state.avatar.startX = event.clientX;
|
||||||
|
state.avatar.startY = event.clientY;
|
||||||
byId("avatar-control").classList.add("active");
|
byId("avatar-control").classList.add("active");
|
||||||
if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId);
|
if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId);
|
||||||
updateAvatarControl(event);
|
updateAvatarControl(event);
|
||||||
@@ -797,7 +814,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, 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;
|
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); });
|
||||||
|
|||||||
Reference in New Issue
Block a user