Add opt-in phone gameplay recording and JSON export
This commit is contained in:
1 parent
ede1f48152
commit
fe64242090
8 files changed
+394
-12
No files matched your search
+126
@@ -0,0 +1,126 @@
|
||||
// Opt-in, local-only match diagnostics. Imports are read only inside functions (the existing
|
||||
// state/zombies/gameplay cycles); recording never consumes randomness or changes game state.
|
||||
import { G, P, ball, score, court } from './state.js';
|
||||
import { zombies } from './zombies.js';
|
||||
import { TUNE, matchFoe } from './config.js';
|
||||
import { prof, levelAt } from './profile.js';
|
||||
|
||||
const KEY = 'splatter-playtest-v1', LIMIT = 1500000;
|
||||
let session = null, active = false, openRun = false, tail = [], warning = '', changed = () => {}, environment = {};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw && raw.length < LIMIT + 30000) {
|
||||
const saved = JSON.parse(raw);
|
||||
if (saved.schema === 1 && Array.isArray(saved.events)
|
||||
&& ['bytes', 'runs', 'encounter', 'points', 'elapsed'].every(k => Number.isFinite(saved[k]) && saved[k] >= 0)) session = saved;
|
||||
}
|
||||
} catch { warning = 'Phone storage is unavailable. Export before closing this tab.'; }
|
||||
const round = n => Number.isFinite(n) ? Math.round(n * 1000) / 1000 : null;
|
||||
const position = c => c ? [round(c.x), round(c.z)] : null;
|
||||
const opponent = () => zombies.find(z => z.variant === 'r');
|
||||
const eligible = () => !!G.match && !G.net;
|
||||
|
||||
function snapshot() {
|
||||
const z = opponent(), m = G.match;
|
||||
return {
|
||||
phase: G.phase, pts: [...score.pts], games: [...score.games], server: score.server,
|
||||
serve: G.serveNum, hp: G.hp, player: position(P), swing: round(P.swingT),
|
||||
opponent: z ? { pos: position(z), hp: z.hp, hpMax: z.hpMax,
|
||||
velocity: [round(z.vx), round(z.vz)], cooldown: round(z.swingCool) } : null,
|
||||
ball: { pos: [round(ball.x), round(ball.y), round(ball.z)],
|
||||
velocity: [round(ball.vx), round(ball.vy), round(ball.vz)], gravity: round(ball.g),
|
||||
quality: round(ball.pow), hitter: ball.lastHit, bounces: ball.bounces, serve: G.wasServe },
|
||||
read: m ? { lane: m.lastLane, streak: m.laneRun, expected: !!m.expected, surprised: !!m.surprised } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function onRecorderChange(fn) { changed = fn; }
|
||||
export function recordingInfo() {
|
||||
return { active, exists: !!session, full: !!session?.full, points: session?.points || 0,
|
||||
events: session?.events.length || 0, warning };
|
||||
}
|
||||
export function beginRecording(meta = {}) {
|
||||
environment = meta;
|
||||
session = { schema: 1, started: new Date().toISOString(), meta, runs: 0, encounter: 0,
|
||||
points: 0, elapsed: 0, bytes: 0, events: [], full: false,
|
||||
tailColumns: ['seconds', 'playerX', 'playerZ', 'swingSeconds', 'opponentX', 'opponentZ',
|
||||
'ballX', 'ballY', 'ballZ', 'lastHitter', 'bounces'],
|
||||
notes: 'Positions in metres. Point snapshots precede scoring. Tail is up to 90 live update frames, not video or a deterministic replay.' };
|
||||
active = true; openRun = false; tail = [];
|
||||
saveRecording(); changed();
|
||||
}
|
||||
export function setRecording(value, meta = environment) {
|
||||
if (!session || (value && session.full)) return;
|
||||
// Preserve an interrupted rally on pause. Resume begins a separately labelled segment.
|
||||
if (!value) recordEvent('run_end', { outcome: 'paused' });
|
||||
saveRecording(); active = value; environment = meta; openRun = false; tail = []; changed();
|
||||
}
|
||||
export function saveRecording() {
|
||||
if (!session) return;
|
||||
if (openRun && tail.length) session.pending = { run: session.runs, point: session.points + 1,
|
||||
state: snapshot(), tail: tail.slice() };
|
||||
try { localStorage.setItem(KEY, JSON.stringify(session)); warning = ''; }
|
||||
catch { warning = 'Phone storage is full or unavailable. Export before closing this tab.'; }
|
||||
}
|
||||
export function recordingJSON() {
|
||||
saveRecording();
|
||||
return JSON.stringify(session);
|
||||
}
|
||||
|
||||
export async function exportRecording() {
|
||||
const filename = 'splatter-playtest-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
|
||||
const file = new File([recordingJSON()], filename, { type: 'application/json' });
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
try { await navigator.share({ files: [file], title: 'Splatter Tennis playtest' }); return 'shared'; }
|
||||
catch (e) { if (e.name === 'AbortError') return 'cancelled'; }
|
||||
}
|
||||
const a = document.createElement('a'), url = URL.createObjectURL(file);
|
||||
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
return 'download';
|
||||
}
|
||||
|
||||
export function recordEvent(type, detail = {}) {
|
||||
if (!active || !eligible()) return;
|
||||
if (type === 'run_start') {
|
||||
if (openRun) recordEvent('run_end', { outcome: 'restarted' });
|
||||
if (!active) return;
|
||||
session.runs++; openRun = true; tail = [];
|
||||
detail = { ...detail, environment: { ...environment }, mode: G.match.cup ? 'cup' : G.match.train ? 'training' : 'climb',
|
||||
league: G.league, level: levelAt(prof.xp), court: court.id, tuning: { ...TUNE } };
|
||||
} else if (!openRun) {
|
||||
if (type === 'run_end') return;
|
||||
recordEvent('run_start', { partial: true });
|
||||
}
|
||||
if (!active) return;
|
||||
if (type === 'opponent') session.encounter++;
|
||||
const event = { type, t: round(session.elapsed), run: session.runs, encounter: session.encounter,
|
||||
point: session.points + 1, rung: G.match.opp, round: G.match.cup?.round ?? null,
|
||||
foe: matchFoe(G.match.opp).short, ...detail, state: snapshot() };
|
||||
if (type === 'point' || type === 'run_end') event.tail = tail.slice();
|
||||
const bytes = JSON.stringify(event).length;
|
||||
if (session.bytes + bytes > LIMIT) {
|
||||
session.full = true; active = false; saveRecording(); changed(); return;
|
||||
}
|
||||
session.bytes += bytes;
|
||||
session.events.push(event);
|
||||
if (type === 'point') { session.points++; tail = []; delete session.pending; }
|
||||
if (type === 'run_end') { openRun = false; tail = []; delete session.pending; }
|
||||
// Only write at calm boundaries, never per frame or per shot.
|
||||
if (type === 'point' || type === 'run_end' || type === 'run_start') saveRecording();
|
||||
}
|
||||
|
||||
export function recordFrame(dt) {
|
||||
if (!active || !eligible()) return;
|
||||
session.elapsed += dt;
|
||||
if (G.done || G.mode === 'eaten') {
|
||||
recordEvent('run_end', { outcome: G.done?.won ? 'won' : 'lost' }); return;
|
||||
}
|
||||
if (G.modal || G.hitstop > 0 || G.phase !== 'rally' || !ball.active || G.match.decided) return;
|
||||
if (!openRun) recordEvent('run_start', { partial: true });
|
||||
if (!active) return;
|
||||
const z = opponent();
|
||||
tail.push([round(session.elapsed), ...position(P), round(P.swingT),
|
||||
...(position(z) || [null, null]), round(ball.x), round(ball.y), round(ball.z), ball.lastHit, ball.bounces]);
|
||||
if (tail.length > 90) tail.shift();
|
||||
}
|
||||
Reference in new issue
Block a user