127 lines
6.3 KiB
JavaScript
127 lines
6.3 KiB
JavaScript
// 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();
|
|
}
|