diff --git a/docs/backend.md b/docs/backend.md index 1c5783e..ba04711 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -492,6 +492,7 @@ Node版との差分(意図的な改善): |---|---|---|---| | `bell` | 鈴の緒(`omikujiMachine.js`) | 房をつかんで左右に振る | 玉が絵馬・水車・ドミノ階段をリレーして狐に直撃 | | `slingshot` | 弾き玉(`omikujiSlingshot.js`) | 御神玉をつまんで引き絞り、放す | 木札の櫓に直撃して崩れ、木札か玉が狐に当たる | +| `pinball` | 弾き盤(`omikujiPinball.js`) | 右端の御神玉を下に引いて、放す | 玉が天辺の弧を回って盤面へ、バンパーに弾かれながら中央の穴の狐へ落ちる | インターフェース(詳細は `omikujiMachines.js` の冒頭コメント): `GEO`(論理座標系)/ `FOX`(寝床)/ `HINT`(案内文)/ `TIMELINE`(フォールバック時刻)/ @@ -499,7 +500,7 @@ Node版との差分(意図的な改善): `createRitual`(儀式の完了判定)/ `fallbackRitual`(「うまくできないとき」)/ `onRitualDone` / `nudge`(詰まったときの押し)/ `createSettleDetector`(任意)。 -確認用に `?scene=slingshot` のようにクエリで装置を固定できる。 +確認用に `?scene=slingshot` `?scene=pinball` のようにクエリで装置を固定できる。 #### 弾き玉(slingshot)の設計 @@ -519,6 +520,41 @@ Node版との差分(意図的な改善): 45点を掃引し、狐への到達と静止までの秒数、無操作で装置が動かないことを確認する。 GEO を触ったら必ず回す。 +#### 弾き盤(pinball)の設計 + +- 右端のレーンにある御神玉(プランジャー)を**下に引いて放す**。引きはレーンに + 沿った縦方向だけに正規化し、支点を上に通り過ぎた瞬間に切り離す。射出速度は + [17, 21]px/step に収める。弱すぎると天辺の弧まで登り切れずレーンに落ち戻り、 + 速すぎると薄い壁(レーン壁 8px・弧 20px)を突き抜ける。 +- 天辺の弧は静止セグメントで近似する。弧の右端は盤面の外(x>480)まで出す。 + レーンの中に端が残ると、登ってきた玉が端に下から当たって跳ね返り、盤面へ出られない。 +- **レーンの蓋(一方通行)**。玉が盤面へ出た瞬間、レーンの口に蓋を閉じる + (`collisionFilter.mask` を 0 → 全部に切り替える)。跳ね返ってきた玉がレーンに + 落ち戻ると、井戸の底で止まって狐から遠く、静止検知まで 10 秒近くかかった。 + 蓋は左下がりのくさび形にする。平らな板だと天辺を右へ戻ってきた玉が板の上に + 載って右上の隅で止まった。 +- ポップバンパーは衝突した瞬間に玉を中心から外向きへ**一定速度**(9px/step)で弾く。 + 反発係数で表現すると速度が発散したり減衰したりして安定しない。弾く向きに僅かな + 乱れ(±0.35rad)を混ぜて、同じ引きでも毎回違う軌道になるようにする(乱数は + `build(Matter, { rnd })` で注入でき、検証はシード付き)。 +- 床は中央の穴へ向かう**2枚の斜面(台形)**。薄い板だと板の下や外側へ落ちた玉が + 床で止まって穴に入らなかった。右の斜面はレーン壁の手前で止める(レーンに + 食い込むと、井戸に引き下げた玉が斜面の上で止まって放てない)。 +- 左右のスリングショット(三角)は壁に張り付け、下の頂点を斜面の上に載せる。 + 壁との間に隙間があると、そこへ落ちた玉が斜面の上で詰まった。 +- バンパーは玉の軌跡(`--trace`)を見て道筋の上に置く。強く放った玉は弧を回りきって + 左壁に着く(42,182)、中くらいは弧を離れて斜めに落ちる(100,240)、弱いと + 右上から中央へ落ちる(290,270)。 +- 玉が止まったら(`createSettleDetector`)物音で狐が起きる。詰まりの押し(8s)、 + 起こし(14s)、フェイルセーフ(20s)。押しはまだ跳ね回っている玉には触らない + (長いラリーの途中で速度を書き換えると不自然)。止まった玉は静止検知が先に + 起こすので押しはほぼ保険。起こしの 14s は掃引の最遅到達(11.0s)に余裕を足したもの。 +- ヘッドレス検証は `web/scripts/simulate-omikuji-pinball.js`。引き 11 段階 × シード + (既定 6、`--seeds 30` で 330 点)を掃引し、狐への到達秒数・バンパー回数・ + 無操作で装置が動かないことを確認する。狐に届かず静まった場合は、止まった場所が + 穴の中でなければ失敗にする(どこかの棚に載ったまま終わる演出を見逃さない)。 + GEO を触ったら必ず回す。 + ### デプロイ - `omikujiGo` は HTTP 関数として dev/prod 両ワークフローでデプロイ(`sanpaiGo` と同型。 diff --git a/web/components/omikujiMachines.js b/web/components/omikujiMachines.js index b4b29a0..3647637 100644 --- a/web/components/omikujiMachines.js +++ b/web/components/omikujiMachines.js @@ -13,7 +13,7 @@ // TIMELINE : { nudgeMs, wakeMs, failsafeMs } 詰まり時のフォールバック時刻 // wakeLabels : fox-sensor に触れたら狐が起きるボディのラベル // grabFilter : 指でつまめるものの collisionFilter(MouseConstraint 用) -// build(Matter) : 世界を組む。{ engine, world, ...handles } +// build(Matter, opts?): 世界を組む。{ engine, world, ...handles }。opts.rnd で乱数を注入可(検証用) // pulseAt(built): 儀式完了の波紋を出す論理座標 { x, y } // createRitual(Matter, built): { step(dragging) → 儀式完了で true } // fallbackRitual(Matter, built, ritual): 「うまくできないとき」の代替操作。 @@ -26,8 +26,9 @@ // 抽選の正しさは装置に依存しない(狐の最終着地は omikujiFox.js)。 const bell = require("./omikujiMachine"); const slingshot = require("./omikujiSlingshot"); +const pinball = require("./omikujiPinball"); -const ALL = { bell, slingshot }; +const ALL = { bell, slingshot, pinball }; const IDS = Object.keys(ALL); function byId(id) { diff --git a/web/components/omikujiPinball.js b/web/components/omikujiPinball.js new file mode 100644 index 0000000..6fb9f4d --- /dev/null +++ b/web/components/omikujiPinball.js @@ -0,0 +1,396 @@ +// おみくじ演出「ピンボール」の装置モジュール(matter-js)。 +// +// 描画(Render)を含まない構築部分だけを切り出し、Node でもヘッドレスに +// 「放った玉が狐まで落ちてくるか」を検証できるようにする +// (scripts/simulate-omikuji-pinball.js)。 +// +// 演出の流れ: +// 右端のレーンにある御神玉(プランジャー)を下へ引いて、放す +// → 玉がレーンを駆け上がり、天辺の弧で左へ折り返して盤面へ +// → バンパーに当たるたびに弾き飛ばされ(ポップ)、盤面を跳ね回る +// → 下のフリッパー形の受けに集められ、中央の穴で寝ている狐に落ちる +// → 狐が起きてビンを飛び移る(DOM側。OmikujiScene が担当) +// +// 設計の要: +// - 抽選の正しさはこの装置に依存しない(狐の最終着地は omikujiFox.js で制御)。 +// - バンパーは衝突した瞬間に玉を中心から外向きへ一定速度で弾く(ポップバンパー)。 +// 反発係数で表現すると速度が発散したり減衰したりして安定しないため。 +// 弾く向きに僅かな乱れ(rnd)を混ぜて、同じ引きでも毎回違う軌道になるようにする。 +// - フリッパーは動かない(受けの形だけ)。動かすと操作を期待させるが、結果は +// サーバーが決めているので操作の意味が無い。 +// - 玉が盤面のどこかで止まったら(静止検知)、物音で狐が起きる。 +// +// OmikujiScene から使う共通インターフェースは omikujiMachines.js を参照。 + +const GEO = { + W: 480, + H: 760, + BIN_COUNT: 7, + FIXED_DELTA: 1000 / 60, + + CAT_DEFAULT: 0x0001, + CAT_GRAB: 0x0002, + CAT_MOUSE: 0x0004, + + // 盤面の床(上面の y)。狐はここで寝る。 + GROUND_Y: 620, + + // プランジャーレーン(右端)。玉は LANE.x の上で上下だけ動く。 + LANE: { x: 458, wallX: 438, wallTop: 160, width: 36 }, + BALL: { r: 11, density: 0.004, restitution: 0.35, friction: 0.02, frictionAir: 0.0012 }, + // プランジャー(ゴム)。下へ引いて放す。 + // 射出速度は [minSpeed, maxSpeed] に収める。レーン上の弧の内面(y≈104)まで + // 支点(540)から約 430px 登るには 15.5px/step 要る。弱い引きでも盤面へ出るよう + // 下限を置き、速すぎて薄い壁(レーン壁 8px・弧 20px)を突き抜けないよう上限を置く。 + ELASTIC: { anchorY: 540, stiffness: 0.02, maxPull: 90, releaseMin: 14, minSpeed: 17, maxSpeed: 21 }, + + // 天辺の弧(レーンの上端から左上へ)。静止セグメントで近似。 + // 右端(x = cx + r)が盤面の外(480超)に出るようにする。弧の端がレーンの中に + // 残ると、登ってきた玉が端に下から当たって跳ね返り、盤面へ出られない。 + ARC: { cx: 240, cy: 230, r: 252, segs: 20, thick: 20 }, + + // ポップバンパー(位置・半径)と、弾く速度。 + BUMPERS: [ + { x: 150, y: 300, r: 22 }, + { x: 290, y: 270, r: 22 }, + { x: 225, y: 390, r: 22 }, + { x: 355, y: 400, r: 18 }, + { x: 42, y: 182, r: 20 }, // 強く放った玉が弧を回りきって左壁に着く所 + { x: 78, y: 400, r: 18 }, + { x: 100, y: 240, r: 18 }, // 中くらいの引きで弧を離れ、左下へ斜めに落ちる道筋 + ], + POP: { speed: 9, jitter: 0.35 }, // jitter: 弾く向きの乱れ(rad) + + // 左右の三角(スリングショット)。壁に張り付いた三角で、落ちてきた玉を中央へ + // 押し戻す。壁との間に隙間を作らない(隙間に落ちた玉が斜面の上で詰まる)。 + // top: 壁に付く上端の y、reach: 壁から中央へ張り出す幅(下端は斜面の上に載る)。 + SLING: { top: 430, reach: 90 }, + + // 床は中央の穴へ向かう2枚の斜面(フリッパー形の受け)。脇に抜け道を作らない + // (薄い板だと、板の下や外側へ落ちた玉が床で止まって穴に入らなかった)。 + // 左右の壁ぎわ(高い側)の上面 y と、穴のふち(低い側)の上面 y、穴の幅。 + FUNNEL: { edgeY: 530, holeY: 606, gap: 88 }, + + // 狐の寝床(中央の穴)と、おしりの当たり判定。 + FOX_X: 240, + FOX_SENSOR: { x: 240, y: 592, w: 96, h: 54 }, + + BIN_TOP: 648, + FLOOR_Y: 744, +}; + +const FOX = { + sleepLeft: (GEO.FOX_X / GEO.W) * 100, + sleepBottom: ((GEO.H - GEO.GROUND_Y) / GEO.H) * 100, + flip: -1, // 左向きに寝る(穴の真ん中でどちらでもよいが、鈴の緒と変える) +}; + +const HINT = { + title: "右端の御神玉を下に引いて、放とう", + fallback: "うまく放てないときはここをタップ", + button: "玉を放つ", +}; + +const wakeLabels = ["ball"]; +const grabFilter = { category: GEO.CAT_MOUSE, mask: GEO.CAT_GRAB }; + +// 詰まったら押す(8s)、それでも来なければ起こす(14s)、フェイルセーフ(20s)。 +// 止まった玉は静止検知(約2s)で先に狐が起きるので、押しはほぼ保険。 +// 起こしは掃引の最遅到達(11.0s)より余裕を持たせる(長いラリーの途中で起きない)。 +const TIMELINE = { nudgeMs: 8000, wakeMs: 14000, failsafeMs: 20000 }; + +function buildWorld(Matter, opts) { + const rnd = (opts && opts.rnd) || Math.random; + const { Engine, World, Bodies, Body, Constraint, Events } = Matter; + const engine = Engine.create({ enableSleeping: true }); + engine.gravity.scale = 0.001; + const world = engine.world; + const add = (b) => World.add(world, b); + + const wood = { fillStyle: "#8a5a34" }; + const stone = { fillStyle: "#3a3230" }; + const metal = { fillStyle: "#c9b27a" }; + + // 外壁・天井・床(厚め。速い玉が突き抜けないように) + const WALL = 60; + add([ + Bodies.rectangle(GEO.W / 2, -WALL / 2, GEO.W + WALL * 2, WALL, { isStatic: true, render: stone }), + Bodies.rectangle(-WALL / 2, GEO.H / 2, WALL, GEO.H + WALL * 2, { isStatic: true, render: stone }), + Bodies.rectangle(GEO.W + WALL / 2, GEO.H / 2, WALL, GEO.H + WALL * 2, { isStatic: true, render: stone }), + Bodies.rectangle(GEO.W / 2, GEO.FLOOR_Y + WALL / 2, GEO.W + WALL * 2, WALL, { isStatic: true, render: stone }), + ]); + + // 床(上面 GROUND_Y〜BIN_TOP)。レーンの手前で切る。レーンは床より下まで続く + // 井戸で、プランジャー(玉)はそこへ引き下げる(床が続いていると、引いた玉が + // 床の下へ押し出されて戻ってこない)。 + const L = GEO.LANE; + add(Bodies.rectangle(L.wallX / 2, (GEO.GROUND_Y + GEO.BIN_TOP) / 2, L.wallX, GEO.BIN_TOP - GEO.GROUND_Y, { + isStatic: true, + label: "ground", + render: { fillStyle: "#5a4a3a" }, + })); + + // レーンの仕切り壁(左側)。上端から盤面へ抜ける口を開け、下は井戸の底まで。 + add(Bodies.rectangle(L.wallX, (L.wallTop + GEO.FLOOR_Y) / 2, 8, GEO.FLOOR_Y - L.wallTop, { isStatic: true, chamfer: { radius: 3 }, render: wood })); + + // レーンの蓋(一方通行)。玉が盤面へ出たら閉じて、跳ね返ってきた玉が + // レーンに落ち戻らないようにする(井戸の底で止まると狐から遠く、時間もかかる)。 + // 形は左下がりのくさび。平らな板だと、天辺を右へ戻ってきた玉が板の上に + // 載って右上の隅で止まった(330点中3点)。斜面なら仕切り壁の上を伝って盤面へ落ちる。 + const lidPts = [ + { x: L.wallX, y: L.wallTop }, + { x: GEO.W, y: L.wallTop - 40 }, + { x: GEO.W, y: L.wallTop }, + ]; + const lidC = Matter.Vertices.centre(lidPts); + const lid = Bodies.fromVertices(lidC.x, lidC.y, [lidPts], { + isStatic: true, + label: "lid", + collisionFilter: { mask: 0 }, + render: { fillStyle: "#8a5a34", visible: false }, + }); + add(lid); + + // 天辺の弧。レーン上端の右上から左上へ、静止セグメントで近似する。 + const A = GEO.ARC; + for (let i = 0; i < A.segs; i++) { + const t0 = Math.PI + (i / A.segs) * Math.PI; // 左(π) → 右(2π) + const t1 = Math.PI + ((i + 1) / A.segs) * Math.PI; + const x0 = A.cx + A.r * Math.cos(t0); + const y0 = A.cy + A.r * Math.sin(t0); + const x1 = A.cx + A.r * Math.cos(t1); + const y1 = A.cy + A.r * Math.sin(t1); + const len = Math.hypot(x1 - x0, y1 - y0) + 2; + add(Bodies.rectangle((x0 + x1) / 2, (y0 + y1) / 2, len, A.thick, { + isStatic: true, + angle: Math.atan2(y1 - y0, x1 - x0), + render: wood, + })); + } + + // ポップバンパー + for (const b of GEO.BUMPERS) { + add(Bodies.circle(b.x, b.y, b.r, { isStatic: true, label: "bumper", render: { fillStyle: "#d9542e" } })); + add(Bodies.circle(b.x, b.y, b.r * 0.45, { isStatic: true, collisionFilter: { mask: 0 }, render: metal })); + } + + // 中央の穴へ向かう2枚の斜面。上面が (壁, edgeY)→(穴のふち, holeY) を通る台形で、 + // 下は床まで詰める(下や外側へ抜けられない)。右の斜面はレーン壁の手前で止める。 + // レーンに食い込むと、井戸に引き下げた玉が斜面の上で止まって放てなくなる。 + const F = GEO.FUNNEL; + const half = F.gap / 2; + const slab = (pts) => { + const c = Matter.Vertices.centre(pts); + return Bodies.fromVertices(c.x, c.y, [pts], { isStatic: true, label: "flipper", render: metal }); + }; + add(slab([ + { x: -20, y: F.edgeY }, + { x: GEO.W / 2 - half, y: F.holeY }, + { x: GEO.W / 2 - half, y: GEO.GROUND_Y }, + { x: -20, y: GEO.GROUND_Y }, + ])); + add(slab([ + { x: GEO.W / 2 + half, y: F.holeY }, + { x: L.wallX, y: F.edgeY }, + { x: L.wallX, y: GEO.GROUND_Y }, + { x: GEO.W / 2 + half, y: GEO.GROUND_Y }, + ])); + + // 左右の三角(スリングショット)。下の頂点を斜面の上面に載せ、壁側は床まで詰める。 + const S = GEO.SLING; + const runL = GEO.W / 2 - half + 20; // 左斜面の上面: (-20,edgeY)→(W/2-half,holeY) + const yOnL = (x) => F.edgeY + ((x + 20) / runL) * (F.holeY - F.edgeY); + const runR = L.wallX - (GEO.W / 2 + half); // 右斜面の上面: (wallX,edgeY)→(W/2+half,holeY) + const yOnR = (x) => F.edgeY + ((L.wallX - x) / runR) * (F.holeY - F.edgeY); + const slingOpts = { isStatic: true, label: "sling", restitution: 0.6, render: { fillStyle: "#b23a48" } }; + const sling = (pts) => { + const c = Matter.Vertices.centre(pts); + return Bodies.fromVertices(c.x, c.y, [pts], slingOpts); + }; + add(sling([ + { x: -20, y: S.top }, + { x: S.reach, y: yOnL(S.reach) }, + { x: -20, y: GEO.GROUND_Y }, + ])); + add(sling([ + { x: L.wallX, y: S.top }, + { x: L.wallX, y: GEO.GROUND_Y }, + { x: L.wallX - S.reach, y: yOnR(L.wallX - S.reach) }, + ])); + + // 御神玉(プランジャー)。レーンの中で指でつまめる。 + const anchor = { x: L.x, y: GEO.ELASTIC.anchorY }; + const ball = Bodies.circle(anchor.x, anchor.y, GEO.BALL.r, { + density: GEO.BALL.density, + restitution: GEO.BALL.restitution, + friction: GEO.BALL.friction, + frictionAir: GEO.BALL.frictionAir, + label: "ball", + collisionFilter: { category: GEO.CAT_GRAB, mask: GEO.CAT_DEFAULT | GEO.CAT_MOUSE }, + render: { fillStyle: "#f2c14e" }, + }); + ball.sleepThreshold = Infinity; + add(ball); + + const elastic = Constraint.create({ + pointA: anchor, + bodyB: ball, + stiffness: GEO.ELASTIC.stiffness, + length: 0, + render: { strokeStyle: "#b23a48", lineWidth: 4 }, + }); + add(elastic); + // 繋がっている間は自重を打ち消す(柔らかいゴムがたわんで眠れないのを防ぐ)。 + Events.on(engine, "beforeUpdate", () => { + if (elastic.bodyB === ball) { + ball.force.y -= ball.mass * engine.gravity.y * engine.gravity.scale; + } + }); + + // 玉がレーンの仕切りより左(盤面)へ出たら蓋を閉じる。 + Events.on(engine, "beforeUpdate", () => { + if (lid.collisionFilter.mask === 0 && ball.position.x < L.wallX - GEO.BALL.r) { + lid.collisionFilter.mask = 0xffffffff; + lid.render.visible = true; + } + }); + + // ポップバンパー: 当たった瞬間、中心から外向きへ一定速度で弾く。 + Events.on(engine, "collisionStart", (e) => { + for (const p of e.pairs) { + let bumper = null; + if (p.bodyA.label === "bumper" && p.bodyB === ball) bumper = p.bodyA; + else if (p.bodyB.label === "bumper" && p.bodyA === ball) bumper = p.bodyB; + if (!bumper) continue; + const dx = ball.position.x - bumper.position.x; + const dy = ball.position.y - bumper.position.y; + const base = Math.atan2(dy, dx) + (rnd() - 0.5) * 2 * GEO.POP.jitter; + Body.setVelocity(ball, { x: Math.cos(base) * GEO.POP.speed, y: Math.sin(base) * GEO.POP.speed }); + } + }); + + // 狐のおしりの当たり判定(中央の穴) + add(Bodies.rectangle(GEO.FOX_SENSOR.x, GEO.FOX_SENSOR.y, GEO.FOX_SENSOR.w, GEO.FOX_SENSOR.h, { + isStatic: true, + isSensor: true, + label: "fox-sensor", + render: { visible: false }, + })); + + // ビン仕切り + const bw = GEO.W / GEO.BIN_COUNT; + for (let i = 1; i < GEO.BIN_COUNT; i++) { + add(Bodies.rectangle(i * bw, (GEO.BIN_TOP + GEO.FLOOR_Y) / 2, 6, GEO.FLOOR_Y - GEO.BIN_TOP, { isStatic: true, chamfer: { radius: 2 }, render: { fillStyle: "#8a6a3a" } })); + } + + return { engine, world, ball, elastic, anchor, lid }; +} + +// 儀式(プランジャーを下へ引いて放す)。引きはレーンに沿った縦方向だけに正規化する。 +function createRitual(Matter, built) { + const { ball, elastic, anchor, world } = built; + const { Body, Sleeping } = Matter; + let pulled = false; + let launched = false; + + function clampPull() { + let dy = ball.position.y - anchor.y; + if (dy < 0) dy = 0; + if (dy > GEO.ELASTIC.maxPull) dy = GEO.ELASTIC.maxPull; + Body.setPosition(ball, { x: anchor.x, y: anchor.y + dy }); + Body.setVelocity(ball, { x: 0, y: 0 }); + return dy; + } + + function detach() { + const v = ball.velocity; + const sp = Math.hypot(v.x, v.y) || 1; + const target = Math.min(GEO.ELASTIC.maxSpeed, Math.max(GEO.ELASTIC.minSpeed, sp)); + if (target !== sp) { + const k = target / sp; + Body.setVelocity(ball, { x: v.x * k, y: v.y * k }); + } + Matter.Composite.remove(world, elastic); + elastic.bodyB = null; // 自重の打ち消しを止める + launched = true; + } + + return { + step(dragging) { + if (launched) return false; + if (dragging) { + Sleeping.set(ball, false); + pulled = clampPull() >= GEO.ELASTIC.releaseMin; + return false; + } + if (!pulled) return false; + // 放してゴムに引かれ、支点を上へ通り過ぎた瞬間に切り離す + if (ball.position.y <= anchor.y) { + detach(); + return true; + } + return false; + }, + pull(dy) { + Sleeping.set(ball, false); + Body.setPosition(ball, { x: anchor.x, y: anchor.y + dy }); + Body.setVelocity(ball, { x: 0, y: 0 }); + pulled = clampPull() >= GEO.ELASTIC.releaseMin; + }, + launched: () => launched, + }; +} + +function fallbackRitual(Matter, built, ritual) { + ritual.pull(70); + return false; +} + +function onRitualDone() {} + +const SETTLE = { speed: 0.15, steps: 45 }; + +// 詰まったら玉を下へ落とす(バンパーの上で止まった等)。 +// まだ跳ね回っている玉には触らない(長いラリーの途中で速度を書き換えると不自然)。 +// 止まった玉は静止検知が先に狐を起こすので、これが効くのは押しの直前に止まった +// 玉だけ(保険)。 +function nudge(Matter, built) { + const { ball } = built; + if (Math.hypot(ball.velocity.x, ball.velocity.y) >= SETTLE.speed) return; + Matter.Sleeping.set(ball, false); + Matter.Body.setVelocity(ball, { x: (Math.random() - 0.5) * 4, y: 6 }); +} + +function createSettleDetector(Matter, built) { + const { ball } = built; + let quiet = 0; + return { + step() { + const moving = Math.hypot(ball.velocity.x, ball.velocity.y) >= SETTLE.speed; + quiet = moving ? 0 : quiet + 1; + return quiet >= SETTLE.steps; + }, + }; +} + +function pulseAt(built) { + const a = (built && built.anchor) || { x: GEO.LANE.x, y: GEO.ELASTIC.anchorY }; + return { x: a.x, y: a.y }; +} + +module.exports = { + id: "pinball", + GEO, + FOX, + HINT, + TIMELINE, + wakeLabels, + grabFilter, + build: buildWorld, + createRitual, + createSettleDetector, + fallbackRitual, + onRitualDone, + nudge, + pulseAt, +}; diff --git a/web/scripts/simulate-omikuji-pinball.js b/web/scripts/simulate-omikuji-pinball.js new file mode 100644 index 0000000..a0a0d21 --- /dev/null +++ b/web/scripts/simulate-omikuji-pinball.js @@ -0,0 +1,163 @@ +// おみくじ「ピンボール」装置(omikujiPinball.js)のヘッドレス検証。 +// +// 引きの強さ × 乱数シードを掃引し、放った玉が狐(中央の穴)に落ちるか、 +// 落ちるまでの秒数を計測する。GEO を調整したら必ずここを回す。 +// +// 使い方(web/ ディレクトリで実行): +// node scripts/simulate-omikuji-pinball.js 引き × シードの格子を掃引 +// node scripts/simulate-omikuji-pinball.js --verbose 各点の結果を全部出す +// node scripts/simulate-omikuji-pinball.js --seeds 30 シードを 1..30 に広げる +// node scripts/simulate-omikuji-pinball.js --one 70 3 引き70・シード3 を1回 +// node scripts/simulate-omikuji-pinball.js --trace 70 3 玉の軌跡を 0.25s ごとに出す +// +// 合否: 放てて狐に届くこと。届かず静まった場合は、SETTLE_LIMIT_SEC 以内で +// かつ止まった場所が狐の穴の中のときだけ合格(棚止まりは失敗)。 + +/* eslint-disable no-console */ +const Matter = require("matter-js"); +const machine = require("../components/omikujiPinball.js"); + +const STEPS_PER_SEC = 1000 / machine.GEO.FIXED_DELTA; +const SETTLE_LIMIT_SEC = 8; + +// 決定論的な乱数(シードごとに同じ列) +function seeded(seed) { + let s = seed * 9301 + 49297; + return () => { + s = (s * 9301 + 49297) % 233280; + return s / 233280; + }; +} + +function runOnce(pull, seed, maxSeconds) { + const built = machine.build(Matter, { rnd: seeded(seed) }); + const engine = built.engine; + const ritual = machine.createRitual(Matter, built); + + let step = 0; + let hitStep = null; + let bumps = 0; + Matter.Events.on(engine, "collisionStart", (e) => { + for (const p of e.pairs) { + const labels = [p.bodyA.label, p.bodyB.label]; + if (labels.includes("bumper") && labels.includes("ball")) bumps++; + if (labels.includes("fox-sensor") && labels.includes("ball") && hitStep === null) hitStep = step; + } + }); + + for (let i = 0; i < STEPS_PER_SEC; i++) { + Matter.Engine.update(engine, machine.GEO.FIXED_DELTA, 1); + step++; + } + ritual.pull(pull); + const settle = machine.createSettleDetector(Matter, built); + let launchStep = null; + let settledStep = null; + let speedAtLaunch = 0; + let maxSpeed = 0; + const maxSteps = step + Math.round(maxSeconds * STEPS_PER_SEC); + while (step < maxSteps && hitStep === null && settledStep === null) { + if (launchStep === null && ritual.step(false)) { + launchStep = step; + speedAtLaunch = Math.hypot(built.ball.velocity.x, built.ball.velocity.y); + } + Matter.Engine.update(engine, machine.GEO.FIXED_DELTA, 1); + step++; + if (launchStep !== null) { + maxSpeed = Math.max(maxSpeed, Math.hypot(built.ball.velocity.x, built.ball.velocity.y)); + if (settle.step()) settledStep = step; + } + } + const b = built.ball.position; + const escaped = b.x < -50 || b.x > machine.GEO.W + 50 || b.y < -50 || b.y > machine.GEO.H + 50; + return { + launched: launchStep !== null, + hitSec: hitStep === null ? null : (hitStep - (launchStep || 0)) / STEPS_PER_SEC, + settledSec: settledStep === null ? null : (settledStep - (launchStep || 0)) / STEPS_PER_SEC, + bumps, + escaped, + ballX: Math.round(b.x), + ballY: Math.round(b.y), + speedAtLaunch: speedAtLaunch.toFixed(1), + maxSpeed: maxSpeed.toFixed(1), + }; +} + +function checkIdle(seconds) { + const built = machine.build(Matter, { rnd: seeded(1) }); + for (let i = 0; i < seconds * STEPS_PER_SEC; i++) Matter.Engine.update(built.engine, machine.GEO.FIXED_DELTA, 1); + const drift = Math.hypot(built.ball.position.x - built.anchor.x, built.ball.position.y - built.anchor.y); + console.log(`無操作 ${seconds}s: 玉の支点からのずれ ${drift.toFixed(2)}px`); + return drift < 3; +} + +function main() { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + const oneIdx = args.indexOf("--one"); + if (oneIdx >= 0) { + console.log(runOnce(parseFloat(args[oneIdx + 1]), parseInt(args[oneIdx + 2] || "1", 10), 30)); + return; + } + // 軌跡ダンプ(バンパー配置の当たりをつける用): 放ってから 0.25s ごとの玉の位置 + const traceIdx = args.indexOf("--trace"); + if (traceIdx >= 0) { + const pull = parseFloat(args[traceIdx + 1]); + const seed = parseInt(args[traceIdx + 2] || "1", 10); + const built = machine.build(Matter, { rnd: seeded(seed) }); + const ritual = machine.createRitual(Matter, built); + for (let i = 0; i < STEPS_PER_SEC; i++) Matter.Engine.update(built.engine, machine.GEO.FIXED_DELTA, 1); + ritual.pull(pull); + let launched = false; + const pts = []; + for (let i = 0; i < 12 * STEPS_PER_SEC; i++) { + if (!launched && ritual.step(false)) launched = true; + Matter.Engine.update(built.engine, machine.GEO.FIXED_DELTA, 1); + if (launched && i % Math.round(STEPS_PER_SEC / 4) === 0) { + pts.push(`(${Math.round(built.ball.position.x)},${Math.round(built.ball.position.y)})`); + } + } + console.log(pts.join(" ")); + return; + } + const idleOk = checkIdle(20); + // --seeds N で乱数シードを 1..N に広げる(既定 6)。引きは releaseMin〜maxPull を刻む。 + const seedsIdx = args.indexOf("--seeds"); + const seedCount = seedsIdx >= 0 ? parseInt(args[seedsIdx + 1], 10) : 6; + const pulls = [14, 20, 28, 35, 42, 50, 58, 65, 72, 80, 90]; + const seeds = Array.from({ length: seedCount }, (_, i) => i + 1); + let fail = 0; + let total = 0; + let slowest = 0; + let bumpTotal = 0; + const failures = []; + let settledNoHit = 0; + const FS = machine.GEO.FOX_SENSOR; + for (const pull of pulls) { + for (const seed of seeds) { + const r = runOnce(pull, seed, 30); + total++; + bumpTotal += r.bumps; + // 狐に届かず静まった場合は、止まった場所が狐の穴の中でなければ失敗 + // (どこかの棚に載ったまま終わる演出は避ける)。 + const inHole = Math.abs(r.ballX - FS.x) <= FS.w / 2 && Math.abs(r.ballY - FS.y) <= FS.h / 2; + if (r.hitSec === null && r.settledSec !== null) settledNoHit++; + const ok = r.launched && !r.escaped && (r.hitSec !== null || (r.settledSec !== null && r.settledSec <= SETTLE_LIMIT_SEC && inHole)); + if (!ok) { + fail++; + failures.push({ pull, seed, ...r }); + } else if (r.hitSec !== null) slowest = Math.max(slowest, r.hitSec); + if (verbose) { + const outcome = r.hitSec !== null ? `狐到達 ${r.hitSec.toFixed(1)}s` : r.settledSec !== null ? `静止 ${r.settledSec.toFixed(1)}s` : "未到達"; + console.log(`引き${String(pull).padStart(3)} seed${seed} 射出${r.speedAtLaunch} 最高速${r.maxSpeed} バンパー${r.bumps}回 → ${outcome} 玉(${r.ballX},${r.ballY})${r.escaped ? " 場外!" : ""}`); + } + } + } + console.log(`掃引 ${total} 点: 失敗 ${fail} / 最遅到達 ${slowest.toFixed(1)}s / 平均バンパー ${(bumpTotal / total).toFixed(1)}回 / 無操作静止 ${idleOk ? "OK" : "NG"} / 届かず静止 ${settledNoHit}`); + for (const f of failures.slice(0, 20)) { + console.log(` 引き${f.pull} seed${f.seed}: 射出${f.speedAtLaunch} 最高速${f.maxSpeed} バンパー${f.bumps} 玉(${f.ballX},${f.ballY}) launched=${f.launched} 静止=${f.settledSec === null ? "なし" : f.settledSec.toFixed(1) + "s"}${f.escaped ? " 場外" : ""}`); + } + process.exitCode = fail > 0 || !idleOk ? 1 : 0; +} + +main();