Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import puppeteer from 'puppeteer-core';
/**
* Boot the app in a headless browser, let it skate for a while, and report
* back what happened: console errors, frame rate, and where everyone ended up.
*
* The point is not the screenshots — it is that a spike whose whole success
* criterion is "does this look and run right" needs an answer that does not
* depend on someone having the tab open.
*
* node tools/capture.mjs [seconds]
*/
const ROOT = path.resolve(import.meta.dirname, '..');
const OUT = path.join(ROOT, 'shots');
const PORT = 4181;
const URL = process.env.TILT_URL ?? `http://127.0.0.1:${PORT}/`;
const SECONDS = Number(process.argv[2] ?? 12);
function findChrome() {
const cache = path.join(process.env.HOME, '.cache/puppeteer/chrome');
if (fs.existsSync(cache)) {
const builds = fs.readdirSync(cache).sort().reverse();
for (const b of builds) {
const exe = path.join(cache, b, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing');
if (fs.existsSync(exe)) return exe;
}
}
const system = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
if (fs.existsSync(system)) return system;
throw new Error('no Chrome found — set CHROME_PATH');
}
async function waitForServer(url, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
// Vite is still starting.
}
if (Date.now() > deadline) throw new Error('vite did not come up at ' + url);
await new Promise((r) => setTimeout(r, 250));
}
}
let server = null;
let browser = null;
try {
fs.mkdirSync(OUT, { recursive: true });
if (!process.env.TILT_URL) {
server = spawn(
path.join(ROOT, 'node_modules/.bin/vite'),
['--host', '127.0.0.1', '--port', String(PORT), '--strictPort'],
{ cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
);
server.stderr.on('data', (d) => process.stderr.write('[vite] ' + d));
}
await waitForServer(URL);
browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH ?? findChrome(),
headless: true,
args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader', '--no-sandbox'],
});
const page = await browser.newPage();
// deviceScaleFactor 2, not 1: the target is a retina Mac, and running this at
// 1 hid a canvas-sizing bug that made the element twice the window on the
// machine anyone actually looks at it on.
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
const errors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
await page.goto(URL, { waitUntil: 'domcontentloaded' });
// The boot overlay is removed once physics is up and the first frame ran.
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 });
// The canvas must fill the window exactly, at whatever pixel ratio. Checked
// at two window sizes so a resize path that only works on first load fails
// here rather than in someone's browser.
for (const [w, h] of [[1280, 720], [900, 1000]]) {
await page.setViewport({ width: w, height: h, deviceScaleFactor: 2 });
await new Promise((r) => setTimeout(r, 300));
const fit = await page.evaluate(() => {
const c = document.getElementById('stage');
const r = c.getBoundingClientRect();
return {
css: [Math.round(r.width), Math.round(r.height)],
win: [window.innerWidth, window.innerHeight],
buffer: [c.width, c.height],
dpr: window.devicePixelRatio,
};
});
const fits = fit.css[0] === fit.win[0] && fit.css[1] === fit.win[1];
console.log(`viewport ${w}x${h} @${fit.dpr}x: canvas ${fit.css.join('x')} css, `
+ `${fit.buffer.join('x')} buffer — ${fits ? 'fills the window' : 'DOES NOT FIT'}`);
if (!fits) {
errors.push(`canvas ${fit.css.join('x')} does not fill window ${fit.win.join('x')}`);
}
}
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
await new Promise((r) => setTimeout(r, 300));
// The starting lineup, before anyone has moved: both teams in their own half.
await page.evaluate(() => {
window.tilt.match.reset();
Object.assign(window.tilt.cam.state, { mode: 'broadcast', distance: 46, pitch: 0.85 });
});
await new Promise((r) => setTimeout(r, 400));
await page.screenshot({ path: path.join(OUT, 'lineup.png') });
await page.evaluate(() => {
window.tilt.match.reset();
Object.assign(window.tilt.cam.state, { distance: 34, pitch: 0.62 });
});
// Let them skate. Software rasterisation is slow, so this is wall-clock time
// rather than a frame count — the sim is dt-driven and does not care.
await new Promise((r) => setTimeout(r, SECONDS * 1000));
const hud = await page.$eval('#hud', (el) => el.textContent);
await page.screenshot({ path: path.join(OUT, 'broadcast.png') });
/** Frame a shot through the debug handle and wait for the camera to settle. */
async function shot(name, camState, settleMs = 2500) {
await page.evaluate((s) => Object.assign(window.tilt.cam.state, s), camState);
await new Promise((r) => setTimeout(r, settleMs));
await page.screenshot({ path: path.join(OUT, name + '.png') });
}
// Follow-cam: the only view that shows whether the stride and the direction
// of travel actually agree.
await shot('follow', { mode: 'follow', followIndex: 0, distance: 9, pitch: 0.28 });
// Close enough to judge the stance, the arm carry and the blade angle.
await shot('closeup', { mode: 'follow', followIndex: 0, distance: 3.6, pitch: 0.16 });
// From the side, where a lean into a turn actually reads.
await shot('side', { mode: 'follow', followIndex: 1, distance: 5.5, pitch: 0.1 });
// Fastest skater's numbers, so the shot can be read against real motion.
const detail = await page.evaluate(() => window.tilt.match.states.map((s) => ({
speed: +Math.hypot(s.vx, s.vz).toFixed(2),
effort: +s.effort.toFixed(2),
gait: +window.tilt.match.skaters[s.id].animator.gait.toFixed(2),
bank: +window.tilt.match.skaters[s.id].animator.bank.toFixed(2),
state: window.tilt.match.skaters[s.id].animator.state,
})));
console.log('HUD: ' + hud.replace(/\n/g, ' | '));
console.log('skaters: ' + JSON.stringify(detail));
console.log(`shots → ${path.relative(ROOT, OUT)}/`);
if (errors.length) {
console.error('\nbrowser errors:\n' + errors.join('\n'));
process.exitCode = 1;
} else {
console.log('no console errors');
}
} finally {
await browser?.close();
server?.kill('SIGTERM');
}
+78
View File
@@ -0,0 +1,78 @@
import * as THREE from 'three';
import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
import { createMatch } from '../src/game/match.js';
import { describeHit } from '../src/game/hits.js';
/**
* Fire skaters at each other from various run-ups and angles and print what
* comes out. A tuning aid, not a test: the numbers below are the ones you stare
* at when deciding what should count as a bump, a stagger and a knockdown.
*
* node tools/hitprobe.mjs
*/
const DT = 1 / 60;
await initPhysics();
/**
* @param {'stationary'|'full'} mode is the victim skating into it too
* @param {number} gap metres between them at the start
* @param {number} offsetZ lateral offset — 0 is dead centre
*/
function probe(mode, gap, offsetZ = 0) {
const physics = createPhysicsWorld();
const match = createMatch({ scene: new THREE.Group(), physics, perTeam: 1, teams: 2 });
const [a, b] = match.states;
a.x = -gap / 2; a.z = 0; a.yaw = Math.PI / 2;
b.x = gap / 2; b.z = offsetZ; b.yaw = mode === 'full' ? -Math.PI / 2 : Math.PI / 2;
match.skaters[0].proxy.teleport(a.x, a.z);
match.skaters[1].proxy.teleport(b.x, b.z);
match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
match.setControl(1, mode === 'full'
? { x: -1, y: 0, sprint: true, brake: false, cameraYaw: 0 }
: { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
const seen = new Set();
const out = [];
for (let n = 0; n < 6 / DT; n++) {
match.update(DT);
for (const h of match.recentHits) {
const id = `${h.at}|${h.attacker}`;
if (!seen.has(id)) {
seen.add(id);
out.push(h);
}
}
}
physics.destroy();
return out;
}
const rows = [
['stationary', 2.5, 0],
['stationary', 5, 0],
['stationary', 10, 0],
['stationary', 22, 0],
['stationary', 22, 0.45],
['stationary', 22, -0.45],
['full', 10, 0],
['full', 24, 0],
['full', 24, 0.5],
];
console.log('mode gap offZ | outcome m/s sev limbs description');
console.log('-'.repeat(96));
for (const [mode, gap, off] of rows) {
const hits = probe(mode, gap, off);
if (!hits.length) {
console.log(`${mode.padEnd(11)} ${String(gap).padStart(4)} ${String(off).padStart(5)} | (no hit)`);
continue;
}
for (const h of hits) {
console.log(
`${mode.padEnd(11)} ${String(gap).padStart(4)} ${String(off).padStart(5)} | `
+ `${h.outcome.padEnd(10)} ${h.speed.toFixed(1).padStart(4)} ${h.severity.toFixed(1).padStart(5)} `
+ `${(h.attackerPart + '→' + h.victimPart).padEnd(24)} ${describeHit(h)}`,
);
}
}
+234
View File
@@ -0,0 +1,234 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import puppeteer from 'puppeteer-core';
/**
* img2mesh harness — capture a shot sheet of the player and goalie for
* equipment / animation iteration.
*
* npm run img2mesh
* npm run img2mesh -- --subject goalie --poses ready,butterfly --views front,side
* npm run img2mesh -- --list
*
* Writes PNGs + manifest.json under shots/img2mesh/. Pair each PNG with a
* reference (drop into shots/img2mesh/ref/) and re-run after code changes.
*/
const ROOT = path.resolve(import.meta.dirname, '..');
const OUT = path.join(ROOT, 'shots', 'img2mesh');
const PORT = 4182;
const BASE = process.env.TILT_URL ?? `http://127.0.0.1:${PORT}/`;
const STUDIO = new URL('character.html', BASE).href;
function parseArgs(argv) {
const out = {
subjects: ['player', 'goalie'],
poses: null,
views: null,
list: false,
settle: 50,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--list') out.list = true;
else if (a === '--subject' || a === '--subjects') {
out.subjects = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '--poses') {
out.poses = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '--views') {
out.views = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '--settle') {
out.settle = Number(argv[++i]) || 50;
} else if (a === '--help' || a === '-h') {
console.log(`img2mesh — character shot sheet
Usage:
node tools/img2mesh.mjs [options]
Options:
--subject player|goalie|player,goalie (default: both)
--poses carry,windup,butterfly,... (default: all for subject)
--views front,side,threequarter,... (default: front,3/4,side,closeup,gear)
--settle N frames of settle before shot (via API)
--list print catalogs and exit
--help
`);
process.exit(0);
}
}
return out;
}
function findChrome() {
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH;
}
const cache = path.join(process.env.HOME, '.cache/puppeteer/chrome');
if (fs.existsSync(cache)) {
const builds = fs.readdirSync(cache).sort().reverse();
for (const b of builds) {
const exe = path.join(
cache,
b,
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
);
if (fs.existsSync(exe)) return exe;
}
}
const system = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
if (fs.existsSync(system)) return system;
throw new Error('no Chrome found — set CHROME_PATH');
}
async function waitForServer(url, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const res = await fetch(url);
if (res.ok || res.status === 404) return; // 404 on / is fine; studio is /character.html
} catch {
// still booting
}
if (Date.now() > deadline) throw new Error('vite did not come up at ' + url);
await new Promise((r) => setTimeout(r, 200));
}
}
const args = parseArgs(process.argv.slice(2));
let server = null;
let browser = null;
try {
fs.mkdirSync(OUT, { recursive: true });
fs.mkdirSync(path.join(OUT, 'ref'), { recursive: true });
if (!process.env.TILT_URL) {
server = spawn(
path.join(ROOT, 'node_modules/.bin/vite'),
['--host', '127.0.0.1', '--port', String(PORT), '--strictPort'],
{ cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
);
server.stderr.on('data', (d) => process.stderr.write('[vite] ' + d));
}
await waitForServer(BASE);
browser = await puppeteer.launch({
executablePath: findChrome(),
headless: true,
args: [
'--enable-unsafe-swiftshader',
'--use-gl=angle',
'--use-angle=swiftshader',
'--no-sandbox',
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
const errors = [];
page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto(STUDIO, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.img2mesh?.captureShot, { timeout: 45000 });
// Boot overlay gone.
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 10000 }).catch(() => {});
if (args.list) {
const catalogs = await page.evaluate(() => ({
playerPoses: window.img2mesh.catalogs.playerPoses(),
goaliePoses: window.img2mesh.catalogs.goaliePoses(),
views: window.img2mesh.catalogs.views(),
}));
console.log(JSON.stringify(catalogs, null, 2));
process.exit(0);
}
const sheet = await page.evaluate((opts) => {
return window.img2mesh.shotSheet(opts);
}, {
subjects: args.subjects,
poses: args.poses,
views: args.views ?? ['front', 'threequarter', 'side', 'closeup', 'gear'],
});
console.log(`img2mesh: ${sheet.length} shots → ${path.relative(ROOT, OUT)}/`);
const manifest = {
createdAt: new Date().toISOString(),
subjects: args.subjects,
shots: [],
};
for (let i = 0; i < sheet.length; i++) {
const spec = sheet[i];
const meta = await page.evaluate(async (s) => {
return window.img2mesh.captureShot(s);
}, { ...spec, settleMs: args.settle });
const filePath = path.join(OUT, spec.file);
await page.screenshot({ path: filePath, type: 'png' });
const entry = {
...spec,
path: path.relative(ROOT, filePath),
measures: meta.measures,
};
manifest.shots.push(entry);
const m = meta.measures.player || meta.measures.goalie;
const foot = m ? ` feetY=${m.footLY.toFixed(2)}` : '';
console.log(
`[${String(i + 1).padStart(3)}/${sheet.length}] ${spec.file}`
+ ` anim=${m?.anim ?? '—'}${foot}`,
);
}
const manifestPath = path.join(OUT, 'manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`manifest → ${path.relative(ROOT, manifestPath)}`);
// Index HTML for quick visual review in a browser.
const indexPath = path.join(OUT, 'index.html');
const cards = manifest.shots.map((s) => {
const m = s.measures.player || s.measures.goalie || {};
return `<figure>
<a href="${s.file}"><img src="${s.file}" alt="${s.file}" loading="lazy"></a>
<figcaption><strong>${s.subject}</strong> · ${s.pose} · ${s.view}<br>
anim=${m.anim ?? '—'} feetY=${m.footLY?.toFixed?.(2) ?? '—'} hands=${m.handLY?.toFixed?.(2) ?? '—'}/${m.handRY?.toFixed?.(2) ?? '—'}
</figcaption>
</figure>`;
}).join('\n');
fs.writeFileSync(indexPath, `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>img2mesh sheet</title>
<style>
body { margin:0; background:#0a0e14; color:#9ec0dc; font:12px/1.4 ui-monospace, Menlo, monospace; }
h1 { margin:16px; font-size:14px; letter-spacing:2px; color:#6ea8dc; }
main { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; padding:0 16px 24px; }
figure { margin:0; background:#121a24; border:1px solid #1e3348; border-radius:8px; overflow:hidden; }
img { display:block; width:100%; height:auto; background:#0c1018; }
figcaption { padding:8px 10px; }
</style></head>
<body>
<h1>IMG2MESH · ${manifest.shots.length} shots · ${manifest.createdAt}</h1>
<main>
${cards}
</main>
</body></html>`);
console.log(`gallery → ${path.relative(ROOT, indexPath)}`);
if (errors.length) {
console.error('\nbrowser errors:\n' + errors.join('\n'));
process.exitCode = 1;
} else {
console.log('no console errors');
}
} finally {
await browser?.close();
server?.kill('SIGTERM');
}