Enludus
お問い合わせ

あそび横丁の中身 ── 1 ファイル完結の 20 本、物理で決める判定、定期実行の関門

ブラウザで遊べるミニゲーム集「あそび横丁」のリポジトリを、コードを引用しながら書きます。1 ファイル完結の構成、three.js・Rapier・matter.js と自前の物理の使い分け、物理の結果で成功を決める判定、ゲームごとの重さ、シェア画像の自動撮影、ハブ、Cloudflare Pages への公開、エージェントがゲームを足す定期実行の手順と関門まで。

これは技術編です。どんなゲームがあって、どう遊ぶのかを読みやすくまとめた読みもの版は「ミニゲームの屋台が並ぶ「あそび横丁」と、エージェントに足してもらう仕組み」にあります。

あそび横丁は、ブラウザですぐ遊べるミニゲームを集めた静的サイトです。asobi.enludus.com で公開していて、この記事は 20 本目までの時点で書いています(その後も増えています)。ソースは github.com/naki0227/mini-game にあります。

ゲームのコードは、コーディングエージェントが書いています。31 件のコミットにはすべてエージェントが共同作者として入っています。自分が決めているのは、作る題材、エージェントに渡す共通仕様、出す前に通す確認の手順です。数字やコードはすべてリポジトリから取ったものです。

全体の構成

index.html             ハブ(ゲーム一覧)
404.html               存在しない URL 用
games/index.html       /games/ をトップへ移すだけのページ
games/<id>/index.html  各ゲーム(1 ファイル完結)
games/<id>/ogp.png     シェアされたときのカード画像(1200×630)
games/<id>/thumb.png   ハブに並べるサムネイル(640×480)
.dev/README.md         1 本足す手順
.dev/spec.md           各ゲームの共通仕様(エージェントに渡す)
.dev/ideas.md          作るゲームの候補リスト
.dev/compose.mjs       OGP 画像とサムネイルを撮る Playwright スクリプト

ビルドの工程はありません。package.json もなく、ローカルでは python3 -m http.server 8000 で開くだけです。

ゲームは 1 本につき index.html が 1 つで、HTML・CSS・JavaScript がすべてその中に入っています。画像ファイルは使わず、絵は Canvas か CSS、インライン SVG、three.js で描いています。外から読むのは、物理エンジンや 3D のライブラリと Google Fonts だけです。

1 ファイル完結にしているのは、エージェントに渡す単位をはっきりさせるためです。共通仕様には「games//index.html のみ作る」「games//index.html 以外は触らない」と書いてあります。1 本のゲームを作る作業が、ほかのゲームやハブを壊しにくくなります。

共通の部品

ファイルを分けていないので、共有ライブラリはありません。代わりに、共通仕様で「同じ形で入れる」ものを決めています。

記録の保存。 ハイスコアは localStorage に、キー <id>:v1、値は JSON で保存します。読み書きは必ず try/catch で囲み、消えても動くこと。ハンガーキャッチの読み込みはこうなっています(games/catch/index.html)。

js
const KEY = 'catch:v1';
const DEF = { ver: 2, best: 0, wins: 0, throws: 0, winThrows: 0, plays: 0, bestChain: 0 };
function load() {
  try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); if (d && d.ver === 2) return Object.assign({ ...DEF }, d); } catch {}
  return { ...DEF };   // 古い形式(10本勝負のころ)は使わない
}

ルールを変えたゲームは、値の中に ver を持たせて、古い形式を読まないようにしています。キー名は変えないので、ハブ側は同じキーを読みつつ d.ver === 2 を確かめます。

結果画面。 スコア、自己ベスト、新記録のバッジ、もう一回、X でシェア、結果をコピー、「← ほかのゲームを見る」をそろえます。結果が出たら画面のどこをタップしても即リトライで、結果表示の直後 0.4 秒だけは誤タップとして無視します。シェア用の URL を作る gameUrl() も、参考実装と同じものを使います。

