feat(diagnostics): correlate movement acknowledgements with painted views
This commit is contained in:
+44
-4
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user