tools/play.mjs

既存の検品コード。今回の作業では実行していない

150行 · 6,716 bytes · SHA-256 15e4c72ff3cf301a7e046af037fc89ed3354c900302ef46ed393c756e39ad1b6

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