ハブへ戻るボタン。 8 本目まで出したところで、全ゲームにプレイ中でもハブへ戻れる「← 横丁」ボタンを足しました。仕様には HTML・CSS・JS をそのまま貼ってあり、pointerdown などを stopPropagation して、押してもゲームの入力に伝わらないようにしています。

ほかに、フォントはシリーズ共通で Dela Gothic One と Zen Kaku Gothic New、prefers-reduced-motion のときは画面の揺れを止める、効果音は WebAudio で、ユーザーの操作のあとに鳴らし始める、を決めています。

ライブラリの使い分け

20 本を、使っているものごとに分けるとこうなります。

使っているもの ゲーム
なし(Canvas と DOM) 連打おみくじ、お湯ぴったり、だるまさんがころんだ、ハンコ押し、台所ピンボール、ぴったり 10 秒
matter.js 0.19.0 にぎり積み、ハンガー投げ、ボトルフリップ
three.js 0.160.0 + Rapier 0.12.0 ハンガーキャッチ、だるま落とし、けん玉 もしかめ、輪投げ
three.js 0.160.0 + 自前の物理 たこ焼き返し、紙飛行機、水切り、靴飛ばし、皿回し、竹とんぼ、雪だるま転がし

matter.js は cdnjs から <script> で、three.js と Rapier(@dimforge/rapier3d-compat)は jsDelivr から ES モジュールとして読みます。共通仕様のはじめには「外部スクリプトは cdnjs.cloudflare.com の固定バージョンのみ」とありますが、10 月 18 日からの定期追加ぶんには「基本は 3D(three.js@0.160.0 を cdn.jsdelivr.net から ES module)」という追加ルールがあり、こちらが優先されています。どちらもバージョンは固定です。

3D のゲームは、ライブラリを動的 import で読み、失敗したときはスタートボタンに理由を出します(games/catch/index.html)。

