/* ============================================================ * debug_physics_balls_stuck_escape.js · 物理弹球 · 卡球逃脱验证 * 规则:球陷缝隙 0.5s 后横向滑出缺口继续下落;禁止向上弹射 * ("必须落下后才能上去",唯一上升途径是底部输送带)。 * 验证:1) 卡球能逃脱并下落/回收 2) 上升幅度 <=120px 3) 左右方向有随机性 * 运行:node dev_test_scripts/debug/debug_physics_balls_stuck_escape.js * ============================================================ */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const LAB = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls'); const ctx = { Math, console, PBAudio: { fire() {}, hit() {}, wall() {}, item() {}, breakBlock() {}, over() {} }, PBStorage: { getEscape() { return 50; } } }; vm.createContext(ctx); for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js']) { vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f }); } const P = ctx.PBPhysics, G = ctx.PBGame; // 跑 8s:成功 = 被回收 或 下落到 startY+200 以下;同时记录最小 y(上升幅度) function runScenario(setup, startY) { const g = G.create(); g.state = 'fly'; setup(g); let minY = 1e9, maxX = 0, minX = 1e9, outcome = 'stuck'; for (let i = 0; i < 60 * 8; i++) { G.update(g, 1 / 60); if (!g.balls.length) { outcome = 'recycled'; break; } const b = g.balls[0]; minY = Math.min(minY, b.y); maxX = Math.max(maxX, b.x); minX = Math.min(minX, b.x); if (b.y > startY + 200) { outcome = 'fell'; break; } } return { outcome, minY, spread: maxX - minX, g }; } let ok = true; function check(name, res, startY) { const escaped = res.outcome !== 'stuck'; const noLaunch = res.minY > startY - 120; // 不允许向上弹射超过 120px const pass = escaped && noLaunch; console.log((pass ? 'PASS ' : 'FAIL ') + name + ' outcome=' + res.outcome + ' minY=' + (res.minY === 1e9 ? 'recycled' : res.minY.toFixed(1)) + ' hSpread=' + res.spread.toFixed(0)); ok = pass && ok; } // rest 默认 0.2:隔离"逃脱冲量本身",排除高弹块自然反弹的干扰 const mkCircle = (g, x, y, r, rest) => { const b = P.makeCircle({ shape: 'circle', x, y, r, isStatic: true, friction: 0.35, restitution: rest === undefined ? 0.2 : rest, tag: 'block' }); b.data = { hp: 99, shape: 'circle', r }; g.blocks.push(b); }; const mkBall = (g, x, y) => { const ball = P.makeCircle({ shape: 'circle', x, y, r: 7, friction: 0.08, restitution: 0.95, tag: 'ball' }); ball.data = { big: false }; g.balls.push(ball); }; // 场景1:球静止放在两圆块谷地 → 应横向滑出后下落,不上天 check('谷地卡球逃脱', runScenario((g) => { mkCircle(g, 180, 400, 22); mkCircle(g, 224, 400, 22); mkBall(g, 202, 381); }, 381), 381); // 场景2:球静止放在单块顶部 → 应被推离块顶后下落;且单接触不触发研磨(块 HP 不变) { const res = runScenario((g) => { mkCircle(g, 200, 400, 22); mkBall(g, 200, 371); }, 371); check('块顶停球逃脱', res, 371); const intact = res.g.blocks.length === 1 && res.g.blocks[0].data.hp === 99; console.log((intact ? 'PASS ' : 'FAIL ') + '单接触不研磨 hp=' + (res.g.blocks[0] ? res.g.blocks[0].data.hp : 'gone')); ok = intact && ok; } // 场景5:球楔入两低血块夹角 → 研磨快速打碎砖块、留出空间后下落 { const res = runScenario((g) => { mkCircle(g, 180, 400, 22); mkCircle(g, 224, 400, 22); g.blocks.forEach(b => { b.data.hp = 8; }); mkBall(g, 202, 381); }, 381); const ground = res.g.blocks.length === 0; const pass = ground && res.outcome !== 'stuck'; console.log((pass ? 'PASS ' : 'FAIL ') + '夹角研磨开空间 outcome=' + res.outcome + ' blocksLeft=' + res.g.blocks.length); ok = pass && ok; } // 场景3:球停底部弧面(无砖块接触)→ 1.2s 后应被回收 check('底部停球回收', runScenario((g) => { mkBall(g, 210, 790); }, 790), 790); // 场景4:单接触停球逃脱,横向方向应有随机性(左右都出现) { const dirs = []; for (let k = 0; k < 10; k++) { const g = G.create(); g.state = 'fly'; mkCircle(g, 200, 400, 22); mkBall(g, 200 + (Math.random() * 4 - 2), 371); // 真实落点总有微小偏移 let firstVx = null; for (let i = 0; i < 60 * 3; i++) { G.update(g, 1 / 60); if (!g.balls.length) break; const b = g.balls[0]; if (firstVx === null && Math.abs(b.vx) > 200) { firstVx = b.vx; break; } } if (firstVx !== null) dirs.push(firstVx > 0 ? 'R' : 'L'); } const hasL = dirs.includes('L'), hasR = dirs.includes('R'); const pass = dirs.length >= 6 && hasL && hasR; console.log((pass ? 'PASS ' : 'FAIL ') + '逃逸方向随机性 ' + dirs.join('')); ok = pass && ok; } process.exit(ok ? 0 : 1);