feat(diagnostics): correlate movement acknowledgements with painted views

This commit is contained in:
2026-09-09 22:12:12 +03:00
parent 3827ba7ec7
commit 37704028c5
5 changed files with 389 additions and 14 deletions
+44 -4
View File
@@ -12,6 +12,7 @@
const JOYSTICK_HYSTERESIS = .18;
const JOYSTICK_SLOW_REPEAT_MS = 360;
const JOYSTICK_FAST_REPEAT_MS = 170;
const LATENCY_SAMPLE_LIMIT = 256;
const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни кружок", text: "Кружок ведёт героя по лабиринту."}),
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
@@ -145,6 +146,40 @@
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
}
class LatencyTracker {
constructor(now) {
this.now = typeof now === "function" ? now : () => root.performance.now();
this.pending = new Map();
this.samples = [];
}
sent(requestId) {
if (!requestId) return;
this.pending.set(requestId, {sent: this.now(), acknowledged: null, stateSequence: null});
if (this.pending.size > LATENCY_SAMPLE_LIMIT) this.pending.delete(this.pending.keys().next().value);
}
acknowledged(requestId, stateSequence) {
const sample = this.pending.get(requestId);
if (!sample) return;
sample.acknowledged = this.now();
sample.stateSequence = Number.isInteger(stateSequence) ? stateSequence : null;
}
painted(stateSequence) {
for (const [requestId, sample] of this.pending) {
if (sample.acknowledged == null || sample.stateSequence !== stateSequence) continue;
const painted = this.now();
this.samples.push({requestId, commandToAckMs: sample.acknowledged - sample.sent,
commandToPaintMs: painted - sample.sent, ackToPaintMs: painted - sample.acknowledged});
if (this.samples.length > LATENCY_SAMPLE_LIMIT) this.samples.shift();
this.pending.delete(requestId);
}
}
snapshot() { return this.samples.map(sample => Object.assign({}, sample)); }
}
function createEnvelope(type, sequence, requestId, payload) {
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
}
@@ -179,6 +214,7 @@
}
const first = this.pending.parts[0];
const snapshot = Object.assign({}, first, {
stateSequence: message.seq,
cells: this.pending.parts.flatMap(part => part.cells),
objects: this.pending.parts.flatMap(part => part.objects)
});
@@ -208,7 +244,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(), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, joystick: {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, joystick: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
function showNotice(text, kind) {
const element = byId("notice");
@@ -287,6 +323,7 @@
const requestId = `${type}-${sequence}`;
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
setPending(requestId, element);
if (type === "move") state.latency.sent(requestId);
return requestId;
}
@@ -411,6 +448,9 @@
byId("compass-stat").dataset.direction = hint;
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
renderMap(snapshot);
if (Number.isInteger(snapshot.stateSequence)) {
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
}
if (snapshot.phase === "paused") { resetJoystick(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else {
@@ -551,7 +591,7 @@
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
} else if (message.type === "match_result") { renderResult(message.payload.result);
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetJoystick(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
} else if (message.type === "ack") { feedback(true);
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); feedback(true);
} else if (message.type === "error") {
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
@@ -689,10 +729,10 @@
}
});
connect();
return {state, handleMessage, move, resetJoystick};
return {state, handleMessage, move, resetJoystick, latencySamples: () => state.latency.snapshot()};
}
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, JOYSTICK_DEAD_ZONE, JOYSTICK_HYSTERESIS, JOYSTICK_SLOW_REPEAT_MS, JOYSTICK_FAST_REPEAT_MS, directionFromKey, directionFromSwipe, joystickDirection, joystickRepeatMs, joystickVector, 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, JOYSTICK_DEAD_ZONE, JOYSTICK_HYSTERESIS, JOYSTICK_SLOW_REPEAT_MS, JOYSTICK_FAST_REPEAT_MS, LatencyTracker, directionFromKey, directionFromSwipe, joystickDirection, joystickRepeatMs, joystickVector, playerCellSize, 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); });