/* 自分で何十回も遊ぶ道具(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);