tools/play.mjs
既存の検品コード。今回の作業では実行していない
/* 自分で何十回も遊ぶ道具(2026-09-13)。K1にテストをやらせないため。
人の代わりに投げる子を動かして、**何秒生き延びて何体倒せるか**を数える。
狙いは1つ: テンポ(湧く間隔・歩く速さ)が理不尽になっていないかを数字で見る。
投げる子は「山なりで頭の上へ落ちる軌道」を総当たりで探して投げる=上手い人の上限。
これで死ぬなら人には無理。これで楽勝なら緩すぎる。
使い方: node tools/play.mjs [--n 10] [--sec 120] [--aim 0.6] [--prod]
--n 何回遊ぶか(既定10)
--sec 1回の上限(ゲーム内秒。既定120)
--aim 1投に使う狙いの時間(秒。人の手の遅さ。既定0.6) */
let webkit;
try { ({ webkit } = await import('playwright')); }
catch (e) { ({ webkit } = await import('file:///C:/平成ゾンビ学園/node_modules/playwright/index.mjs')); }
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/* 静的ファイルは public/ が本体(Workersの[assets]と同じ)。
無ければプロジェクト直下も見る(作りかけの見本ページ用) */
const pick = (u) => {
const a = path.join(ROOT, 'public', u);
return fs.existsSync(a) ? a : path.join(ROOT, u);
};
const MIME = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.json':'application/json' };
const argv = process.argv.slice(2);
const opt = (n, d) => { const i = argv.indexOf('--' + n); return i >= 0 ? argv[i + 1] : d; };
const N = +opt('n', 10), SEC = +opt('sec', 120), AIM = +opt('aim', 0.6);
let url = 'https://ishi-to-zombie.pages.dev/', srv = null;
if (!argv.includes('--prod')) {
const port = 8700 + Math.floor(Math.random() * 90);
srv = http.createServer((q, r) => {
let u = decodeURIComponent(q.url.split('?')[0]);
if (u === '/') u = '/index.html';
fs.readFile(pick(u), (e, d) => {
if (e) { r.writeHead(404); r.end(); return; }
r.writeHead(200, { 'Content-Type': MIME[path.extname(u).toLowerCase()] || 'application/octet-stream' });
r.end(d);
});
});
await new Promise(ok => srv.listen(port, ok));
url = 'http://localhost:' + port + '/index.html';
}
const browser = await webkit.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 797 } });
const errs = [];
page.on('pageerror', e => errs.push(String(e.message).slice(0, 200)));
await page.goto(url, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await page.evaluate(() => window.ZG.render(false)); /* 絵は描かない(速く回すため) */
const runs = await page.evaluate(({ N, SEC, AIM }) => {
const Z = window.ZG, st = Z.state();
const ppm = st.ppm, G = 9.8 * ppm, DRAG = 0.0022, h = 1 / 60;
/* 石の軌道を、本物と同じ式で先読みする(重力・空気抵抗・1/60刻み) */
function fly(ang, pf) {
const v = (4.6 + 4.6 * Math.pow(pf, 1.15)) * ppm, r = ang * Math.PI / 180;
let vx = Math.cos(r) * v, vy = -Math.sin(r) * v, x = st.hx, y = st.ry;
const pts = [];
for (let i = 0; i < 260; i++) {
vx *= (1 - DRAG); vy *= (1 - DRAG); vy += G * h;
const px = x, py = y;
x += vx * h; y += vy * h;
pts.push([px, py, x, y, vx, vy]);
if (y > st.floorY || x > st.W + 60) break;
}
return pts;
}
/* 目標の高さ(頭のてっぺん)を、落ちながら急な角度で通る投げ方を探す */
function aim(tx, crownY, tol) {
let bestE = 1e9, best = null;
for (let ang = 28; ang <= 84; ang += 1) {
for (let k = 4; k <= 40; k++) {
const pf = k / 40, pts = fly(ang, pf);
for (const [x0, y0, x1, y1, vx, vy] of pts) {
if (!(vy > 0 && y0 <= crownY && y1 > crownY)) continue;
if (vy < Math.abs(vx) * 0.75) continue; /* 余裕をみて0.6より厳しく狙う */
const t = (crownY - y0) / (y1 - y0 || 1), cx = x0 + (x1 - x0) * t;
const e = Math.abs(cx - tx);
if (e < bestE) { bestE = e; best = { ang, pf, e }; }
break;
}
}
}
return (best && best.e < tol) ? best : null;
}
/* 何コマ後に届くか(先回りして狙うため) */
function timeTo(ang, pf, crownY) {
const pts = fly(ang, pf);
for (let i = 0; i < pts.length; i++) {
const [, y0, , y1, , vy] = pts[i];
if (vy > 0 && y0 <= crownY && y1 > crownY) return i / 60;
}
return 0.8;
}
const out = [];
for (let run = 0; run < N; run++) {
Z.start(false); Z.freeze(false); /* false=通信を使わない(ひとりで回す) */
let t = 0, aimT = 0, thrown = 0, maxAlive = 0;
const steps = Math.round(SEC * 60);
for (let i = 0; i < steps; i++) {
Z.step(1 / 60); t += 1 / 60;
const s = Z.state();
if (s.mode !== 'play') break;
const zs = Z.zs();
if (zs.length > maxAlive) maxAlive = zs.length;
aimT -= 1 / 60;
if (aimT <= 0 && Z.can() && zs.length) {
const z = zs[0];
const crownY = z.hy - z.hr;
/* 先回り: だいたいの飛行時間だけ手前へ */
let tx = z.x, sol = null;
for (let it = 0; it < 3; it++) {
sol = aim(tx, crownY, (z.hr + s.stoneR) * 0.85);
if (!sol) break;
const ft = timeTo(sol.ang, sol.pf, crownY);
tx = z.x - z.spd * ppm * ft;
}
if (sol) { Z.throwAt(sol.ang, sol.pf); thrown++; aimT = AIM; }
else aimT = 0.2;
}
}
const s2 = Z.state();
out.push({ sec: +t.toFixed(1), kills: s2.kills, thrown, maxAlive, died: s2.mode !== 'play' });
}
return out;
}, { N, SEC, AIM });
console.log('回 生きた秒 倒した 投げた 命中率 同時に最大 結末');
let sum = { sec: 0, kills: 0, thrown: 0, died: 0 };
for (let i = 0; i < runs.length; i++) {
const r = runs[i];
sum.sec += r.sec; sum.kills += r.kills; sum.thrown += r.thrown; sum.died += r.died ? 1 : 0;
console.log(String(i + 1).padEnd(4) + String(r.sec).padEnd(11) + String(r.kills).padEnd(8) +
String(r.thrown).padEnd(8) + ((r.thrown ? r.kills / r.thrown * 100 : 0).toFixed(0) + '%').padEnd(9) +
String(r.maxAlive).padEnd(12) + (r.died ? 'やられた' : '時間切れ'));
}
const n = runs.length;
console.log('\n平均: ' + (sum.sec / n).toFixed(1) + '秒 / ' + (sum.kills / n).toFixed(1) + '体 / ' +
(sum.thrown ? sum.kills / sum.thrown * 100 : 0).toFixed(0) + '%命中 / やられた ' + sum.died + '/' + n + '回');
if (errs.length) console.log('JSエラー: ' + errs.slice(0, 4).join(' / '));
await browser.close();
if (srv) srv.close();
process.exit(0);