js
const THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
const RAPIER_URL = 'https://cdn.jsdelivr.net/npm/@dimforge/rapier3d-compat@0.12.0/rapier.es.js';
let THREE, RAPIER;
try {
  const [t, r] = await Promise.all([import(THREE_URL), import(RAPIER_URL)]);
  THREE = t; RAPIER = r.default;
  await RAPIER.init();
} catch (e) {
  $('startBtn').textContent = '読み込めませんでした';

物理の側は three.js に頼らない、という書き方も共通しています。だるま落としやけん玉は、物理の部分を /*PHYS-BEGIN*/ と /*PHYS-END*/ で囲み、頭に「three には頼らない。Rapier の剛体だけ」と書いています。描画と物理が分かれているので、同じ物理を画面なしでボットに何百回も回せます。

matter.js:にぎり積みとボトルフリップ

最初の 1 本のにぎり積みは、共通仕様で「参考実装」に指定している 2 本のうちの 1 本です。お寿司は角を丸めた長方形の剛体で、揺れる箸の振り子の速さをそのまま初速にして落とします。

js
const omega = () => Math.min(1.7 + score * 0.06, 3.8);
...
Body.set(b, { friction: 0.9, frictionStatic: 1.2, restitution: 0, density: 0.0018, frictionAir: 0.01 });
Body.setVelocity(b, { x: vx, y: Math.max(vy, 0) });

積むほど omega(揺れの速さ)が上がるので、難しくなります。1 貫が「積めた」とするのは、速さと回転が小さい状態が 14 フレーム続いたとき(または落としてから 200 フレームたったとき)で、皿より下に落ちた 1 貫が 1 つでもあれば終わりです。

ボトルフリップは、ボトルを中が空洞の壁でつくり、水を「胴の中だけを動ける重り」として入れています。判定の関数は、頭のコメントがそのまま方針です(games/flip/index.html)。

js
// 物理の結果だけで判定する
function judge(w, dts) {
  const b = w.bottle; if (!b || w.done) return;
  w.t += dts;
  const up = Math.cos(b.angle), sp = Body.getSpeed(b), av = Math.abs(Body.getAngularVelocity(b));
  const s = w.stage.surf;
  const calm = sp < 0.35 && av < 0.02;
  const upright = Math.abs(up) > 0.97;

まっすぐ立ち、止まっている状態が台の上で 0.6 秒続いたら成功です。台以外で立ったら「おしい!立ったけど、そこじゃない」、7 秒で決着しなければ「時間切れ」になります。

序盤を易しくする仕組みもここにあります。投げの回転に「狙った台に届くころ 1 回転になる」ための手助けを混ぜていて、その割合は成功の回数で減っていきます。

js
// 手助けは成功するたびに弱くなり、7回目以降はほぼ自分の腕だけ。判定はあくまで物理の結果
const rawness = n => RAW_FORCE ?? Math.min(0.5, n * 0.07);

手助けするのは投げる瞬間の回転だけで、立ったかどうかは物理に任せています。

Rapier:ハンガー、だるま、けん玉、輪投げ

3D で物がぶつかったり引っかかったりするゲームには、Rapier を使っています。

ハンガーキャッチ は、針金ハンガーを細いカプセルの折れ線でつくった剛体です。掛かったかどうかは、カギの巻いた部分の中心と竿の軸の距離で見ます。

js
// 掛かった判定: 竿の周りをくるくる回っている間や、すべって落ちる途中では成功にしない。
// カギが竿に入ったまま、本体が竿より下にぶら下がり、動きが落ち着いたら成功。
function judgeStep(h, dt) {
  const hs = hookState(h.body);
  if (hs.inside) { h.inT += dt; h.wasIn = true; } else { h.inT = 0; h.calmT = 0; }
  if (hs.inside) {
    const b = h.body, v = b.linvel(), w = b.angvel(), t = b.translation();
    const below = t.y < BAR.y - 0.04;
    const calm = Math.hypot(v.x, v.y, v.z) < 0.35 && Math.hypot(w.x, w.y, w.z) < 2.2;
    h.calmT = below && calm ? (h.calmT || 0) + dt : 0;
    if (h.inT >= HOLD && h.calmT >= SETTLE) return 'hang';

最初は「カギの内側に竿が 0.7 秒入っていたら成功」でした。それだと、竿の周りを回って落ちていく途中でも成功になりうるので、「本体が竿より下にあって、動きが落ち着いている」を条件に足しています。竿に絡んで決着がつかないときのために、8 秒で必ず決める行もあります。

輪投げ の輪は、細いカプセル 16 本を輪の形に並べた 1 つの剛体(複合コライダー)です(games/wanage/index.html)。

js
const K = PH.K, half = PH.R * Math.sin(Math.PI / K), rm = PH.R * Math.cos(Math.PI / K);
for (let i = 0; i < K; i++) {
  const a = (i + 0.5) * 2 * Math.PI / K;
  const tan = { x: -Math.sin(a), y: 0, z: Math.cos(a) };
  world.createCollider(RAPIER.ColliderDesc.capsule(half, PH.RT).setTranslation(rm * Math.cos(a), 0, rm * Math.sin(a)).setRotation(qFromToO(UPO, tan))
    .setFriction(PH.MU_RING).setRestitution(PH.E_RING).setDensity(1), b);
}

入ったかどうかは、輪が止まったとき、狙った棒の軸が「輪の面」を輪の内側、かつ棒の頭より下で通っているかで決めます。中心が近いだけでは成功にしません。空中だけは輪のぐらつきに簡単な空気の効果を入れていて、スピンがないとぐらつきが育ち、スピンがあるとジャイロの効果でおさまります。「曲げて引くと入りやすい」はここから来ています。

だるま落とし は、長さの単位を 10cm にして、重力も 10 倍(98.1)にしています。小さな物を本物と同じ速さで動かすためです。木づちも剛体で、手から離れた瞬間の速さと向きで飛び、当たった胴と運動量をやりとりします。抜けたかどうかは、止まったあとの位置と傾きだけで決めます。

js
// 胴が「だるまの柱」から外れたか: 頭の真下から半径ぶん以上外れた・大きく傾いた・頭より上に出た
function isOut(p) {
  const t = pos(p.body), h = pos(g.head.body);
  const dxz = Math.hypot(t.x - h.x, t.z - h.z);
  return dxz > PH.R * 1.25 || g.tiltOf(p.body) > 0.75 || t.y > h.y + 0.2;
}

けん玉 は、けんを手で持っている物として kinematicPositionBased の剛体にし、玉は Rapier のロープ拘束でつないでいます。

js
world.createImpulseJoint(RAPIER.JointData.rope(PH.ROPE, { x: 0, y: 0, z: 0 }, HOLE), ken, ball, true);

単位は cm で、重力は本物の 15〜33% に弱めています。指の操作でも間に合うようにするためで、乗るたびに少しずつ強くなり、テンポが上がります。成功は「玉が指定の皿の輪の内側で、皿との相対速度が 7 以下の状態が 0.15 秒続いたとき」です。

けん玉のプレイ画面。和室で、赤い玉がけんの中皿に乗っている。上に「12 回つづいた」の表示
けん玉。玉が中皿に乗って止まったところ

Rapier のゲームはどれも、固定の時間刻み(だるま落としとけん玉は 1/600 秒)で world.step() を回し、CCD のサブステップ数を明示しています(ハンガーキャッチ以外の 3 本はソルバーの反復回数も)。

自前の物理:皿回し、紙飛行機、竹とんぼ、雪だるま

物理エンジンの剛体と衝突では表しにくい題材は、運動の式を直接書いています。

皿回し は、皿を対称なコマとして、向き(クォータニオン)と支点まわりの角運動量 L を持ち、毎ステップ「dL/dt = トルク」を足しています。トルクは、重力と棒の先が動くことによる見かけの力、先端の摩擦が軸を起こす力、空気が首ふりを止める力などです。

js
// 重力と見かけの力: 重心 rc × (g − a)
const rcx = PH.HC * n[0] + PH.EPS * b[0], rcy = PH.HC * n[1] + PH.EPS * b[1], rcz = PH.HC * n[2] + PH.EPS * b[2];
const gx = -A[0], gy = -PH.G - A[1], gz = -A[2];
let tqx = rcy * gz - rcz * gy, tqy = rcz * gx - rcx * gz, tqz = rcx * gy - rcy * gx;
// 先端の摩擦が軸を棒の向きへ起こす
const s = p.s, sn = s[0] * n[0] + s[1] * n[1] + s[2] * n[2];
tqx += PH.CR * ws * (s[0] - sn * n[0]); tqy += PH.CR * ws * (s[1] - sn * n[1]); tqz += PH.CR * ws * (s[2] - sn * n[2]);

回っていればジャイロ効果で倒れずに横へ回り(歳差運動)、遅くなると歳差の円が育って落ちます。コメントによると、皿の重心はほんの少しだけ軸からずらしてあり、乱数で落ちることはありません。

紙飛行機 は、折り方ごとに重さ・翼面積・揚力傾斜・失速角・抗力などを持たせ、迎え角から揚力係数と抗力係数を出しています(aeroCoef)。失速角をこえると揚力が落ち、機首が下がります。風と校庭の上昇気流は投げるたびに種から作り直すので、同じ投げ方でも毎回少し違う飛び方になります。

竹とんぼ は、羽根のねじれ角と流入角から迎え角を出し、揚力を速さの 2 乗に比例させています。上がるほど流入角が大きくなって迎え角が減るので、上昇の速さには限りがあります。雪だるま転がし は、転がった距離と前の雪の深さで体積を増やし、ぶつかる向きの速さが雪玉の強さをこえたら割れる、というモデルです。強さは大きさに比例しないので、大きい玉ほど割れやすくなります。

台所ピンボール だけは少し方向が違います。物理と面のデータを SIM にまとめ、頭に「DOM に触らない・乱数なし・決定論的」と書いています。15° 刻みの回転を Math.sqrt だけで作っていて、コメントには「環境差が出ない」とあります。毎回同じに飛ぶ玉を使う配置パズルなので、端末によって答えが変わらないことが大事だからです。

難しさのカーブとボット

共通仕様には「1 投目・1 回目は誰でもそこそこ成功し、だんだん難しくなるカーブ。ボットや物理シミュレーションで成功率を測って報告する」とあります。

そのために、3D のゲームの多くは、人の操作をまねるボットを持っています。けん玉のボットは、腕前を 5 段階のパラメータで持っています(games/kendama/index.html)。

js
const SKILLS = {
  first:  { wait: 0.5, A: 6.5, Asd: 0.35, D: 0.1, Dsd: 0.3, xErr: 2.2, react: 0.24, percep: 1.0, gain: 6, knee: 0.15, kneeSd: 0.3, kneeT: 0.16, kneeTsd: 0.1 },
  casual: { wait: 0.4, A: 6.5, Asd: 0.25, D: 0.1, Dsd: 0.2, xErr: 1.7, react: 0.2, percep: 0.8, gain: 7, knee: 0.35, kneeSd: 0.25, kneeT: 0.16, kneeTsd: 0.06 },
  ...

ボットが動かすのは指の目標だけで、玉の位置は少し遅れて、少しぶれて見えています。ポンの強さ、受けるときにひざを曲げるタイミングにもばらつきがあります。人と同じ入り口から操作するので、ボットの成功率がそのまま「人がやったらどのくらいか」の目安になります。

14 本のゲームには、検証用の関数があります(多くは URL に #debug を付けたときだけ有効)。ハンガーキャッチなら、投げる角度と向きの格子で成功マップを出せます。

こうした測定の結果は、コミットメッセージに残っています。

  • 靴飛ばし:初めての人でも 5m 以上飛ぶよう、最初の振れ幅を 20° から 40° に。ボットで 5m 未満が 37% から 18% に
  • 水切り:ふつうの速さではじいた 1 投目で、およそ 4〜8 回
  • ハンガーキャッチ:ど真ん中を狙ったときの成功率が 4〜7 割だったので、ブレを 2 倍にして約 2〜3 割に

続けるほど難しくする方法は、ゲームごとに違います。にぎり積みは箸の揺れが速くなり、だるま落としは続けるほど胴が薄くなり、途中から重い胴とすべる胴が混じります。ハンガーキャッチは 1 本かけるごとに投げる人が 25cm 下がり、けん玉は 20 回で小皿が加わります。

重さ

repo の中のファイルサイズを測りました。ゲームの index.html は 1 本あたり 25〜130KB です。gzip(-9)をかけると 9〜42KB になります。

ゲーム index.html gzip ogp.png thumb.png
にぎり積み 24.8KB 9.1KB 61KB 18KB
ボトルフリップ 69.5KB 23.2KB 147KB 78KB
ハンガーキャッチ 71.5KB 24.5KB 180KB 173KB
輪投げ 96.2KB 33.2KB 260KB 285KB
雪だるま転がし 111.0KB 38.7KB 171KB 97KB
たこ焼き返し 129.6KB 41.8KB 234KB 275KB

20 本の index.html を合わせると約 1.43MB、PNG は合わせて約 6.1MB(OGP が 3.5MB、サムネイルが 2.6MB)です。ハブの index.html は 16.2KB(gzip で 6.0KB)です。これはリポジトリの中のサイズで、three.js や Rapier、matter.js、フォントは CDN から読むので、この表には入っていません。

サイズの大半はコードより画像です。サムネイルはハブに 20 枚並びます。ハブでは loading="lazy" と width・height を付けていて、画面に入るまで読まず、読んでもレイアウトがずれないようにしています。

3D の描画では、three.js のシャドウマップをどのゲームでも使っていません。影は「簡易の丸い影」のような描き方で済ませています。共通仕様の「影は控えめ、ポリゴン少なめ」に沿ったものです。描画の解像度も、devicePixelRatio をそのまま使わず、上限を決めています。

js
W = innerWidth; H = innerHeight; dpr = Math.min(devicePixelRatio || 1, 2);
renderer.setPixelRatio(Math.min(dpr, 1.75)); renderer.setSize(W, H, false);

シェア画像とサムネイル

ゲームごとの ogp.png(1200×630)と thumb.png(640×480)は、.dev/compose.mjs が撮っています。

  1. Playwright の Chromium で、400×760 の画面にゲームを開く
  2. 渡された操作の列(クリック、押す、動かす、離す、キー、待つ)をそのまま再生して、見栄えのいい瞬間を作る
  3. その画面を撮り、真ん中あたりを 4:3 に切り抜いてサムネイルにする
  4. 1200×630 のページを組み、左にタイトルと説明、右に少し傾けたスマホの画面を置いて OGP を撮る
だるま落としのシェア用カード画像。左に「だるま落とし」のタイトルと説明、右に傾けたスマホのゲーム画面
compose.mjs が作った、だるま落としの ogp.png(1200×630)
js
for (const a of JSON.parse(actions)) {
  if (a.click) await p.mouse.click(a.click[0], a.click[1]);
  if (a.down) { await p.mouse.move(a.down[0], a.down[1]); await p.mouse.down(); }
  if (a.move) await p.mouse.move(a.move[0], a.move[1], { steps: a.move[2] || 1 });
  if (a.up) await p.mouse.up();
  if (a.key) await p.keyboard.press(a.key);
  if (a.eval) await p.evaluate(a.eval);
  if (a.wait) await p.waitForTimeout(a.wait);
}

3D のゲームも撮れるように、ブラウザは --use-gl=angle --use-angle=swiftshader で起動します。GPU のない環境でもソフトウェアで WebGL を描くためです。

操作の列は、ゲームを作ったエージェントが「そのまま再生できる形」で報告することになっています。皿回し・竹とんぼ・雪だるま転がしには、#debug&skip=秒 で最初にその秒数だけ進めておく仕組みもあり、ボットに遊ばせた途中の画面を撮れます。

ハブ

ハブの index.html は、GAMES という配列を 1 つ持っていて、そこからカードを組み立てます。

js
{
  no: 15, id: 'otoshi', title: 'だるま落とし', noren: ['だ', 'る', 'ま', '落', 'とし'],
  desc: '木づちで胴を1段ずつスコーン。頭を落とさず何段ぬける?',
  href: '/games/otoshi/', thumb: '/games/otoshi/thumb.png',
  storageKey: 'otoshi:v1', best: d => d.plays ? `<b>${d.best}</b> 段・${d.plays}回` : null,
},

noren はカードの上ののれんに 1 枠ずつ入る文字です。ハブとゲームは同じオリジンなので、ハブは各ゲームの localStorage を読んで、best 関数で「あなたの最高」を出します。読めなかったら「まだ遊んでいません」です。

あそび横丁のハブのページ。タイトルの下に、のれんつきのゲームのカードが3列に並んでいる
ハブのページ(幅 1280px)。カードの左下に「あなたの最高」か「まだ遊んでいません」が出る
js
function readBest(g) {
  try { const d = JSON.parse(localStorage.getItem(g.storageKey) || 'null'); return d ? g.best(d) : null; } catch { return null; }
}

この best 関数も、ゲームを作ったエージェントが報告に書くことになっています。

公開

Cloudflare Pages に、GitHub の main ブランチをつないでいます。Framework preset は None、Build command は空、出力ディレクトリは / です。main に push すると、そのまま公開されます。リポジトリに CI の設定はありません。

公開してすぐに 1 つ問題が出ました。Cloudflare Pages は、存在しない URL にトップのページを返します。そのため /games/ で開くとハブが表示され、ハブの中の相対パスの画像が /games/games/... を探して切れていました。直し方は 3 つです。

  • ハブのリンクと画像をルートからのパスにする
  • /games/ にはトップへ移動するだけのページを置く
  • 404.html を置いて、存在しない URL には「この屋台はありません」を出す

定期実行の手順

10 月 18 日に、それまでの作業で使っていた共通仕様、OGP の撮影スクリプト、候補リストを .dev/ にまとめました。それ以降の 12〜20 本目は、定期実行で足しています。13 本目からのコミットの間隔は、およそ 4〜5 時間です。

.dev/README.md の「1 本足す手順」は 8 段です。

  1. ideas.md の未着手から 1 つ選ぶ(希望があればそちらを優先)
  2. games/<id>/ を作り、.git/info/exclude に一時的に入れておく(push すると即公開なので、作りかけを Git に入れない)
  3. エージェントに spec.md と題材を渡して作らせる
  4. 実際に遊んで確かめる(Playwright。3D は swiftshader)
  5. compose.mjs で ogp.png と thumb.png を作る
  6. ハブの GAMES に 1 件足し、README の表にも足す
  7. ハブのスクリプトを node --check で構文チェックする
  8. exclude から外して commit、push、本番で表示を確かめる

手順を進める側と、ゲームを書く側が分かれています。ゲームを書くエージェントは games/<id>/index.html だけを作り、commit も push もしません。仕上がったら、ハブ用の情報、遊び方とスコアの仕組み、テストで確かめたことと確かめられなかったこと、OGP 撮影の操作列を短く報告します。ハブへの追加、画像、公開は、手順を進める側がやります。

ゲームを書くエージェントが、渡される前に自分で確かめることも、共通仕様に書いてあります。

  • 400×800 で、タイトル → プレイ → 結果 → 即リトライまで操作できる
  • pageerror が出ない
  • localStorage に記録が残る
  • 横スクロールが出ない
  • スクリーンショットを撮って見た目を自分で確かめ、おかしければ直す

壊れたことと、その見つけ方

壊れて直したことのいくつかは、コミットメッセージに残っています。

ハブの一覧が丸ごと消えた。 ハンガーキャッチの説明文を差し替えたときに、GAMES の行末のカンマが抜けていました。ハブのスクリプトが構文エラーになり、ゲームの一覧が表示されなくなっていました。1 本のゲームの説明を直しただけで、全部の入り口が消えるのがハブの怖いところです。手順 7 の node --check には、「カンマ抜けで一覧が消えた前例あり」と添えてあります。

音が耳に痛い。 ハンガーキャッチの、竿や床に当たる音が 2〜5kHz の金属音で、外れた 1 本で 21 回鳴っていました。低めの「コトッ」「カタン」に変え、強い衝突だけ、0.25 秒以上あけて、1 本あたり最大 4 回にしました(外れ 1 本で 21 音から 4 音)。共通仕様の「耳に痛い高音(2kHz 以上のノイズや高い音の連打)は使わない」は、この直しのあとの 10 月 18 日に入ったルールです。それ以降のゲームの音のコードには、たとえば「音(木の音。低めでやわらかく、2kHz 以上は使わない)」という見出しが付いています。

判定が甘い・決着しない。 ハンガーキャッチの成功判定を「ぶら下がって落ち着いたら」に厳しくしたのと同じコミットで、竿に絡んだまま止まらないことがあったのを、8 秒で必ず決着するようにしています。ボトルフリップの判定にも、7 秒で「時間切れ」にする条件があります。

見つけ方をまとめると、構文はハブだけ node --check、動きと見た目は Playwright で遊ばせてスクリーンショット、難しさはボットの成功率、です。音の聞こえ方や「遊んで気持ちいいか」は、自動の確認ではまだ見られていません。

書いてみて

こうして並べると、自分の決めたことがいちばん濃く残っているのは、.dev/ の仕様と手順です。ゲームの数が増えるほど、1 本のコードよりも、仕様の 1 行と手順の 1 段のほうが効くようになりました。

候補リストは 2026-10-24 に 30 本足していて、風車まわしとお手玉もそこから出ています(末尾の更新を見てください)。

更新(2026-10-20)

この記事は 20 本目までの時点で書いていて、そのあとに 4 本が増えて 24 本になりました(ペン回し・積み木抜き・缶つみ・金魚すくい)。上の本文の「候補リスト」の文を直しています。

使っているものは、次のとおりです。

ゲーム 物理
ペン回し 自前(支点まわりの剛体と親指のばね壁)。three.js は描画だけ
積み木抜き Rapier
缶つみ Rapier
金魚すくい 自前(水の抵抗・紙の濡れ・金魚の群れ)。three.js は描画だけ

4 本とも、これまでと同じ 1 ファイル完結で、games/<名前>/ に index.html・ogp.png・thumb.png を置く形です。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。

更新(2026-10-22)

さらに 5 本が増えて 29 本になりました。コマ回し・メンコ・竹馬・おはじき・凧あげで、どれも Rapier や matter.js は使わず、物理は自前です。three.js は描画だけに使っています。

ゲーム 物理
コマ回し 自前(歳差・起き上がり・摩擦の剛体)
メンコ 自前(風圧と薄い板の剛体)
竹馬 自前(倒立振子と竹の先の接地)
おはじき 自前(円盤の衝突・摩擦)
凧あげ 自前(平板の揚力・糸の張力・突風)

5 本とも、games/<名前>/ に index.html・ogp.png・thumb.png を置く 1 ファイル完結の形は同じです。これまでと同じく、ハブの index.html の一覧と README の表にも 1 行ずつ足しています。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。上の「候補リストの残り」の文を直しています。

さらに 5 本が増えて 34 本になりました(2026-10-21〜22)。石積みだけが Rapier で、あとの 4 本は自前の物理です。どれも three.js は描画に使っています。

ゲーム 物理
ヨーヨー 自前(ひもと回転・ねじれ)
石積み Rapier
羽根つき 自前(羽根の空気抵抗と板の衝突)
紙相撲 自前(板の振動と脚の摩擦)
ビー玉転がし 自前(球の転がり・壁・穴の縁)

石積みの物理は、積み木抜きや缶つみと同じく、/*PHYS-BEGIN*/ と /*PHYS-END*/ で囲んだ「three には頼らない。Rapier の剛体だけ」の部分に入っています。コードのコメントには、次のような判断が書いてあります。

  • 石は roundCuboid(角を丸めた箱)で作る。convex hull は、Rapier 0.12 で接触が不安定だった(缶つみの報告)ので使わない。
  • Rapier 0.12 の roundCuboid は、質量を丸めの内側の箱だけで数える。そのままだと丸い石ほど軽く回りやすくなるので、質量と回りにくさは自分で入れている。
  • 乗ったか崩れたかは、Rapier が返す位置だけで決める。どれか 1 個でも台石より下に落ちたら終わり。

5 本とも、games/<名前>/ に index.html・ogp.png・thumb.png を置く 1 ファイル完結の形です。ハブの index.html の一覧と README の表にも 1 行ずつ足しています。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。

更新(2026-10-23)

さらに 5 本が増えて 39 本になりました(2026-10-22〜23)。将棋倒しだけが Rapier で、あとの 4 本は自前の物理です。どれも three.js は描画に使っています。

ゲーム 物理
長縄跳び 自前(質点とばねの鎖の縄)
将棋倒し Rapier
雪合戦 自前(放物線・風・当たり判定)
鉛筆ころがし 自前(六角柱の転がり)
水風船キャッチ 自前(膜と水の揺れ・張力)

5 本とも、games/<名前>/ に index.html・ogp.png・thumb.png を置く 1 ファイル完結の形です。ハブの index.html の一覧と README の表にも 1 行ずつ足しています。「20 本を、使っているものごとに分けると」の表と、ゲームごとの重さの表、約 1.43MB・約 6.1MB という合計は、20 本の時点のままで、数え直していません。上の「候補リストの残り」の文を直しています。

更新(2026-10-25)

さらに 5 本が増えて 44 本になりました(2026-10-23〜24)。Rapier を使ったものはなく、5 本とも自前の物理です。

ゲーム 物理
風車まわし 自前(見かけの風と回転・息)
お手玉 自前(布袋と手のひらの接触)
ところてん突き 自前(やわらかい紐の束)
傘まわし 自前(回転面を転がる球とジャイロ)
打ち水 自前(水滴の粒子と濡れ・乾き)