Initial commit
This commit is contained in:
@@ -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');
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user